From a8f7e50daeae7d237f7455a1f6ac0774027617cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 06:21:00 +0000 Subject: [PATCH 1/6] fix(api): stream anonymous fallback answers Co-authored-by: BigSimmo --- src/app/api/answer/stream/route.ts | 51 ++++++++++++--------- tests/private-access-routes.test.ts | 69 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index b8509d1025..2d4c8358e8 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -20,6 +20,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; +import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; import { parseJsonBody } from "@/lib/validation/body"; @@ -102,6 +103,29 @@ function logStreamError(error: unknown) { }); } +function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { + const demo = demoAnswer(body.query, body.documentId, body.documentIds); + const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode); + const sources = annotateSearchResults(answerFocusQuery, demo.sources); + const relevance = buildEvidenceRelevance(answerFocusQuery, sources); + return { + ...demo, + sources, + relevance, + smartPanel: demo.smartPanel ? { ...demo.smartPanel, relevance } : demo.smartPanel, + smartApiPlan: buildSmartRagApiPlan({ + query: answerFocusQuery, + queryClass: queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass, + results: sources, + routeMode: demo.routingMode, + retrievalStrategy: "hybrid", + }), + demoMode: true, + degradedMode: fallbackReason ? { active: true, reason: fallbackReason } : answerDegradedModeSignal(demo), + ...(fallbackReason ? { fallbackMode: "non_production_demo", fallbackReason } : {}), + }; +} + function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, publicOnly = false) { const encoder = new TextEncoder(); @@ -142,27 +166,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, body.documentId && !body.documentIds?.length && scope?.activeFilterCount === 0, ); const answer = isDemoMode() - ? (() => { - const demo = demoAnswer(body.query, body.documentId, body.documentIds); - const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode); - const sources = annotateSearchResults(answerFocusQuery, demo.sources); - const relevance = buildEvidenceRelevance(answerFocusQuery, sources); - return { - ...demo, - sources, - relevance, - smartPanel: demo.smartPanel ? { ...demo.smartPanel, relevance } : demo.smartPanel, - smartApiPlan: buildSmartRagApiPlan({ - query: answerFocusQuery, - queryClass: - queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass, - results: sources, - routeMode: demo.routingMode, - retrievalStrategy: "hybrid", - }), - demoMode: true, - }; - })() + ? buildDemoStreamAnswer(body) : await answerQuestionWithScope({ query: body.query, documentId: singleDocumentScope ? body.documentId : undefined, @@ -206,6 +210,11 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, }); } catch (error) { logStreamError(error); + const fallbackReason = nonProductionSupabaseDemoFallbackReason(error); + if (fallbackReason) { + send("final", buildDemoStreamAnswer(body, fallbackReason)); + return; + } const streamError = streamErrorPayload(error); send("error", { error: streamError.message, status: streamError.status, details: streamError.details }); } finally { diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index b4c6b3d101..a01b7f9772 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -3367,6 +3367,75 @@ describe("private document API access", () => { expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); + it("falls back to a final streamed demo answer outside production when Supabase rejects the API key", async () => { + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "Live answer", + grounded: true, + confidence: "medium", + citations: [], + sources: [], + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { answerQuestionWithScope }); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST( + request("/api/answer/stream", { + method: "POST", + body: JSON.stringify({ query: "clozapine monitoring" }), + }), + ); + const body = await response.text(); + const finalPayload = ssePayload(body, "final"); + + expect(response.status).toBe(200); + expect(body).not.toContain("event: error"); + expect(finalPayload).toMatchObject({ + demoMode: true, + fallbackMode: "non_production_demo", + fallbackReason: "supabase_api_key_configuration_unavailable", + degradedMode: { active: true, reason: "supabase_api_key_configuration_unavailable" }, + }); + expect(String(finalPayload.answer)).toContain("Synthetic"); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + + it("does not stream demo fallback in production when Supabase rejects the API key", async () => { + vi.stubEnv("NODE_ENV", "production"); + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "Live answer", + grounded: true, + confidence: "medium", + citations: [], + sources: [], + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { answerQuestionWithScope }); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST( + request("/api/answer/stream", { + method: "POST", + body: JSON.stringify({ query: "clozapine monitoring" }), + }), + ); + const body = await response.text(); + const errorPayload = ssePayload(body, "error"); + + expect(response.status).toBe(200); + expect(body).not.toContain("event: final"); + expect(errorPayload).toMatchObject({ + error: "Answer generation failed. Retry with a narrower question.", + status: 500, + details: { code: "Error" }, + }); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + it("uses an anonymous in-memory limiter for managed local no-auth search", async () => { const searchChunksWithTelemetry = vi.fn(async () => ({ results: [], From c0f6e0385ea4e02d7c6811a1df37093c8e69c867 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 07:15:15 +0000 Subject: [PATCH 2/6] Fix answer stream final event handling Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 14ae434f90..0aa970d39b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -328,6 +328,11 @@ function answerStreamProgressMessage(data: unknown) { return typeof message === "string" && message.trim() ? message.trim() : null; } +function findSseSeparator(buffer: string) { + const match = /\r?\n\r?\n/.exec(buffer); + return match ? { index: match.index, length: match[0].length } : null; +} + async function readAnswerStream(response: Response, onProgress: (message: string) => void): Promise { if (!response.body) throw makeSearchError("Answer stream could not be opened.", undefined, true); @@ -378,25 +383,31 @@ async function readAnswerStream(response: Response, onProgress: (message: string } if (event === "final") { finalPayload = data as AnswerPayload; + return true; } + + return false; } while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); - let separatorIndex = buffer.indexOf("\n\n"); - while (separatorIndex >= 0) { - const block = buffer.slice(0, separatorIndex).trim(); - buffer = buffer.slice(separatorIndex + 2); - if (block) processEvent(block); - separatorIndex = buffer.indexOf("\n\n"); + let separator = findSseSeparator(buffer); + while (separator) { + const block = buffer.slice(0, separator.index).trim(); + buffer = buffer.slice(separator.index + separator.length); + if (block && processEvent(block) && finalPayload) { + await reader.cancel().catch(() => undefined); + return finalPayload as AnswerPayload; + } + separator = findSseSeparator(buffer); } if (done) break; } - if (buffer.trim()) processEvent(buffer.trim()); + if (buffer.trim() && processEvent(buffer.trim()) && finalPayload) return finalPayload as AnswerPayload; if (!finalPayload) throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); return finalPayload as AnswerPayload; } From e7fcf59d35b6327a12f8c08e2e426aafc7074b2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 07:43:01 +0000 Subject: [PATCH 3/6] fix(ui): allow preview fallback search Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 0aa970d39b..90c0e7feed 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -312,6 +312,18 @@ type SearchResultModePayload = type SourceLibrarySearchMode = Extract; +function hasNonProductionSupabaseApiKeyFallback(checks: SetupCheck[]) { + return ( + process.env.NODE_ENV !== "production" && + checks.some( + (check) => + check.id === "search" && + check.status !== "ready" && + /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(check.detail), + ) + ); +} + function parseSseData(lines: string[]) { const data = lines.join("\n").trim(); if (!data) return null; @@ -1722,12 +1734,17 @@ export function ClinicalDashboard({ const canUsePublicSearchApis = localProjectReady && hasReadyPublicSearchSetup(setupChecks); const canUseDegradedLocalSearchApis = process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); + const canUseNonProductionDemoFallback = localProjectReady && hasNonProductionSupabaseApiKeyFallback(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); const canUploadDocuments = canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; const canRunSearch = - explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; + explicitDemoMode || + canUsePublicSearchApis || + canUseDegradedLocalSearchApis || + canUseNonProductionDemoFallback || + canAttemptDeployedPublicSearch; const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); From a6e0b3650e0909b4666d63f2264d9280ef7e35f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 08:09:09 +0000 Subject: [PATCH 4/6] fix(ui): close command menu on answer submit Co-authored-by: BigSimmo --- src/components/clinical-dashboard/master-search-header.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 002dcf7dbf..0115a7ca27 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -330,6 +330,11 @@ export function MasterSearchHeader({ useEffect(() => { onBottomComposerScrollHiddenChange?.(bottomComposerHidden); }, [bottomComposerHidden, onBottomComposerScrollHiddenChange]); + + useEffect(() => { + if (loading) setCommandDropdownOpen(false); + }, [loading]); + // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState(null); From 420a2f442a6104a5991066abf847c1793c03e4f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 08:10:06 +0000 Subject: [PATCH 5/6] fix(ui): schedule command menu close Co-authored-by: BigSimmo --- src/components/clinical-dashboard/master-search-header.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 0115a7ca27..459d998192 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -332,8 +332,10 @@ export function MasterSearchHeader({ }, [bottomComposerHidden, onBottomComposerScrollHiddenChange]); useEffect(() => { - if (loading) setCommandDropdownOpen(false); - }, [loading]); + if (!loading || !commandDropdownOpen) return undefined; + const frame = window.requestAnimationFrame(() => setCommandDropdownOpen(false)); + return () => window.cancelAnimationFrame(frame); + }, [commandDropdownOpen, loading]); // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. From aa7bd2e81ea0f7186bb441e2d07d6c5b516963bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 09:22:00 +0000 Subject: [PATCH 6/6] ci: rerun verification Co-authored-by: BigSimmo