From dfab17755b63a692e2e6ee1b3cffcb5ed2bf35cc Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:59:01 +0800 Subject: [PATCH 01/10] fix(workspace): turn Agent Todo proposals into confirmable previews The Chat agent prompt asks for Todo proposals and the service emits them in turn.completed, but the Personal Workspace wrote them into a legacy proposalsByContext state that nothing rendered since #3274. The reply said it found a reviewable step while no step could be seen or confirmed. In a Goal conversation each Todo proposal now becomes a typed todo.create preview (goal, text and priority) keyed by its Turn, so a repeated completion reuses the stored preview. The cards appear in the conversation without opening the drawer, survive a reload, and still require explicit confirmation. A recovered Turn stores its previews for the Session's own Goal only, instead of falling back to the selected or first Goal. Manager answers without a target Goal create nothing. Remove the unused legacy card state, its preview/approve/settle handlers and state labels. Signed-off-by: song --- .../src/features/personal-workspace/i18n.tsx | 2 + .../personal-workspace-model.ts | 5 +- .../personal-workspace-page.tsx | 5 +- .../dashboard/src/views/dashboard-page.tsx | 189 +++--------------- 4 files changed, 41 insertions(+), 160 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5d077767c3..00a16a2a93 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -118,6 +118,7 @@ const en = { "feedback.preparingPreview": "Preparing confirmation preview: {title}", "feedback.previewFailed": "Could not prepare the confirmation preview: {error}", "feedback.sendFailed": "Send failed: {error}", + "feedback.proposalDraftFailed": "The reply suggested a task, but its draft could not be prepared. Ask again to retry.", "feedback.sendGenericError": "Could not send the message. Try again later.", "feedback.stale": "State changed, so nothing was applied. Generate a new confirmation preview.", "feedback.taskDraftCreated": "Created a Task draft from the reply. Edit and send it to review the confirmation preview.", @@ -1288,6 +1289,7 @@ const zhCN: Record = { "feedback.preparingPreview": "正在准备确认预览:{title}", "feedback.previewFailed": "无法准备确认预览:{error}", "feedback.sendFailed": "发送失败:{error}", + "feedback.proposalDraftFailed": "回答里提出了任务建议,但草稿未能生成。可以再问一次重试。", "feedback.sendGenericError": "消息发送失败,请稍后重试。", "feedback.stale": "状态已变化,操作未执行,请重新生成确认预览。", "feedback.taskDraftCreated": "已根据回复生成 Task 草稿。编辑后发送,LoopX 会先展示确认预览。", 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 7c37004c64..0c5051cc3e 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 @@ -462,7 +462,10 @@ export type PersonalWorkspaceCallbacks = { agentId: string, goalId: string | null, attachments?: WorkspaceImageAttachment[], - ) => void | WorkspaceActionPreviewRequest | Promise; + // One request is a decision the owner reviews now; a list is candidate cards + // (for example an Agent's Todo proposals) left in the conversation. + ) => void | WorkspaceActionPreviewRequest | WorkspaceActionPreviewRequest[] + | Promise; onPrepareLoopX?: (agentId: string, goalId: string) => Promise; onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => 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 dab614eca8..8a67a4eb03 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 @@ -1587,7 +1587,10 @@ export function PersonalWorkspacePage({ if (!selectedGoalId) setManagerConversationReceiptVisible(true); else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true); const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined); - if (semanticPreview) await createPreview(semanticPreview); + if (Array.isArray(semanticPreview)) { + const drafted = await Promise.allSettled(semanticPreview.map((request) => createPreview(request, { select: false }))); + if (drafted.some((result) => result.status === "rejected")) setActionFeedback(t("feedback.proposalDraftFailed")); + } else if (semanticPreview) await createPreview(semanticPreview); } catch (error) { if (!messageOverride) { setComposer(message); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index d589a01065..0a7dcb98f5 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -37,7 +37,6 @@ import { ChatApiError, applyGoalSubagentConfiguration, applyTypedAction, - applyTodo, closeChatSession, createChatSession, updateLoopXMode, @@ -49,7 +48,6 @@ import { interruptChatTurn, steerChatTurn, previewGoalSubagentConfiguration, - previewTodo, previewTypedAction, recordProjectionExchange, resumeChatSession, @@ -57,8 +55,6 @@ import { sendChatTurnStreaming, selectAvailableChatAgent, sessionInvalidatedByPayload, - todoNoWriteReceiptFromPayload, - todoReceiptLabel, isTodoProposal, type ChatSessionSnapshot, type ChatSessionSummary, @@ -66,8 +62,8 @@ import { type ChatVisibleMessage, type ManagerChannelBinding, type ManagerRuntimeSessionReadback, + type AgentResponse, type ProtectedActionProposal, - type TodoProposal, } from "../data/chat"; import { beginStatusRequest, @@ -149,6 +145,24 @@ function semanticProtectedActionPreview( summary: `请求受保护操作:${protectedOperationLabels[proposal.operation]} · ${proposal.target}`, }; } + +// An Agent's Todo proposals are untrusted drafts. Each becomes a typed +// todo.create preview the owner confirms in the Goal conversation. The key is +// derived from the Turn, so observing the same completion twice reuses the +// stored preview instead of offering a duplicate. +function todoProposalPreviewRequests( + goalId: string, + turnId: string, + proposals: AgentResponse["proposals"], +): WorkspaceActionPreviewRequest[] { + return proposals.filter(isTodoProposal).map((proposal, index) => ({ + actionKind: "todo.create", + context: { goal_id: goalId, kind: "goal" }, + idempotencyKey: `chat-todo-proposal:${turnId}:${index}`, + normalizedParameters: { goal_id: goalId, priority: proposal.priority, text: proposal.text }, + summary: proposal.text, + })); +} import type { StatusSourceControl } from "../features/personal-workspace/status-source-switcher"; import { applyRemoteGoalLifecycle, ensureSshSource } from "../data/ssh-host-catalog"; import { @@ -538,27 +552,6 @@ function workspaceImageAttachments(attachments?: ChatImageAttachment[]): Workspa })); } -type PersonalProposalState = - | "candidate" - | "previewing" - | "ready" - | "applying" - | "approved" - | "rejected" - | "cancelled" - | "stale" - | "error"; - -type PersonalProposalCard = { - goalId: string; - id: number; - previewId: string | null; - proposal: TodoProposal; - receiptLabel: string | null; - state: PersonalProposalState; - statusMessage: string | null; -}; - type PersonalAgentOption = { adapterKind?: string; agentId: string; @@ -864,21 +857,6 @@ function personalDecisionPrimaryLabel(goal: PersonalGoalItem) { return /approve|approval|merge|release|submit|write|publish/.test(signal) ? "确认处理" : "回复 Agent"; } -function personalProposalStateLabel(state: PersonalProposalState) { - const labels: Record = { - candidate: "候选 Todo", - previewing: "正在生成写入预览", - ready: "预览已锁定,等待你批准", - applying: "正在写入", - approved: "已批准并写入", - rejected: "已拒绝,未写入", - cancelled: "已取消,未写入", - stale: "状态已变化,需重新预览", - error: "暂时无法处理", - }; - return labels[state]; -} - function isPersonalGoalTerminal(row: GoalDirectoryRow) { return [row.status, row.goal.status, row.latestRun?.classification, row.lifecyclePhase] .filter(Boolean) @@ -1397,7 +1375,6 @@ function PersonalGoalHome({ const [mobilePanel, setMobilePanel] = useState<"chat" | "goals">("chat"); const [managerInput, setManagerInput] = useState(""); const [messagesByContext, setMessagesByContext] = useState>({}); - const [proposalsByContext, setProposalsByContext] = useState>({}); const [sendingContextId, setSendingContextId] = useState(null); const [runtimeBindings, setRuntimeBindings] = useState>({}); const [executionSessions, setExecutionSessions] = useState([]); @@ -1406,7 +1383,6 @@ function PersonalGoalHome({ // undefined: not read yet; null: the session owner could not be read. const [goalSessionFacts, setGoalSessionFacts] = useState(undefined); const managerMessageId = useRef(1); - const proposalId = useRef(1); const sessionIds = useRef(new Map()); const newSessionRequired = useRef(new Set()); const activeTurnIds = useRef(new Map()); @@ -1420,7 +1396,6 @@ function PersonalGoalHome({ const managerInputRef = useRef(null); const managerQuickPrompts = ["我现在该做什么?", "哪些 Goal 在等我?", "Agent 在做什么?"]; const contextMessages = messagesByContext[contextId] ?? []; - const contextProposals = proposalsByContext[contextId] ?? []; // Who is speaking in the transcript. The manager channel answers as the LoopX // Manager: the executor that served the turn (and the model behind it) belongs @@ -1737,29 +1712,15 @@ function PersonalGoalHome({ || streamedText.trim() || `${answerIdentityLabel(targetContextId, selectedAgent.label)} 已完成分析。`, }); - const recoveryGoal = model.goals.find((goal) => goal.goalId === activeSnapshot?.session.goal_id) - ?? selectedGoal - ?? model.goals[0] - ?? null; - if (recoveryGoal && streamed.response.proposals.length > 0) { - // A recovered Turn may carry the steward's admitted team plan beside - // its todo proposals. The plan is not a candidate Todo: the manager - // channel already stored it as the typed card the owner confirms, so - // only the todos become cards here. - const cards = streamed.response.proposals.filter(isTodoProposal).map((proposal) => ({ - goalId: recoveryGoal.goalId, - id: proposalId.current++, - previewId: null, - proposal, - receiptLabel: null, - state: "candidate" as const, - statusMessage: null, - })); - if (cards.length > 0) { - setProposalsByContext((current) => ({ - ...current, - [targetContextId]: [...(current[targetContextId] ?? []), ...cards], - })); + // A recovered Turn cannot hand previews back to the composer, so its + // Todo proposals are stored directly and restored with the Goal's + // other pending previews. Only the Session's own Goal may own them. + const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; + if (recoveryGoalId && model.goals.some((goal) => goal.goalId === recoveryGoalId)) { + for (const request of todoProposalPreviewRequests(recoveryGoalId, streamed.turnId, streamed.response.proposals)) { + void previewTypedAction(request).catch(() => { + // The answer stays readable; the owner can ask again for a draft. + }); } } } catch (error) { @@ -1995,19 +1956,6 @@ function PersonalGoalHome({ })); } - function updatePersonalProposal( - targetContextId: string, - targetProposalId: number, - update: Partial, - ) { - setProposalsByContext((current) => ({ - ...current, - [targetContextId]: (current[targetContextId] ?? []).map((proposal) => - proposal.id === targetProposalId ? { ...proposal, ...update } : proposal - ), - })); - } - async function prepareGoalConversation(goalId: string, agentId: string) { const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); @@ -2198,21 +2146,6 @@ function PersonalGoalHome({ lines: ["请进入要修改的 Goal,预览并确认具体变更。"], }); } - if (todoProposals.length > 0 && targetGoal) { - const cards = todoProposals.map((proposal) => ({ - goalId: targetGoal.goalId, - id: proposalId.current++, - previewId: null, - proposal, - receiptLabel: null, - state: "candidate" as const, - statusMessage: null, - })); - setProposalsByContext((current) => ({ - ...current, - [targetContextId]: [...(current[targetContextId] ?? []), ...cards], - })); - } if (targetContextId !== "manager" && response.protected_action) { const protectedPreview = semanticProtectedActionPreview( targetContextId, @@ -2221,6 +2154,9 @@ function PersonalGoalHome({ ); if (protectedPreview) return protectedPreview; } + if (targetGoal && todoProposals.length > 0) { + return todoProposalPreviewRequests(targetGoal.goalId, streamed.turnId, response.proposals); + } } catch (error) { const userInterrupted = (submittedTurnId && interruptedTurnIds.current.delete(submittedTurnId)) || (error instanceof ChatApiError && error.payload.error_code === "turn_interrupted"); @@ -2366,69 +2302,6 @@ function PersonalGoalHome({ recordRuntimeBinding(run.goalId, null); } - async function previewPersonalProposal(card: PersonalProposalCard) { - const targetContextId = contextId; - updatePersonalProposal(targetContextId, card.id, { - state: "previewing", - statusMessage: null, - }); - try { - const preview = await previewTodo(card.goalId, card.proposal.text); - updatePersonalProposal(targetContextId, card.id, { - previewId: preview.preview_id, - state: "ready", - statusMessage: "LoopX 已锁定这次写入预览,请确认后再提交。", - }); - } catch (error) { - updatePersonalProposal(targetContextId, card.id, { - state: "error", - statusMessage: error instanceof Error ? error.message : "无法生成 Todo 预览。", - }); - } - } - - async function approvePersonalProposal(card: PersonalProposalCard) { - if (!card.previewId) { - return; - } - const targetContextId = contextId; - updatePersonalProposal(targetContextId, card.id, { - state: "applying", - statusMessage: null, - }); - try { - const result = await applyTodo(card.goalId, card.proposal.text, card.previewId); - updatePersonalProposal(targetContextId, card.id, { - receiptLabel: todoReceiptLabel(result.receipt), - state: "approved", - statusMessage: result.receipt.already_exists ? "Todo 已存在,本次没有重复写入。" : "Todo 已写入,并返回可核对回执。", - }); - onRefresh(); - } catch (error) { - const noWriteReceipt = error instanceof ChatApiError - ? todoNoWriteReceiptFromPayload(error.payload) - : null; - updatePersonalProposal(targetContextId, card.id, noWriteReceipt ? { - previewId: null, - receiptLabel: `未写入 · 回执 ${noWriteReceipt.receipt_id.slice(0, 12)}`, - state: "stale", - statusMessage: "Goal 状态已变化,本次保持零写入。请重新生成预览。", - } : { - state: "error", - statusMessage: error instanceof Error ? error.message : "Todo 写入失败。", - }); - } - } - - function settlePersonalProposal(card: PersonalProposalCard, state: "rejected" | "cancelled") { - updatePersonalProposal(contextId, card.id, { - previewId: null, - receiptLabel: "未写入", - state, - statusMessage: state === "rejected" ? "你已拒绝这个候选 Todo。" : "你已取消本次处理。", - }); - } - function chooseAgent(agentId: string) { if (!agentOptions.some((agent) => agent.agentId === agentId && agent.available)) { return; From c2f128fd97f181e5dca5664d3d735e9b6ae1bf18 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:59:01 +0800 Subject: [PATCH 02/10] test(workspace): cover Agent Todo proposals in Goal and manager chat The browser fixture now forwards scripted proposals in turn.completed, as the Chat service does. A new chat-todo-proposal scenario requires one typed preview per Goal proposal that survives a reload and applies on confirmation, and no preview for a manager answer without a target Goal. Signed-off-by: song --- examples/personal-workspace-browser-smoke.mjs | 3 +- .../chat-todo-proposal.mjs | 74 +++++++++++++++++++ .../personal-workspace-browser/fixture.mjs | 6 +- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 examples/personal-workspace-browser/chat-todo-proposal.mjs diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 593fcd654a..7beb94085c 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -44,8 +44,9 @@ import { goalActivityScenario } from "./personal-workspace-browser/goal-activity import { stewardGroupTriggerScenario } from "./personal-workspace-browser/steward-group-trigger.mjs"; import { goalDraftScenario } from "./personal-workspace-browser/goal-draft.mjs"; +import { chatTodoProposalScenario } from "./personal-workspace-browser/chat-todo-proposal.mjs"; -const scenarioCatalog = [goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; +const scenarioCatalog = [goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, chatTodoProposalScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs new file mode 100644 index 0000000000..455c9ee5ca --- /dev/null +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -0,0 +1,74 @@ +import { openWorkspacePage } from "./scenario-context.mjs"; + +// An Agent answers with a Todo proposal, the shape its turn prompt asks for. +// In a Goal conversation the proposal must become a typed todo.create preview +// the owner confirms; without a target Goal it must not create anything. +const GOAL_PROMPT = "请给出一个下一步任务建议。"; +const MANAGER_PROMPT = "请为全局给出一个任务建议。"; +const PROPOSAL_TEXT = "[P1] 核对发布清单并补齐缺失的验证记录"; +const proposalAnswer = { + message: "我找到一个可评审的步骤。", + proposals: [{ kind: "todo", priority: "P1", rationale: "发布前需要可核对的证据。", text: PROPOSAL_TEXT }], +}; + +async function waitFor(predicate, message) { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(message); + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } +} + +export const chatTodoProposalScenario = { + id: "chat-todo-proposal", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { collectCoverage }); + const { api, page } = context; + api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer : null); + const todoPreviews = () => api.actionPreviews.filter((preview) => preview.action_kind === "todo.create" + && preview.normalized_parameters?.text === PROPOSAL_TEXT); + const composer = page.getByLabel("向 LoopX 发送消息"); + try { + // Manager channel: no target Goal, so the proposal cannot become a write. + await composer.fill(MANAGER_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await page.getByText("我找到一个可评审的步骤。", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 }); + await page.waitForTimeout(500); + if (todoPreviews().length) throw new Error("A manager-channel proposal created a Todo preview without a target Goal"); + + const openGoalChat = async () => { + await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + }; + await openGoalChat(); + await composer.fill(GOAL_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + const card = page.locator(".personal-proposal-row", { hasText: PROPOSAL_TEXT }); + await card.waitFor({ state: "visible", timeout: 10_000 }); + await waitFor(() => todoPreviews().length === 1, "The Goal proposal did not create exactly one Todo preview"); + const [preview] = todoPreviews(); + if (preview.normalized_parameters.goal_id !== "product-release" || preview.normalized_parameters.priority !== "P1") { + throw new Error(`Todo preview lost its Goal or priority: ${JSON.stringify(preview.normalized_parameters)}`); + } + if (!String(preview.idempotency_key).startsWith("chat-todo-proposal:")) { + throw new Error(`Todo preview key is not derived from its Turn: ${preview.idempotency_key}`); + } + if (await page.getByRole("dialog").count()) throw new Error("A proposal card opened the drawer without the owner asking"); + + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await openGoalChat(); + await card.waitFor({ state: "visible", timeout: 10_000 }); + + await card.click(); + await page.getByRole("dialog").getByRole("button", { name: "确认并应用", exact: true }).click(); + await waitFor(() => api.actionApplies.map(decodeURIComponent).includes(preview.proposalId), "Confirming the proposal did not apply its preview"); + } finally { + await context.close(); + } + return { + coverageEntries: context.coverageEntries, + note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, and none is created without a target Goal.", + }; + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index ccaad4d719..e3c07842dd 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -379,7 +379,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true } // Like ChatStore, persist completion before serving it and replay after disconnect. const completedTurns = runtime.completedTurns ??= new Map(); - const finishTurn = (sessionId, turnId, answer, protectedAction = null, goalDraft = null) => { + const finishTurn = (sessionId, turnId, answer, protectedAction = null, goalDraft = null, proposals = []) => { const key = JSON.stringify([sessionId, turnId]); if (completedTurns.has(key)) return completedTurns.get(key); const current = sessions.get(sessionId); @@ -390,7 +390,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true } messages.set(sessionId, visible); const event = (id, kind, payload) => `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: id, sequence: Number(id), kind, created_at: "2026-08-13T01:00:02Z", payload })}\n\n`; - const body = event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, ...(goalDraft ? {goal_draft: goalDraft} : {}), proposals: [], protected_action: protectedAction, gate: null } }); + const body = event("1", "assistant.delta", { text: answer }) + event("2", "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, ...(goalDraft ? {goal_draft: goalDraft} : {}), proposals, protected_action: protectedAction, gate: null } }); completedTurns.set(key, body); sessions.set(sessionId, { ...current, active_turn_id: null, status: "ready", updated_at: "2026-08-13T01:00:02Z" }); return body; @@ -1683,7 +1683,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true ? "我识别到一个明确的合并请求。LoopX 会先展示受保护操作预览,不会直接执行。" : "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"); await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(operatorMessage) ? 5000 : 1200)); - await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, protectedAction, scriptedAnswer?.goal_draft), status: 200 }); + await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, protectedAction, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); }); await page.route("**/api/actions?**", async (route) => { const url = new URL(route.request().url()); From 6e696dbe965308803fab5d841f9f6f256db438da Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:34:11 -0400 Subject: [PATCH 03/10] fix(workspace): show a recovered Turn's Todo proposal without a reload A Turn recovered after a reload stored its Todo previews directly, but the page reads the typed-action store only on mount or Goal change, so the confirmation card appeared only after a second reload. The recovery now bumps a revision the page includes in that store read, so the card appears as soon as the recovery stores it. A failed draft adds a line to the answer instead of being dropped silently. The browser fixture completes a resumed Turn with its scripted answer. The chat-todo-proposal scenario reloads into a running Turn, requires the recovered card without another reload, confirms it, and checks a further reload applies nothing again. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../personal-workspace-page.tsx | 6 ++- .../dashboard/src/views/dashboard-page.tsx | 28 +++++++++---- .../chat-todo-proposal.mjs | 42 +++++++++++++++++-- .../personal-workspace-browser/fixture.mjs | 7 +++- 4 files changed, 68 insertions(+), 15 deletions(-) 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 8a67a4eb03..a44e3076ee 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 @@ -736,6 +736,7 @@ export function PersonalWorkspacePage({ managerRuntime, model, readOnly = false, + typedActionsRevision = 0, selectedAgentId: controlledAgentId, selectedGoalId: controlledGoalId, statusSourceControl, @@ -750,6 +751,9 @@ export function PersonalWorkspacePage({ model: WorkspaceModel; ownerLabel?: string; readOnly?: boolean; + // Bumped when typed previews were stored outside this page, so the page + // re-reads the store instead of waiting for the next mount. + typedActionsRevision?: number; selectedAgentId?: string; selectedGoalId?: string | null; statusSourceControl?: StatusSourceControl; @@ -1104,7 +1108,7 @@ export function PersonalWorkspacePage({ // The workspace remains usable when the optional local proposal store is unavailable. }); return () => { cancelled = true; }; - }, [readOnly, selectedGoalId, t]); + }, [readOnly, selectedGoalId, t, typedActionsRevision]); async function createPreview( request: WorkspaceActionPreviewRequest, diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 0a7dcb98f5..df6efd7d18 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1378,6 +1378,7 @@ function PersonalGoalHome({ const [sendingContextId, setSendingContextId] = useState(null); const [runtimeBindings, setRuntimeBindings] = useState>({}); const [executionSessions, setExecutionSessions] = useState([]); + const [typedActionsRevision, setTypedActionsRevision] = useState(0); const [executionDiscoveryError, setExecutionDiscoveryError] = useState<"partial" | "offline" | null>(null); const [executionSessionSnapshots, setExecutionSessionSnapshots] = useState>({}); // undefined: not read yet; null: the session owner could not be read. @@ -1713,15 +1714,25 @@ function PersonalGoalHome({ || `${answerIdentityLabel(targetContextId, selectedAgent.label)} 已完成分析。`, }); // A recovered Turn cannot hand previews back to the composer, so its - // Todo proposals are stored directly and restored with the Goal's - // other pending previews. Only the Session's own Goal may own them. + // Todo proposals are stored directly and the page re-reads the store + // to show their cards. Only the Session's own Goal may own them. const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; - if (recoveryGoalId && model.goals.some((goal) => goal.goalId === recoveryGoalId)) { - for (const request of todoProposalPreviewRequests(recoveryGoalId, streamed.turnId, streamed.response.proposals)) { - void previewTypedAction(request).catch(() => { - // The answer stays readable; the owner can ask again for a draft. - }); - } + const recoveryRequests = recoveryGoalId && model.goals.some((goal) => goal.goalId === recoveryGoalId) + ? todoProposalPreviewRequests(recoveryGoalId, streamed.turnId, streamed.response.proposals) + : []; + if (recoveryRequests.length) { + void Promise.allSettled(recoveryRequests.map((request) => previewTypedAction(request))).then((results) => { + if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1); + if (!results.some((result) => result.status === "rejected")) return; + // The answer stays readable; say its draft is missing so the + // owner knows to ask again. + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).map((message) => message.id !== streamingMessageId + ? message + : { ...message, lines: [...message.lines, t("feedback.proposalDraftFailed")] }), + })); + }); } } catch (error) { if (cancelled) return; @@ -2531,6 +2542,7 @@ function PersonalGoalHome({
{executionDiscoveryError ?

{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}

: null} ({ adapterKind: agent.adapterKind, agentId: agent.agentId, diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs index 455c9ee5ca..6ceefb8412 100644 --- a/examples/personal-workspace-browser/chat-todo-proposal.mjs +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -6,6 +6,9 @@ import { openWorkspacePage } from "./scenario-context.mjs"; const GOAL_PROMPT = "请给出一个下一步任务建议。"; const MANAGER_PROMPT = "请为全局给出一个任务建议。"; const PROPOSAL_TEXT = "[P1] 核对发布清单并补齐缺失的验证记录"; +// The fixture keeps a "刷新恢复" Turn running long enough to reload into it. +const RECOVERY_PROMPT = "刷新恢复:请再给出一个任务建议。"; +const RECOVERY_TEXT = "[P2] 复核恢复回合给出的下一步"; const proposalAnswer = { message: "我找到一个可评审的步骤。", proposals: [{ kind: "todo", priority: "P1", rationale: "发布前需要可核对的证据。", text: PROPOSAL_TEXT }], @@ -24,9 +27,12 @@ export const chatTodoProposalScenario = { async run({ browser, collectCoverage, url }) { const context = await openWorkspacePage(browser, url, { collectCoverage }); const { api, page } = context; - api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer : null); - const todoPreviews = () => api.actionPreviews.filter((preview) => preview.action_kind === "todo.create" - && preview.normalized_parameters?.text === PROPOSAL_TEXT); + api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer + : message === RECOVERY_PROMPT ? { message: "恢复后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "恢复回合同样需要可确认的草稿。", text: RECOVERY_TEXT }] } + : null); + const previewsWithText = (text) => api.actionPreviews.filter((preview) => preview.action_kind === "todo.create" + && preview.normalized_parameters?.text === text); + const todoPreviews = () => previewsWithText(PROPOSAL_TEXT); const composer = page.getByLabel("向 LoopX 发送消息"); try { // Manager channel: no target Goal, so the proposal cannot become a write. @@ -63,12 +69,40 @@ export const chatTodoProposalScenario = { await card.click(); await page.getByRole("dialog").getByRole("button", { name: "确认并应用", exact: true }).click(); await waitFor(() => api.actionApplies.map(decodeURIComponent).includes(preview.proposalId), "Confirming the proposal did not apply its preview"); + + // A Turn recovered after a reload must offer its proposal card as soon + // as the recovery completes, not only after another reload. + const turnsBeforeRecovery = api.turnRequests.length; + await composer.fill(RECOVERY_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await waitFor(() => api.turnRequests.length > turnsBeforeRecovery, "The recovery prompt was not sent"); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await openGoalChat(); + await page.locator(".personal-channel-timeline").getByText("恢复后给出一个步骤。", { exact: true }).waitFor({ state: "visible", timeout: 15_000 }); + const recoveredCard = page.locator(".personal-proposal-row", { hasText: RECOVERY_TEXT }); + await recoveredCard.waitFor({ state: "visible", timeout: 5_000 }); + await waitFor(() => new Set(previewsWithText(RECOVERY_TEXT).map((item) => item.idempotency_key)).size === 1, + "The recovered proposal did not map to exactly one Todo preview"); + const [recoveredPreview] = previewsWithText(RECOVERY_TEXT); + if (recoveredPreview.normalized_parameters.goal_id !== "product-release") { + throw new Error(`Recovered Todo preview lost its Goal: ${JSON.stringify(recoveredPreview.normalized_parameters)}`); + } + await recoveredCard.click(); + await page.getByRole("dialog").getByRole("button", { name: "确认并应用", exact: true }).click(); + await waitFor(() => api.actionApplies.map(decodeURIComponent).includes(recoveredPreview.proposalId), "Confirming the recovered proposal did not apply its preview"); + const appliesAfterRecovery = api.actionApplies.length; + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await openGoalChat(); + await page.waitForTimeout(1_000); + if (api.actionApplies.length !== appliesAfterRecovery) throw new Error("Reloading after the recovered Turn applied a preview again"); } finally { await context.close(); } return { coverageEntries: context.coverageEntries, - note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, and none is created without a target Goal.", + note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, including one from a Turn recovered after a reload, and none is created without a target Goal.", }; }, }; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index e3c07842dd..4398e33de7 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1282,9 +1282,12 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (resumedEvents && request.method() === "GET") { const sessionId = resumedEvents[1]; const turnId = resumedEvents[2]; - const answer = "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; + // A resumed Turn completes with the same scripted answer it was sent for. + const scriptedAnswer = typeof state.answerForMessage === "function" ? state.answerForMessage(turnMessages.get(turnId) ?? "") : null; + const answer = (typeof scriptedAnswer === "object" ? scriptedAnswer?.message : scriptedAnswer) + || "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(turnMessages.get(turnId) ?? "") ? 5000 : 1200)); - await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer), status: 200 }); + await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); return; } if (url.pathname === "/api/chat/goals/contexts") { From 5b901e78c74ac332cac3fcdd65c63fba561a69e3 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 29 Sep 2026 17:42:46 +0800 Subject: [PATCH 04/10] fix(workspace): keep Todo proposals beside a protected action An answer may carry a protected action and Todo proposals together; the schema allows both. The send result was either one preview or a list, so the protected preview returned first and the Todos never reached the typed store. onSendMessage now returns a typed WorkspaceSendPreviews with an optional decision and a list of candidates. Candidates become cards; the decision is created last so it keeps the drawer selection. Signed-off-by: song --- .../personal-workspace-model.ts | 13 +++++++++---- .../personal-workspace-page.tsx | 10 ++++++---- .../dashboard/src/views/dashboard-page.tsx | 18 +++++++----------- 3 files changed, 22 insertions(+), 19 deletions(-) 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 0c5051cc3e..ff28cb733f 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 @@ -462,10 +462,7 @@ export type PersonalWorkspaceCallbacks = { agentId: string, goalId: string | null, attachments?: WorkspaceImageAttachment[], - // One request is a decision the owner reviews now; a list is candidate cards - // (for example an Agent's Todo proposals) left in the conversation. - ) => void | WorkspaceActionPreviewRequest | WorkspaceActionPreviewRequest[] - | Promise; + ) => void | WorkspaceSendPreviews | Promise; onPrepareLoopX?: (agentId: string, goalId: string) => Promise; onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => void; @@ -478,6 +475,14 @@ export type PersonalWorkspaceCallbacks = { onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; }; +// What one send hands back for review: at most one decision the owner reviews +// now (it opens the drawer) plus candidate cards left in the conversation, such +// as an Agent's Todo proposals. One answer may carry both. +export type WorkspaceSendPreviews = { + candidates?: WorkspaceActionPreviewRequest[]; + decision?: WorkspaceActionPreviewRequest; +}; + export type WorkspaceActionPreviewRequest = { actionKind: WorkspaceActionPreview["actionKind"]; context: Record; 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 a44e3076ee..5b0067f7a4 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 @@ -1590,11 +1590,13 @@ export function PersonalWorkspacePage({ try { if (!selectedGoalId) setManagerConversationReceiptVisible(true); else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true); - const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined); - if (Array.isArray(semanticPreview)) { - const drafted = await Promise.allSettled(semanticPreview.map((request) => createPreview(request, { select: false }))); + const previews = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined); + if (previews?.candidates?.length) { + const drafted = await Promise.allSettled(previews.candidates.map((request) => createPreview(request, { select: false }))); if (drafted.some((result) => result.status === "rejected")) setActionFeedback(t("feedback.proposalDraftFailed")); - } else if (semanticPreview) await createPreview(semanticPreview); + } + // The decision is created last so it keeps the drawer selection. + if (previews?.decision) await createPreview(previews.decision); } catch (error) { if (!messageOverride) { setComposer(message); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index df6efd7d18..9ff8963de9 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -2157,17 +2157,13 @@ function PersonalGoalHome({ lines: ["请进入要修改的 Goal,预览并确认具体变更。"], }); } - if (targetContextId !== "manager" && response.protected_action) { - const protectedPreview = semanticProtectedActionPreview( - targetContextId, - question, - response.protected_action, - ); - if (protectedPreview) return protectedPreview; - } - if (targetGoal && todoProposals.length > 0) { - return todoProposalPreviewRequests(targetGoal.goalId, streamed.turnId, response.proposals); - } + const decision = targetContextId !== "manager" && response.protected_action + ? semanticProtectedActionPreview(targetContextId, question, response.protected_action) ?? undefined + : undefined; + const candidates = targetGoal + ? todoProposalPreviewRequests(targetGoal.goalId, streamed.turnId, response.proposals) + : []; + if (decision || candidates.length > 0) return { candidates, decision }; } catch (error) { const userInterrupted = (submittedTurnId && interruptedTurnIds.current.delete(submittedTurnId)) || (error instanceof ChatApiError && error.payload.error_code === "turn_interrupted"); From 43aa13908c44a4de2ab15579d465e4d21d05061f Mon Sep 17 00:00:00 2001 From: song Date: Tue, 29 Sep 2026 17:42:46 +0800 Subject: [PATCH 05/10] test(workspace): cover a protected action and a Todo in one answer chat-todo-proposal now answers the fixture's protected merge message with a Todo proposal as well and requires both typed previews, the protected decision in the drawer, and the Todo card one step behind it. Signed-off-by: song --- .../chat-todo-proposal.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs index 6ceefb8412..6b84879526 100644 --- a/examples/personal-workspace-browser/chat-todo-proposal.mjs +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -9,6 +9,9 @@ const PROPOSAL_TEXT = "[P1] 核对发布清单并补齐缺失的验证记录"; // The fixture keeps a "刷新恢复" Turn running long enough to reload into it. const RECOVERY_PROMPT = "刷新恢复:请再给出一个任务建议。"; const RECOVERY_TEXT = "[P2] 复核恢复回合给出的下一步"; +// The fixture answers this exact message with a protected merge action. +const COMBINED_PROMPT = "请合并 PR #123"; +const COMBINED_TEXT = "[P1] 合并前补齐 PR #123 的发布说明"; const proposalAnswer = { message: "我找到一个可评审的步骤。", proposals: [{ kind: "todo", priority: "P1", rationale: "发布前需要可核对的证据。", text: PROPOSAL_TEXT }], @@ -28,6 +31,7 @@ export const chatTodoProposalScenario = { const context = await openWorkspacePage(browser, url, { collectCoverage }); const { api, page } = context; api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer + : message === COMBINED_PROMPT ? { message: "我识别到一个明确的合并请求,并建议先补齐发布说明。", proposals: [{ kind: "todo", priority: "P1", rationale: "合并前需要可核对的说明。", text: COMBINED_TEXT }] } : message === RECOVERY_PROMPT ? { message: "恢复后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "恢复回合同样需要可确认的草稿。", text: RECOVERY_TEXT }] } : null); const previewsWithText = (text) => api.actionPreviews.filter((preview) => preview.action_kind === "todo.create" @@ -97,6 +101,19 @@ export const chatTodoProposalScenario = { await openGoalChat(); await page.waitForTimeout(1_000); if (api.actionApplies.length !== appliesAfterRecovery) throw new Error("Reloading after the recovered Turn applied a preview again"); + + // One answer may carry a protected action and Todo proposals together: + // the protected decision keeps the drawer and the Todo still becomes a card. + await composer.fill(COMBINED_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await page.getByText("确认执行").waitFor({ state: "visible", timeout: 10_000 }); + await waitFor(() => api.actionPreviews.some((item) => item.action_kind === "goal.update" && item.summary.includes("PR #123")), + "The combined answer lost its protected preview"); + await waitFor(() => previewsWithText(COMBINED_TEXT).length === 1, "The combined answer dropped its Todo proposal"); + // The newest pending draft (the protected decision) leads; the Todo waits + // one step behind it in the backlog, as for any other older draft. + await page.locator(".personal-proposal-backlog > summary").click(); + await page.locator(".personal-proposal-row", { hasText: COMBINED_TEXT }).waitFor({ state: "visible", timeout: 10_000 }); } finally { await context.close(); } From 3a611b3e3e174660021a15adc52046f51e316c9f Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:07:43 -0400 Subject: [PATCH 06/10] test(workspace): pin the typed send previews in the drawer contract The contract smoke still matched the removed single-preview call, so it failed once onSendMessage returned WorkspaceSendPreviews. It now pins the same invariant on the new shape: the decision enters createPreview, and candidates enter it too without taking the drawer. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../personal-workspace/personal-workspace-contract.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9307c196ba..9260669183 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 @@ -116,7 +116,8 @@ assert.match(page, /proposal\.action_kind !== "operation\.execute"[\s\S]*reviewP assert.match(drawer, /selection\.item\.actionKind !== "operation\.execute"/, "Dashboard hides generic local controls for authenticated group operations"); assert.match(dashboard, /response\.protected_action/, "Agent semantic protected intent is projected only after the Chat response"); assert.match(dashboard, /normalizedMessage\.includes\(normalizedTarget\)/, "A model-invented protected target cannot reach typed preview"); -assert.match(page, /if \(semanticPreview\) await createPreview\(semanticPreview\)/, "Semantic intent still enters the typed preview boundary"); +assert.match(page, /if \(previews\?\.decision\) await createPreview\(previews\.decision\)/, "Semantic intent still enters the typed preview boundary"); +assert.match(page, /previews\.candidates\.map\(\(request\) => createPreview\(request, \{ select: false \}\)\)/, "Agent candidate proposals enter the same typed preview boundary without taking the drawer"); for (const legacyClassifier of ["hasHeartbeatIntent", "hasMonitorIntent", "hasTodoCreationIntent", "isExecutionIntent"]) { assert.doesNotMatch(page, new RegExp(`function ${legacyClassifier}`), `${legacyClassifier} no longer bypasses the Router contract`); } From b81ca4f81362c2839e7d1fb2130ec4368c970f77 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 30 Sep 2026 10:52:04 +0800 Subject: [PATCH 07/10] fix(workspace): keep Goal-created cards out of Manager Chat Manager Chat showed every preview this page created, so a Todo proposal offered in a Goal conversation also appeared in Manager Chat. Record which previews were created from the Manager channel and let only those (and the Manager-scoped store readback) join the Manager transcript. The chat-todo-proposal browser scenario now checks the card stays out of Manager Chat and another Goal, returns with its Goal, and keeps its Goal when the answer lands after the owner left for Manager Chat. Signed-off-by: song --- .../personal-workspace-page.tsx | 23 +++++++++-- .../chat-todo-proposal.mjs | 40 ++++++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) 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 5b0067f7a4..3a2c54aa1a 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 @@ -793,6 +793,9 @@ export function PersonalWorkspacePage({ const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle"); const [sessionProposalIds, setSessionProposalIds] = useState([]); const [managerChannelProposalIds, setManagerChannelProposalIds] = useState([]); + // Cards this page created from the Manager channel. A card created from a + // Goal conversation stays in that Goal's timeline and never joins Manager Chat. + const [managerSessionProposalIds, setManagerSessionProposalIds] = useState([]); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [theme, setTheme] = useState(readWorkspaceTheme); const [goalContexts, setGoalContexts] = useState>({}); @@ -999,9 +1002,9 @@ export function PersonalWorkspacePage({ }, [goalMessages, selectedGoal, selectedGoalTab]); const managerChatItems = useMemo( () => items.filter((item) => item.kind === "message" - || (item.kind === "proposal" && (sessionProposalIds.includes(item.proposal.previewId) + || (item.kind === "proposal" && (managerSessionProposalIds.includes(item.proposal.previewId) || managerChannelProposalIds.includes(item.proposal.previewId)))), - [items, sessionProposalIds, managerChannelProposalIds], + [items, managerSessionProposalIds, managerChannelProposalIds], ); const lastChatItem = managerChatItems[managerChatItems.length - 1]; const latestMessageTextLength = lastChatItem?.kind === "message" ? lastChatItem.message.text.length : 0; @@ -1110,10 +1113,20 @@ export function PersonalWorkspacePage({ return () => { cancelled = true; }; }, [readOnly, selectedGoalId, t, typedActionsRevision]); + function rememberSessionProposal(previewId: string, channelGoalId: string | null) { + setSessionProposalIds((current) => current.includes(previewId) ? current : [...current, previewId]); + if (channelGoalId === null) { + setManagerSessionProposalIds((current) => current.includes(previewId) ? current : [...current, previewId]); + } + } + async function createPreview( request: WorkspaceActionPreviewRequest, options: { select?: boolean } = {}, ) { + // The card belongs to the conversation on screen when the request started + // (this render's selectedGoalId), even if its answer lands after the owner + // moved elsewhere. if (readOnly) throw new Error(t("source.readOnlyWriteError")); let local: WorkspaceActionPreview; try { @@ -1151,7 +1164,7 @@ export function PersonalWorkspacePage({ workspaceCandidates, }; } - setSessionProposalIds((current) => current.includes(local.previewId) ? current : [...current, local.previewId]); + rememberSessionProposal(local.previewId, selectedGoalId); setProposals((current) => ({ ...current, [local.previewId]: local })); if (options.select !== false) setSelection({ item: local, kind: "proposal" }); return local; @@ -1483,7 +1496,9 @@ export function PersonalWorkspacePage({ }, onTransitionProposal: async (proposal, transition) => { const transitioned = workspaceProposal(await transitionTypedAction(proposal.previewId, transition), t); - setSessionProposalIds((current) => current.includes(transitioned.previewId) ? current : [...current, transitioned.previewId]); + const managerOwned = managerSessionProposalIds.includes(proposal.previewId) + || managerChannelProposalIds.includes(proposal.previewId); + rememberSessionProposal(transitioned.previewId, managerOwned ? null : proposal.goalId ?? selectedGoalId); setProposals((current) => { const next = { ...current }; if (transition === "regenerate") delete next[proposal.previewId]; diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs index 6b84879526..255f461cdb 100644 --- a/examples/personal-workspace-browser/chat-todo-proposal.mjs +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -12,6 +12,9 @@ const RECOVERY_TEXT = "[P2] 复核恢复回合给出的下一步"; // The fixture answers this exact message with a protected merge action. const COMBINED_PROMPT = "请合并 PR #123"; const COMBINED_TEXT = "[P1] 合并前补齐 PR #123 的发布说明"; +// The owner leaves this Goal conversation before the answer arrives. +const DEPARTED_PROMPT = "请给出一个任务建议,我先去看看别处。"; +const DEPARTED_TEXT = "[P2] 补充发布回滚预案"; const proposalAnswer = { message: "我找到一个可评审的步骤。", proposals: [{ kind: "todo", priority: "P1", rationale: "发布前需要可核对的证据。", text: PROPOSAL_TEXT }], @@ -31,6 +34,7 @@ export const chatTodoProposalScenario = { const context = await openWorkspacePage(browser, url, { collectCoverage }); const { api, page } = context; api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer + : message === DEPARTED_PROMPT ? { message: "离开后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "离开对话不改变建议归属。", text: DEPARTED_TEXT }] } : message === COMBINED_PROMPT ? { message: "我识别到一个明确的合并请求,并建议先补齐发布说明。", proposals: [{ kind: "todo", priority: "P1", rationale: "合并前需要可核对的说明。", text: COMBINED_TEXT }] } : message === RECOVERY_PROMPT ? { message: "恢复后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "恢复回合同样需要可确认的草稿。", text: RECOVERY_TEXT }] } : null); @@ -50,6 +54,12 @@ export const chatTodoProposalScenario = { await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); }; + const openManagerChat = async () => { + await page.locator(".personal-manager-link").first().click(); + const managerChatTab = page.getByRole("navigation", { name: /Manager|管家/ }).getByRole("button", { name: /^(Chat|对话)$/ }); + await managerChatTab.click(); + if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open"); + }; await openGoalChat(); await composer.fill(GOAL_PROMPT); await page.getByRole("button", { name: "发送", exact: true }).click(); @@ -65,9 +75,37 @@ export const chatTodoProposalScenario = { } if (await page.getByRole("dialog").count()) throw new Error("A proposal card opened the drawer without the owner asking"); + // The card belongs to the Goal conversation that offered it: switching + // to Manager Chat or to another Goal on the same page must not show it, + // and returning to its Goal still does. + await openManagerChat(); + await page.getByText("我找到一个可评审的步骤。", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 }); + if (await card.count()) throw new Error("A Goal Todo proposal leaked into Manager Chat"); + await page.locator(".personal-goal-link", { hasText: "Research Monitor" }).click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.waitForTimeout(300); + if (await card.count()) throw new Error("A Goal Todo proposal leaked into another Goal's conversation"); + await openGoalChat(); + await card.waitFor({ state: "visible", timeout: 10_000 }); + + // An answer that lands after the owner left for Manager Chat still + // belongs to the Goal conversation that asked for it. + await composer.fill(DEPARTED_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await openManagerChat(); + if (previewsWithText(DEPARTED_TEXT).length) throw new Error("The departed answer arrived before the owner left its Goal"); + await waitFor(() => previewsWithText(DEPARTED_TEXT).length === 1, "The departed Goal answer did not create its Todo preview"); + await page.waitForTimeout(300); + const departedCard = page.locator(".personal-proposal-row", { hasText: DEPARTED_TEXT }); + if (await departedCard.count()) throw new Error("A Goal answer that arrived after leaving showed its Todo in Manager Chat"); + await openGoalChat(); + await departedCard.waitFor({ state: "visible", timeout: 10_000 }); + await page.reload({ waitUntil: "networkidle" }); await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); await openGoalChat(); + // The newer departed card leads; the first one waits in the backlog. + await page.locator(".personal-proposal-backlog > summary").click(); await card.waitFor({ state: "visible", timeout: 10_000 }); await card.click(); @@ -119,7 +157,7 @@ export const chatTodoProposalScenario = { } return { coverageEntries: context.coverageEntries, - note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, including one from a Turn recovered after a reload, and none is created without a target Goal.", + note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, including one from a Turn recovered after a reload; it stays out of Manager Chat and other Goals, and none is created without a target Goal.", }; }, }; From 9207e69675aff426af7a434f2f647f6e9e12cb56 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 30 Sep 2026 07:04:53 -0400 Subject: [PATCH 08/10] fix(workspace): keep a recovered Turn's drafts when the owner leaves its Goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Turn recovered after a reload exists because the owner reloaded into it. Its recovery stream is owned by the mounted conversation, so switching to Manager Chat — or to another Goal — aborts that stream. The completion event then returned at the cancellation guard, before the Todo projection, and the drafts the Turn had already produced were never persisted as previews. Re-entering the Goal restored the answer text from history but no card, and because `active_turn_id` is null by then nothing replayed it: the owner had to ask again to get the step back. The projection now outlives the view that started it, which is what the cancellation flag actually governs — the display subscription, not the owner's claim on drafts their Turn already produced: - `pendingRecoveryTurns` records the Goals whose recovered Turn is still running, keyed by `sessionId:turnId`. It survives the teardown of the view that created it. - The completion path projects through `projectRecoveredTurnProposals` before the guard, so a Turn that finished while its Goal was unmounted still reaches the typed-action store. The Turn-derived key makes a repeat a no-op. - Returning to the owning Goal replays any still-pending entry by re-reading the stored completion, so the card appears on re-entry rather than on a manual reload. An interrupted or failed Turn drops its claim instead of replaying. Only the Session's own Goal may own a recovered draft; a card never follows its owner into Manager Chat or another Goal, and nothing is auto-applied. Coverage extends `chat-todo-proposal` with the reviewer's sequence: reload into a running Turn, leave for Manager Chat before it completes, return to the Goal, then leave and reload again. It asserts the preview exists only after the owner returns to its Goal, that re-entry and reload map to exactly one preview, and that no apply happened without confirmation. Signed-off-by: song --- .../dashboard/src/views/dashboard-page.tsx | 111 ++++++++++++++---- .../chat-todo-proposal.mjs | 50 ++++++++ 2 files changed, 139 insertions(+), 22 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 8c250127df..292664d051 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1396,6 +1396,12 @@ function PersonalGoalHome({ const preparationControllers = useRef(new Map()); const interruptedTurnIds = useRef(new Set()); const recoveringTurnKeys = useRef(new Set()); + // Recovered Turns whose Todo drafts still owe a preview, keyed by + // `sessionId:turnId`. Leaving a Goal aborts its recovery stream before the + // completion event arrives, so the projection cannot depend on that view + // staying subscribed: an entry survives the teardown and is replayed when + // the owner returns to the Goal that owns it. + const pendingRecoveryTurns = useRef(new Map()); const agentMenuRef = useRef(null); const agentTriggerRef = useRef(null); const detailsCloseRef = useRef(null); @@ -1657,7 +1663,25 @@ function PersonalGoalHome({ turnId: activeTurnId || undefined, }); newSessionRequired.current.delete(sessionKey); - if (!activeTurnId) return; + if (!activeTurnId) { + // The Turn this Goal was waiting on has since finished. Its stream was + // aborted when the owner left, so replay its stored completion to give + // the drafts the card the owner never saw. + if (contextKind === "goal" && pendingRecoveryTurns.current.size) { + await replayPendingRecoveryProposals(sessionGoalId, created.session_id, () => cancelled); + } + return; + } + // Leaving this Goal before the Turn completes aborts the stream below, + // so remember the Turn: the Goal it belongs to still owes its drafts a + // card, and returning settles that here instead of on a manual reload. + if (contextKind === "goal") { + pendingRecoveryTurns.current.set(`${created.session_id}:${activeTurnId}`, { + goalId: sessionGoalId, + sessionId: created.session_id, + turnId: activeTurnId, + }); + } const recoveryKey = `${created.session_id}:${activeTurnId}`; if (recoveringTurnKeys.current.has(recoveryKey)) return; recoveringTurnKeys.current.add(recoveryKey); @@ -1711,6 +1735,14 @@ function PersonalGoalHome({ })); }, }); + // The completed Turn's proposal projection outlives this view: the + // owner may have left for another conversation while it finished, and + // returning must still find the card. Only the transcript update below + // belongs to the mounted view, so this runs before the cancellation + // guard that retires the pending reply. + const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; + projectRecoveredTurnProposals(targetContextId, recoveryGoalId, streamed.turnId, streamed.response.proposals, streamingMessageId); + pendingRecoveryTurns.current.delete(recoveryKey); if (cancelled) return; updateManagerAssistantMessage(targetContextId, streamingMessageId, { lines: streamed.response.gate @@ -1722,27 +1754,6 @@ function PersonalGoalHome({ || streamedText.trim() || `${answerIdentityLabel(targetContextId, selectedAgent.label)} 已完成分析。`, }); - // A recovered Turn cannot hand previews back to the composer, so its - // Todo proposals are stored directly and the page re-reads the store - // to show their cards. Only the Session's own Goal may own them. - const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; - const recoveryRequests = recoveryGoalId && model.goals.some((goal) => goal.goalId === recoveryGoalId) - ? todoProposalPreviewRequests(recoveryGoalId, streamed.turnId, streamed.response.proposals) - : []; - if (recoveryRequests.length) { - void Promise.allSettled(recoveryRequests.map((request) => previewTypedAction(request))).then((results) => { - if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1); - if (!results.some((result) => result.status === "rejected")) return; - // The answer stays readable; say its draft is missing so the - // owner knows to ask again. - setMessagesByContext((messages) => ({ - ...messages, - [targetContextId]: (messages[targetContextId] ?? []).map((message) => message.id !== streamingMessageId - ? message - : { ...message, lines: [...message.lines, t("feedback.proposalDraftFailed")] }), - })); - }); - } } catch (error) { if (cancelled) return; const interrupted = interruptedTurnIds.current.delete(activeTurnId) @@ -1793,6 +1804,62 @@ function PersonalGoalHome({ }; }, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents]); + // The projection deliberately outlives the mounted view. Its caller may be + // running for a Goal the owner has already left, and the recovery stream's + // teardown aborts only the display subscription, never the owner's claim on + // the drafts the Turn already produced. A Goal other than the Session's own + // never owns them, and the Turn-derived idempotency key makes re-projecting + // the same completion a no-op. + function projectRecoveredTurnProposals( + targetContextId: string, + goalId: string | undefined, + turnId: string, + proposals: AgentResponse["proposals"], + streamingMessageId: number | null, + ) { + const requests = goalId && model.goals.some((goal) => goal.goalId === goalId) + ? todoProposalPreviewRequests(goalId, turnId, proposals) + : []; + if (!requests.length) return; + void Promise.allSettled(requests.map((request) => previewTypedAction(request))).then((results) => { + if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1); + if (!results.some((result) => result.status === "rejected")) return; + // The answer stays readable; say its draft is missing so the owner knows + // to ask again. A view that has since been left has no message to amend. + if (streamingMessageId === null) return; + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).map((message) => message.id !== streamingMessageId + ? message + : { ...message, lines: [...message.lines, t("feedback.proposalDraftFailed")] }), + })); + }); + } + + // A Goal Turn that finished after its owner left keeps its identity in + // `pendingRecoveryTurns`. Re-reading its stored completion re-derives the Todo + // drafts it produced and projects them through the same preview endpoint, so + // returning to the Goal shows the card without a manual reload. The + // Turn-derived idempotency key makes the replay safe to repeat. + async function replayPendingRecoveryProposals(goalId: string, sessionId: string, isCancelled: () => boolean) { + const pending = [...pendingRecoveryTurns.current.values()] + .filter((entry) => entry.goalId === goalId && entry.sessionId === sessionId && entry.turnId); + for (const entry of pending) { + if (isCancelled()) return; + const key = `${entry.sessionId}:${entry.turnId}`; + try { + const streamed = await resumeChatTurnStreaming(entry.sessionId, entry.turnId); + if (isCancelled()) return; + projectRecoveredTurnProposals(goalId, goalId, entry.turnId, streamed.response.proposals, null); + pendingRecoveryTurns.current.delete(key); + } catch { + // An interrupted or failed Turn owes no card. Dropping the claim keeps a + // permanently broken Turn from replaying on every re-entry. + pendingRecoveryTurns.current.delete(key); + } + } + } + useEffect(() => { if (readOnly) return; if (selectedGoal || model.goals.length === 0 || sessionDiscoveryKey === "loading") return; diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs index 255f461cdb..1444c907ab 100644 --- a/examples/personal-workspace-browser/chat-todo-proposal.mjs +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -15,6 +15,11 @@ const COMBINED_TEXT = "[P1] 合并前补齐 PR #123 的发布说明"; // The owner leaves this Goal conversation before the answer arrives. const DEPARTED_PROMPT = "请给出一个任务建议,我先去看看别处。"; const DEPARTED_TEXT = "[P2] 补充发布回滚预案"; +// A Turn the owner reloads into recovery and then leaves before it completes. +// The fixture answers a resumed "刷新恢复" Turn after 5s, which is the window the +// owner leaves in. +const DEPARTED_RECOVERY_PROMPT = "刷新恢复:离开后仍会完成的回合。"; +const RECOVERED_DEPARTURE_TEXT = "[P2] 离开后仍完成的恢复草稿"; const proposalAnswer = { message: "我找到一个可评审的步骤。", proposals: [{ kind: "todo", priority: "P1", rationale: "发布前需要可核对的证据。", text: PROPOSAL_TEXT }], @@ -37,6 +42,7 @@ export const chatTodoProposalScenario = { : message === DEPARTED_PROMPT ? { message: "离开后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "离开对话不改变建议归属。", text: DEPARTED_TEXT }] } : message === COMBINED_PROMPT ? { message: "我识别到一个明确的合并请求,并建议先补齐发布说明。", proposals: [{ kind: "todo", priority: "P1", rationale: "合并前需要可核对的说明。", text: COMBINED_TEXT }] } : message === RECOVERY_PROMPT ? { message: "恢复后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "恢复回合同样需要可确认的草稿。", text: RECOVERY_TEXT }] } + : message === DEPARTED_RECOVERY_PROMPT ? { message: "离开后仍然完成的恢复回合。", proposals: [{ kind: "todo", priority: "P2", rationale: "离开 Goal 不改变恢复回合草稿的归属。", text: RECOVERED_DEPARTURE_TEXT }] } : null); const previewsWithText = (text) => api.actionPreviews.filter((preview) => preview.action_kind === "todo.create" && preview.normalized_parameters?.text === text); @@ -140,6 +146,50 @@ export const chatTodoProposalScenario = { await page.waitForTimeout(1_000); if (api.actionApplies.length !== appliesAfterRecovery) throw new Error("Reloading after the recovered Turn applied a preview again"); + // A recovery Turn whose completion lands after the owner has left its Goal + // must still give its Todo draft a card in the Goal that owns it. The + // fixture answers a resumed "刷新恢复" Turn after 5s, which is the window + // the owner leaves in. + const turnsBeforeDeparture = api.turnRequests.length; + await composer.fill(DEPARTED_RECOVERY_PROMPT); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await waitFor(() => api.turnRequests.length > turnsBeforeDeparture, "The departure recovery prompt was not sent"); + // Reload so the still-running Turn is owned by recovery rather than the + // original send, then leave for Manager Chat before it completes. + await page.reload({ waitUntil: "domcontentloaded" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await openGoalChat(); + const departedRecoveryCard = page.locator(".personal-proposal-row", { hasText: RECOVERED_DEPARTURE_TEXT }); + await openManagerChat(); + // The recovery stream is aborted by the switch; the worker still finishes. + if (await departedRecoveryCard.count()) throw new Error("A recovery Turn's Todo leaked into Manager Chat"); + await page.waitForTimeout(6_000); + if (api.actionPreviews.filter((item) => item.normalized_parameters?.text === RECOVERED_DEPARTURE_TEXT).length) { + throw new Error("The departed recovery Turn wrote its preview while its Goal was not mounted"); + } + + // Returning to the owning Goal must show the card without another reload. + await openGoalChat(); + await waitFor(() => api.actionPreviews.filter((item) => item.normalized_parameters?.text === RECOVERED_DEPARTURE_TEXT).length === 1, + "Returning to the Goal did not replay the completed recovery's Todo preview"); + await departedRecoveryCard.waitFor({ state: "visible", timeout: 5_000 }); + await departedRecoveryCard.waitFor({ state: "visible", timeout: 5_000 }); + // Re-entering and reloading must not add a second card or write a Todo. + const appliesBeforeDepartureReplay = api.actionApplies.length; + await openManagerChat(); + await openGoalChat(); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await openGoalChat(); + await departedRecoveryCard.waitFor({ state: "visible", timeout: 10_000 }); + await page.waitForTimeout(1_000); + await waitFor(() => new Set( + api.actionPreviews.filter((item) => item.normalized_parameters?.text === RECOVERED_DEPARTURE_TEXT).map((item) => item.idempotency_key), + ).size === 1, "Replaying the departed recovery mapped to more than one Todo preview"); + if (api.actionApplies.length !== appliesBeforeDepartureReplay) { + throw new Error("A departed recovery applied a preview without the owner confirming it"); + } + // One answer may carry a protected action and Todo proposals together: // the protected decision keeps the drawer and the Todo still becomes a card. await composer.fill(COMBINED_PROMPT); From 1c85112149f08515ee241cea561259b6d1223f23 Mon Sep 17 00:00:00 2001 From: song Date: Thu, 1 Oct 2026 05:49:31 -0400 Subject: [PATCH 09/10] fix(workspace): replay stored Todo proposals across reload and transient failure Signed-off-by: song --- apps/presentation/dashboard/src/data/chat.ts | 14 ++ .../dashboard/src/views/dashboard-page.tsx | 164 +++++++++++------- .../chat-todo-proposal.mjs | 8 +- .../personal-workspace-browser/fixture.mjs | 15 +- 4 files changed, 131 insertions(+), 70 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c7b57a1519..b12a37c8b9 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { todoApplyResultMatchesRequest, todoPreviewMatchesRequest, + type AgentResponse, type CollaborationReadback, type LoopXModeSettings, type TodoApplyResult, @@ -427,6 +428,7 @@ const typedOperationEnvelopeSchema = z.object({ export const typedActionProposalSchema = z.object({ schema_version: z.literal("loopx_chat_action_proposal_v1"), + idempotency_key: z.string().optional(), proposal_id: z.string().min(1), action_kind: typedActionKindSchema, summary: z.string().min(1), @@ -1240,6 +1242,18 @@ async function receiveChatTurnStreaming( }; } +/** Read a stored Turn's terminal outcome without submitting or resuming work. + * Failed/interrupted Turns have no completed proposals; transport failures throw + * so callers can retry instead of treating an unavailable response as empty. + */ +export async function readCompletedChatTurn(sessionId: string, turnId: string, signal: AbortSignal): Promise { + let completed: AgentResponse | null = null; + await streamChatTurn(`/api/chat/sessions/${sessionId}/turns/${turnId}/events`, (event) => { + if (event.kind === "turn.completed") completed = agentResponseSchema.parse(event.payload.response); + }, signal); + return completed; +} + export async function resumeChatTurnStreaming( sessionId: string, turnId: string, diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index bbfaeff0e8..36dca5264b 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -46,10 +46,12 @@ import { fetchChatSession, fetchChatSessions, interruptChatTurn, + listTypedActions, steerChatTurn, previewGoalSubagentConfiguration, previewTypedAction, recordProjectionExchange, + readCompletedChatTurn, resumeChatSession, resumeChatTurnStreaming, sendChatTurnStreaming, @@ -1406,12 +1408,6 @@ function PersonalGoalHome({ const preparationControllers = useRef(new Map()); const interruptedTurnIds = useRef(new Set()); const recoveringTurnKeys = useRef(new Set()); - // Recovered Turns whose Todo drafts still owe a preview, keyed by - // `sessionId:turnId`. Leaving a Goal aborts its recovery stream before the - // completion event arrives, so the projection cannot depend on that view - // staying subscribed: an entry survives the teardown and is replayed when - // the owner returns to the Goal that owns it. - const pendingRecoveryTurns = useRef(new Map()); // A running Turn a 409 reported, keyed by context: its pending reply holds // the composer closed until the recovery effect adopts it or an // authoritative Session read finds no such Turn, so the handoff never leaves @@ -1708,25 +1704,7 @@ function PersonalGoalHome({ turnId: activeTurnId || undefined, }); newSessionRequired.current.delete(sessionKey); - if (!activeTurnId) { - // The Turn this Goal was waiting on has since finished. Its stream was - // aborted when the owner left, so replay its stored completion to give - // the drafts the card the owner never saw. - if (contextKind === "goal" && pendingRecoveryTurns.current.size) { - await replayPendingRecoveryProposals(sessionGoalId, created.session_id, () => cancelled); - } - return; - } - // Leaving this Goal before the Turn completes aborts the stream below, - // so remember the Turn: the Goal it belongs to still owes its drafts a - // card, and returning settles that here instead of on a manual reload. - if (contextKind === "goal") { - pendingRecoveryTurns.current.set(`${created.session_id}:${activeTurnId}`, { - goalId: sessionGoalId, - sessionId: created.session_id, - turnId: activeTurnId, - }); - } + if (!activeTurnId) return; const recoveryKey = `${created.session_id}:${activeTurnId}`; if (recoveringTurnKeys.current.has(recoveryKey)) return; recoveringTurnKeys.current.add(recoveryKey); @@ -1788,19 +1766,40 @@ function PersonalGoalHome({ // belongs to the mounted view, so this runs before the cancellation // guard that retires the pending reply. const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; - projectRecoveredTurnProposals(targetContextId, recoveryGoalId, streamed.turnId, streamed.response.proposals, streamingMessageId); - pendingRecoveryTurns.current.delete(recoveryKey); + const proposalsProjected = await projectRecoveredTurnProposals(targetContextId, recoveryGoalId, streamed.turnId, streamed.response.proposals, streamingMessageId); if (cancelled) return; updateConversationMessage(targetContextId, streamingMessageId, { - lines: streamed.response.gate - ? [streamed.response.gate.summary, streamed.response.gate.next_action].filter(Boolean).slice(0, 2) - : [], + lines: [ + ...(streamed.response.gate + ? [streamed.response.gate.summary, streamed.response.gate.next_action].filter(Boolean).slice(0, 2) + : []), + ...(!proposalsProjected ? [t("feedback.proposalDraftFailed")] : []), + ], pending: false, goalDraft: streamed.response.goal_draft, text: streamed.response.message || streamedText.trim() || `${answerIdentityLabel(targetContextId, selectedAgent.label)} 已完成分析。`, }); + // Refresh the existing history owner so this terminal Turn no longer + // appears active in the replay snapshot. A failed preview stays + // discoverable and the history projection can retry it without a + // new Turn, navigation or an in-memory completion ledger. + const refreshCompletedHistory = async (attempt = 0) => { + if (cancelled) return; + try { + const refreshed = await conversationHistory.refresh(); + if (refreshed.unavailableSessionIds.length) conversationHistory.retry(); + } catch { + if (!cancelled) { + handoffRetryTimer = window.setTimeout( + () => void refreshCompletedHistory(attempt + 1), + Math.min(3000 * 2 ** attempt, 30_000), + ); + } + } + }; + void refreshCompletedHistory(); } catch (error) { if (cancelled) return; const interrupted = interruptedTurnIds.current.delete(activeTurnId) @@ -1902,7 +1901,7 @@ function PersonalGoalHome({ // the drafts the Turn already produced. A Goal other than the Session's own // never owns them, and the Turn-derived idempotency key makes re-projecting // the same completion a no-op. - function projectRecoveredTurnProposals( + async function projectRecoveredTurnProposals( targetContextId: string, goalId: string | undefined, turnId: string, @@ -1912,45 +1911,76 @@ function PersonalGoalHome({ const requests = goalId && model.goals.some((goal) => goal.goalId === goalId) ? todoProposalPreviewRequests(goalId, turnId, proposals) : []; - if (!requests.length) return; - void Promise.allSettled(requests.map((request) => previewTypedAction(request))).then((results) => { - if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1); - if (!results.some((result) => result.status === "rejected")) return; - // The answer stays readable; say its draft is missing so the owner knows - // to ask again. A view that has since been left has no message to amend. - if (streamingMessageId === null) return; - setMessagesByContext((messages) => ({ - ...messages, - [targetContextId]: (messages[targetContextId] ?? []).map((message) => message.id !== streamingMessageId - ? message - : { ...message, lines: [...message.lines, t("feedback.proposalDraftFailed")] }), - })); - }); + if (!requests.length) return true; + // Existing previews own their lifecycle, including applied/rejected states. + // Recomputing a preview after Goal state changes can conflict with its stored + // fingerprint; use the persisted Turn key before attempting any new preview. + const stored = await listTypedActions({ contextKind: "goal", goalId }).catch(() => null); + if (!stored) return false; + const known = new Set(stored.filter((proposal) => proposal.action_kind === "todo.create" + && proposal.context.goal_id === goalId).map((proposal) => proposal.idempotency_key)); + const missing = requests.filter((request) => !known.has(request.idempotencyKey)); + const results = await Promise.allSettled(missing.map((request) => previewTypedAction(request))); + if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1); + if (!results.some((result) => result.status === "rejected")) return true; + // The answer stays readable; say its draft is missing so the owner knows + // to retry. A view that has since been left has no message to amend. + if (streamingMessageId === null) return false; + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).map((message) => message.id !== streamingMessageId + ? message + : { ...message, lines: [...message.lines, t("feedback.proposalDraftFailed")] }), + })); + return false; } - // A Goal Turn that finished after its owner left keeps its identity in - // `pendingRecoveryTurns`. Re-reading its stored completion re-derives the Todo - // drafts it produced and projects them through the same preview endpoint, so - // returning to the Goal shows the card without a manual reload. The - // Turn-derived idempotency key makes the replay safe to repeat. - async function replayPendingRecoveryProposals(goalId: string, sessionId: string, isCancelled: () => boolean) { - const pending = [...pendingRecoveryTurns.current.values()] - .filter((entry) => entry.goalId === goalId && entry.sessionId === sessionId && entry.turnId); - for (const entry of pending) { - if (isCancelled()) return; - const key = `${entry.sessionId}:${entry.turnId}`; - try { - const streamed = await resumeChatTurnStreaming(entry.sessionId, entry.turnId); - if (isCancelled()) return; - projectRecoveredTurnProposals(goalId, goalId, entry.turnId, streamed.response.proposals, null); - pendingRecoveryTurns.current.delete(key); - } catch { - // An interrupted or failed Turn owes no card. Dropping the claim keeps a - // permanently broken Turn from replaying on every re-entry. - pendingRecoveryTurns.current.delete(key); + // History, not an in-memory pending list, discovers answers missed while the + // page was closed. Read each stored Turn's canonical completion; a transcript + // message alone cannot authorize a preview. This also covers older Sessions. + useEffect(() => { + const history = conversationHistory.history; + const goalId = selectedGoal?.goalId; + if (readOnly || !goalId || !history) return; + const controller = new AbortController(); + const projected = new Set(); // Optimization only; never a durable claim. + let timer: number | undefined; + let failures = 0; + const replay = async () => { + let retry = false; + for (const snapshot of history.snapshots) { + const session = snapshot.session; + if (session.goal_id !== goalId || session.channel_id !== `goal.${goalId}`) continue; + const turnIds = new Set(snapshot.messages + .map((message) => message.turn_id) + .filter((turnId): turnId is string => Boolean(turnId) && turnId !== session.active_turn_id)); + for (const turnId of turnIds) { + if (controller.signal.aborted) return; + const key = `${session.session_id}:${turnId}`; + if (projected.has(key)) continue; + try { + const completed = await readCompletedChatTurn(session.session_id, turnId, controller.signal); + if (controller.signal.aborted) return; + const persisted = !completed || await projectRecoveredTurnProposals(goalId, session.goal_id, turnId, completed.proposals, null); + if (persisted) projected.add(key); + else retry = true; + } catch { + // A transport/preview failure is not evidence of an empty response. + // Leave the completion discoverable and retry without another Turn. + retry = true; + } + } } - } - } + if (retry && !controller.signal.aborted) { + timer = window.setTimeout(() => void replay(), Math.min(3000 * 2 ** failures++, 30_000)); + } + }; + void replay(); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, [conversationHistory.history, selectedGoal?.goalId, readOnly]); useEffect(() => { if (readOnly) return; diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs index 1444c907ab..a3c3c4c580 100644 --- a/examples/personal-workspace-browser/chat-todo-proposal.mjs +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -127,6 +127,7 @@ export const chatTodoProposalScenario = { await page.reload({ waitUntil: "domcontentloaded" }); await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); await openGoalChat(); + api.failNextActionPreview = true; await page.locator(".personal-channel-timeline").getByText("恢复后给出一个步骤。", { exact: true }).waitFor({ state: "visible", timeout: 15_000 }); const recoveredCard = page.locator(".personal-proposal-row", { hasText: RECOVERY_TEXT }); await recoveredCard.waitFor({ state: "visible", timeout: 5_000 }); @@ -168,12 +169,17 @@ export const chatTodoProposalScenario = { throw new Error("The departed recovery Turn wrote its preview while its Goal was not mounted"); } + // A new page has no in-memory recovery claim; persisted completion must suffice. + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + // Returning to the owning Goal must show the card without another reload. + // A transient preview failure must leave the stored completion retryable. + api.failNextActionPreview = true; await openGoalChat(); await waitFor(() => api.actionPreviews.filter((item) => item.normalized_parameters?.text === RECOVERED_DEPARTURE_TEXT).length === 1, "Returning to the Goal did not replay the completed recovery's Todo preview"); await departedRecoveryCard.waitFor({ state: "visible", timeout: 5_000 }); - await departedRecoveryCard.waitFor({ state: "visible", timeout: 5_000 }); // Re-entering and reloading must not add a second card or write a Todo. const appliesBeforeDepartureReplay = api.actionApplies.length; await openManagerChat(); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 28ea9db61b..fbf1cf3c10 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1282,6 +1282,11 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (resumedEvents && request.method() === "GET") { const sessionId = resumedEvents[1]; const turnId = resumedEvents[2]; + const completed = completedTurns.get(JSON.stringify([sessionId, turnId])); + if (completed) { + await route.fulfill({ contentType: "text/event-stream", body: completed, status: 200 }); + return; + } // A resumed Turn completes with the same scripted answer it was sent for. const scriptedAnswer = typeof state.answerForMessage === "function" ? state.answerForMessage(turnMessages.get(turnId) ?? "") : null; const answer = (typeof scriptedAnswer === "object" ? scriptedAnswer?.message : scriptedAnswer) @@ -1713,7 +1718,6 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const goalId = url.searchParams.get("goal_id"); const contextKind = url.searchParams.get("context_kind"); const matching = Array.from(actionProposals.values()).filter((proposal) => { - if (proposal.status === "cancelled") return false; if (goalId && (proposal.context?.goal_id ?? proposal.normalized_parameters?.goal_id) !== goalId) return false; return !contextKind || proposal.context?.kind === contextKind; }); @@ -1759,10 +1763,17 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true return; } const proposal_id = `proposal-${body.idempotency_key}`; + // Match the native store: replay preserves even an applied/cancelled + // preview's identity and lifecycle, rather than resetting it to ready. + const existing = actionProposals.get(proposal_id); + if (existing) { + await route.fulfill({ contentType: "application/json", json: { ok: true, proposal: existing }, status: 200 }); + return; + } actionKinds.set(proposal_id, body.action_kind); state.actionPreviews.push({ ...body, proposalId: proposal_id }); const proposal = { - schema_version: "loopx_chat_action_proposal_v1", proposal_id, action_kind: body.action_kind, + schema_version: "loopx_chat_action_proposal_v1", proposal_id, idempotency_key: body.idempotency_key, action_kind: body.action_kind, summary: body.summary, normalized_parameters: body.normalized_parameters, context: body.context, expected_state_fingerprint: "fixture-r1", permission_classification: "durable_write", validation_evidence: ["fixture validation"], available_transitions: ["apply", "cancel"], From d487f01b80a6ce747a7a31ec6bad36059d1dbfeb Mon Sep 17 00:00:00 2001 From: song Date: Thu, 1 Oct 2026 06:13:43 -0400 Subject: [PATCH 10/10] test(workspace): stabilize time and confirmation-delivery fixtures Signed-off-by: song --- .../conversation-activity.mjs | 3 +++ .../typed-actions.mjs | 20 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 954ffae369..278a1c5239 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -36,6 +36,9 @@ export const conversationActivityScenario = { const pending = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) }); await page.clock.install(); await page.clock.fastForward(25000); + // Compare the same instant across views; clock.install alone keeps + // advancing and can cross a second boundary while navigation completes. + await page.clock.pauseAt(await page.evaluate(() => Date.now())); await pending.locator(".personal-message-quiet").waitFor(); const elapsedBefore = await pending.locator(".personal-message-elapsed").textContent(); await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 1b38dfafb9..175ccf4113 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -79,8 +79,15 @@ function operationProposal({ id, title, lifecycleState, status, resultDelivery = confirmation_digest: "a".repeat(64), payload_digest: "b".repeat(64), projection_digest: "c".repeat(64), - expires_at: "2026-09-15T10:00:00Z", - delivery: { provider: "lark", message_id: `${id}-message` }, + // Pending confirmation must remain live when the test runs later; + // a dated fixture becomes expired and legitimately leaves the gate view. + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + delivery: { + provider: "lark", message_id: `${id}-message`, + chat_id: "synthetic-group", app_id: "synthetic-app", + binding_digest: "d".repeat(64), card_digest: "e".repeat(64), + delivered_at: new Date().toISOString(), + }, confirmation: outcomeObserved ? { provider: "lark" } : null, claim: outcomeObserved ? { claim_id: `${id}-claim` } : null, outcome: outcomeObserved ? { @@ -199,11 +206,14 @@ export const typedActionsScenario = { await page.screenshot({ path: resolve(outputDir, "operation-result-verified.png"), fullPage: false, animations: "disabled" }); await page.getByRole("button", { name: /关闭详情/ }).click(); - const gatedSummary = page.locator(".personal-gated-summary"); - await gatedSummary.locator("summary").click(); - const awaiting = gatedSummary.locator(".personal-proposal-row", { + const awaiting = page.locator(".personal-proposal-row", { hasText: "Simulated order awaiting group confirmation", }); + // Current operations remain visible beside their outcomes. Older views + // fold them into the gate section; either route must reach the same card. + if (!(await awaiting.isVisible())) { + await page.locator(".personal-gated-summary summary").click(); + } await awaiting.waitFor({ state: "visible" }); if (!(await awaiting.innerText()).includes("前往飞书群确认")) { throw new Error("Awaiting operation did not route confirmation to Feishu");