/** * BUG-1047 D3 (TASK-rectification-latency-20260926), live path in the real * `RectificationAgenticChat`: a typed answer shows 「收到,正在对照你的档案…」 * the moment it is sent — before the server has answered at all — and the * server's `turn.progress` events move the live row through the four stage * lines. Tool events no longer overwrite it with tool names or 「正在分析…」. * When the reply settles, no progress line is left in the transcript. A * `turn.rejected` event is handled exactly like the old non-2xx response. * * Harness copied from `rectification-dup-question-20260926.test.tsx`. */ import assert from "node:assert/strict"; import test from "node:test"; import { RectificationAgenticChat } from "../src/components/rectification-agentic-chat.tsx"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RECTIFICATION_TURN_PROGRESS_LABELS } from "../src/lib/rectification-activity-labels.ts"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; const CASE_ID = "44444444-4444-4444-8444-444444444444"; const SESSION_ID = "33333333-3333-4333-8333-333333333333"; const FOCUS_1 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; const FOCUS_2 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2"; const STEM_1 = "2023 年前后,有没有换过工作或职责明显变化?"; const STEM_2 = "2024 年 3 月前后,有没有收入明显变化、大笔支出或欠债?"; const OPTIONS = [ { key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes", role: "primary" }, { key: "B" as const, label: "发生过但程度较弱", answer_class: "weak_yes", role: "primary" }, { key: "C" as const, label: "没有这回事", answer_class: "no", role: "primary" }, { key: "D" as const, label: "这段记不清楚", answer_class: "unsure", role: "primary" }, ]; function choiceSchema(prompt: string) { return { prompt, probe_id: `probe:${prompt.slice(0, 4)}`, choice: { prompt, option_a: OPTIONS[0].label, option_b: OPTIONS[1].label, option_c: OPTIONS[2].label, option_d: OPTIONS[3].label, options: OPTIONS.map(({ key, label, answer_class }) => ({ key, label, answer_class })), }, }; } function focus(id: string, prompt: string, overrides: Partial = {}): ConversationFocus { return { id, caseId: CASE_ID, questionId: `distinguish:${id.slice(-1)}`, intent: "distinguish_candidates", targetEvidenceId: null, targetDomain: "career", targetKind: null, expectedAnswerSchema: choiceSchema(prompt), status: "active", askedAt: "2026-09-26T08:00:00.000Z", resolvedAt: null, askedTurnId: null, answerOption: null, ...overrides, }; } function choiceCard(focusId: string, prompt: string, questionId: string) { return { question_id: questionId, method_id: "candidate_discriminator", prompt, choice_mode: "A/B/C/D", options: OPTIONS, focus_id: focusId, case_revision: 3, probe_id: `probe:${prompt.slice(0, 4)}`, }; } type RawTurn = { id: string; role: "user" | "assistant"; text: string; status: string }; /** The GET route's turn assembly: the real `attachQuestionsToTurns`. */ function snapshot(rawTurns: RawTurn[], focuses: ConversationFocus[], current: ConversationFocus | null) { const turns = attachQuestionsToTurns(rawTurns, focuses); const collect = current && current.expectedAnswerSchema.collect === true; return { case: { status: "collecting_evidence" }, question_source: current ? "focus" : null, current_question: current ? { kind: collect ? "collect_spoken" : "choice", prompt: current.expectedAnswerSchema.prompt, focus_id: current.id, question_id: current.questionId, } : null, choice_card: current && !collect ? choiceCard(current.id, String(current.expectedAnswerSchema.prompt), current.questionId) : null, turns, }; } function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } type Route = (request: { url: string; method: string; body: Record | null }) => Response | Promise; async function mountChat(input: { initialTurns: RawTurn[]; initialFocuses: ConversationFocus[]; initialCurrent: ConversationFocus | null; route: Route; }) { const harness = createClientLifecycleHarness(); const win = (globalThis as unknown as { window: Record }).window; win.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); win.setInterval = setInterval; win.clearInterval = clearInterval; // Host DOM has no layout: the scroll-anchor hook only needs these to exist. const proto = (harness.container as unknown as { constructor: { prototype: Record } }).constructor.prototype; Object.assign(proto, { querySelector: () => null, querySelectorAll: () => [], getBoundingClientRect: () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }), compareDocumentPosition: () => 0, scrollTo() {}, scrollTop: 0, scrollHeight: 0, clientHeight: 0, }); const doc = (globalThis as unknown as { document: { createElement: (tag: string) => { style: object } } }).document; const create = doc.createElement.bind(doc); const styled = (element: T): T => { Object.assign(element.style, { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" }); return element; }; doc.createElement = (tag: string) => styled(create(tag)); styled(harness.container as unknown as { style: object }); const originalFetch = globalThis.fetch; const requests: Array<{ url: string; method: string; body: Record | null }> = []; globalThis.fetch = (async (resource: RequestInfo | URL, init?: RequestInit) => { const request = { url: String(resource), method: init?.method ?? "GET", body: typeof init?.body === "string" ? JSON.parse(init.body) as Record : null, }; requests.push(request); return input.route(request); }) as typeof fetch; const initial = snapshot(input.initialTurns, input.initialFocuses, input.initialCurrent); await harness.render( {}} headerSlot={null} />, ); await harness.idle(); type Host = ReturnType[number]; const byTag = (tag: string) => harness.elements().filter((node) => node.tagName === tag); const attr = (node: Host, name: string) => node.getAttribute(name); const insideDisabledFieldset = (node: Host): boolean => { for (let at: Host | null = node; at; at = at.parentNode as Host | null) { if (at.tagName === "FIELDSET" && at.hasAttribute("disabled")) return true; } return false; }; // What a sighted reader sees: screen-reader-only nodes (the card's // ``) are not a second visible stem. const visibleText = (node: Host): string => { if ((node.props.className as string | undefined)?.split(/\s+/).includes("sr-only")) return ""; return node.textContent + node.childNodes.map((child) => visibleText(child as Host)).join(""); }; const optionButtons = () => byTag("BUTTON").filter((node) => (node.props.className as string | undefined)?.includes("birth-time-choice-option")); return { harness, requests, text: () => visibleText(harness.container as Host), occurrences: (needle: string) => visibleText(harness.container as Host).split(needle).length - 1, cards: () => byTag("SECTION").filter((node) => (node.props.className as string | undefined)?.includes("rectification-choice-card")), clickableOptions: () => optionButtons().filter((node) => !insideDisabledFieldset(node)), selectedOptions: () => optionButtons().filter((node) => attr(node, "data-selected") === "true"), persistedBlocks: () => harness.elements().filter((node) => attr(node, "data-testid") === "persisted-question"), async type(message: string) { const textarea = byTag("TEXTAREA")[0]; assert.ok(textarea, "composer textarea"); await harness.event(textarea, "onChange", { target: { value: message } }); const form = byTag("FORM")[0]; assert.ok(form, "composer form"); await harness.event(form, "onSubmit"); for (let i = 0; i < 5; i += 1) await harness.idle(); }, async tap(label: string) { const button = optionButtons().find((node) => node.text.includes(label) && !insideDisabledFieldset(node)); assert.ok(button, `clickable option ${label}`); await harness.event(button, "onClick"); for (let i = 0; i < 5; i += 1) await harness.idle(); }, async close() { globalThis.fetch = originalFetch; await harness.close(); }, }; } const OPENING: RawTurn = { id: "t0", role: "assistant", text: "眼下按 14:35–15:05 来核对。", status: "completed" }; const LABELS = RECTIFICATION_TURN_PROGRESS_LABELS; const ALL_LINES = Object.values(LABELS); function controllableStream() { const encoder = new TextEncoder(); let controller!: ReadableStreamDefaultController; const body = new ReadableStream({ start(c) { controller = c; } }); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); return { respond: async () => { await gate; return new Response(body, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8" } }); }, release: () => release(), push: (event: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)), end: () => controller.close(), }; } async function settle(chat: { harness: { idle(): Promise } }) { for (let i = 0; i < 8; i += 1) { await chat.harness.idle(); await new Promise((resolve) => setTimeout(resolve, 5)); } } function shown(chat: { text(): string }) { const text = chat.text(); return ALL_LINES.filter((line) => text.includes(line)); } test("D3 · the first stage line shows on send (before any server byte), then follows turn.progress; none survives the settle", async () => { const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" }); const stream = controllableStream(); const reply = "记下了:2023 年 3 月换了工作。"; const chat = await mountChat({ initialTurns: [OPENING], initialFocuses: [q1], initialCurrent: q1, route: ({ method }) => { if (method === "POST") return stream.respond(); const q2 = focus(FOCUS_2, STEM_2, { askedTurnId: "t2" }); return json(snapshot([ OPENING, { id: "t1", role: "user", text: "2023 年 3 月换了工作", status: "completed" }, { id: "t2", role: "assistant", text: reply, status: "completed" }, ], [{ ...q1, status: "resolved" as const }, q2], q2)); }, }); try { const sentAt = Date.now(); await chat.type("2023 年 3 月换了工作"); // The fetch has not resolved yet: this line is on screen with no server byte. assert.deepEqual(shown(chat), [LABELS.received], chat.text()); assert.ok(Date.now() - sentAt <= 300, "first progress line within 300 ms of send"); assert.equal(chat.text().includes("正在处理…"), false); stream.release(); stream.push({ type: "turn.progress", stage: "received" }); await settle(chat); assert.deepEqual(shown(chat), [LABELS.received]); stream.push({ type: "run.started" }); stream.push({ type: "tool.activity", tool: "rectification-read-case", status: "started" }); stream.push({ type: "tool.activity", tool: "rectification-read-case", status: "completed" }); await settle(chat); assert.deepEqual(shown(chat), [LABELS.received], "a tool name or 正在分析… does not replace the stage line"); assert.equal(chat.text().includes("正在分析…"), false); // 原值: "正在读取校正记录…" / 新值: "正在看你的资料…" / 原因: BUG-1050 读档工具的进行中句改大白话;断言含义不变(工具句不顶替阶段句) assert.equal(chat.text().includes("正在看你的资料…"), false); stream.push({ type: "turn.progress", stage: "recording" }); stream.push({ type: "tool.activity", tool: "rectification-record-evidence-batch", status: "started" }); await settle(chat); assert.deepEqual(shown(chat), [LABELS.recording]); // 原值: 断言不出现 "正在整理多条事件证据…" / 新值: 批量记录工具的进行中句就是阶段句「正在记下这件事…」,只出现一行 / 原因: BUG-1050 工具句改大白话后与 BUG-1047 阶段句同文 assert.equal(chat.text().includes("正在整理多条事件证据…"), false); assert.equal(chat.text().split(LABELS.recording).length - 1, 1); stream.push({ type: "turn.progress", stage: "rescoring" }); await settle(chat); assert.deepEqual(shown(chat), [LABELS.rescoring]); stream.push({ type: "tool.activity", tool: "rectification-record-evidence-batch", status: "completed" }); stream.push({ type: "turn.progress", stage: "preparing_question" }); await settle(chat); assert.deepEqual(shown(chat), [LABELS.preparing_question]); stream.push({ type: "answer.delta", text: reply }); stream.push({ type: "run.completed", turnId: "t2" }); stream.end(); await settle(chat); assert.equal(chat.harness.errors.length, 0, String(chat.harness.errors[0] ?? "")); assert.deepEqual(shown(chat), [], `no progress line after settle:\n${chat.text()}`); assert.equal(chat.occurrences(reply), 1); } finally { await chat.close(); } }); test("D3 · turn.rejected on the open stream behaves like the old HTTP rejection: row gone, typed mark withdrawn, message shown", async () => { const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" }); const stream = controllableStream(); const chat = await mountChat({ initialTurns: [OPENING], initialFocuses: [q1], initialCurrent: q1, route: ({ method }) => (method === "POST" ? stream.respond() : json(snapshot([OPENING], [q1], q1))), }); try { await chat.type("换过"); assert.deepEqual(shown(chat), [LABELS.received]); stream.release(); stream.push({ type: "turn.progress", stage: "received" }); stream.push({ type: "turn.rejected", httpStatus: 409, code: "stale_question", message: "这道题已经过期,请回答当前问题" }); await settle(chat); assert.deepEqual(shown(chat), [], chat.text()); assert.ok(chat.text().includes("这道题已经过期,请回答当前问题"), chat.text()); assert.equal(chat.cards().length, 1); assert.equal(chat.clickableOptions().length, 4, "the typed mark is withdrawn: the card is tappable again"); assert.equal(chat.occurrences(STEM_1), 1); } finally { await chat.close(); } });