From 47f2e9c989d7ae7a0d2d55686e19ca7307a676e7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 10:45:42 +0800 Subject: [PATCH] feat: resume questions after birth-time confirmation --- frontend/src/app/api/consult/route.ts | 2 +- frontend/src/app/page.tsx | 151 +++++++- ...onversational-birth-time-rectification.tsx | 10 +- .../src/lib/rectification-question-handoff.ts | 106 ++++++ .../rectification-question-handoff.test.ts | 346 ++++++++++++++++++ 5 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/rectification-question-handoff.ts create mode 100644 frontend/tests/rectification-question-handoff.test.ts diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 66a95699..4ea3728b 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -57,7 +57,7 @@ const chartChatRequestSchema = consultationInputSchema.extend({ .optional() .default("verified_chart"), entrypoint: consultationEntrypointSchema.optional(), -}); +}).strict(); const generalChatRequestSchema = z.object({ ...chatRequestMetadataSchema.shape, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ca8a8763..15477c5e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -50,6 +50,7 @@ import { import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client"; import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; +import { createRectificationQuestionHandoffCoordinator } from "@/lib/rectification-question-handoff"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; import { requestBirthTimeAssessment, @@ -747,6 +748,7 @@ export default function Home() { const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); + const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false); const [rectificationError, setRectificationError] = useState(""); const [hydrated, setHydrated] = useState(false); const [profileSaving, setProfileSaving] = useState(false); @@ -781,6 +783,8 @@ export default function Home() { const chartLibraryLoadedAccount = useRef(""); const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); + const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); + const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); @@ -806,6 +810,7 @@ export default function Home() { || Boolean(pendingSessionId) || cancellationPending || rectificationMutationPending + || rectificationContinuationPending || !account || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; @@ -1777,8 +1782,18 @@ export default function Home() { chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage"); } + function synchronizeRectificationQuestion(turn: ConversationalRectificationTurn) { + if (!turn.pendingConsultationQuestion || !activeSession) return; + rectificationQuestionHandoff.current.synchronizeDurableQuestion( + turn.pendingConsultationQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, + ); + setRectificationPendingQuestion(turn.pendingConsultationQuestion); + } + async function openBirthTimeRectification(pendingConsultationQuestion: string | null = null) { - if (!account || rectificationLoading || rectificationMutationPending) return; + if (!account || rectificationLoading || rectificationMutationPending + || rectificationContinuationInFlight.current) return; const action = resolveRectificationCardAction({ rectificationCase: account.rectificationCase, hasConfirmedBirthTime: account.hasConfirmedBirthTime, @@ -1787,7 +1802,11 @@ export default function Home() { setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); - setRectificationPendingQuestion(pendingConsultationQuestion); + setRectificationPendingQuestion( + pendingConsultationQuestion + ?? rectificationQuestionHandoff.current.peek()?.question + ?? null, + ); setRectificationInitialTurn(null); setRectificationError(""); setRectificationSurfaceOpen(true); @@ -1803,6 +1822,7 @@ export default function Home() { turnVersion: current.turnVersion, }); setRectificationInitialTurn(turn); + synchronizeRectificationQuestion(turn); } catch (caught) { setRectificationError(caught instanceof Error ? caught.message @@ -1815,6 +1835,7 @@ export default function Home() { function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) { const requestIdentity = accountRefreshGuard.current.begin(); setRectificationInitialTurn(turn); + synchronizeRectificationQuestion(turn); setAccount((current) => current ? { ...current, hasConfirmedBirthTime: current.hasConfirmedBirthTime @@ -2050,26 +2071,30 @@ export default function Home() { requestedTheme?: Theme, entrypoint: ConsultationEntrypoint | null = null, consentGrantedForRequest: ConsultationBirthTimeMode | null = null, - ) { + targetSessionId: string | null = null, + ): Promise { const originalQuestion = text; const question = text.trim(); - if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return; + const currentSession = targetSessionId + ? sessions.find((session) => session.id === targetSessionId) + : activeSession; + if (!question || !currentSession || !modelCatalog || pendingSessionId + || cancellationInFlight.current || pendingConsultation.current || !account) return false; if (!isProfileComplete(profile)) { openAccountDialog("profile"); setProfileNotice("请先补充出生资料,才能进行星盘计算。"); - return; + return false; } if (entrypoint === "birth_time_rectification") { await openBirthTimeRectification(null); - return; + return false; } const birthPlace = selectedBirthPlace(profile); - if (!birthPlace) return; + if (!birthPlace) return false; - const currentSession = activeSession; const theme = requestedTheme ?? currentSession.theme; const sessionId = currentSession.id; const consentForDecision = consentGrantedForRequest === "unverified_birth_time" @@ -2094,12 +2119,12 @@ export default function Home() { setComposerNotice(consultationRoute.canUseUnverifiedTime ? "请选择在当前聊天临时使用填报时间,或先校正再询问。" : "你还没有可使用的具体出生分钟,可以先校正,或改问不依赖出生分钟的一般问题。"); - return; + return false; } if (account.credits <= 0) { openAccountDialog("redeem", creditTrigger.current); - return; + return false; } const [year, month, day] = profile.date.split("-").map(Number); @@ -2160,7 +2185,7 @@ export default function Home() { await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800)); if (controller.signal.aborted) { if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null; - return; + return false; } const previewReply = parseAgentReply([ "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", @@ -2179,11 +2204,11 @@ export default function Home() { }; updateSession(sessionId, () => previewSession); completeConsultationInterface(requestId); - return; + return true; } await waitForUndoWindow(controller.signal); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return false; if (pendingConsultation.current?.requestId === requestId) { pendingConsultation.current = { ...pendingConsultation.current, @@ -2253,7 +2278,7 @@ export default function Home() { } } answer += decoder.decode(); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return Boolean(latestPartialReply); if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。"); const reply = parseAgentReply(answer, theme); if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。"); @@ -2275,6 +2300,7 @@ export default function Home() { }); } void refreshAccount(); + return true; } catch (caught) { const cancelled = controller.signal.aborted; const ownsInterface = pendingConsultation.current?.requestId === requestId; @@ -2327,6 +2353,7 @@ export default function Home() { setComposerNotice("回答中途断开,已保留现有内容,本次已计费。"); } } + return Boolean(partialReply); } finally { cancellationRequests.current.delete(requestId); completeConsultationInterface(requestId); @@ -2343,6 +2370,85 @@ export default function Home() { } } + + async function continueRectificationOriginalQuestion(question: string) { + if (rectificationContinuationInFlight.current || rectificationMutationPending + || rectificationLoading || !activeSession) return; + if (rectificationQuestionHandoff.current.peek() + && !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) { + rectificationQuestionHandoff.current.clear(); + } + + rectificationContinuationInFlight.current = true; + setRectificationContinuationPending(true); + setRectificationError(""); + try { + const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion( + question, + { sessionId: activeSession.id, theme: activeSession.theme }, + async (context) => { + activeSessionIdRef.current = context.sessionId; + setActiveSessionId(context.sessionId); + setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent( + current, + context.sessionId, + )); + return send(context.question, context.theme, null, null, context.sessionId); + }, + ); + if (completed) { + setRectificationSurfaceOpen(false); + setRectificationPendingQuestion(null); + setRectificationInitialTurn(null); + setComposerNotice("已使用新确认时间继续回答原问题。"); + } else { + setComposerNotice("原问题仍保留,可再次点击继续回答。"); + } + } catch { + setComposerNotice("原问题仍保留,可再次点击继续回答。"); + } finally { + rectificationContinuationInFlight.current = false; + setRectificationContinuationPending(false); + } + } + + function restoreQuestionFromRectification() { + if (rectificationLoading || rectificationMutationPending + || rectificationContinuationInFlight.current) return; + const durableQuestion = rectificationInitialTurn?.pendingConsultationQuestion + ?? rectificationPendingQuestion + ?? rectificationQuestionHandoff.current.peek()?.question + ?? null; + let handoff = activeSession + ? rectificationQuestionHandoff.current.synchronizeDurableQuestion( + durableQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, + ) + : null; + const handoffSessionId = handoff?.sessionId ?? null; + if (handoffSessionId + && !sessions.some((session) => session.id === handoffSessionId) + && activeSession) { + rectificationQuestionHandoff.current.clear(); + handoff = rectificationQuestionHandoff.current.synchronizeDurableQuestion( + durableQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, + ); + } + if (handoff) { + activeSessionIdRef.current = handoff.sessionId; + setActiveSessionId(handoff.sessionId); + setDraft(handoff.question); + setDraftTheme(handoff.theme); + setDraftEntrypoint(null); + setComposerNotice("原问题已放回输入框;没有发起普通咨询,也未扣咨询点数。"); + rectificationQuestionHandoff.current.clear(); + window.requestAnimationFrame(() => composerInput.current?.focus()); + } + setRectificationSurfaceOpen(false); + setRectificationInitialTurn(null); + } + function useUnverifiedTimeForPendingConsultation() { if (!pendingBirthTimeChoice || !activeSession @@ -2385,6 +2491,11 @@ export default function Home() { || !activeSession || pendingBirthTimeChoice.sessionId !== activeSession.id) return; const pending = pendingBirthTimeChoice; + rectificationQuestionHandoff.current.capture({ + question: pending.question, + sessionId: pending.sessionId, + theme: pending.theme, + }); setPendingBirthTimeChoice(null); setComposerNotice(""); void openBirthTimeRectification(pending.question); @@ -2708,15 +2819,18 @@ export default function Home() {
{rectificationLoading ? ( @@ -2726,6 +2840,7 @@ export default function Home() {

{rectificationError}

)} @@ -458,6 +462,7 @@ export function ConversationalRectificationSurface({ type ConversationalBirthTimeRectificationProps = Readonly<{ initialTurn?: ConversationalRectificationTurn | null; pendingConsultationQuestion?: string | null; + continuationPending?: boolean; onTurn?: (turn: ConversationalRectificationTurn) => void; onPendingChange?: (pending: boolean) => void; onContinueOriginalQuestion?: (question: string) => void; @@ -480,6 +485,7 @@ export function ConversationalBirthTimeRectification( ); diff --git a/frontend/src/lib/rectification-question-handoff.ts b/frontend/src/lib/rectification-question-handoff.ts new file mode 100644 index 00000000..5a7060ab --- /dev/null +++ b/frontend/src/lib/rectification-question-handoff.ts @@ -0,0 +1,106 @@ +export type RectificationQuestionHandoff = Readonly<{ + question: string; + sessionId: string; + theme: Theme; +}>; + +type HandoffFallback = Readonly<{ + sessionId: string; + theme: Theme; +}>; + +type ContinueOriginalQuestion = ( + handoff: RectificationQuestionHandoff, +) => Promise; + +function normalizedQuestion(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const question = value.trim(); + return question.length > 0 && question.length <= 500 ? question : null; +} + +function sameHandoff( + left: RectificationQuestionHandoff | null, + right: RectificationQuestionHandoff, +) { + return left?.question === right.question + && left.sessionId === right.sessionId + && left.theme === right.theme; +} + +/** + * Keeps the presentation-only session/theme context beside the question that + * the v3 case persists. The durable question always wins after refresh; local + * context is retained only while it still belongs to that same question. + */ +export function createRectificationQuestionHandoffCoordinator() { + let current: RectificationQuestionHandoff | null = null; + let activeContinuation: Promise | null = null; + let activeContinuationToken: symbol | null = null; + let consumedQuestion: string | null = null; + + const fromDurableQuestion = ( + questionValue: string | null | undefined, + fallback: HandoffFallback, + ): RectificationQuestionHandoff | null => { + const question = normalizedQuestion(questionValue); + if (!question) return current; + if (current?.question === question) return current; + if (consumedQuestion === question) return null; + if (!fallback.sessionId) return null; + current = Object.freeze({ question, ...fallback }); + return current; + }; + + return Object.freeze({ + capture(input: RectificationQuestionHandoff) { + const question = normalizedQuestion(input.question); + if (!question || !input.sessionId) { + throw new TypeError("A visible question and session are required for rectification handoff"); + } + consumedQuestion = null; + current = Object.freeze({ ...input, question }); + return current; + }, + synchronizeDurableQuestion: fromDurableQuestion, + peek() { + return current; + }, + clear() { + current = null; + }, + continueOriginalQuestion( + questionValue: string, + fallback: HandoffFallback, + send: ContinueOriginalQuestion, + ) { + if (activeContinuation) return activeContinuation; + const handoff = fromDurableQuestion(questionValue, fallback); + if (!handoff) return Promise.resolve(false); + + const token = Symbol("rectification-question-continuation"); + const operation = Promise.resolve() + .then(() => send(handoff)) + .then((completed) => { + if (completed && sameHandoff(current, handoff)) { + current = null; + consumedQuestion = handoff.question; + } + return completed; + }) + .finally(() => { + if (activeContinuationToken === token) { + activeContinuation = null; + activeContinuationToken = null; + } + }); + activeContinuation = operation; + activeContinuationToken = token; + return operation; + }, + }); +} + +export type RectificationQuestionHandoffCoordinator = ReturnType< + typeof createRectificationQuestionHandoffCoordinator +>; diff --git a/frontend/tests/rectification-question-handoff.test.ts b/frontend/tests/rectification-question-handoff.test.ts new file mode 100644 index 00000000..166e478e --- /dev/null +++ b/frontend/tests/rectification-question-handoff.test.ts @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ConversationalRectificationSurface } from "../src/components/conversational-birth-time-rectification.tsx"; +import { createConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts"; +import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts"; +import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts"; +import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts"; +import { + createRectificationQuestionHandoffCoordinator, +} from "../src/lib/rectification-question-handoff.ts"; + +Object.assign(globalThis, { React }); + +const pendingQuestion = "未来半年是否适合换工作?"; + +function confirmedTurn(): ConversationalRectificationTurn { + return { + caseId: "00000000-0000-4000-8000-000000001010", + journeyProtocol: "conversational-evidence-v3", + status: "completed", + turnVersion: 5, + narrative: "候选时间已经完成确认。", + candidate: { + status: "confirmed", + representativeTime: "05:18", + rangeStart: "05:16", + rangeEnd: "05:20", + }, + technicalReceipt: { + calculationVersion: "rectification-technical-v1", + stableLayers: ["D1"], + sensitiveLayers: ["D9", "D10"], + candidateDifferenceRefs: ["candidate-05:18"], + }, + evidenceRequest: null, + evidenceRecap: [], + actions: ["continue_original_question"], + pendingConsultationQuestion: pendingQuestion, + }; +} + +function controllerFor(turn: ConversationalRectificationTurn): ConversationalRectificationController { + const snapshot = { + turn, + draft: "", + selectedDomain: null, + correctionTarget: null, + pending: false, + error: "", + } as const; + return { + ...snapshot, + getSnapshot: () => snapshot, + subscribe: () => () => undefined, + synchronizeInitialTurn: () => undefined, + setDraft: () => undefined, + selectDomain: () => undefined, + beginEvidenceCorrection: () => undefined, + cancelEvidenceCorrection: () => undefined, + start: async () => turn, + resume: async () => turn, + answer: async () => turn, + pause: async () => turn, + abandon: async () => turn, + confirm: async () => turn, + }; +} + +test("confirmed surface requires an explicit click before continuing the original question", () => { + let continuationCalls = 0; + const markup = renderToStaticMarkup(React.createElement( + ConversationalRectificationSurface, + { + controller: controllerFor(confirmedTurn()), + onContinueOriginalQuestion: () => { continuationCalls += 1; }, + }, + )); + + assert.match(markup, /原问题:未来半年是否适合换工作?/); + assert.match(markup, />使用新确认时间继续回答原问题 { + const markup = renderToStaticMarkup(React.createElement( + ConversationalRectificationSurface, + { + controller: controllerFor(confirmedTurn()), + continuationPending: true, + onContinueOriginalQuestion: () => undefined, + }, + )); + + assert.match(markup, /]+disabled=""[^>]*>正在继续回答原问题…<\/button>/); +}); + +test("choosing rectify-first captures the visible question and passes it only to v3 start", async () => { + const coordinator = createRectificationQuestionHandoffCoordinator<"career" | "general">(); + const commands: unknown[] = []; + const handoff = coordinator.capture({ + question: ` ${pendingQuestion} `, + sessionId: "session-original", + theme: "career", + }); + const firstTurn: ConversationalRectificationTurn = { + ...confirmedTurn(), + status: "active" as const, + turnVersion: 0, + candidate: { + ...confirmedTurn().candidate, + status: "pending_validation" as const, + }, + actions: ["answer", "pause", "abandon"], + }; + const rectification = createConversationalRectificationController({ + async send(command) { + commands.push(command); + return firstTurn; + }, + createActionId: () => "00000000-0000-4000-8000-000000001011", + }); + + await rectification.start(handoff.question); + + assert.deepEqual(commands, [{ + type: "start", + actionId: "00000000-0000-4000-8000-000000001011", + pendingConsultationQuestion: pendingQuestion, + }]); +}); + +test("pause, refresh, and a new device recover the durable question without losing local session and theme", () => { + const paused = { + ...confirmedTurn(), + status: "paused" as const, + candidate: { + ...confirmedTurn().candidate, + status: "pending_validation" as const, + }, + actions: ["answer", "abandon"] as const, + }; + const local = createRectificationQuestionHandoffCoordinator<"career" | "timing">(); + local.capture({ question: pendingQuestion, sessionId: "session-original", theme: "career" }); + + assert.deepEqual(local.synchronizeDurableQuestion( + paused.pendingConsultationQuestion, + { sessionId: "session-after-refresh", theme: "timing" }, + ), { + question: pendingQuestion, + sessionId: "session-original", + theme: "career", + }); + + const newDevice = createRectificationQuestionHandoffCoordinator<"career" | "timing">(); + assert.deepEqual(newDevice.synchronizeDurableQuestion( + paused.pendingConsultationQuestion, + { sessionId: "session-current-device", theme: "timing" }, + ), { + question: pendingQuestion, + sessionId: "session-current-device", + theme: "timing", + }); +}); + +test("explicit continuation uses the confirmed server profile, original session and theme, and one normal reservation", async () => { + const coordinator = createRectificationQuestionHandoffCoordinator<"career" | "timing">(); + coordinator.capture({ question: pendingQuestion, sessionId: "session-original", theme: "career" }); + let consultationCalls = 0; + let reservationCalls = 0; + let selectedMinute = -1; + let sentContext: unknown = null; + + const continued = await coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "timing" }, + async (context) => { + consultationCalls += 1; + sentContext = context; + const prepared = await prepareConsultationRoute({ + userId: "synthetic-user", + mode: "verified_chart", + loadProfile: async () => ({ + name: "测试用户", + birth_date: "1990-01-02", + reported_birth_time: "05:30:00", + active_birth_time: "05:18:00", + birth_time_source: "approximate", + birth_time_status: "confirmed", + country_code: "CN", + province_code: "130000", + city_code: "130400", + district_code: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, + }), + reserve: async () => { + reservationCalls += 1; + return "reserved"; + }, + }); + selectedMinute = prepared.serverChart?.toolInput.minute ?? -1; + return true; + }, + ); + + assert.equal(continued, true); + assert.deepEqual(sentContext, { + question: pendingQuestion, + sessionId: "session-original", + theme: "career", + }); + assert.equal(selectedMinute, 18); + assert.equal(consultationCalls, 1); + assert.equal(reservationCalls, 1); + assert.equal(coordinator.peek(), null); +}); + +test("double click shares one in-flight continuation and cannot reserve twice", async () => { + const coordinator = createRectificationQuestionHandoffCoordinator<"career">(); + coordinator.capture({ question: pendingQuestion, sessionId: "session-original", theme: "career" }); + let release!: (success: boolean) => void; + const result = new Promise((resolve) => { release = resolve; }); + let consultationCalls = 0; + const send = async () => { + consultationCalls += 1; + return result; + }; + + const first = coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "career" }, + send, + ); + const second = coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "career" }, + send, + ); + release(true); + + assert.equal(await first, true); + assert.equal(await second, true); + assert.equal(consultationCalls, 1); + assert.equal(await coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "career" }, + send, + ), false); + assert.equal(consultationCalls, 1); +}); + +test("failed continuation keeps the question and can retry once the pending lock releases", async () => { + const coordinator = createRectificationQuestionHandoffCoordinator<"career">(); + const expected = coordinator.capture({ + question: pendingQuestion, + sessionId: "session-original", + theme: "career", + }); + let attempts = 0; + const send = async () => { + attempts += 1; + return attempts > 1; + }; + + assert.equal(await coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "career" }, + send, + ), false); + assert.deepEqual(coordinator.peek(), expected); + + assert.equal(await coordinator.continueOriginalQuestion( + pendingQuestion, + { sessionId: "session-fallback", theme: "career" }, + send, + ), true); + assert.equal(attempts, 2); + assert.equal(coordinator.peek(), null); +}); + +test("returning from rectification restores the composer context without consulting or charging", () => { + const coordinator = createRectificationQuestionHandoffCoordinator<"career" | "timing">(); + const restored = coordinator.synchronizeDurableQuestion( + pendingQuestion, + { sessionId: "session-current", theme: "timing" }, + ); + coordinator.clear(); + + assert.deepEqual(restored, { + question: pendingQuestion, + sessionId: "session-current", + theme: "timing", + }); + assert.equal(coordinator.peek(), null); +}); + +test("homepage wires the tested handoff coordinator without carrying hidden rectification routing", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const chooseStart = page.indexOf("function rectifyBeforePendingConsultation"); + const chooseEnd = page.indexOf("function cancelPendingBirthTimeChoice", chooseStart); + const chooseHandler = page.slice(chooseStart, chooseEnd); + const continuationStart = page.indexOf("async function continueRectificationOriginalQuestion"); + const continuationEnd = page.indexOf("function restoreQuestionFromRectification", continuationStart); + const continuationHandler = page.slice(continuationStart, continuationEnd); + const restoreStart = continuationEnd; + const restoreEnd = page.indexOf("function useUnverifiedTimeForPendingConsultation", restoreStart); + const restoreHandler = page.slice(restoreStart, restoreEnd); + + assert.match(page, /createRectificationQuestionHandoffCoordinator/); + assert.match(chooseHandler, /\.capture\(\{[\s\S]*question:\s*pending\.question,[\s\S]*sessionId:\s*pending\.sessionId,[\s\S]*theme:\s*pending\.theme/); + assert.doesNotMatch(chooseHandler, /\/api\/consult|\bsend\(/); + assert.match(continuationHandler, /continueOriginalQuestion\(/); + assert.match(continuationHandler, /send\(context\.question, context\.theme, null, null, context\.sessionId\)/); + assert.match(continuationHandler, /if \(completed\)[\s\S]*setRectificationSurfaceOpen\(false\)/); + assert.match(restoreHandler, /setDraft\(handoff\.question\)/); + assert.match(restoreHandler, /setDraftTheme\(handoff\.theme\)/); + assert.match(restoreHandler, /setDraftEntrypoint\(null\)/); + assert.doesNotMatch(restoreHandler, /\/api\/consult|\bsend\(/); + assert.match(page, /continuationPending=\{rectificationContinuationPending\}/); + assert.match(page, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/); + assert.match(page, /\? "返回并恢复原问题"\s*:\s*"返回首页"/); +}); + +test("ordinary consult remains strict and bills the confirmed continuation through the normal route", () => { + const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); + const chartSchema = route.slice( + route.indexOf("const chartChatRequestSchema"), + route.indexOf("const generalChatRequestSchema"), + ); + const parse = route.indexOf("chatRequestSchema.safeParse"); + const legacyRejection = route.indexOf('parsed.data.entrypoint === "birth_time_rectification"', parse); + const prepare = route.indexOf("prepareConsultationRoute({", parse); + const reserve = route.indexOf("reserveConsultationModel(", prepare); + + assert.match(chartSchema, /consultationInputSchema\.extend\([\s\S]*?\)\.strict\(\);/); + assert.doesNotMatch(route, /continue_original_question|rectificationHandoff|skipBilling/); + assert.ok(parse >= 0 && legacyRejection > parse && prepare > legacyRejection && reserve > prepare); + assert.match(route, /"begin_consultation_credit"/); + assert.match(route, /"complete_consultation_credit"/); + assert.match(route, /"cancel_consultation_credit"/); +});