From 188cff99ec659e2520757194e5d4752decaa98ca Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 17:33:23 +0800 Subject: [PATCH] fix(consult): replay an unstarted consultation after refresh 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 --- docs/BUG_HISTORY.md | 16 + frontend/src/app/page.tsx | 309 +++++++++++++----- .../chat-navigation-a11y-contract.test.ts | 2 +- .../tests/composer-isolation-contract.test.ts | 2 +- .../tests/consultation-entrypoint.test.ts | 2 +- frontend/tests/consultation-recovery.test.ts | 39 ++- 6 files changed, 269 insertions(+), 101 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5b3a7025..92a92d94 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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,待部署 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 15491707..7b570e99 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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, +): StoredPendingConsultation | null { + if (!raw) return null; + try { + const parsedPending = JSON.parse(raw) as Record; + 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>()); const consultationRecoveryWakeup = useRef<() => void>(() => undefined); const consultationRecoveryCheck = useRef<() => void>(() => undefined); + const consultationReplay = useRef<() => void>(() => undefined); const consultationStatusMissingCount = useRef(0); + const consultationReplayStarted = useRef(null); const modelPersistence = useRef(new SessionModelPersistenceQueue()); const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); @@ -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; - 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 { 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) { event.preventDefault(); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 51a5227c..3cf5cdff 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -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", () => { diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 53ee2335..670f1450 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -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. diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index b35747fd..09ecb8c4 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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/); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 0e5b1c02..b078b5a2 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -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\(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"'));