Consultation replies keep their receipt (scope-lock test). Two source/DOM assertions updated with original/new/reason notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
185 lines
8.3 KiB
TypeScript
185 lines
8.3 KiB
TypeScript
/**
|
||
* 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<string, unknown> }).window;
|
||
win.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
|
||
win.setInterval = setInterval;
|
||
win.clearInterval = clearInterval;
|
||
const proto = (harness.container as unknown as { constructor: { prototype: Record<string, unknown> } }).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 = <T extends { style: object }>(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(
|
||
<RectificationAgenticChat
|
||
caseId={CASE_ID}
|
||
sessionId={SESSION_ID}
|
||
readonly={false}
|
||
shouldStartOpening
|
||
initialTurns={[]}
|
||
initialSnapshot={snapshot([])}
|
||
declaredTime="14:50"
|
||
birthDate="1990-06-15"
|
||
models={[]}
|
||
selectedModelId="model"
|
||
onSelectModel={() => {}}
|
||
headerSlot={null}
|
||
/>,
|
||
);
|
||
for (let i = 0; i < 8; i += 1) await harness.idle();
|
||
|
||
type Host = ReturnType<typeof harness.elements>[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 (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);
|
||
// 原值: turn 以「已完成 2 步」开头;「看了你的资料」「准备好下一个问题」各出现 1 次(收起的步骤回执)。
|
||
// 新值: 结算后无「已完成」回执,两条步骤名出现 0 次;turn 以开场正文开头。
|
||
// 原因: BUG-1137(TASK-rectification-message-cleanup-20261001 D3)——步骤只在生成中作等待态,结算后不渲染;BUG-1050 的「回执去重」随回执一起不再出现。
|
||
assert.ok(turn.startsWith(BODY_SENTENCES[0]!), turn);
|
||
for (const jargon of ["大运", "盘面", "代表分钟", "精确到秒", "对话焦点", "校正记录"]) {
|
||
assert.equal(turn.includes(jargon), false, jargon);
|
||
}
|
||
assert.doesNotMatch(text, /已完成 \d+ 步/);
|
||
assert.equal(text.split("看了你的资料").length - 1, 0);
|
||
assert.equal(text.split("准备好下一个问题").length - 1, 0);
|
||
} finally {
|
||
await chat.close();
|
||
}
|
||
});
|
||
}
|