Files
Jyotisha/frontend/tests/rectification-latency-20260926.test.tsx
T
Jesse_ChenandClaude Opus 5.5 ab0f01a9cf fix(rectification): stream-first typed turns, 10 s classifier cap, stage progress, run timings (BUG-1047)
- D2: each intent-classifier attempt is capped at 10 s (same session model,
  thinking untouched); a hang takes the existing retry -> classifier_unavailable
  path. Success is timed too (RectificationClassifierDiagnostic).
- D3: a typed message builds the NDJSON stream first; the first line is
  turn.progress "received", then the classifier and deterministic replies run
  inside the stream. Preflight rejections become turn.rejected (old status,
  code, message) and the client handles them like the old HTTP rejection.
  Stage lines 收到,正在对照你的档案… / 正在记下这件事… / 正在重新对照盘面… /
  正在准备下一个问题… are driven by existing tool events and engine calls, are
  transient (live row only) and never persisted. VOICE / DESIGN updated.
- D4: RectificationRunDiagnostic records per-step start/end, provider token
  usage incl. reasoning tokens, classifier timing and per-engine-call
  durations (AsyncLocalStorage scope per turn); RectificationTurnDiagnostic
  for deterministic turns. No user text, birth data or model text.
- D5: /v5/versions memo (30 s, complete identities only, per transport); the
  exit gate skips its second persistNextInterviewIfIdle when the run's own
  call found the next focus already active (provably identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 11:45:47 +08:00

346 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);
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]);
assert.equal(chat.text().includes("正在整理多条事件证据…"), false);
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();
}
});