diff --git a/apps/presentation/dashboard/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts index 90ad6ce1e8..ff34cda9cd 100644 --- a/apps/presentation/dashboard/smoke/attention-details-smoke.ts +++ b/apps/presentation/dashboard/smoke/attention-details-smoke.ts @@ -3,7 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { AttentionDetailCard } from "../src/features/personal-workspace/attention-detail-card"; import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n"; import { todoItemSchema } from "../src/data/status"; -import { attentionDetails, attentionDetailsFromSnapshot, attentionSuccessor, canReviewAttention, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details"; +import { attentionDetails, attentionDetailsFromSnapshot, attentionSuccessor, canDecideAttention, canHandleUserAction, canReviewAttention, nextMorningResumeWhen, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details"; +import { parseTodoResumeCondition } from "../src/features/personal-workspace/todo-resume-condition"; import { normalizePersonalHomeModel, type WorkspaceAttention } from "../src/features/personal-workspace/personal-workspace-model"; function assert(condition: unknown, message: string): asserts condition { @@ -27,8 +28,33 @@ assert(model.attentionHistory?.[0].details?.decisionScope?.scopeKey === "route-o assert(canReviewAttention(row), "open gate retains governed preview"); for (const task_class of ["user_action", undefined, "unknown_future_class"]) { const item = { ...row, details: attentionDetails({ ...source, task_class, text: "Please authorize production", note: "read approval required" }) }; - assert(item.details.interaction === "unknown", "prose never classifies interaction"); + // Only the typed task_class classifies; approval wording never makes a decision. + assert(item.details.interaction === (task_class === "user_action" ? "user_action" : "unknown"), "prose never classifies interaction"); + assert(!canDecideAttention(item), "approval wording never grants approve/reject"); assert(canReviewAttention(item), "existing ordinary or legacy preview preserved without granting authority"); + assert(canHandleUserAction(item) === (task_class === "user_action"), "only a typed User action is handled as done/defer/cancel"); +} +// A User action is handled on its own Todo; it needs the stable id the owner validates. +const action = { ...row, details: attentionDetails({ ...source, task_class: "user_action" }) }; +const boundAction = attentionDetails(todoItemSchema.parse({ ...source, task_class: "user_action", bound_agent: "codex-delivery" })); +assert(boundAction.boundAgent === "codex-delivery", "canonical User action actor survives schema and projection"); +assert(!canHandleUserAction({ ...row, details: attentionDetails(source) }), "a User gate is decided, not marked done"); +const { todo_id: _omitted, ...idless } = source; +assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...idless, task_class: "user_action" }) }), "missing todo_id cannot be written"); +assert(!canHandleUserAction({ ...action, decisionSource: "run_operator_gate" }), "run operator gate is never a User action"); +for (const inactive of [{ status: "deferred" }, { status: "done", done: true }, { superseded_by: "todo_next" }]) { + assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...source, task_class: "user_action", ...inactive }) }), "inactive User action is not writable"); +} +assert(!canHandleUserAction(refreshAttention(action, [])), "unobservable User action is not writable"); +// The defer preset is tomorrow 09:00 local, in a form the resume-condition owner accepts. +for (const [now, day] of [["2026-10-04T19:13:00", "2026-10-05"], ["2026-10-04T00:30:00", "2026-10-05"], + ["2026-01-31T23:59:00", "2026-02-01"], ["2026-12-31T10:00:00", "2027-01-01"]] as const) { + const local = new Date(now); + const condition = nextMorningResumeWhen(local); + assert(condition.startsWith(`resume_at:${day}T09:00:00`), `defer preset is the next calendar day at 09:00: ${condition}`); + assert(parseTodoResumeCondition(condition) === condition.toLowerCase(), "defer preset is a supported resume condition"); + const target = new Date(Number(day.slice(0, 4)), Number(day.slice(5, 7)) - 1, Number(day.slice(8, 10)), 9); + assert(Date.parse(condition.slice("resume_at:".length)) === target.getTime(), "offset names the target instant"); } assert(attentionDetails({ ...source, done: true, status: "deferred" }).lifecycle === "deferred", "explicit deferral overrides checked legacy marker"); for (const status of ["done", "deferred", "closed", "completed", "archived"]) { @@ -79,7 +105,7 @@ const canonical = todoItemSchema.parse({...source, role: "user", task_class: "us const compact = todoItemSchema.parse({...canonical, text: `${longRequest.slice(0, 217)}...`, note: null, evidence: null}); const joined = attentionDetailsFromSnapshot(compact, [canonical], row.goalId); assert(joined.requestText === longRequest && joined.reason === canonical.note && joined.evidence === canonical.evidence, "same revision restores full display content"); -assert(joined.interaction === "unknown" && joined.lifecycle === "open", "enrichment cannot reclassify a user action as a gate"); +assert(joined.interaction === "user_action" && joined.lifecycle === "open", "enrichment cannot reclassify a user action as a gate"); const joinedMarkup = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, createElement(AttentionDetailCard, {item: {...row, text: compact.text, details: joined}}))); assert(joinedMarkup.includes("Publish version 2.0 to stable only after acceptance.") @@ -91,7 +117,7 @@ for (const candidates of [[], [canonical, canonical], [{...canonical, goal_id: " [{...canonical, text: "A different request with the same title"}]]) { const rejected = attentionDetailsFromSnapshot(compact, candidates, row.goalId); assert(rejected.requestText === null && rejected.reason === null, "missing/conflicting details remain summary-only"); - assert(rejected.lifecycle === "open" && rejected.interaction === "unknown", "failed enrichment preserves selected lifecycle and authority"); + assert(rejected.lifecycle === "open" && rejected.interaction === "user_action", "failed enrichment preserves selected lifecycle and authority"); } const authoritySource = {...canonical, blocks_agent: "different-agent", decision_scope: {kind: "trade", granularity: "goal", scope_key: "different"}}; const displayOnly = attentionDetailsFromSnapshot(compact, [authoritySource], row.goalId); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx new file mode 100644 index 0000000000..ec402b3a9b --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { CalendarClock, Check, MessageCircleQuestion, MessageSquareReply, MoreHorizontal, Square, X } from "lucide-react"; +import type { DecisionOutcome } from "../../../../../../loopx/control_plane/todos/user_completion_types.js"; +import { canDecideAttention, canHandleUserAction, canReviewAttention, nextMorningResumeWhen } from "./attention-details"; +import { useWorkspaceI18n } from "./i18n"; +import type { PersonalWorkspaceCallbacks, WorkspaceActionPreviewRequest, WorkspaceAttention } from "./personal-workspace-model"; + +type PreviewState = { status: "idle" } | { status: "pending" } | { status: "error"; message: string }; + +/** Owner affordances for one "needs you" item. Every write is a typed preview + * the owner confirms once; the Todo owner validates it and decides effects. */ +export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }: { + item: WorkspaceAttention; + readOnly: boolean; + callbacks: PersonalWorkspaceCallbacks; + fallbackAgentId: string; +}) { + const { t } = useWorkspaceI18n(); + const [preview, setPreview] = useState({ status: "idle" }); + useEffect(() => setPreview({ status: "idle" }), [item.goalId, item.todoId]); + // User actions bind their actor through bound_agent; gates use blocks_agent. + // The native owner still validates registration and the exact Todo binding. + const agentId = (item.details?.interaction === "user_action" + ? item.details.boundAgent : item.details?.blocksAgent) ?? fallbackAgentId; + const context = { goal_id: item.goalId, kind: "todo", todo_id: item.todoId }; + const key = (suffix: string) => `workspace-attention-${item.todoId}-${suffix}-${Date.now().toString(36)}`; + + async function requestPreview(request: WorkspaceActionPreviewRequest) { + if (preview.status === "pending" || !callbacks.onPreviewAction) return; + setPreview({ status: "pending" }); + try { + await callbacks.onPreviewAction(request); + setPreview({ status: "idle" }); + } catch (error) { + setPreview({ status: "error", message: error instanceof Error ? error.message : String(error) }); + } + } + + function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) { + if (readOnly || !canDecideAttention(attention)) return; + void requestPreview({ + actionKind: "gate.resolve", context, idempotencyKey: key(`decision-${decision}`), + normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, decision, todo_id: attention.todoId }, + summary: attention.text, + }); + } + + function previewUserAction(attention: WorkspaceAttention, operation: "complete" | "defer" | "cancel") { + if (readOnly || !canHandleUserAction(attention)) return; + if (operation === "cancel") { + // Cancelling a user_action is the one decision outcome its owner admits; + // it closes only this reminder and never resumes or approves other work. + void requestPreview({ + actionKind: "gate.resolve", context, idempotencyKey: key("cancel"), + normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, decision: "cancel", + note: t("drawer.userActionCancelNote"), todo_id: attention.todoId }, + summary: t("drawer.userActionCancelSummary", { task: attention.text }), + }); + return; + } + void requestPreview({ + actionKind: "todo.update", context, idempotencyKey: key(operation), + normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, operation, todo_id: attention.todoId, + ...(operation === "defer" ? { resume_when: nextMorningResumeWhen(new Date()) } : {}) }, + summary: t(operation === "defer" ? "drawer.userActionDeferSummary" : "drawer.userActionCompleteSummary", { task: attention.text }), + }); + } + + const pending = preview.status === "pending"; + const feedback = preview.status === "error" + ?

{t("drawer.attentionPreviewFailed", { error: preview.message })}

+ : pending ?

{t("drawer.attentionPreparing")}

: null; + const reply = callbacks.onReplyToAttention + ? + : null; + const explain = (label: string) => callbacks.onExplainDecision + ? + : null; + + if (readOnly) { + return canReviewAttention(item) ?

{t("drawer.attentionReadOnly")}

: null; + } + if (canDecideAttention(item)) { + return <> +
+ + +
+ {feedback} +
+ {t("drawer.decisionMore")} +
+ {explain(t("drawer.explainDecision"))} + +
+
+ ; + } + if (canHandleUserAction(item)) { + return <> +

{t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}

+
+ + {reply} +
+ {feedback} +
+ {t("drawer.userActionMore")} +
+ + + {explain(t("drawer.askAgentExplain"))} +
+
+ ; + } + if (!canReviewAttention(item)) return null; + const note = item.decisionSource === "run_operator_gate" ? "drawer.decisionRunGate" + : item.details?.interaction === "user_action" ? "drawer.userActionMissingTodo" : "drawer.decisionNotGate"; + return <> +

{t(note)}

+
+ {reply} + {callbacks.onExplainDecision ? : null} +
+ ; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts index 6a2df68d79..3431138d82 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts @@ -3,12 +3,16 @@ import { todoRequestContent } from "../../../../../../loopx/control_plane/presen /** A display of existing Todo facts, never a gate or dependency evaluator. */ export type AttentionDetails = { - interaction: "decision" | "unknown"; + /** Mirrors the User Todo task_class; anything else stays "unknown". */ + interaction: "decision" | "user_action" | "unknown"; lifecycle: "open" | "closed" | "deferred" | "superseded" | "unknown" | "unavailable"; + /** False when the row was projected without a stable todo_id to write against. */ + todoIdentified: boolean; requestText?: string | null; reason: string | null; evidence: string | null; blocksAgent: string | null; + boundAgent: string | null; unblocksTodoId: string | null; decisionScope: { kind: string; granularity: string; scopeKey: string } | null; supersededBy: string | null; @@ -25,15 +29,18 @@ export function attentionDetails(todo: Record): AttentionDetail const supersededBy = text(todo.superseded_by); // Do not infer a request for authorization from wording, blocking, or scope kind. return { - interaction: todo.task_class === "user_gate" ? "decision" : "unknown", + interaction: todo.task_class === "user_gate" ? "decision" + : todo.task_class === "user_action" ? "user_action" : "unknown", lifecycle: supersededBy ? "superseded" : todo.status === "deferred" ? "deferred" : todo.done === true || ["done", "completed", "closed", "archived"].includes(String(todo.status)) ? "closed" : todo.status === "open" || todo.status === "blocked" ? "open" : "unknown", + todoIdentified: text(todo.todo_id) !== null, requestText: text(todo.text), reason: text(todo.note), evidence: text(todo.evidence), blocksAgent: text(todo.blocks_agent), + boundAgent: text(todo.bound_agent), unblocksTodoId: text(todo.unblocks_todo_id), decisionScope: kind && granularity && scopeKey ? { kind, granularity, scopeKey } : null, supersededBy, @@ -84,3 +91,19 @@ export function canDecideAttention(item: WorkspaceAttention): boolean { return canReviewAttention(item) && item.details?.interaction === "decision" && item.decisionSource !== "run_operator_gate"; } + +/** Complete, defer, or cancel the User action Todo itself; never an approval. + * Requires the stable todo_id the typed Todo owner writes against. */ +export function canHandleUserAction(item: WorkspaceAttention): boolean { + return canReviewAttention(item) && item.details?.interaction === "user_action" + && item.details.todoIdentified && item.decisionSource !== "run_operator_gate"; +} + +/** Tomorrow's local 09:00 as a timezone-aware resume_at condition. */ +export function nextMorningResumeWhen(now: Date): string { + const target = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0, 0); + const pad = (value: number) => String(Math.abs(value)).padStart(2, "0"); + const offset = -target.getTimezoneOffset(); + const zone = `${offset >= 0 ? "+" : "-"}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`; + return `resume_at:${target.getFullYear()}-${pad(target.getMonth() + 1)}-${pad(target.getDate())}T09:00:00${zone}`; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index d2197a521d..564a847832 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1,7 +1,7 @@ import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; +import { AttentionActions } from "./attention-actions"; import { AttentionDetailCard } from "./attention-detail-card"; -import type {DecisionOutcome} from "../../../../../../loopx/control_plane/todos/user_completion_types.js"; -import { attentionSuccessor, canDecideAttention, canReviewAttention } from "./attention-details"; +import { attentionSuccessor } from "./attention-details"; import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, @@ -15,7 +15,6 @@ import { Download, ExternalLink, GitBranch, - MessageCircleQuestion, Maximize2, Minimize2, MoreHorizontal, @@ -418,23 +417,6 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention }); } - async function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) { - if (readOnly || !canDecideAttention(attention)) return; - await previewAction({ - actionKind: "gate.resolve", - context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId }, - idempotencyKey: `workspace-decision-${attention.todoId}-${decision}-${Date.now().toString(36)}`, - normalizedParameters: { - // Hard-lease Goals attribute the decision to the Agent it unblocks. - agent_id: attention.details?.blocksAgent ?? todoAgentId, - goal_id: attention.goalId, - decision, - todo_id: attention.todoId, - }, - summary: attention.text, - }); - } - const currentSubagentConfiguration: WorkspaceGoalSubagentConfiguration = selection.kind === "goal" ? verifiedSubagentConfiguration ?? selection.item.subagentExecution @@ -628,22 +610,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.kind === "attention" ? ( <> - {!readOnly && canDecideAttention(selection.item) ? <> -
- - -
-
- {t("drawer.decisionMore")} -
- - -
-
- : !readOnly && canReviewAttention(selection.item) ? <> -

{t(selection.item.decisionSource === "run_operator_gate" ? "drawer.decisionRunGate" : "drawer.decisionNotGate")}

- - : null} + ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index f5275e7370..49c0db5ea5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -224,6 +224,26 @@ const en = { "drawer.executionRecord": "Run record", "drawer.executionRecordAndResult": "Execution & result", "drawer.explainDecision": "Explain this decision", + "drawer.askAgentExplain": "Ask the Agent to explain", + "drawer.attentionReply": "Reply in conversation", + "drawer.attentionReplyPrefill": "About “{task}”: ", + "drawer.attentionExplainPrefill": "Please explain “{task}”: why it needs me, what exactly I should do, and what happens once it is done.", + "drawer.decisionExplainPrefill": "Please explain the decision “{task}”: what approving or rejecting would change, and what you recommend.", + "drawer.attentionPreparing": "Preparing the change for your confirmation…", + "drawer.attentionPreviewFailed": "Could not prepare this change: {error}. Nothing was saved; you can try again.", + "drawer.attentionReadOnly": "This source is read-only. Handle this request in LoopX on the source host.", + "drawer.userActionGroup": "Handle this request", + "drawer.userActionUnblocks": "This asks you to do something yourself; it is not an approval. Once it is done, mark it done so the work waiting on it can continue.", + "drawer.userActionReminder": "This is a reminder for something only you can do; no Agent is waiting on it. Mark it done when finished, or close it under Other options if it is no longer needed.", + "drawer.userActionComplete": "I've done this", + "drawer.userActionCompleteSummary": "Mark done: {task}", + "drawer.userActionMore": "Other options", + "drawer.userActionDefer": "Defer until tomorrow 9:00", + "drawer.userActionDeferSummary": "Defer until tomorrow 9:00: {task}", + "drawer.userActionCancel": "No longer needed", + "drawer.userActionCancelSummary": "No longer needed: {task}", + "drawer.userActionCancelNote": "The owner closed this request as no longer needed.", + "drawer.userActionMissingTodo": "This request has no stable Todo identity in the current view, so it cannot be marked done here. Reply to the responsible Agent in the conversation instead.", "drawer.gateRequiresHost": "Host confirmation required", "drawer.gateRequiresHostDescription": "This page cannot approve this protected permission change. Nothing was written by your click.", "drawer.goalAutoRun": "Automatic runs for this Goal", @@ -1524,6 +1544,26 @@ const zhCN: Record = { "drawer.executionRecord": "运行记录", "drawer.executionRecordAndResult": "执行过程与结果", "drawer.explainDecision": "解释此决定", + "drawer.askAgentExplain": "请 Agent 说明", + "drawer.attentionReply": "在对话中回复", + "drawer.attentionReplyPrefill": "关于「{task}」:", + "drawer.attentionExplainPrefill": "请说明「{task}」:为什么需要我处理、具体该怎么做、完成后会发生什么?", + "drawer.decisionExplainPrefill": "请解释这项决定「{task}」:批准或拒绝分别会带来什么影响,你建议怎么选?", + "drawer.attentionPreparing": "正在准备变更,稍后请你确认…", + "drawer.attentionPreviewFailed": "未能准备这项变更:{error}。没有保存任何内容,可以重试。", + "drawer.attentionReadOnly": "当前数据源为只读,无法在这里处理;请在源主机上的 LoopX 中处理。", + "drawer.userActionGroup": "处理这项请求", + "drawer.userActionUnblocks": "这项事项需要你亲自完成,不是批准。完成后点“我已完成”,等待它的工作才会继续。", + "drawer.userActionReminder": "这是一项只有你能完成的提醒,没有 Agent 在等它。完成后点“我已完成”;如果不再需要,可在“其他处理”里关闭。", + "drawer.userActionComplete": "我已完成", + "drawer.userActionCompleteSummary": "标记已完成:{task}", + "drawer.userActionMore": "其他处理", + "drawer.userActionDefer": "暂缓到明天 9:00", + "drawer.userActionDeferSummary": "暂缓到明天 9:00:{task}", + "drawer.userActionCancel": "不再需要", + "drawer.userActionCancelSummary": "不再需要:{task}", + "drawer.userActionCancelNote": "负责人确认此事项不再需要,已关闭。", + "drawer.userActionMissingTodo": "当前视图中这条请求缺少稳定的 Todo 标识,无法在这里标记完成。请在对话中回复负责的 Agent。", "drawer.gateRequiresHost": "需要宿主确认", "drawer.gateRequiresHostDescription": "页面无权直接批准这类权限变更,你的点击没有写入任何内容。", "drawer.goalAutoRun": "当前 Goal 的自动运行", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 9403eb1be4..9ef0efdbca 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -9,6 +9,7 @@ const answerText = source("./answer-text.ts"); const model = source("./personal-workspace-model.ts"); const activity = source("./goal-activity.ts"); const drawer = source("./context-drawer.tsx"); +const attentionActions = source("./attention-actions.tsx"); const header = source("./channel-header.tsx"); const sidebar = source("./goal-sidebar.tsx"); const actionForm = source("./workspace-action-form.tsx"); @@ -54,11 +55,17 @@ assert.match(drawer, /actionKind: "todo\.create"/, "Todo successor uses the cano for (const field of ["evidence", "explanation"]) { assert.match(model, new RegExp(`${field}\\??:`), `Decision exposes ${field}`); } +assert.match(drawer, / 0/, "Session empty-state copy distinguishes projected progress from a truly idle run"); assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started"); assert.match(drawer, /t\("drawer\.runRecordEmpty"\)/, "A truly empty Session still explains why there is no timeline yet"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index c598a35566..103d6ad7eb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -490,6 +490,8 @@ export type PersonalWorkspaceCallbacks = { onCorrectRun?: (run: WorkspaceRun, message: string) => void | Promise; onCloseRunSession?: (run: WorkspaceRun) => void | Promise; onExplainDecision?: (attention: WorkspaceAttention) => void | Promise; + /** Open the item's Goal conversation with an editable draft; never sends. */ + onReplyToAttention?: (attention: WorkspaceAttention, intent: "reply" | "explain") => void; onExportOutput?: (output: WorkspaceOutput) => void | Promise; onInterruptRun?: (run: WorkspaceRun) => void | Promise; onCancelConversationPreparation?: (contextId: string) => void; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index a19383d1b7..816de7abb6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -57,6 +57,7 @@ import type { PersonalWorkspaceCallbacks, WorkspaceAgentOption, WorkspaceActionPreview, + WorkspaceAttention, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, @@ -915,6 +916,22 @@ export function PersonalWorkspacePage({ setComposer(composer ? `${composer}\n${text}` : text); composerRef.current?.focus(); } + // A draft for another Goal waits until that Goal's composer is the visible one. + const [composerPrefill, setComposerPrefill] = useState<{ goalId: string; text: string } | null>(null); + useEffect(() => { + if (!composerPrefill || composerPrefill.goalId !== selectedGoalId) return; + setComposerPrefill(null); + suggestReply(composerPrefill.text); + }, [composerPrefill, selectedGoalId]); + function draftAttentionMessage(attention: WorkspaceAttention, intent: "reply" | "explain") { + const task = attention.text.length > 120 ? `${attention.text.slice(0, 119)}…` : attention.text; + const key = intent === "reply" ? "drawer.attentionReplyPrefill" + : attention.details?.interaction === "decision" ? "drawer.decisionExplainPrefill" : "drawer.attentionExplainPrefill"; + if (attention.goalId !== selectedGoalId) selectGoal(attention.goalId, "chat"); + openGoalConversation(); + setSelection(null); + setComposerPrefill({ goalId: attention.goalId, text: t(key, { task }) }); + } // The steward prompt set is owned by the client model; the quick-prompt row // reuses it so one affordance answers "what now / what blocks / what is proven". function stewardPromptText(id: string) { @@ -1583,6 +1600,8 @@ export function PersonalWorkspacePage({ callbacks.onOpenOutput?.(output); }, onApplyProposal: applyProposal, + onExplainDecision: callbacks.onExplainDecision ?? ((attention) => draftAttentionMessage(attention, "explain")), + onReplyToAttention: callbacks.onReplyToAttention ?? draftAttentionMessage, onCancelProposal: async (proposal) => { setSelection(null); setProposals((current) => { diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index d672fca3bd..24db1853ef 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -3,6 +3,7 @@ import { replanCadenceScenario } from "./personal-workspace-browser/replan-caden import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; import {configurationBackupScenario} from "./personal-workspace-browser/configuration-backup.mjs"; +import {attentionUserActionScenario} from "./personal-workspace-browser/attention-user-action.mjs"; import {prReviewAgentOrderScenario} from "./personal-workspace-browser/pr-review-agent-order.mjs"; import {conversationImageRequestScenario} from "./personal-workspace-browser/conversation-image-request.mjs"; import {externalEvidenceReadbackScenario} from "./personal-workspace-browser/external-evidence-readback.mjs"; @@ -86,6 +87,7 @@ scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(privateStewardScopeScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); scenarioCatalog.push(configurationBackupScenario); +scenarioCatalog.push(attentionUserActionScenario); scenarioCatalog.push(researchResultsScenario); scenarioCatalog.push(prReviewAgentOrderScenario); scenarioCatalog.push(taskInspectorReturnScenario); diff --git a/examples/personal-workspace-browser/attention-user-action.mjs b/examples/personal-workspace-browser/attention-user-action.mjs new file mode 100644 index 0000000000..73cce19142 --- /dev/null +++ b/examples/personal-workspace-browser/attention-user-action.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; +import { repoRoot, outputDir } from "./fixture.mjs"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +const actionText = "在桌面 App 中手动创建剩余的 3 个角色会话"; +const idlessText = "核对本机备份目录是否可写"; + +// The compiled UI's own payloads reach the real ChatActionService and File +// hard-lease owner. Other unrelated APIs retain the shared browser fixture. +async function boundActorJourney(browser, url, registerDefault) { + const root = await mkdtemp(join(tmpdir(), "loopx-user-action-")); + const child = spawn(resolveTestPython({ repoRoot }), ["-u", "-c", ` +import json, pathlib, sys +sys.path.insert(0, 'tests/control_plane') +from canonical_authority_fixture import initialize_canonical_authority +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_actions import ChatActionService +from loopx.chat_action_store import ChatActionStore +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.todos import add_goal_todo, list_goal_todos +r=pathlib.Path(sys.argv[1]); runtime=r/'runtime'; registry=r/'registry.json'; state=r/'state.md' +state.write_text('---\\ngoal_id: product-release\\n---\\n\\n## Agent Todo\\n') +agents=['codex-review','codex-delivery'] + (['codex'] if sys.argv[2]=='true' else []) +registry.write_text(json.dumps({'common_runtime_root':str(runtime),'goals':[{'id':'product-release','repo':str(r),'state_file':'state.md','coordination':{'agent_model':'peer_v1','registered_agents':agents}}]})) +ids={} +for operation in ['complete','cancel']: + target=add_goal_todo(registry_path=registry,goal_id='product-release',role='agent',text='Dependent '+operation,status='blocked',task_class='advancement_task',claimed_by='codex-delivery') + action=add_goal_todo(registry_path=registry,goal_id='product-release',role='user',text='Handle '+operation,task_class='user_action',bound_agent='codex-delivery',unblocks_todo_id=target['todo_id']) + ids[operation]=[action['todo_id'],target['todo_id']] +projection=build_todo_runtime_shadow_projection(goal_id='product-release',handoff_mode='hard_lease',todos=list_goal_todos(registry_path=registry,goal_id='product-release')['todos']) +config=json.loads(registry.read_text()); config['goals'][0]['coordination']['handoff_mode']='hard_lease'; registry.write_text(json.dumps(config)) +initialize_canonical_authority(runtime,'product-release',projection,state_path=state,provider='file') +s=ChatHTTPServer(('127.0.0.1',0),ChatRequestHandler) +s.registry_path,s.runtime_root,s.runtime_root_override,s.verbose=registry,runtime,str(runtime),False +s.selected_goal_id,s.scan_roots,s.limit='product-release',[],10 +s.action_store=ChatActionStore(r/'actions') +s.action_service=ChatActionService(store=s.action_store,registry_path=registry) +print(json.dumps({'port':s.server_port,'ids':ids}),flush=True); s.serve_forever() +`, root, String(registerDefault)], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot, LOOPX_USAGE_PING: "0" }, stdio: ["ignore", "pipe", "pipe"] }); + const exited = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = "", context, timer; + child.stderr.on("data", data => { diagnostics = (diagnostics + data).slice(-4000); }); + try { + const ready = await Promise.race([ + once(lines, "line").then(([line]) => JSON.parse(line)), + exited.then(() => { throw new Error(`User action backend exited: ${diagnostics}`); }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`User action backend timeout: ${diagnostics}`)), 30000); }), + ]); + clearTimeout(timer); + const backend = `http://127.0.0.1:${ready.port}`; + const read = async id => { + const role = Object.values(ready.ids).some(([actionId]) => actionId === id) ? "user" : "agent"; + const { stdout } = await promisify(execFile)(resolveTestPython({ repoRoot }), ["-c", "from loopx.entrypoint import main; main()", + "--format", "json", "--registry", join(root, "registry.json"), "todo", "list", "--goal-id", "product-release", "--role", role, "--todo-id", id], + { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot, LOOPX_USAGE_PING: "0" }, timeout: 30000 }); + const result = JSON.parse(stdout); assert.ok(result.ok && result.matched); return result.todo; + }; + const previews = []; + let applications = 0; + context = await openWorkspacePage(browser, url, { beforeGoto: async (_api, page) => { + for (const pattern of ["**/status.json*", "**/api/actions**", "**/api/chat/todo/detail?*", "**/api/chat/completed-todos?*"]) { + await page.route(pattern, async route => { + const request = route.request(), parsed = new URL(request.url()); + if (pattern === "**/status.json*" && parsed.pathname !== "/status.json") { await route.fallback(); return; } + if (parsed.pathname === "/api/actions/preview") previews.push(request.postDataJSON()); + if (parsed.pathname.endsWith("/apply")) applications++; + const response = await route.fetch({ url: `${backend}${parsed.pathname}${parsed.search}` }); + await route.fulfill({ response }); + }); + } + }}); + const { page } = context, drawer = page.locator(".personal-context-drawer"); + for (const operation of ["complete", "cancel"]) { + const [actionId, targetId] = ready.ids[operation]; + // Registration alone never lets an unrelated peer handle this request. + const denied = await fetch(`${backend}/api/actions/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action_kind: "todo.update", normalized_parameters: { agent_id: "codex-review", goal_id: "product-release", todo_id: actionId, operation: "complete" }, context: {}, summary: "Wrong actor", idempotency_key: `wrong-${operation}` }) }); + assert.equal(denied.status, 400); + assert.equal((await read(actionId)).status, "open"); + await page.getByRole("button", { name: /LoopX 管家/ }).first().click(); + await page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); + await page.getByRole("button", { name: /^任务$/ }).first().click(); + await page.getByText(`Handle ${operation}`, { exact: true }).first().click(); + if (operation === "cancel") await drawer.locator("summary", { hasText: "其他处理" }).click(); + await drawer.getByRole("button", { name: operation === "complete" ? "我已完成" : "不再需要", exact: true }).click(); + await page.locator('[data-context-kind="proposal"]').waitFor({ state: "visible" }); + assert.equal(previews.at(-1).normalized_parameters.agent_id, "codex-delivery"); + assert.equal((await read(actionId)).status, "open", "Native preview writes nothing"); + await page.screenshot({ path: resolve(outputDir, `user-action-native-${registerDefault}-${operation}.png`), animations: "disabled" }); + try { + await drawer.getByRole("button", { name: operation === "complete" ? "确认并应用" : "确认撤回", exact: true }).click({ timeout: 5000 }); + } catch (error) { + throw new Error(`${error.message}; drawer=${await drawer.innerText()}; errors=${context.errors.join(" | ")}`); + } + await page.getByRole("button", { name: "查看更新后的 Goal" }).click(); + assert.equal((await read(actionId)).status, "done"); + assert.equal((await read(targetId)).status, operation === "complete" ? "open" : "blocked"); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal((await read(actionId)).status, "done", "Reload preserves canonical completion"); + } + assert.equal(applications, 2, "Exactly one confirmed write per request"); + } finally { + clearTimeout(timer); lines.close(); + if (context) await context.close(); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + await exited; + await rm(root, { recursive: true, force: true }); + } +} + +// A User action in "needs you" is handled in place through the existing typed +// Todo actions: done / defer / no longer needed, each previewed and confirmed once. +export const attentionUserActionScenario = { + id: "attention-user-action", + async run({ browser, collectCoverage, url }) { + const { api, page, close, errors } = await openWorkspacePage(browser, url, { apiOptions: { userActionAttention: true }, collectCoverage }); + const drawer = page.locator(".personal-context-drawer"); + async function openRequest(text) { + await page.getByRole("button", { name: /LoopX 管家/ }).first().click(); + await page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); + await page.getByRole("button", { name: /^任务$/ }).first().click(); + await page.getByText(text, { exact: true }).first().click(); + await drawer.getByText(text, { exact: true }).first().waitFor({ state: "visible" }); + } + try { + await openRequest(actionText); + for (const forbidden of ["批准", "拒绝", "解释此决定"]) { + assert.equal(await drawer.getByRole("button", { name: forbidden, exact: true }).count(), 0, `A User action must not offer "${forbidden}"`); + } + const done = drawer.getByRole("button", { name: "我已完成", exact: true }); + await drawer.getByRole("button", { name: "在对话中回复", exact: true }).waitFor({ state: "visible" }); + + // Failure: nothing is written, the error is visible, and the same action recovers. + const writesBefore = api.durableWriteCount; + api.failNextActionPreview = true; + await done.click(); + await drawer.getByRole("alert").getByText(/未能准备这项变更.*没有保存任何内容/u).waitFor({ state: "visible" }); + assert.equal(api.durableWriteCount, writesBefore, "A failed preview must not write"); + await done.click(); + await page.locator('[data-context-kind="proposal"]').waitFor({ state: "visible" }); + const completion = api.actionPreviews.at(-1); + assert.equal(completion.action_kind, "todo.update"); + assert.equal(completion.normalized_parameters.operation, "complete"); + assert.equal(completion.normalized_parameters.todo_id, "todo-browser-user-action"); + assert.equal(completion.normalized_parameters.agent_id, "codex-delivery", "Uses the request's bound Agent, never the first available Agent"); + assert.equal(api.durableWriteCount, writesBefore, "Preview waits for owner confirmation"); + await page.locator('[data-context-kind="proposal"]').getByRole("button", { name: "确认并应用" }).click(); + await page.getByRole("button", { name: "查看更新后的 Goal" }).click(); + assert.equal(api.durableWriteCount, writesBefore + 1, "Confirmation writes exactly once"); + await page.getByRole("button", { name: /^任务$/ }).first().click(); + await page.getByText(idlessText, { exact: true }).first().waitFor({ state: "visible" }); + assert.equal(await page.getByText(actionText, { exact: true }).count(), 0, "The completed request leaves needs-you on readback"); + + // Without a stable todo_id the drawer explains why and offers the conversation instead. + await openRequest(idlessText); + await drawer.getByText(/缺少稳定的 Todo 标识/u).waitFor({ state: "visible" }); + assert.equal(await drawer.getByRole("button", { name: "我已完成", exact: true }).count(), 0, "No write without a stable Todo identity"); + const previews = api.actionPreviews.length; + await drawer.getByRole("button", { name: "在对话中回复", exact: true }).click(); + await page.locator('[data-goal-panel="chat"]').waitFor({ state: "visible" }); + await page.waitForFunction((expected) => document.querySelector("textarea")?.value === expected, `关于「${idlessText}」:`); + assert.equal(api.actionPreviews.length, previews, "Replying drafts a message; it previews no write"); + assert.equal(api.turnRequests.length, 0, "Replying never sends without the owner"); + + // Defer and "no longer needed" reuse the same owners on a fresh request. + const fresh = await openWorkspacePage(browser, url, { apiOptions: { userActionAttention: true } }); + try { + const freshDrawer = fresh.page.locator(".personal-context-drawer"); + await fresh.page.getByRole("button", { name: /LoopX 管家/ }).first().click(); + await fresh.page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); + await fresh.page.getByRole("button", { name: /^任务$/ }).first().click(); + for (const [label, check] of [ + ["暂缓到明天 9:00", (preview) => { + assert.equal(preview.action_kind, "todo.update"); + assert.equal(preview.normalized_parameters.operation, "defer"); + assert.match(preview.normalized_parameters.resume_when, /^resume_at:\d{4}-\d{2}-\d{2}T09:00:00[+-]\d{2}:\d{2}$/u); + assert.ok(Date.parse(preview.normalized_parameters.resume_when.slice(10)) > Date.now()); + }], + ["不再需要", (preview) => { + assert.equal(preview.action_kind, "gate.resolve"); + assert.equal(preview.normalized_parameters.decision, "cancel"); + assert.equal(preview.normalized_parameters.todo_id, "todo-browser-user-action"); + }], + ]) { + await fresh.page.getByText(actionText, { exact: true }).first().click(); + await freshDrawer.locator("summary", { hasText: "其他处理" }).click(); + await freshDrawer.getByRole("button", { name: label, exact: true }).click(); + await fresh.page.locator('[data-context-kind="proposal"]').waitFor({ state: "visible" }); + check(fresh.api.actionPreviews.at(-1)); + assert.equal(fresh.api.actionPreviews.at(-1).normalized_parameters.agent_id, "codex-delivery"); + await fresh.page.locator(".personal-drawer-close").click(); + } + assert.equal(fresh.api.durableWriteCount, 0, "Opening previews never writes"); + } finally { + await fresh.close(); + } + + // A User gate keeps its approve/reject decision. + await openRequest("确认本轮独立审查范围"); + await drawer.getByRole("button", { name: "批准", exact: true }).waitFor({ state: "visible" }); + assert.equal(await drawer.getByRole("button", { name: "我已完成", exact: true }).count(), 0); + assert.deepEqual(errors.filter((message) => !/Failed to load resource/u.test(message)), [], "Only the injected preview failure may log"); + await boundActorJourney(browser, url, false); + await boundActorJourney(browser, url, true); + return { coverageEntries: await close(), note: "User action done/defer/cancel/reply with failure recovery and readback" }; + } catch (error) { + await close(); + throw error; + } + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index ee0adfb5ec..ee49febc26 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -394,7 +394,7 @@ function filterStatusFixtureToScope(fixture, matchesScope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null, presentationApi = false } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null, userActionAttention = false, presentationApi = false } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -485,6 +485,8 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true todoRequestTexts: new Map(), todoRequestReads: [], decidedGateTodoIds: new Set(), + // Applied User action outcomes, so the needs-you projection reads back like canonical status. + userActionOutcomes: new Map(), hostThreadActivity: {}, answerForMessage: null, loopxModeRequests: [], @@ -574,11 +576,18 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (first) { first.waiting_on = "user_or_controller"; const gateDecided = state.decidedGateTodoIds.has("todo-browser-user-gate"); + const userItems = [{ done: gateDecided, status: gateDecided ? "done" : "open", goal_id: first.goal_id, index: 0, role: "user", task_class: "user_gate", blocks_agent: "codex", text: "确认本轮独立审查范围", todo_id: "todo-browser-user-gate" }]; + if (userActionAttention) { + const outcome = state.userActionOutcomes.get("todo-browser-user-action"); + userItems.unshift({ done: Boolean(outcome), status: outcome ?? "open", goal_id: first.goal_id, index: 1, role: "user", task_class: "user_action", bound_agent: "codex-delivery", + text: "在桌面 App 中手动创建剩余的 3 个角色会话", note: "这是一项不阻塞 Agent 的用户操作提醒,而不是批准请求。", todo_id: "todo-browser-user-action", updated_at: "2026-08-13T00:00:00Z" }); + userItems.push({ done: false, status: "open", goal_id: first.goal_id, index: 2, role: "user", task_class: "user_action", text: "核对本机备份目录是否可写" }); + } first.user_todos = { - items: [{ done: gateDecided, status: gateDecided ? "done" : "open", goal_id: first.goal_id, index: 0, role: "user", task_class: "user_gate", blocks_agent: "codex", text: "确认本轮独立审查范围", todo_id: "todo-browser-user-gate" }], - open_count: gateDecided ? 0 : 1, + items: userItems, + open_count: userItems.filter((todo) => !todo.done).length, source_section: "User Todo", - total_count: 1, + total_count: userItems.length, }; const domainTodos = (first.project_asset?.agent_todos?.items ?? first.agent_todos?.items ?? []) .filter((todo) => !todo.done) @@ -1952,6 +1961,9 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true // Mirrors ChatActionService's gate.resolve receipt (tests/control_plane/test_chat_gate_decisions.py). const decisionParameters = actionKind === "gate.resolve" ? preview?.normalized_parameters : null; if (decisionParameters) state.decidedGateTodoIds.add(decisionParameters.todo_id); + const userActionParameters = preview?.normalized_parameters?.todo_id === "todo-browser-user-action" ? preview.normalized_parameters : null; + if (userActionParameters) state.userActionOutcomes.set(userActionParameters.todo_id, + actionKind === "gate.resolve" ? "done" : userActionParameters.operation === "defer" ? "deferred" : "done"); const decisionReceipt = decisionParameters ? { projection_verified: true, receipt_id: "fixture-receipt", outcome: "gate_resolved", decision_outcome: decisionParameters.decision, unblock_resume_state: { approve: "resumed", reject: "decision_rejected", cancel: "decision_cancelled" }[decisionParameters.decision] ?? null } : null; diff --git a/tests/control_plane/test_chat_user_action_handling.py b/tests/control_plane/test_chat_user_action_handling.py new file mode 100644 index 0000000000..f9685e7484 --- /dev/null +++ b/tests/control_plane/test_chat_user_action_handling.py @@ -0,0 +1,112 @@ +"""The App's needs-you drawer handles a User action through existing typed actions. + +These are the exact payloads the drawer previews; the canonical Todo owner +decides the effects. Completing resumes only the work the action unblocks, +deferring keeps it waiting, and "no longer needed" closes only the reminder. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from test_todo_decision_scope_lifecycle import AGENT_ID, GOAL_ID, _write_fixture +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService +from loopx.control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.todos import add_goal_todo, list_goal_todos + +PROVIDERS = [("legacy", "soft_claim"), ("file", "hard_lease"), ("sqlite", "soft_claim")] + + +def _goal(tmp_path: Path, monkeypatch, provider: str, handoff_mode: str): + isolate_sqlite_runtime(tmp_path, monkeypatch) + _, state, registry = _write_fixture(tmp_path) + config = json.loads(registry.read_text()) + config["common_runtime_root"] = str(tmp_path / "runtime") + registry.write_text(json.dumps(config)) + target = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Continue once the owner has created the sessions.", status="blocked", + task_class="advancement_task", claimed_by=AGENT_ID) + action = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="user", + text="Create the remaining role sessions in the desktop App.", task_class="user_action", bound_agent=AGENT_ID, + unblocks_todo_id=target["todo_id"]) + if provider != "legacy": + config["goals"][0]["coordination"]["handoff_mode"] = handoff_mode + registry.write_text(json.dumps(config)) + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode=handoff_mode, + todos=list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"]) + initialize_canonical_authority(tmp_path / "runtime", GOAL_ID, projection, + state_path=state, provider=provider) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=registry) + return registry, service, target["todo_id"], action["todo_id"] + + +def _rows(registry: Path) -> dict[str, dict]: + return {row["todo_id"]: row for row in list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"]} + + +def _apply(service: ChatActionService, action_kind: str, parameters: dict, key: str) -> dict: + proposal = service.preview({"action_kind": action_kind, "summary": "Handle the request", "context": {}, + "normalized_parameters": {"goal_id": GOAL_ID, "agent_id": AGENT_ID, **parameters}, "idempotency_key": key}) + assert proposal["status"] == "preview_ready", proposal + applied = service.apply(proposal["proposal_id"])["proposal"] + assert applied["status"] == "applied", applied + return applied["receipt"] + + +@pytest.mark.parametrize(("provider", "handoff_mode"), PROVIDERS) +def test_done_completes_the_action_and_resumes_its_dependent(tmp_path, monkeypatch, provider, handoff_mode): + registry, service, target_id, action_id = _goal(tmp_path, monkeypatch, provider, handoff_mode) + receipt = _apply(service, "todo.update", {"todo_id": action_id, "operation": "complete"}, "done") + assert receipt["outcome"] == "todo_completed" + rows = _rows(registry) + assert rows[action_id]["status"] == "done" + assert rows[target_id]["status"] == "open" + + +# Hard-lease Goals currently reject a planning edit of a User Todo at preview +# (no lease execution proof); the drawer surfaces that error without writing. +@pytest.mark.parametrize(("provider", "handoff_mode"), [p for p in PROVIDERS if p[1] != "hard_lease"]) +def test_defer_keeps_the_action_and_its_dependent_waiting(tmp_path, monkeypatch, provider, handoff_mode): + registry, service, target_id, action_id = _goal(tmp_path, monkeypatch, provider, handoff_mode) + resume_when = "resume_at:2099-01-02T09:00:00+08:00" + _apply(service, "todo.update", {"todo_id": action_id, "operation": "defer", "resume_when": resume_when}, "defer") + rows = _rows(registry) + # The owner stores the same instant normalized to UTC. + assert (rows[action_id]["status"], rows[action_id]["resume_when"]) == ("deferred", "resume_at:2099-01-02T01:00:00Z") + assert rows[target_id]["status"] == "blocked" + + +@pytest.mark.parametrize(("provider", "handoff_mode"), PROVIDERS) +def test_no_longer_needed_closes_only_the_reminder(tmp_path, monkeypatch, provider, handoff_mode): + registry, service, target_id, action_id = _goal(tmp_path, monkeypatch, provider, handoff_mode) + receipt = _apply(service, "gate.resolve", {"todo_id": action_id, "decision": "cancel", + "note": "The owner closed this request as no longer needed."}, "cancel") + assert (receipt["decision_outcome"], receipt["unblock_resume_state"]) == ("cancel", "decision_cancelled") + rows = _rows(registry) + assert rows[action_id]["status"] == "done" + assert rows[target_id]["status"] == "blocked", "closing a reminder must not resume or approve other work" + + +@pytest.mark.parametrize("actor", ["codex", "codex-review"]) +@pytest.mark.parametrize("operation", ["complete", "cancel"]) +def test_wrong_actor_is_rejected_without_effects(tmp_path, monkeypatch, actor, operation): + registry, service, target_id, action_id = _goal(tmp_path, monkeypatch, "file", "hard_lease") + before = _rows(registry) + parameters = {"goal_id": GOAL_ID, "agent_id": actor, "todo_id": action_id} + if operation == "cancel": + action_kind = "gate.resolve" + parameters["decision"] = "cancel" + else: + action_kind = "todo.update" + parameters["operation"] = "complete" + with pytest.raises((ValueError, LocalCoordinationAuthorityUnavailable)): + service.preview({"action_kind": action_kind, "summary": "Wrong actor", "context": {}, + "normalized_parameters": parameters, "idempotency_key": "wrong-actor"}) + assert _rows(registry) == before + assert before[action_id]["status"] == "open" and before[target_id]["status"] == "blocked"