Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.
- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
(bodies verbatim, deps destructured to the same names); copy/regenerate,
question repair, pure transcript and snapshot helpers and the per-render
view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
(container + split files, like home-surface.ts); slices of moved code now
call the extracted functions or render the extracted block, each with a
原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
178 lines
8.5 KiB
TypeScript
178 lines
8.5 KiB
TypeScript
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, /<RectificationRangeDelivery/);
|
||
assert.match(delivery, /rangeDeliveryAdopting/);
|
||
// 原值: 组件源码直写「已采用」
|
||
// 新值: 行内状态走 rangeDeliveryAdopted
|
||
// 原因: BUG-595 决策 1
|
||
assert.match(delivery, /rangeDeliveryAdopted/);
|
||
assert.doesNotMatch(delivery, /确认校正时间/);
|
||
assert.match(chatSource, /已确认校正时间/);
|
||
// 原值: 采用后状态条 rectification-adopt-status
|
||
// 新值: 采用状态只在卡片行内
|
||
// 原因: BUG-595 决策 3
|
||
assert.doesNotMatch(chatSource, /rectification-adopt-status/);
|
||
assert.doesNotMatch(chatSource, /rectification-consult-handoff/);
|
||
assert.doesNotMatch(chatSource, /本轮校正已收口|当前排盘时间(代表性时间|本会话以代表性时间收口/);
|
||
assert.doesNotMatch(chatSource, /candidateResult\.confirmationAllowed \? "确认校正时间"/);
|
||
});
|