- 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
349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
/**
|
||
* 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> = {}): 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<string, unknown> | null }) => Response | Promise<Response>;
|
||
|
||
async function mountChat(input: {
|
||
initialTurns: RawTurn[];
|
||
initialFocuses: ConversationFocus[];
|
||
initialCurrent: ConversationFocus | null;
|
||
route: Route;
|
||
}) {
|
||
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;
|
||
// Host DOM has no layout: the scroll-anchor hook only needs these to exist.
|
||
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;
|
||
const requests: Array<{ url: string; method: string; body: Record<string, unknown> | 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<string, unknown> : null,
|
||
};
|
||
requests.push(request);
|
||
return input.route(request);
|
||
}) as typeof fetch;
|
||
|
||
const initial = snapshot(input.initialTurns, input.initialFocuses, input.initialCurrent);
|
||
await harness.render(
|
||
<RectificationAgenticChat
|
||
caseId={CASE_ID}
|
||
sessionId={SESSION_ID}
|
||
readonly={false}
|
||
shouldStartOpening={false}
|
||
initialTurns={initial.turns}
|
||
initialSnapshot={initial}
|
||
declaredTime="14:50"
|
||
birthDate="1990-06-15"
|
||
models={[]}
|
||
selectedModelId="model"
|
||
onSelectModel={() => {}}
|
||
headerSlot={null}
|
||
/>,
|
||
);
|
||
await harness.idle();
|
||
|
||
type Host = ReturnType<typeof harness.elements>[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
|
||
// `<legend className="sr-only">`) 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<Uint8Array>;
|
||
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c; } });
|
||
let release!: () => void;
|
||
const gate = new Promise<void>((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<void> } }) {
|
||
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();
|
||
}
|
||
});
|