diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 24b96936..ec5b6d94 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -441,7 +441,7 @@ button:disabled { cursor: default; opacity: .45; } .conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); overflow-wrap: anywhere; color: var(--color-ink); } .conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; } .conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; } -.conversational-rectification :where(button, textarea, summary):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } +.conversational-rectification :where(button, textarea, summary, .conversational-status):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } .conversational-narrative { padding: var(--space-5); border-left: 3px solid var(--color-action); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; background: var(--color-action-soft); } .conversational-narrative .message-markdown { overflow-wrap: anywhere; } .conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 2b77ff4b..17186382 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -99,7 +99,9 @@ export function ConversationalRectificationSurface({ const abandonTrigger = useRef(null); const abandonCancel = useRef(null); const abandonConfirm = useRef(null); + const terminalStatus = useRef(null); const restoreAbandonFocus = useRef(false); + const focusTerminalForCase = useRef(null); const turn = controller.turn; const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; const abandonIdentity = turn @@ -126,6 +128,19 @@ export function ConversationalRectificationSurface({ } }, [abandonArmed]); + useEffect(() => { + const requestedCase = focusTerminalForCase.current; + if (!requestedCase) return; + if (!turn || turn.caseId !== requestedCase) { + focusTerminalForCase.current = null; + return; + } + if (turn.status === "abandoned") { + focusTerminalForCase.current = null; + terminalStatus.current?.focus(); + } + }, [turn]); + if (!turn) { return (
@@ -179,6 +194,14 @@ export function ConversationalRectificationSurface({ abandonCancel.current?.focus(); } }; + const confirmAbandon = () => { + focusTerminalForCase.current = turn.caseId; + void controller.abandon().catch(() => { + if (focusTerminalForCase.current === turn.caseId) { + focusTerminalForCase.current = null; + } + }); + }; return (
)} -
+
{turn.status === "paused" &&

校正已暂停,输入与现有证据都已保留。

} {turn.status === "abandoned" &&

本次校正已放弃,候选时间没有应用。

} {turn.status === "completed" && turn.candidate.status === "confirmed" @@ -389,7 +418,7 @@ export function ConversationalRectificationSurface({ disabled={controller.pending} ref={abandonConfirm} type="button" - onClick={() => safely(controller.abandon())} + onClick={confirmAbandon} > 确认放弃且不应用候选 diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts index 0a5f0f5b..cf137590 100644 --- a/frontend/src/hooks/use-conversational-rectification.ts +++ b/frontend/src/hooks/use-conversational-rectification.ts @@ -54,6 +54,12 @@ type Mutation = Readonly<{ clearDraftOnSuccess?: boolean; }>; +type ActiveMutation = Readonly<{ + caseContext: number; + token: symbol; + promise: MutationResult; +}>; + function createLatestControllerInput(initial: ControllerInput) { let current = initial; return { @@ -94,7 +100,7 @@ export function createConversationalRectificationController( pending: false, error: "", }; - let activeMutation: MutationResult | null = null; + let activeMutation: ActiveMutation | null = null; let caseContext = 0; const publish = (next: ConversationalRectificationControllerSnapshot) => { @@ -142,10 +148,13 @@ export function createConversationalRectificationController( turnVersion: turn.turnVersion, })); const run = (mutation: Mutation): MutationResult => { - if (activeMutation) return activeMutation; + if (activeMutation?.caseContext === caseContext) return activeMutation.promise; patch({ pending: true, error: "" }); const turnAtStart = snapshot.turn; const caseContextAtStart = caseContext; + const mutationToken = Symbol("conversational-rectification-mutation"); + const ownsCurrentContext = () => caseContext === caseContextAtStart + && activeMutation?.token === mutationToken; const operation = registry.run( mutation.identity, (actionId) => send(mutation.command(actionId)), @@ -155,25 +164,33 @@ export function createConversationalRectificationController( caseContextAtStart, )) .catch(async (error: unknown) => { - if (turnAtStart && staleTurn(error)) { + if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) { try { const recovered = await recoverLatest(turnAtStart); return acceptTurn(recovered, false, caseContextAtStart); } catch (recoveryError) { - patch({ error: displayError(recoveryError) }); + if (ownsCurrentContext()) patch({ error: displayError(recoveryError) }); throw recoveryError; } } - patch({ error: displayError(error) }); + if (ownsCurrentContext()) patch({ error: displayError(error) }); throw error; }) .finally(() => { + if (activeMutation?.token !== mutationToken) return; activeMutation = null; - patch({ pending: false }); + if (caseContext === caseContextAtStart) patch({ pending: false }); }); - activeMutation = operation; + activeMutation = { + caseContext: caseContextAtStart, + token: mutationToken, + promise: operation, + }; return operation; }; + const activeMutationForCurrentContext = () => ( + activeMutation?.caseContext === caseContext ? activeMutation.promise : null + ); const currentTurn = () => snapshot.turn; const currentMutation = ( operation: Exclude, @@ -211,12 +228,14 @@ export function createConversationalRectificationController( if (turn === null) { if (current === null) return; caseContext += 1; - patch({ turn: null, draft: "", selectedDomain: null, error: "" }); + activeMutation = null; + patch({ turn: null, draft: "", selectedDomain: null, pending: false, error: "" }); return; } if (current === null || current.caseId !== turn.caseId) { caseContext += 1; - patch({ turn, draft: "", selectedDomain: null, error: "" }); + activeMutation = null; + patch({ turn, draft: "", selectedDomain: null, pending: false, error: "" }); return; } if (turn.turnVersion <= current.turnVersion) return; @@ -270,7 +289,9 @@ export function createConversationalRectificationController( }, pause() { const turn = currentTurn(); - if (!turn?.actions.includes("pause")) return activeMutation ?? Promise.resolve(turn); + if (!turn?.actions.includes("pause")) { + return activeMutationForCurrentContext() ?? Promise.resolve(turn); + } return currentMutation("pause", {}, (current, actionId) => ({ type: "pause", caseId: current.caseId, @@ -280,7 +301,9 @@ export function createConversationalRectificationController( }, abandon() { const turn = currentTurn(); - if (!turn?.actions.includes("abandon")) return activeMutation ?? Promise.resolve(turn); + if (!turn?.actions.includes("abandon")) { + return activeMutationForCurrentContext() ?? Promise.resolve(turn); + } return currentMutation("abandon", {}, (current, actionId) => ({ type: "abandon", caseId: current.caseId, @@ -292,7 +315,9 @@ export function createConversationalRectificationController( const turn = currentTurn(); const candidateTime = time ?? turn?.candidate.representativeTime ?? null; if (!turn?.actions.includes("confirm") || turn.candidate.status !== "ready_for_confirmation" - || !candidateTime) return activeMutation ?? Promise.resolve(turn); + || !candidateTime) { + return activeMutationForCurrentContext() ?? Promise.resolve(turn); + } return currentMutation("confirm", { time: candidateTime }, (current, actionId) => ({ type: "confirm", caseId: current.caseId, diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index bea2f2e4..2fa729c4 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -142,6 +142,7 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/); assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/); assert.match(css, /\.conversational-rectification[^}]*:focus-visible/); + assert.match(css, /summary, \.conversational-status\):focus-visible/); assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/); assert.match(component, /确认放弃且不应用候选/); }); @@ -152,42 +153,118 @@ type CdpResponse = Readonly<{ error?: Readonly<{ message?: string }>; }>; +const CDP_CONNECT_TIMEOUT_MS = 3_000; +const CDP_COMMAND_TIMEOUT_MS = 3_000; +const EXTERNAL_PROBE_TIMEOUT_MS = 1_000; + +async function withHardDeadline( + operation: Promise, + timeoutMs: number, + label: string, + onTimeout?: () => void, +): Promise { + let timeout: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + try { + onTimeout?.(); + } catch { + // Timeout still rejects even if the best-effort cancellation hook itself fails. + } + reject(new Error(`Timed out waiting for ${label}`)); + }, timeoutMs); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + class CdpSession { private nextId = 0; private readonly pending = new Map; }>(); + private closedError: Error | null = null; private constructor(private readonly socket: WebSocket) { socket.addEventListener("message", (event) => { - const message = JSON.parse(String(event.data)) as CdpResponse; + let message: CdpResponse; + try { + message = JSON.parse(String(event.data)) as CdpResponse; + } catch { + this.rejectPending(new Error("Chromium CDP returned malformed JSON")); + return; + } if (message.id === undefined) return; const request = this.pending.get(message.id); if (!request) return; this.pending.delete(message.id); + clearTimeout(request.timeout); if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed")); else request.resolve(message.result); }); + socket.addEventListener("close", () => { + this.rejectPending(new Error("Chromium CDP socket closed")); + }); + socket.addEventListener("error", () => { + this.rejectPending(new Error("Chromium CDP socket failed")); + }); } 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, - }); - }); + await withHardDeadline(new Promise((resolveConnection, rejectConnection) => { + const connected = () => { + cleanup(); + resolveConnection(); + }; + const failed = () => { + cleanup(); + rejectConnection(new Error("Unable to connect to Chromium CDP")); + }; + const closed = () => { + cleanup(); + rejectConnection(new Error("Chromium CDP closed before connecting")); + }; + const cleanup = () => { + socket.removeEventListener("open", connected); + socket.removeEventListener("error", failed); + socket.removeEventListener("close", closed); + }; + socket.addEventListener("open", connected, { once: true }); + socket.addEventListener("error", failed, { once: true }); + socket.addEventListener("close", closed, { once: true }); + }), CDP_CONNECT_TIMEOUT_MS, "Chromium CDP connection", () => socket.close()); return new CdpSession(socket); } async send(method: string, params: unknown = {}): Promise { + if (this.closedError) throw this.closedError; + if (this.socket.readyState !== WebSocket.OPEN) { + throw new Error("Chromium CDP socket is not open"); + } const id = ++this.nextId; const response = new Promise((resolveResponse, rejectResponse) => { - this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse }); + const timeout = setTimeout(() => { + if (!this.pending.delete(id)) return; + rejectResponse(new Error(`Timed out waiting for CDP command ${method}`)); + }, CDP_COMMAND_TIMEOUT_MS); + this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse, timeout }); }); - this.socket.send(JSON.stringify({ id, method, params })); + try { + this.socket.send(JSON.stringify({ id, method, params })); + } catch (error) { + const request = this.pending.get(id); + if (request) { + this.pending.delete(id); + clearTimeout(request.timeout); + request.reject(error instanceof Error ? error : new Error(String(error))); + } + } return response; } @@ -207,7 +284,19 @@ class CdpSession { } close() { - this.socket.close(); + this.rejectPending(new Error("Chromium CDP session closed")); + if (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN) { + this.socket.close(); + } + } + + private rejectPending(error: Error) { + this.closedError ??= error; + for (const request of this.pending.values()) { + clearTimeout(request.timeout); + request.reject(error); + } + this.pending.clear(); } } @@ -268,16 +357,67 @@ async function waitFor(probe: () => Promise, label: string) let lastError: unknown; while (Date.now() < deadline) { try { - const value = await probe(); + const remaining = deadline - Date.now(); + const value = await withHardDeadline( + Promise.resolve().then(probe), + Math.max(1, Math.min(EXTERNAL_PROBE_TIMEOUT_MS, remaining)), + `${label} probe`, + ); if (value !== null && value !== false) return value; } catch (error) { lastError = error; } - await new Promise((resolveWait) => setTimeout(resolveWait, 30)); + const remaining = deadline - Date.now(); + if (remaining > 0) { + await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(30, remaining))); + } } throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`); } +async function fetchJsonWithDeadline(url: string): Promise { + const abort = new AbortController(); + try { + const response = await withHardDeadline( + fetch(url, { signal: abort.signal }), + EXTERNAL_PROBE_TIMEOUT_MS, + `fetch ${url}`, + () => abort.abort(), + ); + if (!response.ok) throw new Error(`Chromium target list returned HTTP ${response.status}`); + return await withHardDeadline( + response.json() as Promise, + EXTERNAL_PROBE_TIMEOUT_MS, + `JSON body from ${url}`, + () => abort.abort(), + ); + } finally { + abort.abort(); + } +} + +async function terminateChildProcess(browser: ChildProcess): Promise { + try { + const alreadyExited = browser.exitCode !== null || browser.signalCode !== null; + if (!alreadyExited) { + const closed = new Promise((resolveClosed) => { + const done = () => { + browser.removeListener("close", done); + browser.removeListener("error", done); + resolveClosed(); + }; + browser.once("close", done); + browser.once("error", done); + }); + browser.kill("SIGKILL"); + await withHardDeadline(closed, 3_000, "Chromium process exit"); + } + } finally { + browser.stdout?.destroy(); + browser.stderr?.destroy(); + } +} + async function pressEscape(cdp: CdpSession) { await cdp.send("Input.dispatchKeyEvent", { type: "rawKeyDown", @@ -310,28 +450,73 @@ async function launchFixture(htmlPath: string, userDataDirectory: string): Promi "--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})`); + let launchError: Error | null = null; + let cdp: CdpSession | null = null; + browser.on("error", (error) => { + launchError = error; + }); + browser.stdout?.resume(); + browser.stderr?.resume(); + try { + const activePort = join(userDataDirectory, "DevToolsActivePort"); + const port = await waitFor(async () => { + if (launchError) throw launchError; + if (browser.exitCode !== null || browser.signalCode !== null) { + throw new Error( + `Chromium exited before CDP was ready (${browser.exitCode ?? browser.signalCode})`, + ); + } + if (!existsSync(activePort)) return null; + const parsed = Number(readFileSync(activePort, "utf8").split("\n")[0]); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) { + throw new Error("Chromium wrote an invalid DevTools port"); + } + return parsed; + }, "Chromium DevTools port"); + const target = await waitFor(async () => { + const targets = await fetchJsonWithDeadline>(`http://127.0.0.1:${port}/json/list`); + return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null; + }, "Chromium page target"); + cdp = await CdpSession.connect(target); + return { browser, cdp }; + } catch (error) { + cdp?.close(); + let cleanupError: unknown; + try { + await terminateChildProcess(browser); + } catch (caught) { + cleanupError = caught; + } finally { + rmSync(userDataDirectory, { force: true, recursive: true }); } - 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) }; + if (cleanupError) { + throw new AggregateError([error, cleanupError], "Chromium fixture launch and cleanup failed"); + } + throw error; + } } -test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", async () => { +test("Chromium harness keeps the browser sandbox and bounds every external wait", () => { + const source = readFileSync(fileURLToPath(import.meta.url), "utf8"); + const unsafeSandboxFlag = ["--no", "sandbox"].join("-"); + + assert.equal(source.includes(`"${unsafeSandboxFlag}"`), false); + assert.match(source, /withHardDeadline/); + assert.match(source, /rejectPending/); + assert.match(source, /terminateChildProcess/); + assert.match(source, /fetchJsonWithDeadline/); +}); + +test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", { + timeout: 30_000, +}, async () => { const frontendRoot = fileURLToPath(new URL("..", import.meta.url)); const directory = mkdtempSync(join(tmpdir(), "rectification-browser-")); const entryPath = join(directory, "fixture.tsx"); @@ -425,7 +610,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, let cdp: CdpSession | null = null; try { writeFileSync(entryPath, fixture); - await build({ + await withHardDeadline(build({ absWorkingDir: frontendRoot, bundle: true, define: { "process.env.NODE_ENV": '"test"' }, @@ -436,7 +621,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, nodePaths: [join(frontendRoot, "node_modules")], outfile: bundlePath, platform: "browser", - }); + }), 10_000, "browser fixture bundle"); writeFileSync(htmlPath, `
`); ({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory)); @@ -546,17 +731,23 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, () => cdp?.evaluate("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false), "case-B abandon dialog", ); - await cdp.evaluate("globalThis.__rectificationHarness.setTurn('abandonedB2')"); + await cdp.evaluate("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('确认放弃且不应用候选')).click()"); await waitFor( - () => cdp?.evaluate("!document.querySelector('[role=alertdialog]') && document.body.textContent.includes('本次校正已放弃')") ?? Promise.resolve(false), - "terminal dialog reset", + () => cdp?.evaluate(`(() => { + const status = document.querySelector('.conversational-status'); + return !document.querySelector('[role=alertdialog]') + && document.body.textContent.includes('本次校正已放弃') + && status?.tabIndex === -1 + && document.activeElement === status; + })()`) ?? Promise.resolve(false), + "terminal dialog close and terminal status focus", ); } finally { - cdp?.close(); - browser?.kill("SIGKILL"); - browser?.stdout?.destroy(); - browser?.stderr?.destroy(); - browser?.unref(); - rmSync(directory, { force: true, recursive: true }); + try { + cdp?.close(); + if (browser) await terminateChildProcess(browser); + } finally { + 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 2972c69f..f0ed9611 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -56,6 +56,16 @@ function idFactory() { return () => ids.shift() ?? assert.fail("unexpected action id allocation"); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + test("controller admits only one in-flight mutation and clears text only after success", async () => { const commands: ConversationalRectificationCommand[] = []; let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined; @@ -255,6 +265,142 @@ test("switching cases synchronizes immediately and an old in-flight response can assert.equal(controller.getSnapshot().pending, false); }); +test("a case switch detaches the old mutation so the new case can mutate independently", async () => { + const commands: ConversationalRectificationCommand[] = []; + const caseARequest = deferred(); + const caseBRequest = deferred(); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async (command) => { + commands.push(command); + return command.type !== "start" && command.caseId === otherCaseId + ? caseBRequest.promise + : caseARequest.promise; + }, + }); + controller.setDraft("案例 A 的在途输入"); + const caseAPending = controller.answer("career"); + const caseARejected = assert.rejects(caseAPending, /案例 A 普通失败/); + + const caseBTurn = { ...activeTurn(1), caseId: otherCaseId }; + controller.synchronizeInitialTurn(caseBTurn); + assert.deepEqual(controller.getSnapshot(), { + turn: caseBTurn, + draft: "", + selectedDomain: null, + pending: false, + error: "", + }); + + controller.setDraft("案例 B 自己的输入"); + const caseBPending = controller.answer("career"); + assert.notEqual(caseBPending, caseAPending); + assert.deepEqual(commands.map((command) => ( + command.type === "start" ? "start" : `${command.caseId}:${command.type}` + )), [`${caseId}:answer`, `${otherCaseId}:answer`]); + assert.equal(controller.getSnapshot().pending, true); + + caseARequest.reject(new Error("案例 A 普通失败")); + await caseARejected; + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().draft, "案例 B 自己的输入"); + assert.equal(controller.getSnapshot().pending, true); + assert.equal(controller.getSnapshot().error, ""); + + caseBRequest.resolve({ ...activeTurn(2), caseId: otherCaseId }); + await caseBPending; + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().turn?.turnVersion, 2); + assert.equal(controller.getSnapshot().draft, ""); + assert.equal(controller.getSnapshot().pending, false); +}); + +test("synchronizing to no case detaches an ordinary failure without publishing its error", async () => { + const request = deferred(); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async () => request.promise, + }); + controller.setDraft("即将离开的案例输入"); + const pending = controller.answer("career"); + const rejected = assert.rejects(pending, /案例 A 已离线/); + + controller.synchronizeInitialTurn(null); + assert.deepEqual(controller.getSnapshot(), { + turn: null, + draft: "", + selectedDomain: null, + pending: false, + error: "", + }); + + request.reject(new Error("案例 A 已离线")); + await rejected; + assert.deepEqual(controller.getSnapshot(), { + turn: null, + draft: "", + selectedDomain: null, + pending: false, + error: "", + }); +}); + +test("a stale recovery failure from the old case cannot patch or unlock the new case", async () => { + const commands: ConversationalRectificationCommand[] = []; + const recoveryStarted = deferred(); + const recovery = deferred(); + const caseBRequest = deferred(); + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(2), + createActionId: idFactory(), + send: async (command) => { + commands.push(command); + if (command.type === "answer" && command.caseId === caseId) { + throw new ConversationalRectificationRequestError( + 409, + "stale_turn", + "请加载最新进度后再试。", + ); + } + if (command.type === "resume" && command.caseId === caseId) { + recoveryStarted.resolve(); + return recovery.promise; + } + return caseBRequest.promise; + }, + }); + controller.setDraft("案例 A 的陈旧输入"); + const caseAPending = controller.answer("career"); + const caseARejected = assert.rejects(caseAPending, /恢复请求失败/); + await recoveryStarted.promise; + + const caseBTurn = { ...activeTurn(1), caseId: otherCaseId }; + controller.synchronizeInitialTurn(caseBTurn); + const caseBPending = controller.pause(); + assert.deepEqual(commands.map((command) => command.type), ["answer", "resume", "pause"]); + assert.equal(controller.getSnapshot().pending, true); + + recovery.reject(new Error("恢复请求失败")); + await caseARejected; + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().pending, true); + assert.equal(controller.getSnapshot().error, ""); + + caseBRequest.resolve({ + ...activeTurn(2), + caseId: otherCaseId, + status: "paused", + actions: ["answer", "abandon"], + }); + await caseBPending; + assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId); + assert.equal(controller.getSnapshot().turn?.status, "paused"); + assert.equal(controller.getSnapshot().pending, false); + assert.equal(controller.getSnapshot().error, ""); +}); + 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) => {