From 741a395a7791fdddd22417f867272db8ae8ff7a9 Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Tue, 21 Jul 2026 04:23:59 +0800
Subject: [PATCH] fix: harden conversational rectification UI
---
frontend/src/app/globals.css | 4 +-
...onversational-birth-time-rectification.tsx | 135 ++++--
.../hooks/use-conversational-rectification.ts | 83 +++-
.../conversational-rectification/client.ts | 40 +-
...onversational-rectification-client.test.ts | 93 +++-
...ersational-rectification-component.test.ts | 430 +++++++++++++++++-
...rsational-rectification-controller.test.ts | 141 +++++-
7 files changed, 881 insertions(+), 45 deletions(-)
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index b8ea60a5..24b96936 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -481,7 +481,9 @@ button:disabled { cursor: default; opacity: .45; }
.conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
.conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; }
.conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); }
-.conversational-abandon-confirmation { border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); }
+.conversational-abandon-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); }
+.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); }
+.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.phrase-nowrap { white-space: nowrap; }
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx
index eca3905f..2b77ff4b 100644
--- a/frontend/src/components/conversational-birth-time-rectification.tsx
+++ b/frontend/src/components/conversational-birth-time-rectification.tsx
@@ -1,6 +1,11 @@
"use client";
-import { useRef, useState } from "react";
+import {
+ useEffect,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from "react";
import { ChatMessageContent } from "./chat-message-content.tsx";
import {
useConversationalRectification,
@@ -85,10 +90,41 @@ export function ConversationalRectificationSurface({
pendingConsultationQuestion,
onContinueOriginalQuestion,
}: SurfaceProps) {
- const [abandonArmed, setAbandonArmed] = useState(false);
+ const [abandonArmedFor, setAbandonArmedFor] = useState(null);
+ const [localAnnouncement, setLocalAnnouncement] = useState | null>(null);
const composer = useRef(null);
+ const abandonTrigger = useRef(null);
+ const abandonCancel = useRef(null);
+ const abandonConfirm = useRef(null);
+ const restoreAbandonFocus = useRef(false);
const turn = controller.turn;
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
+ const abandonIdentity = turn
+ ? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
+ : null;
+ const canAbandon = Boolean(
+ turn?.actions.includes("abandon")
+ && turn.status !== "abandoned"
+ && turn.status !== "completed",
+ );
+ const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity;
+ const statusAnnouncement = localAnnouncement?.identity === abandonIdentity
+ ? localAnnouncement.message
+ : "";
+
+ useEffect(() => {
+ if (abandonArmed) {
+ abandonCancel.current?.focus();
+ return;
+ }
+ if (restoreAbandonFocus.current) {
+ restoreAbandonFocus.current = false;
+ abandonTrigger.current?.focus();
+ }
+ }, [abandonArmed]);
if (!turn) {
return (
@@ -115,6 +151,34 @@ export function ConversationalRectificationSurface({
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
};
const focusComposer = () => composer.current?.focus();
+ const continueLocally = () => {
+ if (abandonIdentity) {
+ setLocalAnnouncement({
+ identity: abandonIdentity,
+ message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。",
+ });
+ }
+ focusComposer();
+ };
+ const closeAbandonDialog = () => {
+ restoreAbandonFocus.current = true;
+ setAbandonArmedFor(null);
+ };
+ const handleAbandonDialogKey = (event: ReactKeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeAbandonDialog();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ if (event.shiftKey && document.activeElement === abandonCancel.current) {
+ event.preventDefault();
+ abandonConfirm.current?.focus();
+ } else if (!event.shiftKey && document.activeElement === abandonConfirm.current) {
+ event.preventDefault();
+ abandonCancel.current?.focus();
+ }
+ };
return (
}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& 候选时间已经过你的明确确认。
}
+ {statusAnnouncement && {statusAnnouncement}
}
{controller.error && {controller.error}
}
@@ -264,7 +329,7 @@ export function ConversationalRectificationSurface({
className="button-secondary"
disabled={controller.pending}
type="button"
- onClick={() => safely(controller.resume())}
+ onClick={continueLocally}
>
继续校正
@@ -279,12 +344,16 @@ export function ConversationalRectificationSurface({
) : null}
- {turn.actions.includes("abandon") && !abandonArmed && (
+ {canAbandon && !abandonArmed && (
@@ -292,27 +361,41 @@ export function ConversationalRectificationSurface({
{abandonArmed && (
-
- 放弃后会保留审计记录,但不会应用任何候选时间。确定继续吗?
-
-
-
-
-
+
+
+ 确认放弃本次校正?
+
+ 放弃后会保留审计记录,但不会应用任何候选时间。
+
+
+
+
+
+
+
)}
);
diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts
index fa20b592..0a5f0f5b 100644
--- a/frontend/src/hooks/use-conversational-rectification.ts
+++ b/frontend/src/hooks/use-conversational-rectification.ts
@@ -1,6 +1,6 @@
"use client";
-import { useState, useSyncExternalStore } from "react";
+import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
@@ -30,6 +30,7 @@ type MutationResult = Promise;
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
getSnapshot(): ConversationalRectificationControllerSnapshot;
subscribe(listener: () => void): () => void;
+ synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
start(pendingConsultationQuestion?: string | null): MutationResult;
@@ -53,6 +54,21 @@ type Mutation = Readonly<{
clearDraftOnSuccess?: boolean;
}>;
+function createLatestControllerInput(initial: ControllerInput) {
+ let current = initial;
+ return {
+ update(next: ControllerInput) {
+ current = next;
+ },
+ send(command: ConversationalRectificationCommand) {
+ return (current.send ?? sendConversationalRectificationCommand)(command);
+ },
+ onTurn(turn: ConversationalRectificationTurn) {
+ current.onTurn?.(turn);
+ },
+ };
+}
+
function displayError(error: unknown): string {
return error instanceof ConversationalRectificationRequestError
? error.message
@@ -79,6 +95,7 @@ export function createConversationalRectificationController(
error: "",
};
let activeMutation: MutationResult | null = null;
+ let caseContext = 0;
const publish = (next: ConversationalRectificationControllerSnapshot) => {
snapshot = next;
@@ -90,13 +107,27 @@ export function createConversationalRectificationController(
const acceptTurn = (
turn: ConversationalRectificationTurn,
clearDraft: boolean,
+ expectedCaseContext: number,
) => {
+ const current = snapshot.turn;
+ if (caseContext !== expectedCaseContext) return turn;
+ if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn;
+ const selectedDomain = clearDraft
+ ? null
+ : snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
+ ? snapshot.selectedDomain
+ : null;
patch({
turn,
error: "",
- ...(clearDraft ? { draft: "", selectedDomain: null } : {}),
+ selectedDomain,
+ ...(clearDraft ? { draft: "" } : {}),
});
- input.onTurn?.(turn);
+ try {
+ input.onTurn?.(turn);
+ } catch {
+ // A consumer callback is observational. It must never turn a durable success into a failure.
+ }
return turn;
};
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
@@ -114,15 +145,20 @@ export function createConversationalRectificationController(
if (activeMutation) return activeMutation;
patch({ pending: true, error: "" });
const turnAtStart = snapshot.turn;
+ const caseContextAtStart = caseContext;
const operation = registry.run(
mutation.identity,
(actionId) => send(mutation.command(actionId)),
- ).then((turn) => acceptTurn(turn, mutation.clearDraftOnSuccess === true))
+ ).then((turn) => acceptTurn(
+ turn,
+ mutation.clearDraftOnSuccess === true,
+ caseContextAtStart,
+ ))
.catch(async (error: unknown) => {
if (turnAtStart && staleTurn(error)) {
try {
const recovered = await recoverLatest(turnAtStart);
- return acceptTurn(recovered, false);
+ return acceptTurn(recovered, false, caseContextAtStart);
} catch (recoveryError) {
patch({ error: displayError(recoveryError) });
throw recoveryError;
@@ -170,6 +206,29 @@ export function createConversationalRectificationController(
listeners.add(listener);
return () => listeners.delete(listener);
},
+ synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) {
+ const current = snapshot.turn;
+ if (turn === null) {
+ if (current === null) return;
+ caseContext += 1;
+ patch({ turn: null, draft: "", selectedDomain: null, error: "" });
+ return;
+ }
+ if (current === null || current.caseId !== turn.caseId) {
+ caseContext += 1;
+ patch({ turn, draft: "", selectedDomain: null, error: "" });
+ return;
+ }
+ if (turn.turnVersion <= current.turnVersion) return;
+ patch({
+ turn,
+ error: "",
+ selectedDomain: snapshot.selectedDomain
+ && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
+ ? snapshot.selectedDomain
+ : null,
+ });
+ },
setDraft(value: string) {
patch({ draft: value });
},
@@ -250,11 +309,23 @@ export function createConversationalRectificationController(
export function useConversationalRectification(
input: ControllerInput = {},
): ConversationalRectificationController {
- const [controller] = useState(() => createConversationalRectificationController(input));
+ const [latestInput] = useState(() => createLatestControllerInput(input));
+ const [controller] = useState(() => createConversationalRectificationController({
+ initialTurn: input.initialTurn,
+ createActionId: input.createActionId,
+ send: latestInput.send,
+ onTurn: latestInput.onTurn,
+ }));
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
+ useLayoutEffect(() => {
+ latestInput.update(input);
+ }, [input, latestInput]);
+ useEffect(() => {
+ controller.synchronizeInitialTurn(input.initialTurn ?? null);
+ }, [controller, input.initialTurn]);
return { ...controller, ...snapshot };
}
diff --git a/frontend/src/lib/conversational-rectification/client.ts b/frontend/src/lib/conversational-rectification/client.ts
index 9f25dc31..786ffd88 100644
--- a/frontend/src/lib/conversational-rectification/client.ts
+++ b/frontend/src/lib/conversational-rectification/client.ts
@@ -83,16 +83,46 @@ export type ConversationalRectificationActionRegistry = ReturnType<
typeof createConversationalRectificationActionRegistry
>;
+function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException && error.name === "AbortError";
+}
+
+function isRetryableTransportError(error: unknown): boolean {
+ return !isAbortError(error) && (
+ error instanceof TypeError
+ || error instanceof SyntaxError
+ || (error instanceof DOMException && error.name === "SyntaxError")
+ );
+}
+
+async function postCommandWithOneReplay(body: string) {
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ const result = await postJson({
+ url: "/api/birth-time-conversation",
+ body,
+ retryLostResponse: false,
+ });
+ // postJson deliberately projects an unparseable non-ok body to null. Treating all null
+ // error payloads as replayable also covers proxies that mislabel HTML as application/json.
+ const nonJsonFailure = !result.response.ok && result.payload === null;
+ if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue;
+ return result;
+ } catch (error) {
+ if (attempt === 0 && isRetryableTransportError(error)) continue;
+ throw error;
+ }
+ }
+ throw new TypeError("unreachable conversational rectification replay state");
+}
+
export async function sendConversationalRectificationCommand(
command: ConversationalRectificationCommand,
): Promise {
const request = conversationalRectificationCommandSchema.parse(command);
+ const body = JSON.stringify(request);
try {
- const { response, payload } = await postJson({
- url: "/api/birth-time-conversation",
- body: JSON.stringify(request),
- retryLostResponse: true,
- });
+ const { response, payload } = await postCommandWithOneReplay(body);
if (!response.ok) {
const parsed = publicErrorSchema.safeParse(payload);
const safeServerMessage = response.status < 500 && parsed.success
diff --git a/frontend/tests/conversational-rectification-client.test.ts b/frontend/tests/conversational-rectification-client.test.ts
index 370cbd80..07238101 100644
--- a/frontend/tests/conversational-rectification-client.test.ts
+++ b/frontend/tests/conversational-rectification-client.test.ts
@@ -75,6 +75,35 @@ test("action registry keeps one id for a failed canonical command and separates
assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]);
});
+test("action registry releases a successful identity but keeps it for a manual failure retry", async () => {
+ const ids = [firstActionId, secondActionId];
+ const registry = createConversationalRectificationActionRegistry(
+ () => ids.shift() ?? assert.fail("unexpected action id allocation"),
+ );
+ const identity = {
+ caseId,
+ turnVersion: 4,
+ operation: "pause",
+ payload: {},
+ } as const;
+ const seen: string[] = [];
+
+ await assert.rejects(registry.run(identity, async (actionId) => {
+ seen.push(actionId);
+ throw new TypeError("offline");
+ }));
+ await registry.run(identity, async (actionId) => {
+ seen.push(actionId);
+ return turn;
+ });
+ await registry.run(identity, async (actionId) => {
+ seen.push(actionId);
+ return turn;
+ });
+
+ assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]);
+});
+
test("client replays the exact action body after a lost response without adding a price", async (context) => {
const bodies: string[] = [];
let attempts = 0;
@@ -103,11 +132,63 @@ test("client replays the exact action body after a lost response without adding
assert.equal(Object.hasOwn(JSON.parse(bodies[0]) as object, "price"), false);
});
-test("502 and non-JSON failures expose one stable Chinese message", async (context) => {
- context.mock.method(globalThis, "fetch", async () => new Response("upstream html", {
- status: 502,
- headers: { "content-type": "text/html" },
- }));
+test("a JSON 502 is retried once with the exact action body before succeeding", async (context) => {
+ const bodies: string[] = [];
+ context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
+ bodies.push(String(init?.body));
+ if (bodies.length === 1) {
+ return Response.json({ code: "service_unavailable", message: "internal detail" }, { status: 502 });
+ }
+ return Response.json(turn);
+ });
+
+ const result = await sendConversationalRectificationCommand({
+ type: "pause",
+ caseId,
+ actionId: firstActionId,
+ turnVersion: 4,
+ });
+
+ assert.deepEqual(result, turn);
+ assert.equal(bodies.length, 2);
+ assert.equal(bodies[0], bodies[1]);
+ assert.equal(JSON.parse(bodies[0] ?? "{}").actionId, firstActionId);
+});
+
+test("a non-ok non-JSON response is retried once even when a proxy mislabels it as JSON", async (context) => {
+ const bodies: string[] = [];
+ context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
+ bodies.push(String(init?.body));
+ if (bodies.length === 1) {
+ return new Response("proxy html", {
+ status: 503,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return Response.json(turn);
+ });
+
+ const result = await sendConversationalRectificationCommand({
+ type: "pause",
+ caseId,
+ actionId: firstActionId,
+ turnVersion: 4,
+ });
+
+ assert.deepEqual(result, turn);
+ assert.equal(bodies.length, 2);
+ assert.equal(bodies[0], bodies[1]);
+});
+
+test("502 and non-JSON failures retry only once before one stable Chinese message", async (context) => {
+ const bodies: string[] = [];
+ context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
+ bodies.push(String(init?.body));
+ return new Response("upstream html", {
+ status: 502,
+ headers: { "content-type": "text/html" },
+ });
+ });
await assert.rejects(
sendConversationalRectificationCommand({
@@ -120,4 +201,6 @@ test("502 and non-JSON failures expose one stable Chinese message", async (conte
&& error.status === 502
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
);
+ assert.equal(bodies.length, 2);
+ assert.equal(bodies[0], bodies[1]);
});
diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts
index 08d65aca..bea2f2e4 100644
--- a/frontend/tests/conversational-rectification-component.test.ts
+++ b/frontend/tests/conversational-rectification-component.test.ts
@@ -1,6 +1,18 @@
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
+import { spawn, type ChildProcess } from "node:child_process";
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
import test from "node:test";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { build } from "esbuild";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
@@ -53,6 +65,7 @@ function controller(overrides: Partial =
error: "",
getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }),
subscribe: () => () => undefined,
+ synchronizeInitialTurn: () => undefined,
setDraft: () => undefined,
selectDomain: () => undefined,
start: async () => turn,
@@ -132,3 +145,418 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(component, /确认放弃且不应用候选/);
});
+
+type CdpResponse = Readonly<{
+ id?: number;
+ result?: unknown;
+ error?: Readonly<{ message?: string }>;
+}>;
+
+class CdpSession {
+ private nextId = 0;
+ private readonly pending = new Map();
+
+ private constructor(private readonly socket: WebSocket) {
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(String(event.data)) as CdpResponse;
+ if (message.id === undefined) return;
+ const request = this.pending.get(message.id);
+ if (!request) return;
+ this.pending.delete(message.id);
+ if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed"));
+ else request.resolve(message.result);
+ });
+ }
+
+ static async connect(url: string): Promise {
+ const socket = new WebSocket(url);
+ await new Promise((resolveConnection, rejectConnection) => {
+ socket.addEventListener("open", () => resolveConnection(), { once: true });
+ socket.addEventListener("error", () => rejectConnection(new Error("Unable to connect to Chromium CDP")), {
+ once: true,
+ });
+ });
+ return new CdpSession(socket);
+ }
+
+ async send(method: string, params: unknown = {}): Promise {
+ const id = ++this.nextId;
+ const response = new Promise((resolveResponse, rejectResponse) => {
+ this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse });
+ });
+ this.socket.send(JSON.stringify({ id, method, params }));
+ return response;
+ }
+
+ async evaluate(expression: string): Promise {
+ const response = await this.send("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ }) as {
+ result?: { value?: T };
+ exceptionDetails?: { text?: string };
+ };
+ if (response.exceptionDetails) {
+ throw new Error(response.exceptionDetails.text ?? `Browser evaluation failed: ${expression}`);
+ }
+ return response.result?.value as T;
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
+
+function chromiumExecutable(): string {
+ if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
+ const systemCandidates = [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ "/usr/bin/google-chrome",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ ];
+
+ const cacheRoots = [
+ join(homedir(), "Library/Caches/ms-playwright"),
+ join(homedir(), ".cache/ms-playwright"),
+ ];
+ for (const cacheRoot of cacheRoots) {
+ if (!existsSync(cacheRoot)) continue;
+ const headlessVersions = readdirSync(cacheRoot)
+ .filter((entry) => entry.startsWith("chromium_headless_shell-"))
+ .sort()
+ .reverse();
+ for (const version of headlessVersions) {
+ const candidates = [
+ join(cacheRoot, version, "chrome-headless-shell-mac-arm64/chrome-headless-shell"),
+ join(cacheRoot, version, "chrome-headless-shell-mac-x64/chrome-headless-shell"),
+ join(cacheRoot, version, "chrome-headless-shell-linux64/chrome-headless-shell"),
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) return candidate;
+ }
+ }
+ const versions = readdirSync(cacheRoot)
+ .filter((entry) => entry.startsWith("chromium-"))
+ .sort()
+ .reverse();
+ for (const version of versions) {
+ const candidates = [
+ join(cacheRoot, version, "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
+ join(cacheRoot, version, "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
+ join(cacheRoot, version, "chrome-linux/chrome"),
+ join(cacheRoot, version, "chrome-linux64/chrome"),
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) return candidate;
+ }
+ }
+ }
+ for (const candidate of systemCandidates) {
+ if (existsSync(candidate)) return candidate;
+ }
+ throw new Error("Chromium is required for the real DOM contract; set CHROME_PATH to its executable.");
+}
+
+async function waitFor(probe: () => Promise, label: string): Promise {
+ const deadline = Date.now() + 8_000;
+ let lastError: unknown;
+ while (Date.now() < deadline) {
+ try {
+ const value = await probe();
+ if (value !== null && value !== false) return value;
+ } catch (error) {
+ lastError = error;
+ }
+ await new Promise((resolveWait) => setTimeout(resolveWait, 30));
+ }
+ throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`);
+}
+
+async function pressEscape(cdp: CdpSession) {
+ await cdp.send("Input.dispatchKeyEvent", {
+ type: "rawKeyDown",
+ key: "Escape",
+ code: "Escape",
+ windowsVirtualKeyCode: 27,
+ nativeVirtualKeyCode: 27,
+ });
+ await cdp.send("Input.dispatchKeyEvent", {
+ type: "keyUp",
+ key: "Escape",
+ code: "Escape",
+ windowsVirtualKeyCode: 27,
+ nativeVirtualKeyCode: 27,
+ });
+}
+
+async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
+ browser: ChildProcess;
+ cdp: CdpSession;
+}> {
+ const executable = chromiumExecutable();
+ const browser = spawn(executable, [
+ executable.includes("chrome-headless-shell") ? "--headless" : "--headless=new",
+ "--disable-background-networking",
+ "--disable-component-update",
+ "--disable-default-apps",
+ "--disable-extensions",
+ "--disable-features=Translate",
+ "--disable-gpu",
+ "--disable-sync",
+ "--no-first-run",
+ "--no-sandbox",
+ "--remote-debugging-port=0",
+ `--user-data-dir=${userDataDirectory}`,
+ pathToFileURL(htmlPath).href,
+ ], { stdio: ["ignore", "pipe", "pipe"] });
+ const activePort = join(userDataDirectory, "DevToolsActivePort");
+ const port = await waitFor(async () => {
+ if (browser.exitCode !== null) {
+ throw new Error(`Chromium exited before CDP was ready (${browser.exitCode})`);
+ }
+ if (!existsSync(activePort)) return null;
+ return Number(readFileSync(activePort, "utf8").split("\n")[0]);
+ }, "Chromium DevTools port");
+ const target = await waitFor(async () => {
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`);
+ const targets = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>;
+ return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null;
+ }, "Chromium page target");
+ return { browser, cdp: await CdpSession.connect(target) };
+}
+
+test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", async () => {
+ const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
+ const directory = mkdtempSync(join(tmpdir(), "rectification-browser-"));
+ const entryPath = join(directory, "fixture.tsx");
+ const bundlePath = join(directory, "fixture.js");
+ const htmlPath = join(directory, "fixture.html");
+ const userDataDirectory = join(directory, "chrome-profile");
+ const componentPath = join(frontendRoot, "src/components/conversational-birth-time-rectification.tsx");
+ const hookPath = join(frontendRoot, "src/hooks/use-conversational-rectification.ts");
+ const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
+ .replace(/^@import[^;]+;\s*/gm, "");
+ const fixture = `
+ import React, { useEffect, useState } from "react";
+ import { createRoot } from "react-dom/client";
+ import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
+ import { useConversationalRectification } from ${JSON.stringify(hookPath)};
+
+ const caseA = "00000000-0000-4000-8000-000000000821";
+ const caseB = "00000000-0000-4000-8000-000000000829";
+ const longWord = "D9SENSITIVEREFERENCE".repeat(70);
+ const makeTurn = (caseId, turnVersion, status = "active") => ({
+ caseId,
+ journeyProtocol: "conversational-evidence-v3",
+ status,
+ turnVersion,
+ narrative: "## 当前判断\\n\\n**05:18** 只是待验证候选。" + longWord,
+ candidate: {
+ status: "pending_validation",
+ representativeTime: "05:18",
+ rangeStart: "05:10",
+ rangeEnd: "05:26",
+ },
+ technicalReceipt: {
+ calculationVersion: "rectification-technical-v1",
+ stableLayers: ["D1"],
+ sensitiveLayers: ["D9", "D10"],
+ candidateDifferenceRefs: ["consult-d9", "consult-d10"],
+ },
+ evidenceRequest: status === "abandoned" ? null : {
+ domains: ["career", "education", "relocation"],
+ datePrecision: "month_preferred",
+ freeTextAllowed: true,
+ },
+ evidenceRecap: [],
+ actions: status === "abandoned" ? [] : status === "paused"
+ ? ["answer", "abandon"]
+ : ["answer", "pause", "abandon"],
+ pendingConsultationQuestion: null,
+ });
+ const turns = {
+ activeA1: makeTurn(caseA, 1),
+ activeA3: makeTurn(caseA, 3),
+ activeB1: makeTurn(caseB, 1),
+ abandonedB2: makeTurn(caseB, 2, "abandoned"),
+ };
+ const events = [];
+
+ function Harness() {
+ const [initialTurn, setInitialTurn] = useState(null);
+ const [transportLabel, setTransportLabel] = useState("first");
+ const [callbackLabel, setCallbackLabel] = useState("first");
+ const send = async (command) => {
+ events.push("send:" + transportLabel + ":" + command.type);
+ await new Promise((resolveSend) => setTimeout(resolveSend, 20));
+ if (command.type === "pause") {
+ return makeTurn(command.caseId, command.turnVersion + 1, "paused");
+ }
+ if (command.type === "abandon") {
+ return makeTurn(command.caseId, command.turnVersion + 1, "abandoned");
+ }
+ return makeTurn(command.caseId ?? caseA, (command.turnVersion ?? 0) + 1);
+ };
+ const controller = useConversationalRectification({
+ initialTurn,
+ send,
+ onTurn: (next) => events.push("turn:" + callbackLabel + ":" + next.status),
+ });
+ useEffect(() => {
+ globalThis.__rectificationHarness = {
+ events,
+ setCallbackLabel,
+ setTransportLabel,
+ setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
+ };
+ globalThis.__rectificationReady = true;
+ });
+ return ;
+ }
+ createRoot(document.getElementById("root")).render();
+ `;
+ let browser: ChildProcess | null = null;
+ let cdp: CdpSession | null = null;
+ try {
+ writeFileSync(entryPath, fixture);
+ await build({
+ absWorkingDir: frontendRoot,
+ bundle: true,
+ define: { "process.env.NODE_ENV": '"test"' },
+ entryPoints: [entryPath],
+ format: "iife",
+ jsx: "automatic",
+ logLevel: "silent",
+ nodePaths: [join(frontendRoot, "node_modules")],
+ outfile: bundlePath,
+ platform: "browser",
+ });
+ writeFileSync(htmlPath, ``);
+
+ ({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory));
+ await cdp.send("Runtime.enable");
+ await cdp.send("Page.bringToFront");
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 1,
+ mobile: true,
+ });
+ await waitFor(
+ () => cdp?.evaluate("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
+ "React fixture readiness",
+ );
+ await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
+ await waitFor(
+ () => cdp?.evaluate("document.body.textContent.includes('当前判断')") ?? Promise.resolve(false),
+ "async initial turn",
+ );
+
+ const layout = await cdp.evaluate<{
+ viewport: number;
+ scrollWidth: number;
+ surfaceWidth: number;
+ shortestButton: number;
+ }>(`(() => {
+ const buttons = [...document.querySelectorAll('.conversational-rectification button')];
+ return {
+ viewport: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
+ shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
+ };
+ })()`);
+ assert.equal(layout.viewport, 390);
+ assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
+ assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
+ assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
+
+ await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()");
+ await waitFor(
+ () => cdp?.evaluate("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false),
+ "domain-to-composer focus",
+ );
+
+ await cdp.evaluate("globalThis.__rectificationHarness.setTransportLabel('second'); globalThis.__rectificationHarness.setCallbackLabel('second')");
+ await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('暂停,稍后继续')).click()");
+ await waitFor(
+ () => cdp?.evaluate("document.body.textContent.includes('继续校正')") ?? Promise.resolve(false),
+ "paused response",
+ );
+ assert.deepEqual(
+ await cdp.evaluate("globalThis.__rectificationHarness.events.slice()"),
+ ["send:second:pause", "turn:second:paused"],
+ );
+
+ const beforeContinue = await cdp.evaluate("globalThis.__rectificationHarness.events.length");
+ await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('继续校正')).click()");
+ await waitFor(
+ () => cdp?.evaluate("document.activeElement?.id === 'conversational-rectification-answer' && document.body.textContent.includes('现在可以继续填写')") ?? Promise.resolve(false),
+ "local paused continuation feedback",
+ );
+ assert.equal(await cdp.evaluate("globalThis.__rectificationHarness.events.length"), beforeContinue);
+
+ await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
+ await waitFor(
+ () => cdp?.evaluate("document.body.textContent.includes('放弃本次校正') && !document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
+ "newer same-case initial turn",
+ );
+ await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
+ const dialog = await waitFor<{ title: string; description: string; active: string }>(
+ () => cdp!.evaluate(`(() => {
+ const dialog = document.querySelector('[role=alertdialog]');
+ if (!dialog) return false;
+ return {
+ title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '',
+ description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '',
+ active: document.activeElement?.textContent?.trim() ?? '',
+ };
+ })()`),
+ "abandon alertdialog",
+ );
+ assert.match(dialog.title, /确认放弃/);
+ assert.match(dialog.description, /不会应用任何候选时间/);
+ assert.equal(dialog.active, "返回校正");
+
+ await pressEscape(cdp);
+ await waitFor(
+ () => cdp?.evaluate("!document.querySelector('[role=alertdialog]') && document.activeElement?.textContent?.includes('放弃本次校正')") ?? Promise.resolve(false),
+ "Escape close and trigger focus restoration",
+ );
+
+ await cdp.evaluate("document.activeElement.click()");
+ await waitFor(
+ () => cdp?.evaluate("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
+ "reopened abandon dialog",
+ );
+ await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeB1')");
+ await waitFor(
+ () => cdp?.evaluate("!document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
+ "case-switch dialog reset",
+ );
+
+ await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
+ await waitFor(
+ () => cdp?.evaluate("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
+ "case-B abandon dialog",
+ );
+ await cdp.evaluate("globalThis.__rectificationHarness.setTurn('abandonedB2')");
+ await waitFor(
+ () => cdp?.evaluate("!document.querySelector('[role=alertdialog]') && document.body.textContent.includes('本次校正已放弃')") ?? Promise.resolve(false),
+ "terminal dialog reset",
+ );
+ } finally {
+ cdp?.close();
+ browser?.kill("SIGKILL");
+ browser?.stdout?.destroy();
+ browser?.stderr?.destroy();
+ browser?.unref();
+ rmSync(directory, { force: true, recursive: true });
+ }
+});
diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts
index c14f9203..2972c69f 100644
--- a/frontend/tests/conversational-rectification-controller.test.ts
+++ b/frontend/tests/conversational-rectification-controller.test.ts
@@ -13,6 +13,7 @@ import type {
} from "../src/lib/conversational-rectification/contracts.ts";
const caseId = "00000000-0000-4000-8000-000000000811";
+const otherCaseId = "00000000-0000-4000-8000-000000000819";
const actionIds = [
"00000000-0000-4000-8000-000000000812",
"00000000-0000-4000-8000-000000000813",
@@ -129,16 +130,39 @@ test("a stale answer resumes the latest turn and returns recovered state without
},
});
controller.setDraft("2022 年 3 月搬到另一座城市");
+ controller.selectDomain("relocation");
- const result = await controller.answer("relocation");
+ const result = await controller.answer();
assert.deepEqual(result, recovered);
assert.deepEqual(commands.map((command) => command.type), ["answer", "resume"]);
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
assert.equal(controller.getSnapshot().draft, "2022 年 3 月搬到另一座城市");
+ assert.equal(controller.getSnapshot().selectedDomain, null);
assert.equal(controller.getSnapshot().error, "");
});
+test("a stale recovery retains the selected domain only while the recovered turn still requests it", async () => {
+ const recovered = activeTurn(5);
+ const controller = createConversationalRectificationController({
+ initialTurn: activeTurn(2),
+ createActionId: idFactory(),
+ send: async (command) => {
+ if (command.type === "answer") {
+ throw new ConversationalRectificationRequestError(409, "stale_turn", "请加载最新进度后再试。");
+ }
+ return recovered;
+ },
+ });
+ controller.setDraft("2021 年 7 月开始第一份工作");
+ controller.selectDomain("career");
+
+ await controller.answer();
+
+ assert.equal(controller.getSnapshot().draft, "2021 年 7 月开始第一份工作");
+ assert.equal(controller.getSnapshot().selectedDomain, "career");
+});
+
test("a changed payload receives a different action id after a failed send", async () => {
const commands: ConversationalRectificationCommand[] = [];
const controller = createConversationalRectificationController({
@@ -157,3 +181,118 @@ test("a changed payload receives a different action id after a failed send", asy
assert.notEqual(commands[0]?.actionId, commands[1]?.actionId);
});
+
+test("a successful turn stays successful when the consumer onTurn callback throws", async () => {
+ const next = activeTurn(3);
+ const controller = createConversationalRectificationController({
+ initialTurn: activeTurn(2),
+ createActionId: idFactory(),
+ send: async () => next,
+ onTurn: () => {
+ throw new Error("consumer render side effect failed");
+ },
+ });
+ controller.setDraft("2021 年 7 月开始第一份工作");
+
+ const result = await controller.answer("career");
+
+ assert.deepEqual(result, next);
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 3);
+ assert.equal(controller.getSnapshot().draft, "");
+ assert.equal(controller.getSnapshot().error, "");
+});
+
+test("external initial turns adopt only newer same-case state", () => {
+ const controller = createConversationalRectificationController({ initialTurn: null });
+
+ controller.synchronizeInitialTurn(activeTurn(4));
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 4);
+
+ controller.setDraft("仍在填写的经历");
+ controller.selectDomain("career");
+ controller.synchronizeInitialTurn(activeTurn(3));
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 4);
+ assert.equal(controller.getSnapshot().draft, "仍在填写的经历");
+ assert.equal(controller.getSnapshot().selectedDomain, "career");
+
+ const newer = {
+ ...activeTurn(5),
+ evidenceRequest: {
+ domains: ["education", "relocation"],
+ datePrecision: "month_preferred",
+ freeTextAllowed: true,
+ },
+ } satisfies ConversationalRectificationTurn;
+ controller.synchronizeInitialTurn(newer);
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
+ assert.equal(controller.getSnapshot().draft, "仍在填写的经历");
+ assert.equal(controller.getSnapshot().selectedDomain, null);
+});
+
+test("switching cases synchronizes immediately and an old in-flight response cannot overwrite it", async () => {
+ let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
+ const request = new Promise((resolve) => {
+ resolveRequest = resolve;
+ });
+ const controller = createConversationalRectificationController({
+ initialTurn: activeTurn(2),
+ createActionId: idFactory(),
+ send: async () => request,
+ });
+ controller.setDraft("旧案例输入");
+ const pending = controller.answer("career");
+ const newCase = { ...activeTurn(1), caseId: otherCaseId };
+
+ controller.synchronizeInitialTurn(newCase);
+
+ assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId);
+ assert.equal(controller.getSnapshot().draft, "");
+ resolveRequest?.(activeTurn(9));
+ await pending;
+
+ assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId);
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 1);
+ assert.equal(controller.getSnapshot().pending, false);
+});
+
+test("a newer external same-case turn wins over an older in-flight response", async () => {
+ let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
+ const request = new Promise((resolve) => {
+ resolveRequest = resolve;
+ });
+ const controller = createConversationalRectificationController({
+ initialTurn: activeTurn(2),
+ createActionId: idFactory(),
+ send: async () => request,
+ });
+ controller.setDraft("在途输入");
+ const pending = controller.answer("career");
+
+ controller.synchronizeInitialTurn(activeTurn(5));
+ resolveRequest?.(activeTurn(3));
+ await pending;
+
+ assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
+});
+
+test("an external same-version turn is not replaced by a late response", async () => {
+ let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
+ const request = new Promise((resolve) => {
+ resolveRequest = resolve;
+ });
+ const controller = createConversationalRectificationController({
+ initialTurn: activeTurn(4),
+ createActionId: idFactory(),
+ send: async () => request,
+ });
+ controller.setDraft("在途输入");
+ const pending = controller.answer("career");
+ const external = { ...activeTurn(5), narrative: "外部已同步的最新轮次" };
+ const lateResponse = { ...activeTurn(5), narrative: "较晚返回的旧请求" };
+
+ controller.synchronizeInitialTurn(external);
+ resolveRequest?.(lateResponse);
+ await pending;
+
+ assert.equal(controller.getSnapshot().turn?.narrative, "外部已同步的最新轮次");
+});