fix(rectification): reveal the surface once — hydrate the Case before the switch, never remount, static entry feedback

Opening a rectification Case used to switch sessions first and read the
turns afterwards, so the reader saw a plain transcript, then an empty
panel, then a remount when the turns arrived (and again after the first
turn settled, because the panel key carried a ready/loading suffix).
The surface hook now reads turns and snapshot in one Case request under
the home reveal budget and only then makes the session active; the
sidebar defers the switch the same way; a session selected at bootstrap
(deep link, refresh) is hydrated during the prepare phase so the reveal
shows the surface itself; the panel key is the session/Case binding only,
later turns fill an empty transcript as a prop update, and unmounting
aborts any stream or snapshot read. The homepage card and the sidebar row
say 正在打开 statically while the Case opens — no spinner after the reveal.

BUG-505

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 06:53:33 +00:00
parent 0cefaea6a2
commit 53017d4153
13 changed files with 647 additions and 59 deletions
+11
View File
@@ -757,6 +757,14 @@ button:disabled { cursor: default; opacity: .45; }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
.session-opening-note {
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: .02em;
}
.session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); }
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; }
.session-menu-trigger > svg { width: 18px; height: 18px; }
@@ -995,6 +1003,9 @@ button:disabled { cursor: default; opacity: .45; }
.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; }
/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */
.product-entrypoint-card[data-opening="true"],
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); }
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
@media (hover: hover) {
+28 -4
View File
@@ -24,6 +24,11 @@ import {
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
type ChatMessageFeedback,
@@ -295,6 +300,8 @@ export default function Home() {
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
@@ -415,7 +422,10 @@ export default function Home() {
latestTerminal: null,
},
);
const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction];
const rectificationCardLabel = rectificationLoading
? RECTIFICATION_OPENING_LABEL
: rectificationEntryLabels[rectificationCardAction];
const rectificationDeclaredTime = declaredBirthTime(profile);
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
: rectificationError;
@@ -482,7 +492,8 @@ export default function Home() {
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
setRectificationEntrySummary, setRectificationError, setRectificationLoading,
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview,
setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
setRectificationTurns, setSessions, uiPreview,
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
});
@@ -502,8 +513,13 @@ export default function Home() {
return () => window.removeEventListener("popstate", onPopState);
}, [hydrated]);
// A rectification session selected at bootstrap (deep link, refresh, the
// most recent session) is opened and hydrated during the prepare phase, so
// the reveal shows the surface itself rather than a plain transcript that is
// swapped out a moment later. After the reveal the same effect serves
// history navigation.
useEffect(() => {
if (!hydrated
if ((!hydrated && bootstrapPhase === "account")
|| !account
|| !modelCatalog
|| activeSession?.sessionType !== "birth_time_rectification"
@@ -516,6 +532,7 @@ export default function Home() {
}, [
account,
activeSession,
bootstrapPhase,
creatingSession,
hydrated,
modelCatalog,
@@ -647,6 +664,10 @@ export default function Home() {
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
entrySummarySettled: rectificationEntrySummarySettled,
rectificationApplicable: activeSession?.sessionType === "birth_time_rectification",
rectificationSettled: activeSession?.id === rectificationSessionId
|| rectificationError !== ""
|| !profileComplete,
});
useEffect(() => {
@@ -1718,6 +1739,7 @@ export default function Home() {
sessions={sidebarSessions}
charts={sidebarCharts}
activeSessionId={activeSession?.id ?? null}
openingSessionId={rectificationOpeningSessionId}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
@@ -1928,12 +1950,14 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`}
key={`${rectificationSessionId}-${rectificationCaseId}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
shouldStartOpening={rectificationShouldStartOpening}
initialTurns={rectificationTurns}
initialSnapshot={rectificationSnapshot}
declaredTime={rectificationDeclaredTime}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
+4
View File
@@ -59,6 +59,8 @@ export type AppSidebarProps = {
sessions: readonly SidebarSession[];
charts: readonly SidebarChart[];
activeSessionId: string | null;
/** A rectification session whose Case is being opened and hydrated; its row says so, statically. */
openingSessionId?: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
accountTriggerRef: Ref<HTMLButtonElement>;
@@ -82,6 +84,7 @@ export function AppSidebar({
sessions,
charts,
activeSessionId,
openingSessionId = null,
account,
accountMenuOpen,
accountTriggerRef,
@@ -141,6 +144,7 @@ export function AppSidebar({
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
opening={session.id === openingSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
@@ -2,6 +2,7 @@
import type { PublicLanguageModel } from "../lib/public-models.ts";
import type { ChatMessage } from "../lib/chat-message-view.ts";
import type { RectificationCaseSnapshotPayload } from "../lib/rectification-surface-state.ts";
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
export type PersistedRectificationTurn = Readonly<{
@@ -10,9 +11,18 @@ export type PersistedRectificationTurn = Readonly<{
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
@@ -26,6 +36,10 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -43,6 +43,7 @@ import {
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import type { RectificationCaseSnapshotPayload } from "@/lib/rectification-surface-state";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
@@ -196,6 +197,10 @@ type RectificationAgenticChatProps = Readonly<{
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, for the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -386,6 +391,32 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
});
}
type CaseSnapshotState = Readonly<{
candidate: CandidateResult;
question: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choice: ChoiceCardModel | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
}>;
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
return {
candidate: parseRectificationCandidateResult(payload.latest_result),
question: currentQuestionFromSnapshot(payload.current_question),
questionSource: questionSourceFromSnapshot(payload.question_source),
choice: parseRectificationChoiceCard(payload.choice_card),
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
};
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
@@ -393,6 +424,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
readonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
models,
selectedModelId,
onSelectModel,
@@ -411,14 +443,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -432,6 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(null);
const snapshotAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
@@ -606,13 +639,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
snapshotAbort.current?.abort();
const controller = new AbortController();
snapshotAbort.current = controller;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
{ cache: "no-store", signal: controller.signal },
);
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) return undefined;
applyCaseSnapshot(payload);
return {
question: currentQuestionFromSnapshot(payload?.current_question),
@@ -621,25 +658,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
return undefined;
} finally {
if (snapshotAbort.current === controller) snapshotAbort.current = null;
}
}, [applyCaseSnapshot, caseId, sessionId]);
useEffect(() => {
const controller = new AbortController();
void fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store", signal: controller.signal },
)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (controller.signal.aborted) return;
applyCaseSnapshot(payload);
})
.catch(() => {
// Snapshot refresh is best-effort; the durable Case remains on the server.
});
return () => controller.abort();
}, [applyCaseSnapshot, caseId, sessionId]);
// Turns that arrive after mount (the parent refreshes the Case after each
// completed turn) only ever fill an empty transcript; a live or finished
// conversation is never overwritten or remounted. Adjusted during render
// from the previous prop, the way React documents it, not in an effect.
const [seededTurns, setSeededTurns] = useState(initialTurns);
if (seededTurns !== initialTurns) {
setSeededTurns(initialTurns);
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
}
// Leaving the surface mid-turn ends the stream and any snapshot read
// instead of letting them write into an unmounted component.
useEffect(() => () => {
runAbort.current?.abort();
snapshotAbort.current?.abort();
}, []);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
@@ -12,6 +12,7 @@ import {
Trash2,
} from "lucide-react";
import { forwardRef } from "react";
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
@@ -39,6 +40,8 @@ export type SidebarSessionControls = {
type SidebarSessionRowProps = {
readonly session: SidebarSession;
readonly active: boolean;
/** Its Case is being opened: a static note, no spinner. */
readonly opening?: boolean;
readonly disabled: boolean;
readonly menuOpen: boolean;
readonly onMenuOpenChange: (open: boolean) => void;
@@ -53,6 +56,7 @@ type SidebarSessionRowProps = {
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
session,
active,
opening = false,
disabled,
menuOpen,
onMenuOpenChange,
@@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
type="button"
isActive={active}
aria-current={active ? "page" : undefined}
aria-busy={opening ? true : undefined}
disabled={disabled}
onClick={onSelect}
>
<span className="session-title">
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
</span>
</SidebarMenuButton>
<Menu.Trigger
+5 -1
View File
@@ -93,7 +93,11 @@ export function StarterHome({
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
<article
className="birth-rectification-card product-entrypoint-card"
aria-labelledby="birth-rectification-title"
data-opening={rectificationLoading ? "true" : undefined}
>
<button
className="product-entrypoint-hitarea"
type="button"
+31 -23
View File
@@ -27,6 +27,14 @@ import {
} from "@/lib/rectification-entry";
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
rectificationCaseHref,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationSurfaceParams = {
account: Account | null;
@@ -58,6 +66,8 @@ export type RectificationSurfaceParams = {
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
@@ -98,6 +108,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationReadonly,
setRectificationSessionId,
setRectificationShouldStartOpening,
setRectificationSnapshot,
setRectificationOpeningSessionId,
setRectificationTurns,
setSessions,
uiPreview,
@@ -121,28 +133,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
async function refreshRectificationCase(caseId: string, sessionId: string) {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return;
const payload = await response.json().catch(() => null);
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown; question?: unknown }) => ({
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
question: turn?.question ?? null,
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
})));
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
} catch {
// History refresh is best-effort; the stream restores live turns.
}
@@ -172,6 +166,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationError("");
// Read the selection source now: `selectSession` resets it synchronously
// right after handing off, and the URL write below happens after awaits.
const selectionSource = sessionSelectionSource.current;
setRectificationOpeningSessionId(exactSessionId);
try {
const response = await fetch("/api/rectification/cases/open", {
@@ -225,6 +223,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
void persistSession(merged).catch(() => {});
// One reveal: turns and snapshot are read before the surface mounts, so
// the reader never sees an empty transcript or an empty question area
// while they load. Past the deadline the surface opens with what arrived
// and its own retry path fills in the rest.
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
setRectificationPendingQuestion(pendingConsultationQuestion);
setDraft("");
setDraftTheme(null);
@@ -235,13 +241,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationReadonly(
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
);
setRectificationTurns([]);
setRectificationTurns(hydration.turns);
setRectificationSnapshot(hydration.snapshot);
activeSessionIdRef.current = opened.sessionId;
setActiveSessionId(opened.sessionId);
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!uiPreview.current && selectionSource === "user") {
writeSessionUrl(opened.sessionId, "push");
}
void refreshRectificationCase(opened.caseId, opened.sessionId);
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
void refreshRectificationEntrySummary();
return opened;
} catch {
@@ -250,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
} finally {
sessionSelectionSource.current = "user";
rectificationOpenInFlight.current = false;
setRectificationOpeningSessionId(null);
setRectificationLoading(false);
}
}
+10 -3
View File
@@ -314,7 +314,14 @@ export function useSessionManagement(params: SessionManagementParams) {
function selectSession(sessionId: string) {
const nextSession = sessions.find((session) => session.id === sessionId);
setActiveSessionId(sessionId);
// A rectification session that is not the open one is not switched to
// here: the surface hook opens the Case, hydrates turns and snapshot, and
// only then makes it active and writes the URL, so the current view stays
// put instead of flashing a plain transcript and then an empty panel.
// It reads the selection source before we reset it below.
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
&& nextSession.id !== rectificationSessionId;
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
@@ -327,7 +334,7 @@ export function useSessionManagement(params: SessionManagementParams) {
}
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (nextSession.id !== rectificationSessionId) {
if (deferredRectificationSwitch) {
// The exact sessionId is passed to the server; the server resolves
// the exact Case and never switches to another rectification record.
void openRectificationSession(nextSession.id);
@@ -335,7 +342,7 @@ export function useSessionManagement(params: SessionManagementParams) {
} else {
void ensureSessionMessages(sessionId);
}
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
writeSessionUrl(sessionId, "push");
}
sessionSelectionSource.current = "user";
+4
View File
@@ -19,10 +19,14 @@ export type BootstrapPrepareState = Readonly<{
dailyStarlanguageApplicable: boolean;
dailyStarlanguageSettled: boolean;
entrySummarySettled: boolean;
/** The selected session is a rectification session: its Case must be open and hydrated before the reveal. */
rectificationApplicable?: boolean;
rectificationSettled?: boolean;
}>;
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
if (!state.entrySummarySettled) return false;
if (state.rectificationApplicable && !state.rectificationSettled) return false;
if (state.profileComplete && !state.onboardingSettled) return false;
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
return true;
@@ -0,0 +1,289 @@
/**
* Pure state for the rectification chat surface: what the entry, the
* transcript and the composer status show in each phase, and how a Case is
* hydrated (turns + snapshot) before the surface is revealed.
*
* Nothing here touches React. The chat component and the surface hook call
* these so the "is there anything to wait for, and what does the reader see
* while waiting" decisions are testable without a DOM.
*/
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
/**
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
* returns and before the surface is revealed. Past it the surface opens with
* whatever arrived and the composer status's retry path fills the rest. It is
* the home reveal budget: one constant, not two.
*/
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
/** Question recovery: one immediate refetch after a turn, then this many timed retries. */
export const RECTIFICATION_QUESTION_RETRY_LIMIT = 2;
export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
/** Live-row labels by the action that started the turn (task 1.4). */
export const RECTIFICATION_OPENING_LIVE_LABEL = "正在读取你的出生资料,准备第一个问题…";
export const RECTIFICATION_MESSAGE_LIVE_LABEL = "正在处理…";
export function rectificationInitialLiveLabel(
action: "opening" | "message" | "read_only",
continuationLabel?: string,
): string {
if (action === "opening") return RECTIFICATION_OPENING_LIVE_LABEL;
if (action === "read_only" && continuationLabel) return continuationLabel;
return RECTIFICATION_MESSAGE_LIVE_LABEL;
}
export type RectificationCaseSnapshotPayload = Readonly<{
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
question_source?: unknown;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
}>;
turns?: unknown;
}>;
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
type RawTurn = {
id?: unknown;
role?: unknown;
text?: unknown;
status?: unknown;
question?: unknown;
offer_result_id?: unknown;
receipt?: unknown;
};
type RawToolActivity = {
tool?: unknown;
status?: unknown;
methods?: unknown;
started_at?: unknown;
elapsed_ms?: unknown;
detail?: unknown;
};
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function parseToolActivities(value: unknown): NonNullable<NonNullable<PersistedRectificationTurn["receipt"]>["tool_activities"]> | undefined {
if (!Array.isArray(value)) return undefined;
return value.flatMap((item: RawToolActivity) => {
if (!item || typeof item !== "object" || typeof item.tool !== "string" || typeof item.status !== "string") return [];
return [{
tool: item.tool,
status: item.status,
methods: Array.isArray(item.methods) ? item.methods.map(String) : undefined,
started_at: typeof item.started_at === "string" ? item.started_at : null,
elapsed_ms: typeof item.elapsed_ms === "number" ? item.elapsed_ms : null,
detail: item.detail && typeof item.detail === "object" ? item.detail as Record<string, unknown> : null,
}];
});
}
/** The Case API's `turns` rows, normalised to what the chat renders. */
export function parsePersistedRectificationTurns(value: unknown): PersistedRectificationTurn[] {
if (!Array.isArray(value)) return [];
return value.map((turn: RawTurn) => {
const receipt = turn?.receipt && typeof turn.receipt === "object"
? turn.receipt as { status?: unknown; phases?: unknown; tools?: unknown; methods?: unknown; skill_name?: unknown; skill_version?: unknown; tool_activities?: unknown }
: null;
const toolActivities = parseToolActivities(receipt?.tool_activities);
return {
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
question: turn?.question ?? null,
offer_result_id: typeof turn?.offer_result_id === "string" ? turn.offer_result_id : null,
receipt: receipt ? {
status: String(receipt.status ?? ""),
phases: stringList(receipt.phases),
tools: stringList(receipt.tools),
methods: stringList(receipt.methods),
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
...(toolActivities ? { tool_activities: toolActivities } : {}),
} : null,
};
});
}
export type RectificationCaseHydration = Readonly<{
turns: PersistedRectificationTurn[];
snapshot: RectificationCaseSnapshotPayload | null;
/** False when the request failed or the deadline passed first. */
complete: boolean;
}>;
export function rectificationCaseHref(caseId: string, sessionId: string): string {
return `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`;
}
/**
* Turns and snapshot come from one Case read, so hydration is a single request
* raced against the reveal deadline. A late or failed read still resolves, with
* empty turns and no snapshot, so the caller can reveal and let the surface's
* own retry path fill in the rest.
*/
export async function hydrateRectificationCase(
caseId: string,
sessionId: string,
options: Readonly<{
timeoutMs?: number;
fetchImpl?: typeof fetch;
setTimeoutImpl?: (callback: () => void, delayMs: number) => unknown;
clearTimeoutImpl?: (handle: unknown) => void;
}> = {},
): Promise<RectificationCaseHydration> {
const timeoutMs = options.timeoutMs ?? RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch;
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
let deadline: unknown;
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
deadline = schedule(() => resolve(incomplete), timeoutMs);
});
const read = (async (): Promise<RectificationCaseHydration> => {
try {
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return incomplete;
const payload: unknown = await response.json().catch(() => null);
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
return {
turns: parsePersistedRectificationTurns(payload.turns),
snapshot: payload,
complete: true,
};
} catch {
return incomplete;
}
})();
try {
return await Promise.race([read, timeout]);
} finally {
cancel(deadline);
}
}
export type RectificationConversationState =
| "readonly"
| "busy"
| "opening"
| "empty"
| "conversation";
/**
* What the transcript area is doing when there are no rendered turns. `opening`
* means the first turn is about to be requested (server said so); `empty` means
* the Case has nothing to show and no automatic first turn the reader needs a
* way to start.
*/
export function rectificationConversationState(input: Readonly<{
messageCount: number;
busy: boolean;
readonly: boolean;
shouldStartOpening: boolean;
openingStarted: boolean;
}>): RectificationConversationState {
if (input.readonly) return "readonly";
if (input.busy) return "busy";
if (input.messageCount > 0) return "conversation";
if (input.shouldStartOpening || input.openingStarted) return "opening";
return "empty";
}
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
liveQuestionVisible: boolean;
/** The snapshot names no current question at all. */
questionMissing: boolean;
/** The server said the question could not be loaded (`question_source: "unavailable"`). */
questionLoadFailed: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
snapshotLoaded: boolean;
resumableCase: boolean;
retryAttempts: number;
retryLimit?: number;
}>;
/**
* The gap between a settled turn and its next question. While retries remain
* it is `preparing` (one live row, timed refetches); once they run out, or the
* server itself reports the question unavailable, it is `unavailable` with a
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
* too: the same retries fill it. Questions themselves live inside assistant
* messages, so a visible live question means there is no gap.
*/
export function rectificationQuestionGapState(input: RectificationQuestionGapInput): RectificationQuestionGapState {
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
if (input.readonly || input.busy || input.regenerating) return "idle";
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible) return "idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.
return retryGate;
}
/** Whether the gap should run another timed snapshot refetch. */
export function rectificationQuestionRetryActive(state: RectificationQuestionGapState): boolean {
return state === "preparing";
}
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
export function rectificationAdoptingLabel(time: string): string {
return `正在采用 ${time}`;
}
/** Board copy when no candidate result exists yet (task 1.3). */
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
clock: string | null;
lines: readonly string[];
}> {
if (!declaredTime) {
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
}
return {
clock: declaredTime,
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
};
}
/** The declared birth minute from the profile, or null when only a period was given. */
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
const candidate = (profile.reportedTime || profile.time || "").trim();
return /^\d{1,2}:\d{2}$/.test(candidate) ? candidate : null;
}
@@ -7,6 +7,7 @@ const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
@@ -82,9 +83,18 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
assert.doesNotMatch(chat, /spoken-answer/);
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
assert.match(chat, /text: raw,/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
// Was: key=...-${rectificationTurns.length > 0 ? "ready" : "loading"}. That suffix
// remounted the whole surface when the first turns arrived (an empty panel, then
// a flash), and again would have dropped a live stream; turns now hydrate before
// the switch and later arrive as a prop update (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
// Was: an inline `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)`
// parser in the surface hook; the parser moved to rectification-surface-state.ts so
// hydration and refresh share it (BUG-505).
assert.match(page, /setRectificationTurns\(parsePersistedRectificationTurns\(payload\?\.turns\)\)/);
assert.match(surfaceState, /methods: stringList\(receipt\.methods\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
@@ -191,7 +201,9 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
// because nothing remounts: the key is the session/Case binding only (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "../src/lib/home-bootstrap.ts";
import {
declaredBirthTime,
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationAdoptingLabel,
rectificationBoardEmptyCopy,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
rectificationQuestionRetryActive,
type RectificationQuestionGapInput,
} from "../src/lib/rectification-surface-state.ts";
const gapBase: RectificationQuestionGapInput = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
};
test("conversation state: readonly and busy win, then opening, then empty", () => {
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-507).
assert.equal(rectificationConversationState(base), "empty");
});
test("question gap: a live question inside a message means no gap", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true, questionMissing: false }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true }), "idle");
});
test("question gap: preparing while retries remain, then unavailable with a reload", () => {
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
// The server names a question that no settled message carries live yet.
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false }), "preparing");
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: 5 }), "unavailable");
// The server itself said the question could not be loaded: no point retrying on a timer.
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionLoadFailed: true }), "unavailable");
assert.equal(rectificationQuestionRetryActive("preparing"), true);
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
assert.equal(rectificationQuestionRetryActive("idle"), false);
});
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false }), "preparing");
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
});
test("question gap: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
assert.equal(rectificationQuestionGapState({ ...gapBase, busy: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, readonly: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, regenerating: true }), "idle");
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
test("live-row labels follow the action that started the turn", () => {
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
});
test("board copy before any candidate shows the declared minute, never invented data", () => {
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
clock: "05:10",
lines: ["填报出生时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
});
assert.deepEqual(rectificationBoardEmptyCopy(null), {
clock: null,
lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"],
});
assert.equal(declaredBirthTime({ reportedTime: "5:10", time: "" }), "5:10");
assert.equal(declaredBirthTime({ reportedTime: "", time: "14:30" }), "14:30");
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
assert.equal(declaredBirthTime({ reportedTime: "凌晨", time: "" }), null);
});
test("persisted turns keep their question, offer and tool activities through the parser", () => {
const turns = parsePersistedRectificationTurns([
{
id: "t1",
role: "assistant",
text: "先看事业。",
status: "completed",
question: { focus_id: "f1", kind: "choice" },
offer_result_id: "r1",
receipt: {
status: "ready",
phases: ["compute"],
tools: ["rectification-compare-candidates"],
methods: ["d9-navamsa", "d10-dashamsa"],
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
},
},
{ id: "t2", role: "user", text: "2014 年毕业。" },
"junk",
]);
assert.equal(turns.length, 3);
assert.equal(turns[0]?.offer_result_id, "r1");
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
assert.equal(turns[1]?.role, "user");
assert.equal(turns[1]?.status, "completed");
assert.equal(turns[1]?.receipt, null);
assert.equal(turns[2]?.id, "");
assert.deepEqual(parsePersistedRectificationTurns(null), []);
});
test("hydration reads turns and snapshot from one Case read and is one constant with the home reveal budget", async () => {
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, BOOTSTRAP_PREPARE_TIMEOUT_MS);
const calls: string[] = [];
const fetchImpl = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response(JSON.stringify({
turns: [{ id: "t1", role: "assistant", text: "hi", status: "completed" }],
current_question: { kind: "choice", prompt: "哪一年?", focus_id: "f1", question_id: "q1" },
case: { status: "collecting" },
}), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const hydration = await hydrateRectificationCase("case-1", "session-1", { fetchImpl, timeoutMs: 1_000 });
assert.equal(calls.length, 1);
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
assert.equal(hydration.complete, true);
assert.equal(hydration.turns.length, 1);
assert.equal((hydration.snapshot?.case as { status?: unknown } | undefined)?.status, "collecting");
});
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
const failing = (async () => new Response("nope", { status: 500 })) as typeof fetch;
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
let fire: (() => void) | undefined;
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
const late = hydrateRectificationCase("case-1", "session-1", {
fetchImpl: never,
timeoutMs: 4_000,
setTimeoutImpl: (callback) => {
fire = callback;
return 1;
},
clearTimeoutImpl: () => {},
});
assert.ok(fire);
fire?.();
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false });
});