From 2d23e810fdfa50b7515bb32edcc6d1d131531a4b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:55:07 +0800 Subject: [PATCH 1/3] fix(app): retain pending returns across conversation sessions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 3 +- .../src/data/conversation-returns.test.mjs | 42 ++++++++++++ .../src/data/conversation-returns.ts | 57 ++++++++++++++++ .../dashboard/src/views/dashboard-page.tsx | 67 ++++++++----------- .../app-conversation-and-async-inbox-v0.md | 29 ++++++-- .../rfcs/loopx-overall-roadmap-v0.md | 4 +- .../use-cases/steward/golden-queries.md | 2 +- examples/personal-workspace-browser-smoke.mjs | 3 +- .../conversation-return-continuity.mjs | 63 +++++++++++++++++ 9 files changed, 220 insertions(+), 50 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/conversation-returns.test.mjs create mode 100644 apps/presentation/dashboard/src/data/conversation-returns.ts create mode 100644 examples/personal-workspace-browser/conversation-return-continuity.mjs diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index bc3b2b63c..6cec2d8a9 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -56,7 +56,8 @@ "smoke:team-artifact-comparison": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-comparison src/features/personal-workspace/team-artifact-comparison.ts src/vite-env.d.ts && node smoke/team-artifact-comparison-smoke.mjs", "smoke:team-report": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --jsx react-jsx --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-report src/features/personal-workspace/team-artifact-content.tsx src/vite-env.d.ts && node smoke/team-report-smoke.mjs", "build:chat:vite": "tsc --noEmit && vite build --config vite.chat.config.ts", - "smoke:chat-upgrade": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/chat-bundle-upgrade-browser-smoke.mjs" + "smoke:chat-upgrade": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/chat-bundle-upgrade-browser-smoke.mjs", + "test:conversation-returns": "node --experimental-strip-types src/data/conversation-returns.test.mjs" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/src/data/conversation-returns.test.mjs b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs new file mode 100644 index 000000000..617cc913f --- /dev/null +++ b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { conversationReturnSessions, reconcileConversationReturns } from "./conversation-returns.ts"; + +const collaboration = { returns: [] }; +const original = [ + { sourceSessionId: "old", sourceMessageId: "brief", collaboration, text: "Original brief" }, + { sourceSessionId: "current", sourceMessageId: "brief", text: "New conversation" }, + { sourceSessionId: "current", sourceTurnId: "running", text: "Streaming text", pending: true }, +]; +const conclusion = { message_id: "result", turn_id: "original", origin: "manager_followup", text: "Checked result", + return_delivery: { phase: "conclusion", status: "verification_required" } }; +const createReply = (row) => ({ sourceSessionId: "old", sourceMessageId: row.message_id, text: row.text, returnDelivery: row.return_delivery }); +assert.deepEqual(conversationReturnSessions("current", original), ["current", "old"]); +// Reading another session must not erase the old brief, even with colliding IDs. +const current = reconcileConversationReturns(original, "current", [{ message_id: "brief", text: "New conversation" }], createReply); +assert.equal(current, original); +assert.equal(reconcileConversationReturns(original, "old", [], createReply), original); +assert.equal(reconcileConversationReturns(original, "old", [{ message_id: "brief" }], createReply), original); +const arrived = reconcileConversationReturns(original, "old", [conclusion, conclusion], createReply); +assert.equal(arrived.length, original.length + 1); +assert.equal(arrived[2], original[2]); +assert.equal(reconcileConversationReturns(arrived, "old", [conclusion], createReply), arrived); +assert.equal(arrived.at(-1).text, "Checked result"); +// The worker has concluded, but transport uncertainty still requires readback. +const settledBrief = { message_id: "brief", collaboration: { returns: [{ phase: "conclusion", status: "delivered" }] } }; +const waiting = reconcileConversationReturns(arrived, "old", [settledBrief, conclusion], createReply); +assert.deepEqual(conversationReturnSessions("current", waiting), ["current", "old"]); +const delivered = reconcileConversationReturns(waiting, "old", [settledBrief, { ...conclusion, + return_delivery: { phase: "conclusion", status: "delivered" } }], createReply); +assert.deepEqual(conversationReturnSessions("current", delivered), ["current"]); +assert.equal(delivered.length, arrived.length); +// Recovered Turns acquire stored identity without replacing the live text. +const hydrated = reconcileConversationReturns(original, "current", [{ message_id: "answer", turn_id: "running", + role: "agent", text: "Stored text", collaboration }], createReply); +assert.equal(hydrated[2].sourceMessageId, "answer"); +assert.equal(hydrated[2].text, "Streaming text"); +assert.equal(hydrated[2].pending, true); +assert.equal(hydrated[0], original[0]); +assert.deepEqual(conversationReturnSessions(undefined, delivered), ["current"]); +assert.deepEqual(conversationReturnSessions(undefined, original), ["current", "old"]); +assert.deepEqual(conversationReturnSessions(undefined, [delivered[0], delivered.at(-1)]), []); +console.log("conversation-returns: passed (session isolation, late return, deduplication, transport uncertainty, stream preservation and watch retirement)"); diff --git a/apps/presentation/dashboard/src/data/conversation-returns.ts b/apps/presentation/dashboard/src/data/conversation-returns.ts new file mode 100644 index 000000000..a03dbcbb8 --- /dev/null +++ b/apps/presentation/dashboard/src/data/conversation-returns.ts @@ -0,0 +1,57 @@ +import type { ChatVisibleMessage } from "./chat"; + +type ConversationMessage = { + sourceSessionId?: string; + sourceMessageId?: string; + sourceTurnId?: string; + collaboration?: ChatVisibleMessage["collaboration"]; + returnDelivery?: ChatVisibleMessage["return_delivery"]; +}; + +/** Old sessions remain relevant while they owe a result, not forever. */ +export function conversationReturnSessions(activeSessionId: string | undefined, messages: ConversationMessage[]): string[] { + const sessions = new Set(activeSessionId ? [activeSessionId] : []); + for (const message of messages) { + const waitingForConclusion = message.collaboration && !message.collaboration.returns.some( + (reply) => reply.phase === "conclusion" && reply.status === "delivered", + ); + const delivery = message.returnDelivery; + const waitingForDelivery = delivery && !["delivered", "superseded"].includes(delivery.status); + const waitingForTranscript = message.sourceTurnId && !message.sourceMessageId; + if (message.sourceSessionId && (waitingForConclusion || waitingForDelivery || waitingForTranscript)) sessions.add(message.sourceSessionId); + } + return [...sessions].sort(); +} + +/** A snapshot may refresh only its own session; it never replaces streamed text. */ +export function reconcileConversationReturns( + previous: T[], sessionId: string, messages: ChatVisibleMessage[], + createReply: (message: ChatVisibleMessage) => T, +): T[] { + const byId = new Map(messages.map((row) => [row.message_id, row])); + const byTurn = new Map(messages.filter((row) => row.role !== "user" && row.origin !== "manager_followup") + .map((row) => [row.turn_id, row])); + const seen = new Set(previous.filter((row) => row.sourceSessionId === sessionId).map((row) => row.sourceMessageId)); + let changed = false; + const updated = previous.map((row) => { + if (row.sourceSessionId !== sessionId) return row; + const source = row.sourceMessageId ? byId.get(row.sourceMessageId) : row.sourceTurnId ? byTurn.get(row.sourceTurnId) : undefined; + if (!source) return row; + // Projection absence is not a retraction: the backend can temporarily be + // unable to read collaboration metadata. Keep the last observed receipt and + // its outstanding read obligation until a newer observation arrives. + const returnDelivery = source.return_delivery ?? row.returnDelivery; + const collaboration = source.collaboration ?? row.collaboration; + if (row.sourceMessageId === source.message_id && JSON.stringify(row.returnDelivery) === JSON.stringify(returnDelivery) + && JSON.stringify(row.collaboration) === JSON.stringify(collaboration)) return row; + changed = true; + return { ...row, sourceMessageId: source.message_id, returnDelivery, collaboration }; + }); + for (const message of messages) { + if (message.origin !== "manager_followup" || seen.has(message.message_id)) continue; + seen.add(message.message_id); + changed = true; + updated.push(createReply(message)); + } + return changed ? updated : previous; +} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 52e017888..e8deec4e3 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1,3 +1,4 @@ +import { conversationReturnSessions, reconcileConversationReturns } from "../data/conversation-returns"; import {compactWorkspaceText as compactShareText} from "../features/personal-workspace/personal-workspace-model"; import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, sourceAttention } from "../features/personal-workspace/attention-details"; @@ -1473,59 +1474,44 @@ function PersonalGoalHome({ statusSourceControl.activeSource.statusUrl, ]); - // Worker returns are transcript messages, not new model turns. Keep an open - // conversation current without replacing in-flight user/agent text. - const conversationReturnSessionId = runtimeBindings[contextId]?.sessionId; + // Read the active session plus older sessions that still owe a result. The + // stable key changes only when that set changes, never on each stream delta. + const conversationReturnSessionKey = JSON.stringify(conversationReturnSessions( + runtimeBindings[contextId]?.sessionId, messagesByContext[contextId] ?? [], + )); useEffect(() => { - if (readOnly || !conversationReturnSessionId) return; + if (readOnly) return; + const sessionIds: string[] = JSON.parse(conversationReturnSessionKey); let cancelled = false; - let timer: ReturnType | undefined; - const receive = async () => { + const timers = new Set>(); + const receive = async (sessionId: string) => { try { - const snapshot = await fetchChatSession(conversationReturnSessionId); + const snapshot = await fetchChatSession(sessionId); if (cancelled) return; - const replies = snapshot.messages.filter((row) => row.origin === "manager_followup"); setMessagesByContext((current) => { const previous = current[contextId] ?? []; - const seen = new Set(previous.map((row) => row.sourceMessageId)); - const fresh = replies.filter((row) => !seen.has(row.message_id)); - const deliveryByMessage = new Map( - replies.map((row) => [row.message_id, row.return_delivery]), - ); - const byTurn = new Map(snapshot.messages.filter((row) => row.role !== "user" && row.origin !== "manager_followup").map((row) => [row.turn_id, row])); - const collaborationByMessage = new Map(snapshot.messages.map((row) => [row.message_id, row.collaboration])); - let deliveryChanged = false; - const updated = previous.map((row) => { - const delivery = row.sourceMessageId - ? deliveryByMessage.get(row.sourceMessageId) - : undefined; - const source = row.sourceTurnId ? byTurn.get(row.sourceTurnId) : undefined; - const collaboration = row.sourceMessageId ? collaborationByMessage.get(row.sourceMessageId) : source?.collaboration; - if (JSON.stringify(delivery) === JSON.stringify(row.returnDelivery) && JSON.stringify(collaboration) === JSON.stringify(row.collaboration)) return row; - deliveryChanged = true; - return { ...row, sourceMessageId: row.sourceMessageId ?? source?.message_id, - sourceSessionId: row.sourceSessionId ?? (source?.message_id ? conversationReturnSessionId : undefined), - returnDelivery: delivery, collaboration }; - }); - if (!fresh.length && !deliveryChanged) return current; - return { ...current, [contextId]: [...updated, ...fresh.map((row) => ({ + const updated = reconcileConversationReturns(previous, sessionId, snapshot.messages, (row) => ({ id: managerMessageId.current++, sourceMessageId: row.message_id, - sourceSessionId: conversationReturnSessionId, + sourceSessionId: sessionId, role: "assistant" as const, agentLabel: "协作回执", sourceLabel: "协作回执", text: visibleAgentMessage(row.text), lines: [], - returnDelivery: row.return_delivery, - }))] }; + returnDelivery: row.return_delivery, collaboration: row.collaboration, + })); + return updated === previous ? current : { ...current, [contextId]: updated }; }); } catch { - // The durable transcript is retried after reconnection; no model replay. + // Retry this transcript read independently; never replay the model. } finally { - if (!cancelled) timer = setTimeout(receive, 3000); + if (!cancelled) { + const timer = setTimeout(() => { timers.delete(timer); void receive(sessionId); }, 3000); + timers.add(timer); + } } }; - void receive(); - return () => { cancelled = true; if (timer) clearTimeout(timer); }; - }, [readOnly, conversationReturnSessionId, contextId, selectedAgent.label]); + sessionIds.forEach((sessionId) => { void receive(sessionId); }); + return () => { cancelled = true; timers.forEach(clearTimeout); }; + }, [readOnly, conversationReturnSessionKey, contextId]); function recordRuntimeBinding(targetContextId: string, binding: PersonalRuntimeBinding | null) { setRuntimeBindings((current) => { @@ -1692,6 +1678,7 @@ function PersonalGoalHome({ const streamingMessageId = appendManagerAssistantMessage(targetContextId, { activity: ["正在恢复进行中的 Agent 回合"], sourceTurnId: activeTurnId, + sourceSessionId: created.session_id, agentLabel: answerIdentityLabel(targetContextId, selectedAgent.label), lines: [], pending: true, @@ -2143,7 +2130,7 @@ function PersonalGoalHome({ }, onPhase: (_phase: string, turnId: string) => { submittedTurnId = turnId; - if (streamingMessageId !== null) updateManagerAssistantMessage(targetContextId, streamingMessageId, { sourceTurnId: turnId }); + if (streamingMessageId !== null) updateManagerAssistantMessage(targetContextId, streamingMessageId, { sourceTurnId: turnId, sourceSessionId: sessionId }); activeTurnIds.current.set(targetContextId, turnId); recordRuntimeBinding(targetContextId, { agentId: selectedRoute.agentId, @@ -2180,6 +2167,7 @@ function PersonalGoalHome({ item.turn_id === streamed.turnId && ["agent", "assistant"].includes(item.role)); if (answer) updateManagerAssistantMessage(targetContextId, completedMessageId, { sourceMessageId: answer.message_id, sourceSessionId: sessionId, + collaboration: answer.collaboration, returnDelivery: answer.return_delivery, }); }).catch(() => { /* The original conversation remains readable. */ }); } @@ -2694,6 +2682,7 @@ function PersonalGoalHome({ const messageId = appendManagerAssistantMessage(run.goalId, { activity: ["正在把纠偏送入原执行 Session"], sourceTurnId: turnId, + sourceSessionId: run.sessionId, agentLabel: run.agentLabel, lines: [], pending: true, diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 4062b41be..9efe0b817 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -195,7 +195,7 @@ separate; a browser fixture cannot qualify a real attached host. | Scope | Navigate A→B→A, late response, old subscription terminal event: update the original source/session/Turn only. Full snapshots and delta streams have different merge rules | | Stream | Duplicate/late events and hydrate overlap preserve one logical answer. A new event does not force scrolling while the user reads history | | Correction/stop | During tool execution and at completion: actual receiver adopts the latest scope or reports queued/unsupported. Stop targets the original Turn, never its successor or all peers implicitly | -| Result | Missing file retries read only; v1 review cannot certify v2; opening a report is not adoption. Lost return ACK reconciles before another send | +| Result | Replacing the active session must not hide a result owed by an older session in the same conversation. Readback updates only its own session, preserves streamed text and retires old reads after verified return. Missing file retries read only; v1 review cannot certify v2; opening a report is not adoption. Lost return ACK reconciles before another send | | Attached | Native host offline, stale binding, unsupported steering, next-Turn-only adapter and restart: request remains visible; no guessed success or competing driver | | Managed | Runtime start failure, quota denial, missing login and stop/restart: effective profile and actual condition readable; no silent model/account substitution | | Authority | Revoked access or source change rejects stale effects; unrelated permitted branches continue. No private history enters a shared audience | @@ -208,12 +208,27 @@ comparison; no measured improvement is claimed by this proposal. ## Delivery boundary -The delivered conversation-entry repair removes all browser free-text action classification in the App and checks -its ordinary Chat path plus explicit scheduling controls. It changes no authority -or stored message schema. Managed/attached conversation continuity, generic TS -inbox extraction and live two-cycle small-team acceptance remain planned until -their own evidence is recorded. The entry repair can roll back as an App routing -change; later persisted-contract migrations need their own compatibility plan. +The delivered conversation-entry repair removes browser free-text action +classification. Shared queue preparation now settles accepted start failures +instead of leaving requests indefinitely queued. Neither change proves a whole +managed or attached journey. + +The next qualified frontend slice retains late worker returns after active-session +replacement. The existing Chat snapshot supplies session/message lineage to a +shared TypeScript read model; only sessions still owing a conclusion or delivery +verification remain alongside the active session. No model replay, execution +driver, persisted schema or new inbox owner is introduced. Browser acceptance +covers a replacement session, one lost old-session read, automatic return and +cross-session isolation. Real host execution and receiver adoption retain their +separate acceptance requirements. + +Keep work in this order: qualify the installed App's existing-owner-to-original- +conversation journey (GQ02–04, with entry/recovery companions); then G1's two real +small-team cycles (GQ05/GQ11–13, with correction and interruption). Shared report +polish, materials and attention summaries follow; promotional film and scale +follow product evidence. Reuse pending acceptance-recovery and GoalRef work +rather than implement a competing session or inbox lifecycle. Generic TS inbox +extraction remains incremental within those journeys, not their prerequisite. ### Entry behavior compatibility diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index fe372f47d..b04196b71 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -36,7 +36,9 @@ embedding a transcript or sending an inbox ACK does not complete that transition [App conversations and asynchronous inbox](app-conversation-and-async-inbox-v0.md) refines R1–R3 without adding a milestone: repair ordinary input and durable entry; qualify connect/continue, truthful activity, stop/recovery and readable -results; then complete G1's two real collaboration cycles. Prioritize these +results, including late returns after the active session changes; then complete +G1's two real collaboration cycles. Entry routing and queue failure fixes are +prerequisites, not evidence of a complete managed/attached journey. Prioritize these before cross-channel visual parity or scale. Lark's reusable inbox lifecycle should converge into the existing typed coordination owner, while provider authentication/addressing/reactions remain in its extension. Product-facing TS diff --git a/docs/product/use-cases/steward/golden-queries.md b/docs/product/use-cases/steward/golden-queries.md index d23f4c758..3e3f04f48 100644 --- a/docs/product/use-cases/steward/golden-queries.md +++ b/docs/product/use-cases/steward/golden-queries.md @@ -288,7 +288,7 @@ first-use cohorts and release acceptance retain their existing gates. | Batch | Useful exit | Reused owner / next dependency | | --- | --- | --- | -| P0 entry and route | GQ01/GQ02 request durability plus GQ03/GQ04 eligible responsibility, actual work and same-conversation result | Existing creation/Chat services, directory, host binding and collaboration/outbox; ship the complete supported path before general migration | +| P0 entry and route | GQ01/GQ02 request durability plus GQ03/GQ04 eligible responsibility, actual work and same-conversation result, including a late return after the active session changes | Existing creation/Chat services, directory, host binding and collaboration/outbox; ship the complete supported path before general migration | | P0 use and continuity | GQ05/GQ11–GQ13 + GQ07–GQ09: dependency adoption, parallel join, peer review and resolved disagreement across two cycles; correct once, interrupt once, resume and return | R2 small-team and R3/M2/M3, artifact versions, existing driver/monitor and return recovery | | P1 material and attention | GQ06/GQ10/GQ14–GQ15: materials, attention, dependency replan, explicit mixed profiles and retained constraints | Existing material lifecycle, scoped context and presentation; no new memory installation prerequisite | | P2 breadth and launch | GQ16 then GQ17: real host and scale qualification; public-safe showcase/film only claims the separately proven cohort | Existing R6/R7 and release/first-use gates; visual motion explains actual transitions | diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index dc9565071..02c0fffd0 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -12,6 +12,7 @@ import { import { writeDashboardBrowserCoverage } from "./dashboard-browser-coverage.mjs"; import { conversationActivityScenario } from "./personal-workspace-browser/conversation-activity.mjs"; import { chatRecoveryScenario } from "./personal-workspace-browser/chat-recovery.mjs"; +import { conversationReturnContinuityScenario } from "./personal-workspace-browser/conversation-return-continuity.mjs"; import { executionChipScenario } from "./personal-workspace-browser/execution-chip.mjs"; import { collectCoverage, @@ -39,7 +40,7 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs"; import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs"; -const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; +const scenarioCatalog = [conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/conversation-return-continuity.mjs b/examples/personal-workspace-browser/conversation-return-continuity.mjs new file mode 100644 index 000000000..669d9d5e0 --- /dev/null +++ b/examples/personal-workspace-browser/conversation-return-continuity.mjs @@ -0,0 +1,63 @@ +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const conversationReturnContinuityScenario = { + id: "conversation-return-continuity", + async run({ browser, collectCoverage, url }) { + const oldSessionId = "previous-worker-session"; + const request = { + schema_version: "collaboration_request_readback_v0", request_id: "b".repeat(64), agent_id: "researcher", + brief: { purpose: "核对公开现金流数据", context: "继续已有研究。", constraints: [], inputs: [], + acceptance: ["核对期间与单位"], return_requirement: "结果回到这段对话" }, + read_status: "supplied", decision: "adopt", returns: [], + }; + const context = await openWorkspacePage(browser, url, { + collectCoverage, + beforeGoto(_api, page) { + page.__loopxRuntime.sessions.set(oldSessionId, { + session_id: oldSessionId, goal_id: "loopx-manager", agent_id: "codex", adapter_kind: "codex", + channel_id: "manager", status: "ready", active_turn_id: null, resumable: true, + created_at: "2026-08-12T01:00:00Z", updated_at: "2026-08-12T01:00:00Z", + last_activity_at: "2026-08-12T01:00:00Z", last_error_code: null, + }); + page.__loopxRuntime.messages.set(oldSessionId, [{ + message_id: "old-delegation", turn_id: "old-turn", role: "agent", text: "已交给研究负责人。", + created_at: "2026-08-12T01:00:00Z", collaboration: request, + }]); + }, + }); + const { page, api } = context; + try { + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.getByText("已交给研究负责人。", { exact: true }).waitFor({ state: "visible" }); + // A subsequent request runs in the new session while the old delegation + // remains outstanding. Returning a result must not invoke another model. + await page.getByLabel("向 LoopX 发送消息").fill("我现在该做什么?只读回答。"); + await page.getByRole("button", { name: "发送", exact: true }).click(); + await page.getByText("管家已读取当前授权范围的 Goal 证据。", { exact: true }).waitFor({ state: "visible" }); + if (api.turnRequests.at(-1).sessionId === oldSessionId) throw new Error("Fixture did not replace the active session"); + const turnsBeforeReturn = api.turnRequests.length; + let droppedRead = false; + await page.route(`**/api/chat/sessions/${oldSessionId}`, async (route) => { + if (!droppedRead) { droppedRead = true; await route.abort("connectionreset"); } + else await route.fallback(); + }); + const answer = "核对完成:现金流期间和单位一致。"; + page.__loopxRuntime.messages.get(oldSessionId).push({ + message_id: "handoff.old-result", turn_id: "old-turn", role: "agent", origin: "manager_followup", + text: answer, created_at: "2026-08-13T01:00:03Z", + return_delivery: { schema_version: "manager_return_delivery_status_v0", phase: "conclusion", status: "delivered" }, + }); + request.returns = [{ phase: "conclusion", status: "delivered" }]; + await page.getByText(answer, { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); + await page.getByText("结论已回传", { exact: true }).waitFor({ state: "visible" }); + if (!droppedRead) throw new Error("The old-session recovery path was not exercised"); + await page.waitForTimeout(3500); + if (await page.getByText(answer, { exact: true }).count() !== 1) throw new Error("Late result duplicated"); + if (await page.getByRole("region", { name: "交办说明" }).count() !== 1) throw new Error("New-session readback erased the old delegation"); + if (api.turnRequests.length !== turnsBeforeReturn) throw new Error("Readback replayed the model"); + return { coverageEntries: context.coverageEntries, note: "Late result survives active-session replacement, stays linked to its original delegation and invokes no model turn." }; + } finally { + await context.close(); + } + }, +}; From 9521f65467f55f5abfa69fb524e34dd8733c761d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:45:26 +0800 Subject: [PATCH 2/3] test(app): assert the current delivered-reply label in the return scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's \"distinguish recipient disposition from reply delivery\" rename replaced the collaboration-card copy, so the delivered conclusion now reads 回复已送达 / Reply delivered instead of 结论已回传. Assert the current owner copy so the late-return scenario keeps proving the delivered state after session replacement instead of timing out on a retired label. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../conversation-return-continuity.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/personal-workspace-browser/conversation-return-continuity.mjs b/examples/personal-workspace-browser/conversation-return-continuity.mjs index 669d9d5e0..1cca888b3 100644 --- a/examples/personal-workspace-browser/conversation-return-continuity.mjs +++ b/examples/personal-workspace-browser/conversation-return-continuity.mjs @@ -49,7 +49,10 @@ export const conversationReturnContinuityScenario = { }); request.returns = [{ phase: "conclusion", status: "delivered" }]; await page.getByText(answer, { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); - await page.getByText("结论已回传", { exact: true }).waitFor({ state: "visible" }); + // Delivery label owned by collaboration-card.tsx copy: main distinguishes + // "reply awaiting delivery" from "reply delivered", so the delivered + // conclusion reads 回复已送达 (English: Reply delivered). + await page.getByText("回复已送达", { exact: true }).waitFor({ state: "visible" }); if (!droppedRead) throw new Error("The old-session recovery path was not exercised"); await page.waitForTimeout(3500); if (await page.getByText(answer, { exact: true }).count() !== 1) throw new Error("Late result duplicated"); From 5c31549226da2b6f9f23fb64b350b6e0689f27c4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:12:20 +0800 Subject: [PATCH 3/3] test(app): point the return-polling contract at the shared read model The contract smoke greps dashboard-page.tsx for deliveryByMessage, which this refactor removed when the single-session merge moved into conversation-returns.ts. Assert the new owner instead: the page delegates to reconcileConversationReturns, and the read model still refreshes delivery state from source.return_delivery while keeping the last observation. Without this the Frontstage Pages and Desktop 'Verify packaged Personal Workspace is current' jobs fail on a retired implementation detail. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../personal-workspace/personal-workspace-contract.test.mjs | 4 +++- 1 file changed, 3 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 de22b7268..02f01aee7 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 @@ -28,6 +28,7 @@ const statusSourceSwitcher = source("./status-source-switcher.tsx"); const workspaceSettings = source("./workspace-settings-page.tsx"); const styles = source("./personal-workspace.css"); const dashboard = source("../../views/dashboard-page.tsx"); +const conversationReturns = source("../../data/conversation-returns.ts"); const tasks = source("./goal-tasks-view.tsx"); const status = source("../../data/status.ts"); const chatData = source("../../data/chat.ts"); @@ -100,7 +101,8 @@ assert.doesNotMatch(page.match(/function operationProposalFields[\s\S]*?\n\}/)?. assert.match(page, /t\("proposal\.primary\.operationGroup"\)/, "Operation confirmation routes users to the bound group"); assert.match(chatData, /result_delivery:/, "Dashboard retains operation result-delivery readback"); assert.match(chatData, /return_delivery\??:/, "Chat messages retain manager return-delivery readback"); -assert.match(dashboard, /deliveryByMessage/, "Manager return polling refreshes delivery state after the message arrives"); +assert.match(dashboard, /reconcileConversationReturns\(/, "Manager return polling uses the shared conversation-return read model"); +assert.match(conversationReturns, /source\.return_delivery \?\? row\.returnDelivery/, "Manager return polling refreshes delivery state after the message arrives"); assert.match(timeline + page, /ReturnDeliveryStatus/, "Both manager conversation surfaces render return delivery state"); for (const state of ["delivered", "verification_required", "explicit_unverified"]) { assert.match(returnDelivery, new RegExp(state), `Return delivery renders ${state}`);