fix(consult): replay an unstarted consultation after refresh
Independent Staging Quality Gate / validate (push) Successful in 7m57s
Independent Staging Quality Gate / publish (push) Has been cancelled

Sending cleared the draft and stored a request id before the server reserved usage, so a refresh left a sent question with no agent run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 17:33:23 +08:00
parent f4a2ba86ae
commit 188cff99ec
6 changed files with 269 additions and 101 deletions
+16
View File
@@ -4097,3 +4097,19 @@
- 相关记录:无
- 复发自:无
- 修复版本:已推入 staging,待部署
## BUG-276 | 发送后刷新草稿被正确清空,但咨询请求从未落地,Agent 无反应
- 状态:resolved(已推入 staging,待部署)
- 首次发现:2026-08-18
- 最近更新:2026-08-18
- 影响面:`/` 主对话发送、`POST /api/consult``GET /api/consult/status`、tab 内 `jyotisha.pending-consultation` 恢复。
- 用户现象:正常发出一条咨询后立刻刷新。输入框草稿已清空,不会把刚发出的问题填回来;对话里也看不到后台任务,Agent 没有后续反应。`GET /api/consult/status` 对这次 `requestId` 返回「咨询请求不存在」。
- 触发条件:点击发送后、服务端 `reserve_consultation_usage` 写入之前刷新页面。包括 2.5 秒撤回窗口内、问题持久化过程中,以及咨询 POST 已发出但还在选路/扣点、尚未插入 `consultation_requests` 时。
- 根因:发送在点击当下就清草稿、写入 pending `requestId` 并展示用户气泡,但真正的咨询占位发生在撤回窗口和 `persistSession` 之后的 `POST /api/consult` 里。刷新会中断这次 fetch。服务端只有在流开始之后才会 `continueAfterDisconnect`;占位之前断开则行不存在。刷新后恢复链路只轮询 status:把首次 404 当成「可能还在启动」假装 reserved,连打三次 404 后放弃,并且从未用同一 `requestId` 重放 POST。pending 存储也只留了 session/request id,撤回窗口内刷新时问题正文既不在会话里也不在草稿里。
- 修复:pending 存储同步写入问题原文、主题和入口;刷新恢复时用这份原文补回用户气泡。status 首次 404 仍等待一次确认,第二次 404 则跳过撤回窗口、不重复追加用户消息,用原 `requestId` 重放 `POST /api/consult`。若重放撞上 `request_conflict`(原请求稍后占位成功),改回 status 轮询而不是当成失败解锁。三次以上确认仍 404 才停止恢复。草稿仍在发送时清空,避免已发出的问题被填回输入框。
- 验证:`frontend/tests/consultation-recovery.test.ts` 覆盖 pending 带问题正文、404 后重放而非放弃、`request_conflict` 留在恢复态;既有草稿隔离合同继续要求发送清空存储键。
- 防复发:刷新恢复不得把「status 404」直接解释成用户已取消或请求已结束;在占位完成前断开必须能用同一 requestId 重放生成。pending 存储必须包含重放所需的问题原文。已发出的问题不得靠草稿复活,必须出现在会话里并由 Agent 继续。
- 相关记录:BUG-249(草稿持久化清空已发送问题,本条保留该行为)、咨询流恢复迁移 `20260808030000_consultation_stream_recovery.sql`(断线后续跑只覆盖已占位的 reserved 请求)
- 复发自:无
- 修复版本:已推入 staging,待部署
+223 -86
View File
@@ -262,6 +262,46 @@ type PendingConsultation = {
const undoWindowMs = 2_500;
const pendingConsultationStorageKey = "jyotisha.pending-consultation";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type StoredPendingConsultation = {
readonly sessionId: string;
readonly requestId: string;
readonly question: string;
readonly theme: Theme | null;
readonly entrypoint: ConsultationEntrypoint | null;
};
function readStoredPendingConsultation(
raw: string | null,
sessionIds: Iterable<string>,
): StoredPendingConsultation | null {
if (!raw) return null;
try {
const parsedPending = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsedPending.sessionId !== "string"
|| typeof parsedPending.requestId !== "string"
|| !uuidPattern.test(parsedPending.sessionId)
|| !uuidPattern.test(parsedPending.requestId)) {
return null;
}
let sessionKnown = false;
for (const sessionId of sessionIds) {
if (sessionId === parsedPending.sessionId) {
sessionKnown = true;
break;
}
}
if (!sessionKnown) return null;
return {
sessionId: parsedPending.sessionId,
requestId: parsedPending.requestId,
question: typeof parsedPending.question === "string" ? parsedPending.question : "",
theme: normalizeConsultationDomain(parsedPending.theme),
entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null,
};
} catch {
return null;
}
}
const china = chinaLocations.country;
const themes = defaultGuidedJyotishTopics;
@@ -1037,7 +1077,9 @@ export default function Home() {
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const consultationRecoveryWakeup = useRef<() => void>(() => undefined);
const consultationRecoveryCheck = useRef<() => void>(() => undefined);
const consultationReplay = useRef<() => void>(() => undefined);
const consultationStatusMissingCount = useRef(0);
const consultationReplayStarted = useRef<string | null>(null);
const modelPersistence = useRef(new SessionModelPersistenceQueue());
const modelSyncFailures = useRef(new Set<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
@@ -1256,21 +1298,40 @@ export default function Home() {
draftEntrypoint.current = entrypoint;
}
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
function restoreConsultationRecovery(
session: ChatSession,
requestId: string,
stored?: StoredPendingConsultation | null,
) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
const question = lastMessage?.role === "user" ? lastMessage.text : "";
const previousSession = lastMessage?.role === "user"
? { ...session, messages: session.messages.slice(0, -1) }
: session;
const storedQuestion = stored?.question?.trim() ?? "";
const lastIsQuestion = lastMessage?.role === "user"
&& (!storedQuestion || lastMessage.text === storedQuestion);
const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion;
const optimisticSession = lastIsQuestion || !question
? session
: {
...session,
title: session.messages.length === 0 && session.title === "新对话"
? resolveSessionTitle(question)
: session.title,
theme: stored?.theme ?? session.theme,
messages: [...session.messages, { role: "user" as const, text: question }],
updatedAt: timestamp(),
};
const previousSession = optimisticSession.messages.at(-1)?.role === "user"
? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) }
: optimisticSession;
if (optimisticSession !== session) updateSession(session.id, () => optimisticSession);
pendingConsultation.current = {
requestId,
sessionId: session.id,
question,
entrypoint: null,
theme: session.theme,
entrypoint: stored?.entrypoint ?? null,
theme: stored?.theme ?? session.theme,
previousSession,
optimisticSession: session,
optimisticSession,
previousOnboardingState: false,
controller: new AbortController(),
cancelled: false,
@@ -1279,6 +1340,7 @@ export default function Home() {
};
setPendingSessionId(session.id);
setPendingRequestId(requestId);
setActiveSessionId(session.id);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: session.id, text: "" });
setComposerNotice(navigator.onLine
@@ -1434,26 +1496,12 @@ export default function Home() {
}
let reservedConsultation: ConsultationStatus | null = null;
let storedPending: { sessionId: string; requestId: string } | null = null;
const storedPendingJson = sessionStorage.getItem(pendingConsultationStorageKey);
if (storedPendingJson) {
try {
const parsedPending = JSON.parse(storedPendingJson) as Record<string, unknown>;
if (typeof parsedPending.sessionId === "string"
&& typeof parsedPending.requestId === "string"
&& uuidPattern.test(parsedPending.sessionId)
&& uuidPattern.test(parsedPending.requestId)
&& nextSessions.some((session) => session.id === parsedPending.sessionId)) {
storedPending = {
sessionId: parsedPending.sessionId,
requestId: parsedPending.requestId,
};
} else {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
} catch {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
sessionStorage.getItem(pendingConsultationStorageKey),
nextSessions.map((session) => session.id),
);
if (!storedPending && sessionStorage.getItem(pendingConsultationStorageKey)) {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
if (storedPending) {
@@ -1499,7 +1547,7 @@ export default function Home() {
setActiveSessionId(nextSessions[0].id);
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
@@ -1543,6 +1591,9 @@ export default function Home() {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: pendingSessionId,
requestId: pendingRequestId,
question: pendingConsultation.current?.question ?? "",
theme: pendingConsultation.current?.theme ?? null,
entrypoint: pendingConsultation.current?.entrypoint ?? null,
}));
} else {
consultationStatusMissingCount.current = 0;
@@ -1598,7 +1649,15 @@ export default function Home() {
if (controller.signal.aborted) return;
if (caught instanceof ConsultationStatusError && caught.status === 404) {
consultationStatusMissingCount.current += 1;
if (consultationStatusMissingCount.current >= 3) {
if (consultationStatusMissingCount.current === 1) {
setComposerNotice("正在确认本次咨询请求是否已开始…");
return;
}
if (consultationReplayStarted.current !== pendingRequestId) {
consultationReplay.current();
return;
}
if (consultationStatusMissingCount.current >= 4) {
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
@@ -1611,7 +1670,7 @@ export default function Home() {
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
return;
}
setComposerNotice("正在确认本次咨询请求是否已开始…");
setComposerNotice("正在重新发起本次咨询…");
return;
}
consultationStatusMissingCount.current = 0;
@@ -2784,6 +2843,7 @@ export default function Home() {
function completeConsultationInterface(requestId: string) {
if (pendingConsultation.current?.requestId !== requestId) return;
pendingConsultation.current = null;
if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setPendingRequestId(null);
@@ -2796,14 +2856,24 @@ export default function Home() {
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
targetSessionId: string | null = null,
options: { resumeRequestId?: string } = {},
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
const currentSession = targetSessionId
? sessions.find((session) => session.id === targetSessionId)
: activeSession;
if (!question || !currentSession || !modelCatalog || pendingSessionId
|| cancellationInFlight.current || pendingConsultation.current || !account) return false;
if (!question || !currentSession || !modelCatalog || !account) return false;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
const pending = pendingConsultation.current;
if (!pending
|| pending.requestId !== resumeRequestId
|| pending.sessionId !== currentSession.id
|| pending.cancelled) return false;
}
if (!isProfileComplete(profile)) {
openAccountDialog("profile");
@@ -2845,51 +2915,71 @@ export default function Home() {
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
: currentSession.messages;
const lastMessage = currentSession.messages.at(-1);
const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question;
const preservedMessages = questionAlreadyPresent
? currentSession.messages
: (onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
: currentSession.messages);
const userSession: ChatSession = {
...currentSession,
title: currentSession.messages.length === 0 && currentSession.title === "新对话"
? resolveSessionTitle(question)
: currentSession.title,
theme,
messages: [...preservedMessages, { role: "user", text: question }],
updatedAt: timestamp(),
messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }],
updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(),
};
const requestId = globalThis.crypto.randomUUID();
const controller = new AbortController();
const requestId = resumeRequestId ?? globalThis.crypto.randomUUID();
const controller = resuming && pendingConsultation.current
? pendingConsultation.current.controller
: new AbortController();
const previousOnboardingState = onboardingJustCompleted;
cancellationFeedbackRequest.current = null;
setRequestError(null);
setReplyOutcome(null);
setComposerNotice("");
consultationStatusMissingCount.current = 0;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
entrypoint,
theme,
previousSession: currentSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
if (!resuming) {
setComposerNotice("");
consultationStatusMissingCount.current = 0;
consultationReplayStarted.current = null;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
entrypoint,
theme,
previousSession: currentSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
try {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId,
requestId,
question: originalQuestion,
theme,
entrypoint,
}));
} catch {
// Private-mode storage must not block send.
}
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
}
if (process.env.NODE_ENV === "development" && uiPreview.current) {
if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) {
setStreamingReply({ sessionId, text: "" });
if (uiPreviewMode.current === "partial") {
const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。";
@@ -2926,31 +3016,40 @@ export default function Home() {
return true;
}
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
try {
await persistSession(userSession);
} catch (caught) {
if (!resuming) {
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
}
if (!resuming || !questionAlreadyPresent) {
if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession);
try {
await persistSession(userSession);
} catch (caught) {
if (controller.signal.aborted) return false;
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
}
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
}
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
}
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
question: originalQuestion,
entrypoint,
theme,
optimisticSession: userSession,
phase: "streaming",
};
setConsultationPhase("streaming");
@@ -3099,7 +3198,18 @@ export default function Home() {
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
if (caught.message === "request_conflict") {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "recovering",
partialReply,
};
setConsultationPhase("recovering");
setRequestError(null);
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return Boolean(partialReply);
}
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
@@ -3138,6 +3248,33 @@ export default function Home() {
}
}
consultationReplay.current = () => {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return;
if (consultationReplayStarted.current === pending.requestId) return;
consultationReplayStarted.current = pending.requestId;
setComposerNotice("后台尚未开始本次咨询,正在重新发起…");
void send(
pending.question,
pending.theme,
pending.entrypoint,
null,
pending.sessionId,
{ resumeRequestId: pending.requestId },
).then((started) => {
if (started || pendingConsultation.current?.requestId !== pending.requestId) return;
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError({
sessionId: pending.sessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
});
};
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -112,7 +112,7 @@ test("the reply phase covers start, completion and every terminal state", () =>
assert.match(pageSource, /setReplyOutcome\(\{ sessionId: pending\.sessionId, phase: "stopped", replyOrdinal: 0 \}\)/);
// And: a new question clears the previous outcome so a stale reply is never re-announced.
assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*setComposerNotice\(""\)/);
assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*if \(!resuming\) \{\n\s*setComposerNotice\(""\)/);
});
test("completion is announced with where to find the reply, and start says one is coming", () => {
@@ -86,7 +86,7 @@ test("every external draft writer keeps working through the page-owned setters",
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (process.env.NODE_ENV === \"development\" && uiPreview.current)");
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (!resuming && process.env.NODE_ENV === \"development\" && uiPreview.current)");
const sendRestore = sourceBetween(pageSource, "if (activeSessionIdRef.current === sessionId) {", "setRequestError({");
// Then: suggestions fill, session switches clear, stop restores and send clears.
@@ -86,7 +86,7 @@ test("ordinary product drafts keep the public question and clear hidden routing
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /personalChartAvailable[\s\S]*?\? "深入看今日"[\s\S]*?: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,[\s\S]*?\{ role: "user", text: question \}\]/);
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
const requestBody = source.slice(source.indexOf("body: JSON.stringify({"), source.indexOf("history: currentSession.messages", source.indexOf("body: JSON.stringify({")));
assert.match(requestBody, /consultationMode:[\s\S]*?entrypoint: entrypoint \?\? undefined/);
+27 -12
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n useGSAP", source.indexOf(" async function send(")));
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
test("consultation persists the optimistic user message before generation starts", () => {
@@ -50,9 +50,11 @@ test("explicit consultation HTTP failures unlock instead of entering recovery",
sendSource.indexOf("caught instanceof ConsultationResponseError"),
sendSource.indexOf("if (!cancelled && ownsInterface && pendingConsultation.current)"),
);
assert.match(explicitFailure, /setRequestError\(\{ sessionId, message: caught\.message \}\)/);
assert.match(explicitFailure, /completeConsultationInterface\(requestId\)/);
assert.doesNotMatch(explicitFailure, /phase: "recovering"|setConsultationPhase\("recovering"\)/);
assert.match(explicitFailure, /caught\.message === "request_conflict"[\s\S]*setConsultationPhase\("recovering"\)/);
const genericFailure = explicitFailure.slice(explicitFailure.indexOf("setRequestError({ sessionId, message: caught.message })"));
assert.match(genericFailure, /setRequestError\(\{ sessionId, message: caught\.message \}\)/);
assert.match(genericFailure, /completeConsultationInterface\(requestId\)/);
assert.doesNotMatch(genericFailure, /phase: "recovering"|setConsultationPhase\("recovering"\)/);
});
test("reserved consultations recover through the status endpoint", () => {
@@ -70,7 +72,7 @@ test("reserved consultations recover through the status endpoint", () => {
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
});
test("three consecutive strict status misses unlock recovery while transient failures keep polling", () => {
test("a missing reservation is replayed once instead of being polled until the question is lost", () => {
assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/);
assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/);
const recoveryEffect = source.slice(
@@ -78,9 +80,12 @@ test("three consecutive strict status misses unlock recovery while transient fai
source.indexOf("}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId])"),
);
assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/);
assert.match(source, /const consultationReplayStarted = useRef<string \| null>\(null\)/);
assert.match(recoveryEffect, /status\.status === "reserved"[\s\S]*consultationStatusMissingCount\.current = 0/);
assert.match(recoveryEffect, /caught instanceof ConsultationStatusError && caught\.status === 404[\s\S]*consultationStatusMissingCount\.current \+= 1[\s\S]*consultationStatusMissingCount\.current >= 3/);
assert.match(recoveryEffect, /setPendingSessionId\(null\)[\s\S]*setPendingRequestId\(null\)[\s\S]*setConsultationPhase\(null\)/);
assert.match(recoveryEffect, /caught instanceof ConsultationStatusError && caught\.status === 404[\s\S]*consultationStatusMissingCount\.current \+= 1/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current === 1[\s\S]*正在确认本次咨询请求是否已开始/);
assert.match(recoveryEffect, /consultationReplayStarted\.current !== pendingRequestId[\s\S]*consultationReplay\.current\(\)/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current >= 4[\s\S]*setPendingSessionId\(null\)[\s\S]*setPendingRequestId\(null\)[\s\S]*setConsultationPhase\(null\)/);
assert.match(recoveryEffect, /后台未找到本次咨询请求,已停止恢复,请重新发送。/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current = 0;[\s\S]*回答仍在后台生成,正在自动恢复。/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current > 0[\s\S]*window\.setTimeout\(\(\) => void poll\(\), 1_750\)[\s\S]*else \{[\s\S]*void poll\(\)/);
@@ -96,21 +101,31 @@ test("tab-local pending ids drive strict bootstrap recovery before the global fa
source.indexOf('if (consultationPhase !== "recovering"'),
);
assert.match(bootstrap, /sessionStorage\.getItem\(pendingConsultationStorageKey\)/);
assert.match(bootstrap, /uuidPattern\.test\(parsedPending\.sessionId\)[\s\S]*uuidPattern\.test\(parsedPending\.requestId\)[\s\S]*nextSessions\.some\(\(session\) => session\.id === parsedPending\.sessionId\)/);
assert.match(bootstrap, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)[\s\S]*\} catch \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/);
assert.match(bootstrap, /if \(!storedPending && sessionStorage\.getItem\(pendingConsultationStorageKey\)\) \{\s*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.ok(bootstrap.indexOf("if (storedPending)") < bootstrap.indexOf("fetchActiveConsultationStatus(controller.signal)"));
assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId,[\s\S]*\} else \{[\s\S]*fetchActiveConsultationStatus/);
assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.match(bootstrap, /caught instanceof ConsultationStatusError && caught\.status === 404 \? 1 : 0/);
assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId/);
assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/);
assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
});
test("pending storage keeps the question so a refresh during the undo window can replay", () => {
assert.match(source, /function readStoredPendingConsultation\(/);
assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/);
assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/);
assert.match(source, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
assert.match(source, /stored\?\.question\?\.trim\(\)/);
assert.match(sendSource, /resumeRequestId/);
assert.match(sendSource, /questionAlreadyPresent/);
assert.match(source, /consultationReplay\.current = \(\) => \{[\s\S]*resumeRequestId: pending\.requestId/);
});
test("the first default consultation title is persisted with the user question", () => {
const userSessionBlock = sendSource.slice(
sendSource.indexOf("const userSession: ChatSession"),
sendSource.indexOf("const requestId = globalThis.crypto.randomUUID()"),
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
);
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && currentSession\.title === "新对话"[\s\S]*resolveSessionTitle\(question\)/);
assert.ok(sendSource.indexOf("await persistSession(userSession)") < sendSource.indexOf('fetch("/api/consult"'));