Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLButtonElement>(
'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,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
72 changes: 15 additions & 57 deletions apps/web/src/components/chat/ErrorNotificationBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const THREAD_ERROR =
function renderBar(
overrides: Partial<ComponentProps<typeof ErrorNotificationBar>> = {},
): ReactElement {
const { onDismissThreadError, transportState, ...restOverrides } = overrides;
const { onDismissThreadError, onRecoverFromOutOfMemory, transportState, ...restOverrides } =
overrides;
return (
<ErrorNotificationBar
threadError={THREAD_ERROR}
Expand All @@ -35,6 +36,7 @@ function renderBar(
isMobileCompanion={false}
{...restOverrides}
{...(onDismissThreadError ? { onDismissThreadError } : {})}
{...(onRecoverFromOutOfMemory ? { onRecoverFromOutOfMemory } : {})}
{...(transportState ? { transportState } : {})}
/>
);
Expand Down Expand Up @@ -86,72 +88,28 @@ describe("ErrorNotificationBar", () => {
expect(markup).toContain("Base branch &#x27;main&#x27; 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(
<ErrorNotificationBar
threadError={THREAD_ERROR}
showAuthFailuresAsErrors
showNotificationDetails={false}
includeDiagnosticsTipsInCopy={false}
providerStatus={null}
isMobileCompanion={false}
onDismissThreadError={onDismissThreadError}
/>,
);
});

const dismissAll = renderer!.root.findByProps({ "aria-label": "Dismiss notifications" });
await act(async () => {
dismissAll.props.onClick();
});

expect(renderer!.toJSON()).toBeNull();

await act(async () => {
renderer!.update(
<ErrorNotificationBar
threadError="Codex CLI is not authenticated. Run `codex login` and try again."
showAuthFailuresAsErrors
showNotificationDetails={false}
includeDiagnosticsTipsInCopy={false}
providerStatus={null}
isMobileCompanion={false}
onDismissThreadError={onDismissThreadError}
/>,
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(
<ErrorNotificationBar
threadError={null}
showAuthFailuresAsErrors
showNotificationDetails={false}
includeDiagnosticsTipsInCopy={false}
providerStatus={makeProviderStatus()}
isMobileCompanion={false}
/>,
);
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);
});
});
44 changes: 44 additions & 0 deletions apps/web/src/components/chat/ErrorNotificationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildThreadErrorDiagnosticsCopy,
humanizeThreadError,
isAuthenticationThreadError,
isOutOfMemoryThreadError,
} from "./threadError";
import {
getProviderStatusHeading,
Expand All @@ -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) */
Expand All @@ -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;
Expand All @@ -64,6 +70,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
showNotificationDetails = false,
includeDiagnosticsTipsInCopy = false,
onDismissThreadError,
onRecoverFromOutOfMemory,
providerStatus,
transportState,
isMobileCompanion,
Expand Down Expand Up @@ -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",
Expand All @@ -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 } : {}),
Expand All @@ -156,6 +172,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
showAuthFailuresAsErrors,
includeDiagnosticsTipsInCopy,
onDismissThreadError,
onRecoverFromOutOfMemory,
providerStatus,
transportState,
isMobileCompanion,
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -261,6 +281,18 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
</span>

<div className="flex shrink-0 items-center gap-1">
{actionNotification?.onAction && actionNotification.actionLabel ? (
<Button
type="button"
variant="outline"
size="xs"
aria-label={actionNotification.actionAriaLabel ?? actionNotification.actionLabel}
className="min-w-0 px-2 text-[10px] font-medium"
onClick={actionNotification.onAction}
>
{actionNotification.actionLabel}
</Button>
) : null}
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -313,6 +345,18 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
) : null}
</div>
<div className="mt-0.5 flex shrink-0 items-center gap-1">
{notif.onAction && notif.actionLabel ? (
<Button
type="button"
variant="outline"
size="xs"
aria-label={notif.actionAriaLabel ?? notif.actionLabel}
className="h-6 px-2 text-[10px]"
onClick={notif.onAction}
>
{notif.actionLabel}
</Button>
) : null}
{notif.kind === "thread-error" && notif.diagnosticsCopyText ? (
<MessageCopyButton
text={notif.diagnosticsCopyText}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/threadError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildThreadErrorDiagnosticsCopy,
humanizeThreadError,
isOutOfMemoryThreadError,
isAuthenticationThreadError,
} from "./threadError";

Expand Down Expand Up @@ -57,6 +58,18 @@ describe("humanizeThreadError", () => {
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(
Expand Down
Loading
Loading