/** * BUG-1049 / BUG-1050 on the real surface: mount `RectificationAgenticChat`, * let it start the opening (POST action=opening → stream → settle → snapshot * merge) and count what the reader sees — the body sentences, the stem once * with its examples, and a receipt with one row per shown step. */ import assert from "node:assert/strict"; import test from "node:test"; import { RectificationAgenticChat } from "../src/components/rectification-agentic-chat.tsx"; import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { GENERIC_COLLECT_QUESTION, openingSpokenBody } from "../src/lib/rectification-agentic/user-copy.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_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; const BODY = openingSpokenBody(["14:35", "15:05"]); const BODY_SENTENCES = [ "我们来把你的出生时间缩小到更准的范围,现在先在 14:35–15:05 之间找。", "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。", ]; const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"]; type RawTurn = { id: string; role: "user" | "assistant"; text: string; status: string }; const OPENING_FOCUS: ConversationFocus = { id: FOCUS_ID, caseId: CASE_ID, questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "other", targetKind: null, expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true }, status: "active", askedAt: "2026-09-26T08:00:00.000Z", resolvedAt: null, askedTurnId: "t0", answerOption: null, }; function snapshot(rawTurns: RawTurn[]) { return { case: { status: "collecting_evidence" }, question_source: rawTurns.length ? "focus" : null, current_question: rawTurns.length ? { kind: "collect_spoken", prompt: GENERIC_COLLECT_QUESTION, focus_id: FOCUS_ID, question_id: OPENING_FOCUS.questionId, } : null, choice_card: null, turns: attachQuestionsToTurns(rawTurns, rawTurns.length ? [OPENING_FOCUS] : []), }; } function ndjson(events: unknown[]): Response { return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8" }, }); } function json(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); } async function mountOpening(streamedText: string, storedText: string) { const harness = createClientLifecycleHarness(); const win = (globalThis as unknown as { window: Record }).window; win.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); win.setInterval = setInterval; win.clearInterval = clearInterval; 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; let posted = false; globalThis.fetch = (async (_resource: RequestInfo | URL, init?: RequestInit) => { if ((init?.method ?? "GET") === "POST") { posted = true; // The same tool sequence as the real-device receipt: read-case, then // set-focus twice (write the question, then rewrite it). return ndjson([ { type: "run.started" }, { type: "tool.activity", tool: "rectification-read-case", status: "started" }, { type: "tool.activity", tool: "rectification-read-case", status: "completed" }, { type: "tool.activity", tool: "rectification-set-focus", status: "started" }, { type: "tool.activity", tool: "rectification-set-focus", status: "completed" }, { type: "tool.activity", tool: "rectification-set-focus", status: "started" }, { type: "tool.activity", tool: "rectification-set-focus", status: "completed" }, { type: "answer.delta", text: streamedText }, { type: "run.completed", turnId: "t0" }, ]); } return json(snapshot(posted ? [{ id: "t0", role: "assistant", text: storedText, status: "completed" }] : [])); }) as typeof fetch; await harness.render( {}} headerSlot={null} />, ); for (let i = 0; i < 8; i += 1) await harness.idle(); type Host = ReturnType[number]; 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(""); }; return { posted: () => posted, text: () => visibleText(harness.container as Host), async close() { globalThis.fetch = originalFetch; await harness.close(); }, }; } for (const [name, streamed, stored] of [ ["body only (agent opening)", BODY, BODY], ["body + stem in the text (composed)", composeCollectSpokenAssistantText(BODY, GENERIC_COLLECT_QUESTION), BODY], ] as const) { test(`opening on the real surface · ${name}: body kept, stem once with examples, receipt de-duplicated`, async () => { const chat = await mountOpening(streamed, stored); try { assert.equal(chat.posted(), true, "the surface started the opening"); const text = chat.text(); for (const sentence of BODY_SENTENCES) assert.ok(text.includes(sentence), sentence); assert.equal(text.split(GENERIC_COLLECT_QUESTION).length - 1, 1, text); for (const example of EXAMPLES) assert.equal(text.split(example).length - 1, 1, example); assert.match(text, /例如「2015 年夏天换了工作」/); // The opening turn (receipt + body + stem). The side board keeps its own // 「当前盘面」 heading; that panel is not part of this turn. const turn = text.slice(0, text.indexOf(GENERIC_COLLECT_QUESTION) + GENERIC_COLLECT_QUESTION.length); assert.ok(turn.startsWith("已完成 2 步"), turn); for (const jargon of ["大运", "盘面", "代表分钟", "精确到秒", "对话焦点", "校正记录"]) { assert.equal(turn.includes(jargon), false, jargon); } assert.match(text, /已完成 2 步/); assert.equal(text.split("看了你的资料").length - 1, 1); assert.equal(text.split("准备好下一个问题").length - 1, 1); } finally { await chat.close(); } }); }