From 220828d6f0a39c894eb9f81b38c28bfb1c223a4f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 12 Aug 2026 10:45:49 +0800 Subject: [PATCH] fix(rectification): hydrate persisted case turns --- docs/BUG_HISTORY.md | 15 +++++++ frontend/src/app/page.tsx | 3 +- ...onversational-birth-time-rectification.tsx | 1 + .../components/rectification-agentic-chat.tsx | 44 ++++++++++--------- .../tests/rectification-agentic-entry.test.ts | 10 ++++- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d3eeac37..a372071f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2960,3 +2960,18 @@ - 防复发:明确区分“服务端内部 Agent 工具结果”和“公开 UI/API projection”;Activity 不得从泛化 phase 文案推导技法,不得公开思维链、Prompt、工具参数、出生资料、原始分数、权重、规则 ID 或 Provider metadata。 - 相关记录:BUG-163、BUG-170、BUG-171 - 修复版本:待提交 + +## BUG-173 | 恢复生时校正会话时历史消息未随异步 Case GET 渲染 + +- 状态:resolved(本地候选) +- 首次发现:2026-08-12 +- 最近更新:2026-08-12 +- 影响面:V9 生时校正恢复入口、历史 turns 和持久化 Activity 技法展示。 +- 用户现象:点击“继续上次校正”后只看到页面壳层,Case GET 已返回 assistant turn,但消息区为空。 +- 触发条件:恢复已有 Case;聊天组件先以空 `initialTurns` 挂载,Case GET 稍后返回历史 turns。 +- 根因:`RectificationAgenticChat` 仅在 `useState` 初始化时投影 `initialTurns`,未在异步 props 更新后同步;同时 `page.tsx` 映射持久化 receipt 时漏掉 `methods`。 +- 修复:复用同一个 turns→messages 投影函数,并让父层在持久化 turns 到达时用最后一个 Turn ID 重挂载该局部聊天组件;补齐 receipt `methods` 的客户端类型和安全映射。 +- 验证:新增静态回归检查覆盖异步 hydration 和 methods 映射;部署后以真实登录浏览器确认历史 Agent 消息及 Activity 渲染。 +- 防复发:任何异步加载后传入的初始化数据不能只依赖子组件首次 state 初始化;公开 receipt 新字段必须贯通 API projection、page mapping 与组件类型。 +- 相关记录:BUG-172 +- 修复版本:待提交 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3920cea7..cf49ca53 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2372,6 +2372,7 @@ export default function Home() { status: String((turn.receipt as { status?: unknown }).status ?? ""), phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [], tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [], + methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [], skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined, skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined, } : null, @@ -3480,7 +3481,7 @@ export default function Home() { {rectificationSurfaceOpen && rectificationCaseId && ( | null; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index a1806cad..8e5dba54 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -112,6 +112,28 @@ function activityFromReceipt(receipt: PersistedTurn["receipt"]): ReceiptActivity }; } +function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] { + return initialTurns.flatMap((turn, index): RenderMessage[] => { + const key = `persisted-${turn.id}-${index}`; + if (turn.role === "assistant") { + return [{ + role: "assistant", + text: turn.text ?? "", + renderKey: key, + state: turn.status === "completed" ? "settled" : "thinking", + receiptActivity: activityFromReceipt(turn.receipt), + receiptStatus: turn.receipt?.status, + }]; + } + return [{ + role: "user", + text: turn.text ?? "", + renderKey: key, + state: "settled", + }]; + }); +} + export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, @@ -131,27 +153,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onRestart, } = props; - const [messages, setMessages] = useState(() => - initialTurns.flatMap((turn, index): RenderMessage[] => { - const key = `persisted-${turn.id}-${index}`; - if (turn.role === "assistant") { - return [{ - role: "assistant", - text: turn.text ?? "", - renderKey: key, - state: turn.status === "completed" ? "settled" : "thinking", - receiptActivity: activityFromReceipt(turn.receipt), - receiptStatus: turn.receipt?.status, - }]; - } - return [{ - role: "user", - text: turn.text ?? "", - renderKey: key, - state: "settled", - }]; - }), - ); + const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 1fcdc05d..e7fbdb6a 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -29,6 +29,14 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => { assert.match(component, /initialTurns:/); }); +test("persisted rectification turns hydrate after the async Case refresh", () => { + assert.match(chat, /function messagesFromTurns\(initialTurns:/); + assert.match(chat, /useState\(\(\) => messagesFromTurns\(initialTurns\)\)/); + assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/); + assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/); + assert.match(component, /methods\?: readonly string\[\]/); +}); + test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => { assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/); assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/); @@ -100,7 +108,7 @@ test("agent tool calls never end silently; the runner owns completion and failur test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => { assert.match(chat, /initialTurns/); assert.match(chat, /const openingStarted = useRef\(false\)/); - assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/); + assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/); assert.match(page, /initialTurns=\{rectificationTurns\}/); assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/); });