import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { createRectificationActivityReceiptState, reduceRectificationActivityReceipt, receiptFromRectificationActivityState, } from "../src/lib/rectification-activity-receipt"; import { rectificationChatSurface } from "./rectification-chat-surface.ts"; import type { RenderMessage } from "../src/components/rectification-message-entry.tsx"; import { chatRunRecorder, ndjsonResponse, runRectificationChatTurn, withFakeFetch, } from "./rectification-chat-run-fixtures.ts"; test("a successful retry clears the same tool's earlier failure", () => { let state = createRectificationActivityReceiptState(); for (const event of [ { tool: "rectification-read-case", status: "completed" }, { tool: "rectification-propose-evidence", status: "failed" }, { tool: "rectification-propose-evidence", status: "started" }, { tool: "rectification-propose-evidence", status: "completed" }, { tool: "rectification-confirm-evidence", status: "completed" }, ] as const) { state = reduceRectificationActivityReceipt(state, event); } assert.deepEqual(receiptFromRectificationActivityState(state), { steps: [ "rectification-read-case", "rectification-propose-evidence", "rectification-confirm-evidence", ], methods: [], }); }); test("an unrecovered latest tool failure remains visible", () => { let state = createRectificationActivityReceiptState(); state = reduceRectificationActivityReceipt(state, { tool: "rectification-read-case", status: "completed", }); state = reduceRectificationActivityReceipt(state, { tool: "rectification-propose-evidence", status: "failed", }); assert.deepEqual(receiptFromRectificationActivityState(state), { steps: ["rectification-read-case"], methods: [], failedTool: "rectification-propose-evidence", }); }); test("a later failure overrides an earlier completion for the same tool", () => { let state = createRectificationActivityReceiptState(); state = reduceRectificationActivityReceipt(state, { tool: "rectification-read-case", status: "completed", }); state = reduceRectificationActivityReceipt(state, { tool: "rectification-read-case", status: "failed", }); assert.deepEqual(receiptFromRectificationActivityState(state), { steps: [], methods: [], failedTool: "rectification-read-case", }); }); test("a later failure removes methods contributed by the failed tool", () => { let state = createRectificationActivityReceiptState(); state = reduceRectificationActivityReceipt(state, { tool: "rectification-compare-candidates", status: "completed", methods: ["d1-rashi", "vimshottari-dasha"], }); state = reduceRectificationActivityReceipt(state, { tool: "rectification-read-diagnostics", status: "completed", methods: ["d1-rashi", "shadbala"], }); state = reduceRectificationActivityReceipt(state, { tool: "rectification-compare-candidates", status: "failed", }); assert.deepEqual(receiptFromRectificationActivityState(state), { steps: ["rectification-read-diagnostics"], methods: ["d1-rashi", "shadbala"], failedTool: "rectification-compare-candidates", }); }); // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 const chatSource = rectificationChatSurface; test("answer deltas preserve a still-running server activity", async () => { // Former locks: `state: raw.trim() ? "streaming" : "thinking"` and `text: raw,` inside the // delta branch. Those pinned one setMessages per network chunk (BUG-473); the same state // derivation now lives in the frame buffer's flush, which the delta branch feeds. // 原值: 从 chat 源码切 answer.delta 分支(到 run.failed)与 frames 定义(到 new AbortController),匹配 // flush 里 `state: text.trim() ? "streaming" : "thinking"`、`text,`,delta 分支里 `frames.setAnswer(raw)`、 // `event.replace === true ? event.text : raw + event.text`、「正在组织回答」,且不含 settled.spoken / // `activeActivity: undefined`。 // 新值: 调用 runRectificationChatTurn(假 NDJSON 流): // 1) 只有工具开始、没有正文时,flush 出的活行是 thinking、正文为空; // 2) 两段 answer.delta(第二段 replace)后,flush 出的活行是 streaming、正文等于 replace 后的全文、 // 活动标签是「正在组织回答…」且活动没被清掉;每段 delta 不各自 setMessages(一次提交)。 // 原因: TASK-rectification-code-split-20260926 `send` 函数体原样搬进 rectification-chat-turn-run.ts; // 对流读取结果断言比源码 token 更强。 const liveRows = (history: RenderMessage[][]) => history .map((messages) => messages.find((message) => message.renderKey === "v9-assistant-1")) .filter((row): row is RenderMessage => Boolean(row)); const toolOnly = chatRunRecorder(); await withFakeFetch(() => ndjsonResponse([ { type: "tool.activity", tool: "rectification-read-case", status: "started" }, ]), () => runRectificationChatTurn(toolOnly.turnDeps(), "message", "我 2019 年换了工作")); const thinkingRow = liveRows(toolOnly.messageHistory).find((row) => row.state === "thinking" && row.activityTrace?.length); assert.ok(thinkingRow, "the flush of a tool-only stream keeps a thinking row"); assert.equal(thinkingRow.text, ""); const streamed = chatRunRecorder(); await withFakeFetch(() => ndjsonResponse([ { type: "tool.activity", tool: "rectification-read-case", status: "started" }, { type: "answer.delta", text: "第一段" }, { type: "answer.delta", text: "改写后的全文", replace: true }, ]), () => runRectificationChatTurn(streamed.turnDeps(), "message", "我 2019 年换了工作")); const streamingRows = liveRows(streamed.messageHistory).filter((row) => row.state === "streaming"); assert.equal(streamingRows.length, 1, "deltas commit through the frame buffer, not one setMessages each"); assert.equal(streamingRows[0].text, "改写后的全文"); assert.match(streamingRows[0].activity?.label ?? "", /正在组织回答/); assert.equal(streamingRows[0].activity?.phase, "answer-composition"); assert.ok(streamed.liveLabels.includes("正在组织回答…")); }); test("failed server turns retain their receipt state without parsing activity from answer text", () => { assert.match(chatSource, /failed:\s*true/); assert.match(chatSource, /completedReceipt/); // Former lock: `message.state === "settled" && message.failed`, the inline red banner above a // failed reply. That banner was the second, rectification-only failure surface (BUG-476); // failure now reaches the shared error notice while the receipt state still gates actions. assert.match(chatSource, /&& !message\.failed/); assert.doesNotMatch(chatSource, /rectification-activity-failure/); assert.doesNotMatch(chatSource, /parsed\.(?:activity|receipt|tools|methods)/); }); test("candidate acceptance copy never presents adoption as confirmation", () => { // 旧:分钟卡「当前可能的出生时间」+ 「采用不等于确认出生时间」 // 新:区间交付卡;动作是「按这个范围用」;已采用仍不是确认 // 原因:TASK-rectification-range-delivery-card-20260907 交付对象改成区间 const delivery = readFileSync( new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8", ); assert.match(chatSource, /