From 6b5048e31edbad0113520b0766e9388e197a99b2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:44:24 +0800 Subject: [PATCH 1/2] fix(dashboard): let the owner handle a User action from the needs-you drawer A user_action attention item only offered a note and an 'Explain this decision' button whose callback was never wired. The drawer now offers 'I've done this' (todo.update complete), 'Reply in conversation' (drafts an editable message in the Goal chat), and Other options: defer until tomorrow 09:00 (todo.update defer with resume_at), 'No longer needed' (gate.resolve cancel, the outcome the User completion owner admits for a user_action), and 'Ask the Agent to explain'. Preview failures surface inline and can be retried; read-only sources and rows without a stable todo_id explain why the action is unavailable. Attention owner actions move from context-drawer.tsx into attention-actions.tsx. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Co-authored-by: Cursor --- .../personal-workspace/attention-actions.tsx | 125 ++++++++++++++++++ .../personal-workspace/attention-details.ts | 25 +++- .../personal-workspace/context-drawer.tsx | 39 +----- .../src/features/personal-workspace/i18n.tsx | 40 ++++++ .../personal-workspace-model.ts | 2 + .../personal-workspace-page.tsx | 19 +++ 6 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx 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..4562080ca0 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx @@ -0,0 +1,125 @@ +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]); + // Hard-lease Goals attribute the write to the Agent the request unblocks. + const agentId = 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..d335b6e6b2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts @@ -3,8 +3,11 @@ 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; @@ -25,11 +28,13 @@ 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), @@ -84,3 +89,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 42c79930cd..20864b3bf1 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 { ArrowLeft, @@ -14,7 +14,6 @@ import { Download, ExternalLink, GitBranch, - MessageCircleQuestion, Maximize2, Minimize2, MoreHorizontal, @@ -397,23 +396,6 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention }); } - async function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) { - if (readOnly || !canDecideAttention(attention)) return; - await callbacks.onPreviewAction?.({ - 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 @@ -606,22 +588,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 1f0ecc3a84..dd2dfa81a9 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -223,6 +223,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", @@ -1503,6 +1523,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-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 03f703fd50..f576ae6d76 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 @@ -470,6 +470,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 7cd0d09edc..088eeae6a9 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 @@ -55,6 +55,7 @@ import type { PersonalWorkspaceCallbacks, WorkspaceAgentOption, WorkspaceActionPreview, + WorkspaceAttention, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, @@ -895,6 +896,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) { @@ -1552,6 +1569,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) => { From 89e51cdbbc09ff70727e2c02d0d4715e61e77d3d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:44:24 +0800 Subject: [PATCH 2/2] test: cover User action handling from the needs-you drawer Semantic smoke for user_action classification, stable-id and lifecycle fencing and the defer preset; a browser scenario for done with preview failure/recovery and readback, defer, no-longer-needed, reply drafting and the missing-id state; and real ChatActionService coverage of the exact drawer payloads across legacy, file and sqlite authorities. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Co-authored-by: Cursor --- .../smoke/attention-details-smoke.ts | 32 +++++- .../personal-workspace-contract.test.mjs | 15 ++- examples/personal-workspace-browser-smoke.mjs | 2 + .../attention-user-action.mjs | 103 ++++++++++++++++++ .../personal-workspace-browser/fixture.mjs | 20 +++- .../test_chat_user_action_handling.py | 92 ++++++++++++++++ 6 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 examples/personal-workspace-browser/attention-user-action.mjs create mode 100644 tests/control_plane/test_chat_user_action_handling.py diff --git a/apps/presentation/dashboard/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts index 90ad6ce1e8..f2d481fc4c 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,31 @@ 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" }) }; +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 +103,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 +115,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/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index a9f1ead2b4..93627f7e21 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 @@ -8,6 +8,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"); @@ -53,11 +54,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/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 23066c0266..b298d2b208 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {configurationBackupScenario} from "./personal-workspace-browser/configuration-backup.mjs"; +import {attentionUserActionScenario} from "./personal-workspace-browser/attention-user-action.mjs"; import {conversationImageRequestScenario} from "./personal-workspace-browser/conversation-image-request.mjs"; import {externalEvidenceReadbackScenario} from "./personal-workspace-browser/external-evidence-readback.mjs"; // Isolated browser acceptance scenarios for the personal Agent workspace. @@ -78,6 +79,7 @@ scenarioCatalog.push(blockedNoticeSettingsScenario); scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); scenarioCatalog.push(configurationBackupScenario); +scenarioCatalog.push(attentionUserActionScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) 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..87f36bc038 --- /dev/null +++ b/examples/personal-workspace-browser/attention-user-action.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +const actionText = "在桌面 App 中手动创建剩余的 3 个角色会话"; +const idlessText = "核对本机备份目录是否可写"; + +// 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(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)); + 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"); + 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 7326fa376d..2e3ceac96f 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -391,7 +391,7 @@ function filterStatusFixtureToScope(fixture, matchesScope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null, userActionAttention = 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; @@ -480,6 +480,8 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true operatorCredentialWrites: [], turnRequests: [], decidedGateTodoIds: new Set(), + // Applied User action outcomes, so the needs-you projection reads back like canonical status. + userActionOutcomes: new Map(), hostThreadActivity: {}, answerForMessage: null, loopxModeRequests: [], @@ -565,11 +567,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", + 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) @@ -1923,6 +1932,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..0e8d05f13c --- /dev/null +++ b/tests/control_plane/test_chat_user_action_handling.py @@ -0,0 +1,92 @@ +"""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.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"