diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 7af8eb78bb..acae8ae5af 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -119,6 +119,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.", "history.loading": "Reading conversation history…", "history.partial": "Some history is temporarily unavailable. Available messages are shown.", "history.unavailable": "Conversation history is temporarily unavailable. Retrying will only read records.", @@ -1317,6 +1318,7 @@ const zhCN: Record = { "feedback.preparingPreview": "正在准备确认预览:{title}", "feedback.previewFailed": "无法准备确认预览:{error}", "feedback.sendFailed": "发送失败:{error}", + "feedback.proposalDraftFailed": "回答里提出了任务建议,但草稿未能生成。可以再问一次重试。", "history.loading": "正在读取会话记录…", "history.partial": "部分历史暂时无法读取,已显示可用消息。", "history.unavailable": "会话历史暂时无法读取,重试只会读取记录。", 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 05dc90e905..2891d8839a 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 @@ -119,7 +119,8 @@ assert.match(styles, /\.personal-proposal-row\[data-action-kind="operation\.exec 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`); } 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 4edd5b8978..5c732ba0ab 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 @@ -468,7 +468,7 @@ export type PersonalWorkspaceCallbacks = { agentId: string, goalId: string | null, attachments?: WorkspaceImageAttachment[], - ) => void | WorkspaceActionPreviewRequest | Promise; + ) => void | WorkspaceSendPreviews | Promise; onPrepareLoopX?: (agentId: string, goalId: string) => Promise; onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => void; @@ -481,6 +481,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 cdb616ccb4..426484a6b8 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 @@ -760,6 +760,7 @@ export function PersonalWorkspacePage({ managerRuntime, model, readOnly = false, + typedActionsRevision = 0, selectedAgentId: controlledAgentId, selectedGoalId: controlledGoalId, statusSourceControl, @@ -778,6 +779,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; @@ -818,6 +822,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>({}); @@ -1040,9 +1047,9 @@ export function PersonalWorkspacePage({ const composerBlocked = sending || conversationTurnRunning; 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 conversationOpen = selectedGoal ? selectedGoalTab === "chat" : managerChatOpen; const conversationMessages = selectedGoal ? goalMessages : managerMessages; @@ -1167,12 +1174,22 @@ 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]); + + 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 { @@ -1210,7 +1227,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; @@ -1542,7 +1559,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]; @@ -1652,8 +1671,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 (semanticPreview) await createPreview(semanticPreview); + 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")); + } + // 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 7e8349aaaa..92fc499f37 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -38,7 +38,6 @@ import { ChatApiError, applyGoalSubagentConfiguration, applyTypedAction, - applyTodo, closeChatSession, createChatSession, updateLoopXMode, @@ -49,7 +48,6 @@ import { interruptChatTurn, steerChatTurn, previewGoalSubagentConfiguration, - previewTodo, previewTypedAction, recordProjectionExchange, resumeChatSession, @@ -58,8 +56,6 @@ import { chatSessionQueuesFollowUps, selectAvailableChatAgent, sessionInvalidatedByPayload, - todoNoWriteReceiptFromPayload, - todoReceiptLabel, isTodoProposal, type ChatSessionSnapshot, type ChatSessionSummary, @@ -67,8 +63,8 @@ import { type ChatVisibleMessage, type ManagerChannelBinding, type ManagerRuntimeSessionReadback, + type AgentResponse, type ProtectedActionProposal, - type TodoProposal, } from "../data/chat"; import { beginStatusRequest, @@ -150,6 +146,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 { @@ -544,27 +558,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; @@ -870,21 +863,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) @@ -1404,13 +1382,13 @@ 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>({}); // Bound Sessions whose mode queues a message sent while a Turn runs, read // from the Session owner each time this page binds a Session. const [followUpQueueSessionIds, setFollowUpQueueSessionIds] = useState>(() => new Set()); const [executionSessions, setExecutionSessions] = useState([]); + const [typedActionsRevision, setTypedActionsRevision] = useState(0); // Bumped when the service reports a running Turn this page did not know // about, so the Turn recovery effect re-reads the Session and adopts it. const [turnRecoveryRequest, setTurnRecoveryRequest] = useState(0); @@ -1419,7 +1397,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()); @@ -1427,6 +1404,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()); // 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 @@ -1442,7 +1425,6 @@ function PersonalGoalHome({ const managerInputRef = useRef(null); const managerQuickPrompts = ["我现在该做什么?", "哪些 Goal 在等我?", "Agent 在做什么?"]; const contextMessages = messagesByContext[contextId] ?? []; - const contextProposals = proposalsByContext[contextId] ?? []; const conversationHistory = useConversationHistory({ agentId: selectedGoal ? selectedAgent.agentId : undefined, currentAgentId: selectedAgent.agentId, @@ -1716,7 +1698,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); @@ -1772,6 +1772,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; updateConversationMessage(targetContextId, streamingMessageId, { lines: streamed.response.gate @@ -1783,31 +1791,6 @@ 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], - })); - } - } } catch (error) { if (cancelled) return; const interrupted = interruptedTurnIds.current.delete(activeTurnId) @@ -1903,6 +1886,62 @@ function PersonalGoalHome({ }; }, [conversationHistory.connectionKey, contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents, turnRecoveryRequest]); + // 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; @@ -2092,19 +2131,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, signal?: AbortSignal) { const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); @@ -2314,29 +2340,13 @@ 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, - question, - response.protected_action, - ); - if (protectedPreview) return protectedPreview; - } + 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) { if (preparationController.signal.aborted && !submittedTurnId) { updateConversationMessage(targetContextId, streamingMessageId, { @@ -2538,69 +2548,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; @@ -2833,6 +2780,7 @@ function PersonalGoalHome({ return (
({ adapterKind: agent.adapterKind, agentId: agent.agentId, diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 859fcb97a5..413e863eae 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -52,8 +52,9 @@ import { larkCliMissingScenario } from "./personal-workspace-browser/lark-cli-mi import { executionServiceOfflineScenario } from "./personal-workspace-browser/execution-service-offline.mjs"; import { conversationStartupScenario } from "./personal-workspace-browser/conversation-startup.mjs"; +import { chatTodoProposalScenario } from "./personal-workspace-browser/chat-todo-proposal.mjs"; -const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, composerSessionAdmissionScenario, attachedHostFollowUpScenario, conversationReturnContinuityScenario, conversationHistoryRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario]; +const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, composerSessionAdmissionScenario, attachedHostFollowUpScenario, conversationReturnContinuityScenario, conversationHistoryRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario, chatTodoProposalScenario]; scenarioCatalog.push(confirmedOperationsScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = 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..1444c907ab --- /dev/null +++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs @@ -0,0 +1,213 @@ +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] 核对发布清单并补齐缺失的验证记录"; +// 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 的发布说明"; +// 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 }], +}; + +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 + : 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); + const todoPreviews = () => previewsWithText(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(); + }; + 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(); + 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"); + + // 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(); + 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"); + + // 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); + 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(); + } + 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; it stays out of Manager Chat and other Goals, 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 c0aa3e5b3a..fdafc3859d 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; @@ -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") { @@ -1703,7 +1706,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());