feat: resume questions after birth-time confirmation
This commit is contained in:
@@ -57,7 +57,7 @@ const chartChatRequestSchema = consultationInputSchema.extend({
|
||||
.optional()
|
||||
.default("verified_chart"),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
});
|
||||
}).strict();
|
||||
|
||||
const generalChatRequestSchema = z.object({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
|
||||
+134
-17
@@ -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<string | null>(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<Theme>());
|
||||
const rectificationContinuationInFlight = useRef(false);
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(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<boolean> {
|
||||
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() {
|
||||
<div className="onboarding-card-actions">
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={rectificationLoading || rectificationMutationPending}
|
||||
disabled={rectificationLoading || rectificationMutationPending || rectificationContinuationPending}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!rectificationLoading && !rectificationMutationPending) {
|
||||
setRectificationSurfaceOpen(false);
|
||||
restoreQuestionFromRectification();
|
||||
}
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
{(rectificationInitialTurn?.pendingConsultationQuestion
|
||||
?? rectificationPendingQuestion)
|
||||
? "返回并恢复原问题"
|
||||
: "返回首页"}
|
||||
</button>
|
||||
</div>
|
||||
{rectificationLoading ? (
|
||||
@@ -2726,6 +2840,7 @@ export default function Home() {
|
||||
<p className="form-error">{rectificationError}</p>
|
||||
<button
|
||||
className="button-primary"
|
||||
disabled={rectificationLoading || rectificationMutationPending}
|
||||
type="button"
|
||||
onClick={() => void openBirthTimeRectification(rectificationPendingQuestion)}
|
||||
>
|
||||
@@ -2736,8 +2851,10 @@ export default function Home() {
|
||||
<ConversationalBirthTimeRectification
|
||||
initialTurn={rectificationInitialTurn}
|
||||
pendingConsultationQuestion={rectificationPendingQuestion}
|
||||
continuationPending={rectificationContinuationPending}
|
||||
onPendingChange={setRectificationMutationPending}
|
||||
onTurn={handleConversationalRectificationTurn}
|
||||
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -29,6 +29,7 @@ const domainLabels = {
|
||||
type SurfaceProps = Readonly<{
|
||||
controller: ConversationalRectificationController;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onContinueOriginalQuestion?: (question: string) => void;
|
||||
}>;
|
||||
|
||||
@@ -88,6 +89,7 @@ function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectification
|
||||
export function ConversationalRectificationSurface({
|
||||
controller,
|
||||
pendingConsultationQuestion,
|
||||
continuationPending = false,
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const [abandonArmedFor, setAbandonArmedFor] = useState<string | null>(null);
|
||||
@@ -368,11 +370,13 @@ export function ConversationalRectificationSurface({
|
||||
<p>原问题:{pendingQuestion}</p>
|
||||
<button
|
||||
className="button-primary"
|
||||
disabled={controller.pending}
|
||||
disabled={controller.pending || continuationPending}
|
||||
type="button"
|
||||
onClick={() => onContinueOriginalQuestion?.(pendingQuestion)}
|
||||
>
|
||||
继续回答原问题
|
||||
{continuationPending
|
||||
? "正在继续回答原问题…"
|
||||
: "使用新确认时间继续回答原问题"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
@@ -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(
|
||||
<ConversationalRectificationSurface
|
||||
controller={controller}
|
||||
pendingConsultationQuestion={props.pendingConsultationQuestion}
|
||||
continuationPending={props.continuationPending}
|
||||
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
export type RectificationQuestionHandoff<Theme extends string> = Readonly<{
|
||||
question: string;
|
||||
sessionId: string;
|
||||
theme: Theme;
|
||||
}>;
|
||||
|
||||
type HandoffFallback<Theme extends string> = Readonly<{
|
||||
sessionId: string;
|
||||
theme: Theme;
|
||||
}>;
|
||||
|
||||
type ContinueOriginalQuestion<Theme extends string> = (
|
||||
handoff: RectificationQuestionHandoff<Theme>,
|
||||
) => Promise<boolean>;
|
||||
|
||||
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<Theme extends string>(
|
||||
left: RectificationQuestionHandoff<Theme> | null,
|
||||
right: RectificationQuestionHandoff<Theme>,
|
||||
) {
|
||||
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<Theme extends string>() {
|
||||
let current: RectificationQuestionHandoff<Theme> | null = null;
|
||||
let activeContinuation: Promise<boolean> | null = null;
|
||||
let activeContinuationToken: symbol | null = null;
|
||||
let consumedQuestion: string | null = null;
|
||||
|
||||
const fromDurableQuestion = (
|
||||
questionValue: string | null | undefined,
|
||||
fallback: HandoffFallback<Theme>,
|
||||
): RectificationQuestionHandoff<Theme> | 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<Theme>) {
|
||||
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<Theme>,
|
||||
send: ContinueOriginalQuestion<Theme>,
|
||||
) {
|
||||
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<Theme extends string> = ReturnType<
|
||||
typeof createRectificationQuestionHandoffCoordinator<Theme>
|
||||
>;
|
||||
@@ -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, />使用新确认时间继续回答原问题</);
|
||||
assert.equal(continuationCalls, 0);
|
||||
});
|
||||
|
||||
test("continuation action is visibly locked while ordinary consultation is pending", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{
|
||||
controller: controllerFor(confirmedTurn()),
|
||||
continuationPending: true,
|
||||
onContinueOriginalQuestion: () => undefined,
|
||||
},
|
||||
));
|
||||
|
||||
assert.match(markup, /<button[^>]+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<boolean>((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"/);
|
||||
});
|
||||
Reference in New Issue
Block a user