Files
Jyotisha/frontend/tests/rectification-latency-20260926.test.tsx
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- 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 since
  aa7ccb30 (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
2026-09-26 22:04:56 +08:00

349 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
});