diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e6147f1f..8f462a4a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -291,6 +291,34 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { }; } +function withThreadSessionError( + snapshot: OrchestrationReadModel, + input: { + status: OrchestrationSessionStatus; + lastError: string; + }, +): OrchestrationReadModel { + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID && thread.session + ? { + ...thread, + session: { + threadId: thread.session.threadId, + providerName: thread.session.providerName, + runtimeMode: thread.session.runtimeMode, + activeTurnId: thread.session.activeTurnId, + status: input.status, + lastError: input.lastError, + updatedAt: NOW_ISO, + }, + } + : thread, + ), + }; +} + function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, @@ -1634,6 +1662,50 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("resets the provider session from the error banner after an out-of-memory failure", async () => { + wsRequests.length = 0; + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withThreadSessionError( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-oom-reset" as MessageId, + targetText: "oom reset target", + }), + { + status: "error", + lastError: + "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + }, + ), + }); + + try { + const recoverButton = await waitForElement( + () => + document.querySelector( + 'button[aria-label="Reset session after out-of-memory failure"]', + ), + "Unable to find out-of-memory recovery button.", + ); + + recoverButton.click(); + + await vi.waitFor( + () => + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.session.stop" && + request.threadId === THREAD_ID, + ), + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17bf1656..af2e1f2f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3880,6 +3880,17 @@ export default function ChatView({ }); }; + const onRecoverFromOutOfMemory = async () => { + const api = readNativeApi(); + if (!api || !activeThread || isRemoteActionBlocked) return; + await api.orchestration.dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }); + }; + const onClearQueue = useCallback(() => { setOptimisticUserMessages((existing) => { for (const msg of existing) { @@ -4955,6 +4966,7 @@ export default function ChatView({ showNotificationDetails={settings.showNotificationDetails} includeDiagnosticsTipsInCopy={settings.includeDiagnosticsTipsInCopy} onDismissThreadError={() => setThreadError(activeThread.id, null)} + onRecoverFromOutOfMemory={() => void onRecoverFromOutOfMemory()} providerStatus={activeProviderStatus} transportState={transportState} isMobileCompanion={isMobileCompanion} diff --git a/apps/web/src/components/chat/ErrorNotificationBar.test.tsx b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx index 77338234..6b880c16 100644 --- a/apps/web/src/components/chat/ErrorNotificationBar.test.tsx +++ b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx @@ -24,7 +24,8 @@ const THREAD_ERROR = function renderBar( overrides: Partial> = {}, ): ReactElement { - const { onDismissThreadError, transportState, ...restOverrides } = overrides; + const { onDismissThreadError, onRecoverFromOutOfMemory, transportState, ...restOverrides } = + overrides; return ( ); @@ -86,72 +88,28 @@ describe("ErrorNotificationBar", () => { expect(markup).toContain("Base branch 'main' does not resolve to a commit yet."); }); - it("re-shows thread errors when the message changes after dismissal", async () => { - const onDismissThreadError = vi.fn(); + it("shows an out-of-memory recovery action when the thread error is recoverable", async () => { + const onRecoverFromOutOfMemory = vi.fn(); let renderer: ReactTestRenderer | null = null; - await act(async () => { renderer = create( - , - ); - }); - - const dismissAll = renderer!.root.findByProps({ "aria-label": "Dismiss notifications" }); - await act(async () => { - dismissAll.props.onClick(); - }); - - expect(renderer!.toJSON()).toBeNull(); - - await act(async () => { - renderer!.update( - , + renderBar({ + threadError: + "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + onRecoverFromOutOfMemory, + }), ); }); - expect(renderer!.toJSON()).not.toBeNull(); - expect(renderer!.root.findByProps({ "aria-label": "Show 1 notification" })).toBeTruthy(); - }); - - it("does not hide non-dismissible provider notifications via dismiss all", async () => { - let renderer: ReactTestRenderer | null = null; - - await act(async () => { - renderer = create( - , - ); + const root = renderer!.root; + const recoverButton = root.findByProps({ + "aria-label": "Reset session after out-of-memory failure", }); - const dismissAll = renderer!.root.findByProps({ "aria-label": "Dismiss notifications" }); await act(async () => { - dismissAll.props.onClick(); + recoverButton.props.onClick(); }); - expect(renderer!.toJSON()).not.toBeNull(); - expect(renderer!.root.findByProps({ "aria-label": "Show 1 notification" })).toBeTruthy(); - expect(JSON.stringify(renderer!.toJSON())).toContain("OpenAI (Codex CLI) needs verification"); + expect(onRecoverFromOutOfMemory).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/components/chat/ErrorNotificationBar.tsx b/apps/web/src/components/chat/ErrorNotificationBar.tsx index b3bab3ad..17e2ab12 100644 --- a/apps/web/src/components/chat/ErrorNotificationBar.tsx +++ b/apps/web/src/components/chat/ErrorNotificationBar.tsx @@ -13,6 +13,7 @@ import { buildThreadErrorDiagnosticsCopy, humanizeThreadError, isAuthenticationThreadError, + isOutOfMemoryThreadError, } from "./threadError"; import { getProviderStatusHeading, @@ -33,6 +34,8 @@ interface ErrorNotificationBarProps { includeDiagnosticsTipsInCopy?: boolean; /** Dismiss the thread error */ onDismissThreadError?: () => void; + /** Reset a provider session after an OOM failure */ + onRecoverFromOutOfMemory?: () => void; /** Provider health status */ providerStatus: ServerProviderStatus | null; /** Companion transport state (only relevant for mobile companion) */ @@ -49,6 +52,9 @@ interface NotificationItem { description: string; detailsText?: string | null; diagnosticsCopyText?: string | null; + actionLabel?: string; + actionAriaLabel?: string; + onAction?: () => void; severity: "error" | "warning" | "info"; dismissible: boolean; onDismiss?: () => void; @@ -64,6 +70,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ showNotificationDetails = false, includeDiagnosticsTipsInCopy = false, onDismissThreadError, + onRecoverFromOutOfMemory, providerStatus, transportState, isMobileCompanion, @@ -133,6 +140,8 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ if (threadError) { if (showAuthFailuresAsErrors || !isAuthenticationThreadError(threadError)) { const presentation = humanizeThreadError(threadError); + const showOutOfMemoryRecovery = + isOutOfMemoryThreadError(threadError) && onRecoverFromOutOfMemory !== undefined; items.push({ id: buildThreadErrorNotificationId(threadError), kind: "thread-error", @@ -143,6 +152,13 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ diagnosticsCopyText: buildThreadErrorDiagnosticsCopy(threadError, { includeTips: includeDiagnosticsTipsInCopy, }), + ...(showOutOfMemoryRecovery + ? { + actionLabel: "Reset session", + actionAriaLabel: "Reset session after out-of-memory failure", + onAction: onRecoverFromOutOfMemory, + } + : {}), severity: "error", dismissible: !!onDismissThreadError, ...(onDismissThreadError ? { onDismiss: onDismissThreadError } : {}), @@ -156,6 +172,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ showAuthFailuresAsErrors, includeDiagnosticsTipsInCopy, onDismissThreadError, + onRecoverFromOutOfMemory, providerStatus, transportState, isMobileCompanion, @@ -221,6 +238,9 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ if (visibleNotifications.length === 0) return null; const primary = visibleNotifications[0]!; + const actionNotification = visibleNotifications.find( + (notification) => notification.onAction && notification.actionLabel, + ); const PrimaryIcon = primary.icon; const count = visibleNotifications.length; const countLabel = count === 1 ? "1 notification" : `${count} notifications`; @@ -261,6 +281,18 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
+ {actionNotification?.onAction && actionNotification.actionLabel ? ( + + ) : null} + ) : null} {notif.kind === "thread-error" && notif.diagnosticsCopyText ? ( { expect(isAuthenticationThreadError("Provider crashed while starting.")).toBe(false); }); + it("detects out-of-memory failures", () => { + expect( + isOutOfMemoryThreadError( + "Provider crashed: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + ), + ).toBe(true); + expect( + isOutOfMemoryThreadError("Process exited: memory limit exceeded while streaming turn"), + ).toBe(true); + expect(isOutOfMemoryThreadError("Provider crashed while starting.")).toBe(false); + }); + it("builds redacted diagnostics copy without optional tips by default", () => { expect( buildThreadErrorDiagnosticsCopy( diff --git a/apps/web/src/components/chat/threadError.ts b/apps/web/src/components/chat/threadError.ts index c1f61778..ddeb52ed 100644 --- a/apps/web/src/components/chat/threadError.ts +++ b/apps/web/src/components/chat/threadError.ts @@ -26,6 +26,13 @@ const AUTH_FAILURE_PATTERNS = [ "could not resolve authentication method", "authentication required", ] as const; +const OUT_OF_MEMORY_PATTERNS = [ + "out of memory", + "heap out of memory", + "reached heap limit", + "memory limit exceeded", + "allocation failed - javascript heap", +] as const; function extractWorktreeDetail(error: string): string | null { if (!error.startsWith(WORKTREE_COMMAND_PREFIX)) { @@ -62,6 +69,12 @@ function buildTroubleshootingTips(error: string, presentation: ThreadErrorPresen ); } + if (isOutOfMemoryThreadError(error)) { + tips.push( + "Reset the provider session, then retry with a smaller prompt, fewer attachments, or less terminal context.", + ); + } + if (presentation.title === "Worktree thread could not start") { tips.push( "Create the first commit or switch to a base branch that resolves to a commit before starting a worktree thread.", @@ -81,6 +94,16 @@ export function isAuthenticationThreadError(error: string | null | undefined): b return AUTH_FAILURE_PATTERNS.some((pattern) => lower.includes(pattern)); } +export function isOutOfMemoryThreadError(error: string | null | undefined): boolean { + const trimmed = error?.trim(); + if (!trimmed) { + return false; + } + + const lower = trimmed.toLowerCase(); + return OUT_OF_MEMORY_PATTERNS.some((pattern) => lower.includes(pattern)); +} + export function humanizeThreadError(error: string): ThreadErrorPresentation { const trimmed = redactSensitiveText(error).trim(); const worktreeDetail = extractWorktreeDetail(trimmed); @@ -92,6 +115,15 @@ export function humanizeThreadError(error: string): ThreadErrorPresentation { }; } + if (isOutOfMemoryThreadError(trimmed)) { + return { + title: "Session ran out of memory", + description: + "The provider session ran out of memory. Reset the session, then resend the prompt.", + technicalDetails: trimmed, + }; + } + return { title: null, description: trimmed.length > 0 ? trimmed : "An unexpected error occurred.",