Skip to content
Merged
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check |
| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. |
| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. |
21 changes: 19 additions & 2 deletions src/app/api/answer/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { logAnswerDiagnostics } from "@/lib/answer-telemetry";
import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { logger } from "@/lib/logger";
import { startSseHeartbeat } from "@/lib/sse-heartbeat";
import { parseJsonBody } from "@/lib/validation/body";
import type { RagAnswer } from "@/lib/types";

Expand Down Expand Up @@ -144,8 +145,17 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal,
new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(encodeSse(event, data)));
try {
controller.enqueue(encoder.encode(encodeSse(event, data)));
} catch {
// The client may cancel between generation callbacks. Once the
// stream is closed there is no remaining consumer for this frame.
}
};
Comment thread
BigSimmo marked this conversation as resolved.
// Generation can go silent for long stretches (strong-route reasoning
// before the first output token); heartbeat comments keep the
// connection visibly alive for proxies and the client's stall watchdog.
const stopHeartbeat = startSseHeartbeat((frame) => controller.enqueue(encoder.encode(frame)));
const onProgress = (event: AnswerProgressEvent) => send("progress", event);
// Stream the answer prose as it generates (content-preserving) and signal a reset when a
// provisional answer is being revised by the quality gates.
Expand Down Expand Up @@ -242,7 +252,14 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal,
const streamError = streamErrorPayload(error);
send("error", { error: streamError.message, status: streamError.status, details: streamError.details });
} finally {
controller.close();
stopHeartbeat();
// The client may have already cancelled the stream (Stop button /
// watchdog abort), in which case close() throws on a closed stream.
try {
controller.close();
} catch {
// Stream already closed or cancelled — nothing left to release.
}
}
},
}),
Expand Down
41 changes: 28 additions & 13 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { isWeakRelevance } from "@/components/clinical-dashboard/relevance";
import {
answerPayloadIsUsable,
classifyAnswerError,
createAnswerRequestWatchdog,
isAnswerPayload,
isRetryableError,
isRetryableMessage,
Expand Down Expand Up @@ -348,6 +349,7 @@ async function readAnswerStream(
onProgress: (message: string) => void,
onToken?: (delta: string) => void,
onRevising?: () => void,
onActivity?: () => void,
): Promise<AnswerPayload> {
if (!response.body) throw makeSearchError("Answer stream could not be opened.", undefined, true);

Expand Down Expand Up @@ -416,6 +418,9 @@ async function readAnswerStream(

while (true) {
const { value, done } = await reader.read();
// Any received bytes — progress events, token deltas, or server heartbeat
// comments — count as liveness for the caller's stall watchdog.
if (value && value.length > 0) onActivity?.();
buffer += decoder.decode(value, { stream: !done });

let separator = findSseSeparator(buffer);
Expand Down Expand Up @@ -482,14 +487,12 @@ type AnswerTurn = {

const maxVisiblePriorTurns = 10;

// Upper bound on a single answer request. The stream sends only progress events
// then one `final` blob after the full server-side generation, so a hung stream
// otherwise spins the UI forever with no close event. Generous enough not to
// abort a legitimately slow generation; a wait past this is treated as a stall.
const answerRequestTimeoutMs = 60_000;

// Non-retryable so an aborted request does not immediately re-fetch against the
// already-aborted signal; the user re-submits to try again.
// already-aborted signal; the user re-submits to try again. Raised by the
// stall watchdog (see createAnswerRequestWatchdog): a live stream that keeps
// delivering progress/token/heartbeat bytes is never aborted, no matter how
// long a fast->strong escalation takes, so this now only appears when the
// stream genuinely went silent or hit the absolute ceiling.
function answerTimedOutError() {
return makeSearchError("Answer generation timed out. Please try again.", 408, false);
}
Expand Down Expand Up @@ -1795,6 +1798,7 @@ export function ClinicalDashboard({
queryModeOverride: ClinicalQueryMode = requestQueryMode,
onProgress: (message: string) => void = setAnswerProgress,
signal?: AbortSignal,
onStreamActivity?: () => void,
) {
setStreamingAnswer(null);
let response: Response;
Expand Down Expand Up @@ -1836,6 +1840,7 @@ export function ClinicalDashboard({
onProgress,
(delta) => setStreamingAnswer((prev) => ({ text: (prev?.text ?? "") + delta, revising: false })),
() => setStreamingAnswer({ text: "", revising: true }),
onStreamActivity,
);
} catch (error) {
if (answerTimedOutRef.current) throw answerTimedOutError();
Expand Down Expand Up @@ -2056,13 +2061,16 @@ export function ClinicalDashboard({
]
: [{ query: requestQuery, isKeyword: false }];

// Bound this search with a timeout on the shared abort controller so a
// stalled answer stream recovers instead of spinning forever.
// Bound this search with a stall watchdog on the shared abort controller so
// a hung stream recovers instead of spinning forever. Answer streams reset
// the inactivity window on every received chunk, so a slow-but-live
// generation (fast -> strong escalation) is not aborted mid-stream; plain
// document searches never touch the watchdog and keep the flat window.
answerTimedOutRef.current = false;
const answerTimeout = window.setTimeout(() => {
const answerWatchdog = createAnswerRequestWatchdog(() => {
answerTimedOutRef.current = true;
abortController.abort();
}, answerRequestTimeoutMs);
});

try {
let successfulPayload: SearchResultModePayload | null = null;
Expand Down Expand Up @@ -2092,7 +2100,14 @@ export function ClinicalDashboard({
)
: await runWithRetries(
() =>
requestAnswer(entry.query, filtersOverride, targetQueryMode, onProgress, abortController.signal),
requestAnswer(
entry.query,
filtersOverride,
targetQueryMode,
onProgress,
abortController.signal,
answerWatchdog.touch,
),
onProgress,
);

Expand Down Expand Up @@ -2169,7 +2184,7 @@ export function ClinicalDashboard({
setLastFailedQuery(trimmedQuery);
}
} finally {
window.clearTimeout(answerTimeout);
answerWatchdog.cancel();
answerTimedOutRef.current = false;
if (searchAbortRef.current === abortController) searchAbortRef.current = null;
if (requestId === searchRequestSeqRef.current) {
Expand Down
59 changes: 59 additions & 0 deletions src/components/clinical-dashboard/search-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,65 @@ export function progressForRetry(attempt: number) {
return `Retrying... (${Math.min(attempt, searchRetryCount)}/${searchRetryCount})`;
}

// Inactivity window for an in-flight search/answer request. The answer stream
// keeps delivering progress events, token deltas, and periodic server
// heartbeats while generation is running, so a healthy request — even one that
// escalates fast -> strong and runs well past a minute — keeps resetting this
// window. Only a stream with no bytes for this long is treated as a stall.
export const answerStallTimeoutMs = 60_000;

// Hard ceiling on a single request regardless of stream activity, sized above
// the server's worst-case pipeline (retrieval + fast generation + strong
// escalation + strong quality repair, each generation capped at
// OPENAI_ANSWER_TIMEOUT_MS) so it only fires on a genuinely runaway stream.
export const answerRequestMaxDurationMs = 180_000;

export type AnswerRequestWatchdog = {
/** Reset the inactivity window — the stream showed signs of life. */
touch: () => void;
/** True once the watchdog fired (stall or max-duration). */
readonly timedOut: boolean;
/** Disarm both timers; safe to call more than once. */
cancel: () => void;
};

/**
* Watchdog for a streaming answer request. Fires `onTimeout` once when either
* the stream goes silent for `stallMs` or the request exceeds `maxDurationMs`
* in total. `touch()` on every received chunk keeps a live-but-slow generation
* (fast -> strong escalation) from being aborted mid-stream, which previously
* surfaced as "Answer generation timed out" while tokens were still arriving.
*/
export function createAnswerRequestWatchdog(
onTimeout: () => void,
{ stallMs = answerStallTimeoutMs, maxDurationMs = answerRequestMaxDurationMs } = {},
): AnswerRequestWatchdog {
let cancelled = false;
let fired = false;
const fire = () => {
if (cancelled || fired) return;
fired = true;
onTimeout();
};
let stallTimer = setTimeout(fire, stallMs);
const maxDurationTimer = setTimeout(fire, maxDurationMs);
return {
touch() {
if (cancelled || fired) return;
clearTimeout(stallTimer);
stallTimer = setTimeout(fire, stallMs);
},
get timedOut() {
return fired;
},
cancel() {
cancelled = true;
clearTimeout(stallTimer);
clearTimeout(maxDurationTimer);
},
};
}

export type AnswerErrorKind = "no-results" | "failure";

/**
Expand Down
4 changes: 4 additions & 0 deletions src/components/differentials/differentials-home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function DifferentialsHomePage({ query = "", autoRunSearch = false }: Dif
const trimmedQuery = query.trim();
const [loading, setLoading] = useState(false);
const [documentMatches, setDocumentMatches] = useState<DocumentMatch[]>([]);
const [evidenceQuery, setEvidenceQuery] = useState<string | null>(null);
const searchRequestSeqRef = useRef(0);

const runSearch = useCallback(
Expand All @@ -36,6 +37,7 @@ export function DifferentialsHomePage({ query = "", autoRunSearch = false }: Dif
const requestId = ++searchRequestSeqRef.current;

setLoading(true);
setEvidenceQuery(null);
try {
const response = await fetch("/api/search", {
method: "POST",
Expand All @@ -51,6 +53,7 @@ export function DifferentialsHomePage({ query = "", autoRunSearch = false }: Dif

const payload = (await response.json()) as { documentMatches?: DocumentMatch[] };
if (requestId !== searchRequestSeqRef.current) return;
setEvidenceQuery(normalized);
Comment thread
BigSimmo marked this conversation as resolved.
setDocumentMatches(payload.documentMatches ?? []);
} catch {
if (requestId !== searchRequestSeqRef.current) return;
Expand All @@ -75,6 +78,7 @@ export function DifferentialsHomePage({ query = "", autoRunSearch = false }: Dif
query={query}
loading={loading}
documentMatches={documentMatches}
evidenceQuery={evidenceQuery}
desktopComposerSlotId={modeHomeDesktopComposerSlotId}
onRunSearch={(nextQuery) => {
router.push(
Expand Down
31 changes: 31 additions & 0 deletions src/lib/sse-heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SSE liveness for long-running answer streams. Generation legitimately goes
// silent for stretches (e.g. strong-route reasoning before the first output
// token, bounded by OPENAI_ANSWER_TIMEOUT_MS), during which neither progress
// events nor token deltas are emitted. A periodic comment line keeps
// intermediaries from idling out the connection and gives the client's stall
// watchdog a byte-level liveness signal. Comment lines (leading ":") are
// ignored by SSE parsers, so clients need no changes to tolerate them.

export const answerStreamHeartbeatIntervalMs = 15_000;

// A bare SSE comment line followed by a blank line — a complete, ignorable frame.
export const sseHeartbeatFrame = ": heartbeat\n\n";

/**
* Start emitting heartbeat frames via `enqueue` every `intervalMs`. Returns a
* stop function. If `enqueue` throws (stream closed or cancelled by the
* client), the heartbeat stops itself.
*/
export function startSseHeartbeat(enqueue: (frame: string) => void, intervalMs = answerStreamHeartbeatIntervalMs) {
const timer = setInterval(() => {
try {
enqueue(sseHeartbeatFrame);
} catch {
clearInterval(timer);
}
}, intervalMs);
// Node's setInterval keeps the event loop alive; unref where available so a
// stray stream cannot pin the process. No-op in edge/browser runtimes.
(timer as unknown as { unref?: () => void }).unref?.();
return () => clearInterval(timer);
}
Loading