import { afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { act, useMemo, useState, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { FailedSessionActions } from "./failed-session-actions"; import { FailureRecoveryBoundary } from "./failure-recovery-boundary"; import { OpenGeniApiError } from "@opengeni/sdk/browser"; import { createFailedSessionRetry, type FailedSessionRetryInput, } from "../../lib/failed-session-retry"; mock.module("../../lib/navigation", () => ({ Link: ({ children }: { children: ReactNode }) => {children}, })); const { FailedSessionBanner } = await import("./failed-session-banner"); beforeAll(() => { try { GlobalRegistrator.register(); } catch { /* shared DOM */ } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean | undefined }).IS_REACT_ACT_ENVIRONMENT = true; }); let root: Root | undefined; afterEach(async () => { await act(async () => root?.unmount()); document.body.replaceChildren(); }); async function render(children: ReactNode) { const container = document.createElement("div"); document.body.append(container); root = createRoot(container); await act(async () => root!.render(children)); return container; } const failure = { reason: "Connection interrupted.", failedAt: null, consecutiveRecoveryCount: null, }; const actions = { onRetry: async () => true, retryBlocker: null }; test("an incompatible accepted Knowledge scope offers a new message, never Retry", async () => { const reason = "This turn's Knowledge scope no longer matches this chat. Send a new message to continue with the current settings."; const scopedFailure = { ...failure, failureCode: "knowledge_learning_scope_changed", reason }; const container = await render(); expect(container.textContent).toContain(reason); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(container.textContent).toContain(reason); expect(container.querySelector("button")).toBeNull(); }); test("compact row offers one ghost Retry without duplicate controls or guidance", async () => { const container = await render(); expect(container.querySelectorAll("button")).toHaveLength(1); expect(container.querySelector("button")!.textContent).toBe("Retry"); expect(container.querySelector("button")!.dataset.variant).toBe("ghost"); expect(container.querySelector("svg")).not.toBeNull(); const details = container.querySelector("details")!; expect(details.open).toBe(false); expect(details.querySelector("p")!.textContent).toBe("Connection interrupted."); expect(container.querySelector('[data-testid="failed-session-banner"] span')!.textContent).toBe( "The session stopped unexpectedly.", ); expect(container.textContent).not.toMatch(/Choose another model|composer|preserved|Try again/); expect(container.querySelector('[data-testid="failed-session-banner"]')!.className).not.toMatch( /bg-|border|rounded/, ); }); test("uncategorized preclaim details never grant Retry or bypass its blocker", async () => { const detail = "getaddrinfo ENOTFOUND database.example.test\nsynthetic diagnostic"; const preclaim = { ...failure, reason: detail, recordedDetail: detail, failureCode: "pre_claim_failure", }; let retries = 0; const container = await render(); expect(container.querySelector("button")).toBeNull(); const details = container.querySelector("details")!; expect(details.open).toBe(false); expect(details.querySelector("p")!.textContent).toBe(detail); expect(container.querySelector('[data-testid="failed-session-banner"] span')!.textContent).toBe( "The session stopped unexpectedly.", ); await act(async () => root!.render( { retries += 1; return true; }, retryBlocker: "permission", }} />, ), ); expect(container.querySelector("button")).toBeNull(); await act(async () => { details.open = true; details.dispatchEvent(new Event("toggle")); }); expect(retries).toBe(0); }); test("credential failures hide Retry until another model is chosen and keep raw detail folded", async () => { const raw = "401 Incorrect API key provided: sk-proj-****abcd. You can find your API key at https://platform.openai.com/account/api-keys."; const credentialFailure = { ...failure, reason: "The model provider rejected this deployment's engine credentials. Sending messages won't help until the deployment's engine configuration is fixed.", recordedDetail: raw, }; const container = await render( , ); const banner = container.querySelector('[data-testid="failed-session-banner"]')!; expect(banner.querySelector("button")).toBeNull(); const details = banner.querySelector("details")!; expect(details.open).toBe(false); expect(details.querySelector("summary")!.textContent).toBe("Details"); expect(details.querySelector("p")!.textContent).toBe(raw); expect(banner.textContent?.replace(details.textContent ?? "", "")).toBe( "The model provider rejected the credentials for this model. Choose another model below.", ); await act(async () => root!.render( , ), ); expect(container.querySelector("button")!.textContent).toBe("Retry"); }); test("billing and daily-limit failures keep Retry on the same model", async () => { const banner = (raw: string) => ( ); const container = await render(banner("402 Payment Required")); for (const raw of [ "402 This request requires more credits. To increase, visit https://openrouter.ai/settings/credits and upgrade to a paid account", "429 Rate limit exceeded: free-models-per-day. Add 10 credits to unlock more.", ]) { await act(async () => root!.render(banner(raw))); const row = container.querySelector('[data-testid="failed-session-banner"]')!; expect(row.textContent).toContain("Choose another model below."); expect(row.querySelectorAll("button")).toHaveLength(1); expect(row.querySelector("button")!.textContent).toBe("Retry"); } }); test("the free model's daily limit names it and offers credits, a subscription and Retry", async () => { const detail = "This model's daily limit at the model provider has been reached, so automatic retries stopped. Choose another model, or try again after the limit resets.\n429 Rate limit exceeded: free-models-per-day."; const daily = { ...failure, reason: detail.replace("\n", " "), recordedDetail: detail, failureCode: "provider_quota_exhausted", quotaScope: "daily", }; const banner = (props: { freeModel?: boolean | undefined; modelChanged?: boolean | undefined; subscriptions?: { codex: boolean; supergrok: boolean } | undefined; canChooseModel?: boolean | undefined; hasModelPicker?: boolean | undefined; }) => ( ); const container = await render(banner({ freeModel: true })); const row = () => container.querySelector('[data-testid="failed-session-banner"]')!; const headline = () => row().querySelector("span")!.textContent; const labels = () => [...row().querySelectorAll("a, button")].map((node) => node.textContent); expect(headline()).toBe( "The free model has reached its daily limit. Buy Opengeni credits, connect ChatGPT or SuperGrok, or pick another model to keep going.", ); expect(labels()).toEqual(["Buy credits", "Connect a subscription", "Retry"]); expect(row().querySelector("details p")!.textContent).toBe(detail); // A deployment without subscriptions never names them or links to them. await act(async () => root!.render(banner({ freeModel: true, subscriptions: { codex: false, supergrok: false } })), ); expect(headline()).toBe( "The free model has reached its daily limit. Buy Opengeni credits, connect a model provider, or pick another model to keep going.", ); expect(labels()).toEqual(["Buy credits", "Connect a model", "Retry"]); await act(async () => root!.render(banner({ freeModel: true, subscriptions: { codex: true, supergrok: false } })), ); expect(headline()).toContain("connect ChatGPT, or pick another model"); expect(labels()).toEqual(["Buy credits", "Connect ChatGPT", "Retry"]); // Sending or a pending Retry locks the picker briefly; the remedies hold steady. await act(async () => root!.render(banner({ freeModel: true, canChooseModel: false, hasModelPicker: true })), ); expect(headline()).toContain("or pick another model to keep going."); // Once another model is picked, Retry runs on it and the remedies step aside. await act(async () => root!.render(banner({ freeModel: true, modelChanged: true }))); expect(headline()).toBe("The free model has reached its daily limit."); expect(labels()).toEqual(["Retry"]); // Any other model keeps the generic daily-limit wording and actions. await act(async () => root!.render(banner({ freeModel: false }))); expect(headline()).toBe("This model's daily limit has been reached. Choose another model below."); expect(labels()).toEqual(["Retry"]); }); test("the free model keeps ordinary wording for failures other than its daily limit", async () => { const container = await render( , ); const row = container.querySelector('[data-testid="failed-session-banner"]')!; expect(row.querySelector("span")!.textContent).toBe( "This model is throttled due to high demand. Select a different model, or try again in a few minutes.", ); expect([...row.querySelectorAll("a, button")].map((node) => node.textContent)).toEqual(["Retry"]); }); test("throttling points at the chat bar's model picker and keeps only Retry", async () => { const banner = (modelChanged: boolean) => ( ); const container = await render(banner(false)); const row = container.querySelector('[data-testid="failed-session-banner"]')!; expect(row.querySelector("span")!.textContent).toBe( "This model is throttled due to high demand. Select a different model, or try again in a few minutes.", ); expect([...row.querySelectorAll("button")].map((node) => node.textContent)).toEqual(["Retry"]); await act(async () => root!.render(banner(true))); const changedRow = container.querySelector('[data-testid="failed-session-banner"]')!; expect(changedRow.querySelector("span")!.textContent).toBe( "This model is throttled due to high demand. Try again in a few minutes.", ); expect([...changedRow.querySelectorAll("button")].map((node) => node.textContent)).toEqual([ "Retry", ]); }); test("double clicks and accepted submissions never duplicate recovery", async () => { let settle!: (value: boolean) => void; let sends = 0; const container = await render( { sends++; return new Promise((resolve) => { settle = resolve; }); }} />, ); const button = container.querySelector("button")!; await act(async () => { button.click(); button.click(); }); expect(sends).toBe(1); expect(button.textContent).toBe("Retrying…"); await act(async () => settle(true)); expect(container.querySelector("button")).toBeNull(); expect(container.querySelector('[role="status"]')!.textContent).toBe("Retry requested."); }); test("typed structural sandbox failure never exposes retry or model-switch remedies", async () => { const container = document.createElement("div"); document.body.append(container); root = createRoot(container); await act(async () => root!.render( true, retryBlocker: null, }} />, ), ); expect(container.textContent).not.toContain("Retry"); expect(container.textContent).not.toContain("Choose another model"); expect(container.textContent).toBe("Checkpoint is older than workspace"); expect(container.querySelectorAll("button")).toHaveLength(0); }); test("all draft, delivery, work, permission and deliberate pause guards hide Retry", async () => { let sends = 0; const container = await render(null); for (const retryBlocker of [ "draft", "unsent", "delivery", "queued", "loading", "permission", "paused", ] as const) { await act(async () => root!.render( { sends++; return true; }} />, ), ); expect(container.querySelector("button")).toBeNull(); expect(container.textContent).toBe(""); } for (const composerBlocker of [ "upload", "repository", "policy", "personal_decision", "personal_loading", ] as const) { await act(async () => root!.render(), ); expect(container.querySelector("button")).toBeNull(); } expect(sends).toBe(0); }); test("sandbox projection gates compact Retry and structural failures override billing", async () => { const sandboxRecovery = { workspaceId: "workspace-a", sessionId: "session-a", canControl: true, client: { getSandboxRecovery: async () => ({ version: 1 as const, status: "unsupported" as const, reason: null, operationId: null, checkpoint: null, }), recoverSandbox: async () => { throw new Error("Rendering must not submit consent"); }, }, }; const container = await render( , ); expect(container.querySelectorAll("button")).toHaveLength(1); expect(container.querySelector("button")!.textContent).toBe("Retry"); await act(async () => root!.render( , ), ); expect(container.querySelectorAll('[data-testid="failed-session-banner"]')).toHaveLength(1); expect(container.querySelectorAll("a")).toHaveLength(0); expect(container.textContent).toContain("Checkpoint is older than workspace"); expect(container.textContent).not.toMatch(/Retry|Choose another|credits/); expect(container.querySelector("button")!.textContent).toBe("Check recovery status"); }); test("a viewer who cannot use checkpoint recovery keeps Retry and sees no failed check", async () => { const sandboxRecovery = { workspaceId: "workspace-a", sessionId: "session-a", canControl: true, client: { getSandboxRecovery: async () => { throw new OpenGeniApiError( 403, JSON.stringify({ error: { code: "forbidden", message: "Managed human required." } }), ); }, recoverSandbox: async () => { throw new Error("Rendering must not submit consent"); }, }, }; const container = await render( , ); expect(container.textContent).not.toContain("checkpoint recovery"); expect(container.querySelectorAll("button")).toHaveLength(1); expect(container.querySelector("button")!.textContent).toBe("Retry"); await act(async () => root!.render( , ), ); expect(container.textContent).toBe("Connection interrupted."); expect(container.querySelector("button")).toBeNull(); }); test.each(["restored", "connected_machine", "automatic", "fresh_workspace"] as const)( "%s projection exposes only explicit compact Retry through the structural banner", async (route) => { let retries = 0; const sandboxRecovery = { workspaceId: "workspace-a", sessionId: "session-a", canControl: true, client: { getSandboxRecovery: async () => ({ version: 1 as const, status: route === "restored" ? ("restored" as const) : route === "automatic" || route === "fresh_workspace" ? ("eligible" as const) : ("unsupported" as const), reason: route === "connected_machine" ? "connected_machine_selected" : null, operationId: route === "restored" ? "durable-operation" : null, automaticAvailable: route === "automatic" || route === "fresh_workspace", ...(route === "automatic" ? { automaticLane: "checkpoint" as const } : route === "fresh_workspace" ? { automaticLane: "fresh_workspace" as const } : {}), checkpoint: route === "automatic" ? { version: 1 as const, sessionId: "session-a", sandboxGroupId: "group-a", leaseId: "lease-a", routeEpoch: 0, authorityEpoch: 1, leaseEpoch: 2, workspaceGeneration: 12, archiveGeneration: 7, artifactId: "artifact-a", revision: "revision-a", capturedAt: "2026-09-20T08:00:00.000Z", } : null, }), recoverSandbox: async () => { throw new Error("Current route must not submit consent"); }, }, }; const failureProps = { ...failure, structuralSandboxFailure: true }; const retryProps = { ...actions, onRetry: async () => { retries++; return true; }, }; const container = await render( , ); expect(retries).toBe(0); expect(container.querySelectorAll("button")).toHaveLength(1); expect(container.querySelector("button")!.textContent).toBe("Retry"); if (route === "automatic") { expect(container.textContent).toContain("Retry will use the latest verified checkpoint from"); expect(container.textContent).toContain("Newer files are unavailable."); } if (route === "fresh_workspace") expect(container.textContent).toContain( "Retry will continue with an empty workspace. Opengeni cannot restore the previous sandbox files automatically.", ); expect(container.querySelector("button")!.dataset.variant).toBe("ghost"); expect(container.textContent).not.toContain("Choose another model"); await act(async () => root!.render( , ), ); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(retries).toBe(0); await act(async () => container.querySelector("button")!.click()); expect(retries).toBe(1); expect(container.querySelector("button")).toBeNull(); }, ); test("local non-submission remains retryable", async () => { const container = await render( false} />); await act(async () => container.querySelector("button")!.click()); expect(container.querySelector('[role="alert"]')!.textContent).toContain("Could not retry"); expect(container.querySelector("button")!.disabled).toBe(false); }); test("history hydration retains guards; a distinct failure resets them", async () => { let settle!: (value: boolean) => void; const props = { ...actions, onRetry: () => new Promise((resolve) => { settle = resolve; }), }; const container = await render(); await act(async () => container.querySelector("button")!.click()); await act(async () => root!.render()); expect(container.querySelector("button")!.textContent).toBe("Retrying…"); await act(async () => root!.render()); await act(async () => settle(true)); expect(container.querySelector("button")!.textContent).toBe("Retry"); }); test("unknown outcome checks the identical frozen operation instead of a new retry", async () => { const inputs: FailedSessionRetryInput[] = []; function Recovery() { const [retryInput, setRetryInput] = useState(null); const retry = useMemo( () => createFailedSessionRetry(async (input) => { inputs.push(input); if (inputs.length === 1) throw new Error("Response lost"); }, setRetryInput), [], ); return ( retry("failure-a", { model: "selected-model", reasoningEffort: "medium", latencyMode: "standard", }) } /> ); } const container = await render(); await act(async () => container.querySelector("button")!.click()); expect(container.querySelectorAll("button")).toHaveLength(1); expect(container.querySelector("button")!.textContent).toBe("Check retry"); expect(container.textContent).not.toContain("Choose"); await act(async () => container.querySelector("button")!.click()); expect(inputs).toHaveLength(2); expect(inputs[1]).toBe(inputs[0]); expect(container.querySelector("button")).toBeNull(); }); test("definitive unsafe retry rejection removes the action", async () => { const container = await render( { throw new OpenGeniApiError(409, "Unsupported failure", { code: "RETRY_UNSUPPORTED_FAILURE", }); }} />, ); await act(async () => container.querySelector("button")!.click()); expect(container.querySelector("button")).toBeNull(); expect(container.querySelector('[role="alert"]')!.textContent).toContain( "cannot be retried safely", ); }); test("a permanently unknown tool outcome says what to do instead of still settling", async () => { let sends = 0; const container = await render( { sends += 1; throw new OpenGeniApiError(409, "Unknown tool outcome", { code: "RETRY_TOOL_OUTCOME_UNKNOWN", }); }} />, ); await act(async () => container.querySelector("button")!.click()); expect(sends).toBe(1); // Permanent: the action is removed rather than inviting a retry that can // never succeed, and the copy names the person's next step. expect(container.querySelector("button")).toBeNull(); const alert = container.querySelector('[role="alert"]')!.textContent!; expect(alert).not.toContain("settling"); expect(alert).toBe( "A tool may already have run, so Retry could repeat it. Check its result, then send a message to continue.", ); }); for (const code of ["RETRY_PAUSED", "RETRY_EXECUTION_UNRESOLVED"] as const) { test(`${code} allows explicit retry after the same failure's transient blocker clears`, async () => { let sends = 0; const onRetry = async () => { if (++sends === 1) throw new OpenGeniApiError(409, "Blocked", { code }); return true; }; const container = await render( , ); await act(async () => container.querySelector("button")!.click()); expect(container.querySelector('[role="alert"]')!.textContent).toBe( code === "RETRY_PAUSED" ? "This session is paused." : "Earlier work is still settling. Try again in a moment.", ); await act(async () => root!.render( , ), ); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(sends).toBe(1); await act(async () => container.querySelector("button")!.click()); expect(sends).toBe(2); expect(container.querySelector("button")).toBeNull(); expect(container.querySelector('[role="alert"]')).toBeNull(); }); } test("unsupported model directs to existing picker; a changed model permits recovery", async () => { const unavailable = { ...failure, reason: "The model `example` is not supported with this account.", }; const container = await render( , ); expect(container.textContent).toBe("This model isn’t available. Choose another below."); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render( , ), ); expect(container.textContent).toBe("This model isn’t available."); expect(container.querySelector("button")).toBeNull(); await act(async () => root!.render(), ); expect(container.querySelector("button")!.textContent).toBe("Retry"); }); test("safety refusal preserves draft and has no recovery action", async () => { const container = await render( <>