- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and an example answer again and is server-owned on the zero-evidence opening; the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no question). Stem de-dup compares whole sentences / near-equality instead of a 12-char prefix, which had deleted the body's examples sentence sinceaa7ccb30(BUG-604) +dd8f35f7(BUG-648). - BUG-1050: plain step labels; a finished step label shows once and 「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the in-progress wording. - Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated. - VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
182 lines
7.8 KiB
TypeScript
182 lines
7.8 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 (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();
|
|
}
|
|
});
|
|
}
|