From ed702ed3cc00ce0ff1b9c1dfd0c44f6e2b49c3e3 Mon Sep 17 00:00:00 2001
From: Cody Hart <157865967+HartBrook@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:53:43 -0400
Subject: [PATCH 1/3] fix: use Codex account default model
---
README.md | 4 +-
apps/server/src/app.test.ts | 18 +++--
apps/server/src/providers/catalog.test.ts | 19 +++--
apps/server/src/providers/catalog.ts | 39 ++++++----
apps/server/src/providers/codex.test.ts | 90 ++++++++++++++++++++++-
apps/server/src/providers/codex.ts | 60 +++++++++++++--
apps/server/src/tourService.test.ts | 2 +-
apps/web/src/App.tsx | 60 ++++++++-------
apps/web/src/ai.test.ts | 53 +++++++++++--
apps/web/src/ai.ts | 37 +++++++---
apps/web/src/session.ts | 11 ++-
apps/web/src/styles.css | 2 +-
e2e/reviewer-flow.spec.ts | 8 +-
13 files changed, 312 insertions(+), 91 deletions(-)
diff --git a/README.md b/README.md
index 0ad82d6..6eefa7e 100644
--- a/README.md
+++ b/README.md
@@ -174,12 +174,12 @@ Direct API access is an optional fallback. Copy the example environment file and
cp .env.example .env
```
-Available Codex/OpenAI models:
+Codex CLI uses the default model selected for the signed-in ChatGPT account. Wingdiff does not pass an API model ID to Codex, because API availability and ChatGPT-plan availability can differ. Codex CLI reviews offer low, medium, or high reasoning effort; if the account's default model does not accept the chosen effort, Codex reports the error and Wingdiff shows it. Named models are available through the direct OpenAI API provider:
- `gpt-6-sol` — default balance of review quality, speed, and cost
- `gpt-6-astra` — highest-capability option for difficult reviews
- `gpt-6-luna` — fast, cost-efficient investigations
-- `gpt-5.3-codex` — Codex-tuned coding model
+- `gpt-5.3-codex` — deprecated Codex-tuned model available only through the direct OpenAI API provider
Claude Sonnet 4.6 and Opus 4.6 remain available through the same provider interface. Select Codex CLI, OpenAI API, or Anthropic API plus the model and supported reasoning effort from the model control in the top bar.
diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts
index 7a8d3c5..b323d0f 100644
--- a/apps/server/src/app.test.ts
+++ b/apps/server/src/app.test.ts
@@ -87,7 +87,7 @@ describe("generated tour API", () => {
const created = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" } }),
+ body: JSON.stringify({ selection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" } }),
});
expect(created.status).toBe(202);
expect(await created.json()).toMatchObject({ generation: { state: "running", scope: "full" } });
@@ -103,7 +103,7 @@ describe("generated tour API", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" },
+ selection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" },
scope: "full",
stopId: "atomic-counter",
question: "Can concurrent calls race?",
@@ -146,7 +146,7 @@ describe("generated tour API", () => {
await new Promise((resolve) => server!.once("listening", resolve));
const port = (server.address() as AddressInfo).port;
const url = `http://127.0.0.1:${port}/api/sessions/${session.id}/tour`;
- const input = { selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" } };
+ const input = { selection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" } };
const started = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input),
@@ -163,11 +163,17 @@ describe("generated tour API", () => {
});
expect(duplicate.status).toBe(202);
- const conflicting = await fetch(url, {
+ const conflictingScope = await fetch(url, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...input, scope: "update" }),
+ });
+ expect(conflictingScope.status).toBe(409);
+
+ const conflictingSelection = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection: { ...input.selection, reasoningEffort: "high" } }),
});
- expect(conflicting.status).toBe(409);
+ expect(conflictingSelection.status).toBe(409);
release();
const completed = await waitForGeneration(url, "succeeded");
@@ -203,7 +209,7 @@ describe("generated tour API", () => {
const url = `http://127.0.0.1:${port}/api/sessions/${session.id}/tour`;
const created = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" } }),
+ body: JSON.stringify({ selection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" } }),
});
expect(created.status).toBe(202);
await waitForGeneration(url, "succeeded");
diff --git a/apps/server/src/providers/catalog.test.ts b/apps/server/src/providers/catalog.test.ts
index c4dc0fd..ce84a91 100644
--- a/apps/server/src/providers/catalog.test.ts
+++ b/apps/server/src/providers/catalog.test.ts
@@ -4,10 +4,7 @@ import { MODEL_CATALOG, publicProviders, validateSelection } from "./catalog.js"
describe("model catalog", () => {
it("offers OpenAI, Codex, and Anthropic choices", () => {
expect(MODEL_CATALOG.map((model) => model.id)).toEqual([
- "gpt-6-sol",
- "gpt-6-astra",
- "gpt-6-luna",
- "gpt-5.3-codex",
+ "codex-default",
"gpt-6-sol",
"gpt-6-astra",
"gpt-6-luna",
@@ -17,9 +14,10 @@ describe("model catalog", () => {
]);
});
- it("defaults GPT-6 Sol to medium reasoning", () => {
- const model = MODEL_CATALOG.find((candidate) => candidate.id === "gpt-6-sol");
+ it("lets Codex choose the signed-in account's default model", () => {
+ const model = MODEL_CATALOG.find((candidate) => candidate.id === "codex-default");
expect(model?.defaultEffort).toBe("medium");
+ expect(model?.reasoningEfforts).toEqual(["low", "medium", "high"]);
expect(model?.badge).toBe("Recommended");
});
@@ -28,15 +26,20 @@ describe("model catalog", () => {
expect(providers.find((provider) => provider.id === "codex")?.configured).toBe(true);
expect(providers.find((provider) => provider.id === "openai")?.configured).toBe(true);
expect(providers.find((provider) => provider.id === "anthropic")?.configured).toBe(false);
+ expect(providers.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual(["codex-default"]);
+ expect(providers.find((provider) => provider.id === "openai")?.models.map((model) => model.id)).toContain("gpt-5.3-codex");
expect(JSON.stringify(providers)).not.toContain("secret-openai");
});
it("rejects cross-provider and unsupported reasoning selections", () => {
expect(() => validateSelection({ provider: "anthropic", model: "gpt-6-sol", reasoningEffort: "medium" })).toThrow(/Unknown model/);
expect(() => validateSelection({ provider: "openai", model: "gpt-6-astra", reasoningEffort: "none" })).toThrow(/does not support/);
- expect(validateSelection({ provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" })).toEqual({
+ expect(() => validateSelection({ provider: "codex", model: "gpt-5.3-codex", reasoningEffort: "medium" })).toThrow(/must use your account's default model/);
+ expect(() => validateSelection({ provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" })).toThrow(/must use your account's default model/);
+ expect(validateSelection({ provider: "openai", model: "gpt-5.3-codex", reasoningEffort: "medium" }).provider).toBe("openai");
+ expect(validateSelection({ provider: "codex", model: "codex-default", reasoningEffort: "medium" })).toEqual({
provider: "codex",
- model: "gpt-6-sol",
+ model: "codex-default",
reasoningEffort: "medium",
});
});
diff --git a/apps/server/src/providers/catalog.ts b/apps/server/src/providers/catalog.ts
index a728d58..9125e55 100644
--- a/apps/server/src/providers/catalog.ts
+++ b/apps/server/src/providers/catalog.ts
@@ -8,18 +8,31 @@ import type {
const CODEX_MODELS: ModelDefinition[] = [
{
- id: "gpt-6-sol",
+ id: "codex-default",
provider: "codex",
+ name: "Account default",
+ family: "Codex",
+ description: "Let Codex choose the default model supported by your signed-in account.",
+ badge: "Recommended",
+ reasoningEfforts: ["low", "medium", "high"],
+ defaultEffort: "medium",
+ },
+];
+
+const OPENAI_MODELS: ModelDefinition[] = [
+ {
+ id: "gpt-6-sol",
+ provider: "openai",
name: "GPT-6 Sol",
family: "OpenAI",
description: "Balanced reasoning, latency, and cost for everyday code review.",
- badge: "Recommended",
+ badge: "API",
reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
defaultEffort: "medium",
},
{
id: "gpt-6-astra",
- provider: "codex",
+ provider: "openai",
name: "GPT-6 Astra",
family: "OpenAI",
description: "Highest capability for architectural and high-risk reviews.",
@@ -29,7 +42,7 @@ const CODEX_MODELS: ModelDefinition[] = [
},
{
id: "gpt-6-luna",
- provider: "codex",
+ provider: "openai",
name: "GPT-6 Luna",
family: "OpenAI",
description: "Fast, cost-efficient investigation for routine questions.",
@@ -39,23 +52,16 @@ const CODEX_MODELS: ModelDefinition[] = [
},
{
id: "gpt-5.3-codex",
- provider: "codex",
+ provider: "openai",
name: "GPT-5.3-Codex",
- family: "Codex",
- description: "Codex-tuned model for agentic coding and code investigation.",
- badge: "Codex",
+ family: "OpenAI API",
+ description: "Deprecated Codex model available through direct API authentication only.",
+ badge: "Deprecated",
reasoningEfforts: ["low", "medium", "high", "xhigh"],
defaultEffort: "medium",
},
];
-const OPENAI_MODELS: ModelDefinition[] = CODEX_MODELS.map((model) => ({
- ...model,
- provider: "openai",
- family: model.family === "Codex" ? "OpenAI API" : model.family,
- badge: model.id === "gpt-6-sol" ? "API" : model.badge,
-}));
-
const ANTHROPIC_MODELS: ModelDefinition[] = [
{
id: "claude-sonnet-4-6",
@@ -119,6 +125,9 @@ export function publicProviders(
export function validateSelection(input: unknown): ModelSelection {
if (!input || typeof input !== "object") throw new Error("A model selection is required.");
const value = input as Record;
+ if (value.provider === "codex" && value.model !== "codex-default") {
+ throw new Error("Codex CLI must use your account's default model. Choose Account default, or use the OpenAI API provider for a named model.");
+ }
const model = MODEL_CATALOG.find((candidate) => (
candidate.id === value.model && candidate.provider === value.provider
));
diff --git a/apps/server/src/providers/codex.test.ts b/apps/server/src/providers/codex.test.ts
index c7a0e90..9312a2a 100644
--- a/apps/server/src/providers/codex.test.ts
+++ b/apps/server/src/providers/codex.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
-import { CODEX_SECURITY_OVERRIDES, codexInvestigationTimeoutMs, codexTourTimeoutMs } from "./codex.js";
+import {
+ CODEX_SECURITY_OVERRIDES,
+ codexInvestigationTimeoutMs,
+ codexModelArguments,
+ codexTourTimeoutMs,
+ formatCodexCliError,
+} from "./codex.js";
describe("Codex CLI isolation", () => {
it("prevents model-invoked tools from inheriting secrets or browsing", () => {
@@ -10,6 +16,11 @@ describe("Codex CLI isolation", () => {
"tools.web_search=false",
]);
});
+
+ it("lets the CLI choose the signed-in account's default model", () => {
+ expect(codexModelArguments("codex-default")).toEqual([]);
+ expect(codexModelArguments("gpt-6-sol")).toEqual(["--model", "gpt-6-sol"]);
+ });
});
describe("Codex CLI timeouts", () => {
@@ -37,3 +48,80 @@ describe("Codex CLI timeouts", () => {
expect(codexInvestigationTimeoutMs({ WINGDIFF_CODEX_INVESTIGATION_TIMEOUT_MS: "999" })).toBe(180_000);
});
});
+
+describe("Codex CLI errors", () => {
+ it("turns a ChatGPT model rejection into an actionable provider choice", () => {
+ const stderr = 'ERROR request failed: {"status":400,"detail":"The gpt-5.3-codex model is not supported when using Codex with a ChatGPT account."}';
+ expect(formatCodexCliError(stderr, "codex-default")).toBe(
+ "Codex's account-default model is not available with this ChatGPT account. Update Codex CLI and run `codex login` again, or select OpenAI API and configure OPENAI_API_KEY.",
+ );
+ });
+
+ it("explains how to recover when Codex authentication expires", () => {
+ expect(formatCodexCliError("Error: 401 Unauthorized: authentication expired", "gpt-6-sol")).toBe(
+ "Codex authentication is missing or expired. Run `codex login`, then restart Wingdiff.",
+ );
+ });
+
+ it("extracts a structured error detail instead of exposing CLI log noise", () => {
+ const stderr = '2026-10-07T12:00:00Z ERROR codex_core::client: {"error":{"message":"The request was rejected."}}';
+ expect(formatCodexCliError(stderr, "gpt-6-sol")).toBe("The request was rejected.");
+ });
+
+ it("recognizes the unsupported wording of a ChatGPT model rejection", () => {
+ const stderr = "ERROR: The gpt-6-sol model is unsupported when using Codex with a ChatGPT account.";
+ expect(formatCodexCliError(stderr, "gpt-6-sol")).toMatch(/^gpt-6-sol is not available with this ChatGPT account\./);
+ });
+
+ it("recognizes a structured 401 status", () => {
+ expect(formatCodexCliError('ERROR: {"status":401,"detail":"Token rejected."}', "codex-default")).toBe(
+ "Codex authentication is missing or expired. Run `codex login`, then restart Wingdiff.",
+ );
+ });
+
+ it("explains a model that the current sign-in cannot use", () => {
+ const stderr = "ERROR: The model `gpt-6-astra` does not exist or you do not have access to it.";
+ expect(formatCodexCliError(stderr, "codex-default")).toBe(
+ "Codex's account-default model is not available with the current Codex sign-in. Update Codex CLI and run `codex login` again, or choose another provider.",
+ );
+ });
+
+ it("does not mistake echoed review evidence for an authentication failure", () => {
+ const stderr = [
+ '+ if (res.status === 401) throw new Error("Unauthorized");',
+ "ERROR: stream disconnected before completion: You have hit your usage limit.",
+ ].join("\n");
+ expect(formatCodexCliError(stderr, "codex-default")).toBe(
+ "stream disconnected before completion: You have hit your usage limit.",
+ );
+ });
+
+ it("does not mistake model prose for an unavailable model", () => {
+ const stderr = "The data model for sessions is not supported by the old migration.\nERROR: stream error: connection reset";
+ expect(formatCodexCliError(stderr, "codex-default")).toBe("stream error: connection reset");
+ });
+
+ it("reports the error instead of a trailing warning", () => {
+ const stderr = [
+ "ERROR: unexpected status 429 Too Many Requests: rate limit reached",
+ "Warning: no last agent message; wrote empty content to /tmp/wingdiff-codex-abc/tour-output.json",
+ ].join("\n");
+ expect(formatCodexCliError(stderr, "codex-default")).toBe("unexpected status 429 Too Many Requests: rate limit reached");
+ });
+
+ it("ignores structured fields that are not part of the error line", () => {
+ const stderr = 'ssage":"truncated"}\n{"path":"src/a.ts","message":"fix retry"}\nERROR: stream error: connection reset';
+ expect(formatCodexCliError(stderr, "codex-default")).toBe("stream error: connection reset");
+ });
+
+ it("keeps the whole stderr tail when no line reports an error", () => {
+ const stderr = "thread panicked at core/src/lib.rs\nstack backtrace omitted";
+ expect(formatCodexCliError(stderr, "codex-default")).toBe(stderr);
+ expect(formatCodexCliError("Not logged in. Run codex login.", "codex-default")).toMatch(/^Codex authentication is missing or expired\./);
+ });
+
+ it("returns nothing for empty stderr so the caller reports the exit status", () => {
+ expect(formatCodexCliError("", "codex-default")).toBe("");
+ expect(formatCodexCliError("WARNING: proceeding, even though we could not create PATH aliases: denied\n")).toBe("");
+ });
+});
diff --git a/apps/server/src/providers/codex.ts b/apps/server/src/providers/codex.ts
index 36c895f..d469882 100644
--- a/apps/server/src/providers/codex.ts
+++ b/apps/server/src/providers/codex.ts
@@ -66,8 +66,7 @@ export class CodexCliProvider implements TextProvider {
...CODEX_SECURITY_OVERRIDES,
"--color",
"never",
- "--model",
- selection.model,
+ ...codexModelArguments(selection.model),
"-c",
`model_reasoning_effort="${selection.reasoningEffort}"`,
"--output-schema",
@@ -75,7 +74,7 @@ export class CodexCliProvider implements TextProvider {
"--output-last-message",
outputPath,
"-",
- ], prompt, this.generationTimeoutMs, signal);
+ ], selection.model, prompt, this.generationTimeoutMs, signal);
return model.restoreAnchors(parseStructuredJson(await readFile(outputPath, "utf8")));
} finally {
await rm(workingDirectory, { recursive: true, force: true });
@@ -101,8 +100,7 @@ export class CodexCliProvider implements TextProvider {
...CODEX_SECURITY_OVERRIDES,
"--color",
"never",
- "--model",
- selection.model,
+ ...codexModelArguments(selection.model),
"-c",
`model_reasoning_effort="${selection.reasoningEffort}"`,
"-",
@@ -142,7 +140,7 @@ export class CodexCliProvider implements TextProvider {
if (signal?.aborted) throw new Error("Codex CLI investigation was canceled.");
if (timedOut) throw new Error(`Codex CLI exceeded the ${Math.round(timeoutMs / 1_000)} second timeout.`);
if (exitCode !== 0) {
- throw new Error(cleanCliError(stderr) || `Codex CLI exited with status ${String(exitCode)}.`);
+ throw new Error(formatCodexCliError(stderr, selection.model) || `Codex CLI exited with status ${String(exitCode)}.`);
}
} finally {
clearTimeout(timeout);
@@ -157,6 +155,7 @@ async function runCodex(
environment: NodeJS.ProcessEnv,
workingDirectory: string,
arguments_: string[],
+ model: string,
prompt: string,
timeoutMs: number,
signal?: AbortSignal,
@@ -190,7 +189,9 @@ async function runCodex(
const exitCode = await completion;
if (signal?.aborted) throw new Error("Codex CLI tour generation was canceled.");
if (timedOut) throw new Error(`Codex CLI exceeded the ${Math.round(timeoutMs / 1_000)} second timeout.`);
- if (exitCode !== 0) throw new Error(cleanCliError(stderr) || `Codex CLI exited with status ${String(exitCode)}.`);
+ if (exitCode !== 0) {
+ throw new Error(formatCodexCliError(stderr, model) || `Codex CLI exited with status ${String(exitCode)}.`);
+ }
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
@@ -211,6 +212,10 @@ export function codexInvestigationTimeoutMs(environment: NodeJS.ProcessEnv): num
);
}
+export function codexModelArguments(model: string): string[] {
+ return model === "codex-default" ? [] : ["--model", model];
+}
+
function parseTimeout(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : fallback;
@@ -221,3 +226,44 @@ function cleanCliError(stderr: string): string {
.replace(/WARNING: proceeding, even though we could not create PATH aliases:[^\n]*\n?/g, "")
.trim();
}
+
+export function formatCodexCliError(stderr: string, model?: string): string {
+ const cleaned = cleanCliError(stderr);
+ if (!cleaned) return "";
+ // Codex streams the prompt and model prose to stderr, so only the line that reports the failure is classified.
+ const lines = cleaned.split("\n").map((candidate) => candidate.trim()).filter(Boolean).reverse();
+ const errorLine = lines.find((candidate) => CLI_ERROR_LINE.test(candidate));
+ const reported = errorLine ?? lines.find((candidate) => !CLI_WARNING_LINE.test(candidate)) ?? cleaned;
+ const modelName = model === "codex-default" ? "Codex's account-default model" : model ?? "The selected model";
+
+ if (/(?:not supported|unsupported) when using Codex with a ChatGPT account/i.test(reported)) {
+ return `${modelName} is not available with this ChatGPT account. Update Codex CLI and run \`codex login\` again, or select OpenAI API and configure OPENAI_API_KEY.`;
+ }
+ if (/(?:not logged in|log in to Codex|authentication (?:is )?(?:missing|required|expired)|status["': ]+401|\b401 Unauthorized\b)/i.test(reported)) {
+ return "Codex authentication is missing or expired. Run `codex login`, then restart Wingdiff.";
+ }
+ if (/\bmodel\b.*(?:not supported|not available|does not exist|do not have access)/i.test(reported)) {
+ return `${modelName} is not available with the current Codex sign-in. Update Codex CLI and run \`codex login\` again, or choose another provider.`;
+ }
+ return errorLine ? extractCliErrorDetail(errorLine) : cleaned;
+}
+
+const CLI_LOG_TIMESTAMP = String.raw`(?:\d{4}-\d\d-\d\dT\S+\s+)?`;
+const CLI_ERROR_LINE = new RegExp(`^${CLI_LOG_TIMESTAMP}(?:ERROR\\b|[Ee]rror:)`);
+const CLI_WARNING_LINE = new RegExp(`^${CLI_LOG_TIMESTAMP}warn(?:ing)?\\b`, "i");
+
+function extractCliErrorDetail(errorLine: string): string {
+ const fields = [...errorLine.matchAll(/"(?:message|detail)"\s*:\s*("(?:\\.|[^"\\])*")/g)];
+ const encoded = fields.at(-1)?.[1];
+ if (encoded) {
+ try {
+ return JSON.parse(encoded) as string;
+ } catch {
+ // Fall back to the error line itself below.
+ }
+ }
+ return errorLine
+ .replace(/^\d{4}-\d\d-\d\dT\S+\s+ERROR\s+[^:]+:\s*/, "")
+ .replace(/^error:\s*/i, "")
+ .trim();
+}
diff --git a/apps/server/src/tourService.test.ts b/apps/server/src/tourService.test.ts
index 859b3bf..e0c674a 100644
--- a/apps/server/src/tourService.test.ts
+++ b/apps/server/src/tourService.test.ts
@@ -6,7 +6,7 @@ import { SessionStore } from "./sessions.js";
import { parsePullRequestTarget } from "./targets.js";
import { generateSessionTour, getSessionTour } from "./tourService.js";
-const selection: ModelSelection = { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" };
+const selection: ModelSelection = { provider: "codex", model: "codex-default", reasoningEffort: "medium" };
describe("session tour generation", () => {
it("validates, persists, and restores grounded output", async () => {
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index f1931a2..a41cff2 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -4,6 +4,8 @@ import {
DEFAULT_SELECTION,
FALLBACK_PROVIDERS,
fetchProviders,
+ normalizeSelection,
+ preferredAvailableSelection,
selectedModel,
streamInvestigation,
streamSessionInvestigation,
@@ -56,6 +58,7 @@ import {
saveSessionContext,
startSessionTourGeneration,
updateInvestigationEntry,
+ TourGenerationFailedError,
waitForSessionTourGeneration,
type AcquiredReviewSession,
type FindingCheckpoint,
@@ -241,12 +244,13 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
const [tourLoading, setTourLoading] = useState(true);
const [generationStarting, setGenerationStarting] = useState(false);
const [generationStatus, setGenerationStatus] = useState(null);
+ const [generationFailed, setGenerationFailed] = useState(false);
const [completing, setCompleting] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState(null);
- const [error, setError] = useState(null);
+ const [error, setErrorMessage] = useState(null);
const [providers, setProviders] = useState(FALLBACK_PROVIDERS);
- const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION);
+ const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION, restoreModelSelection);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [contextManifest, setContextManifest] = useState(null);
const [contextPreviewOpen, setContextPreviewOpen] = useState(false);
@@ -350,7 +354,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
if (storedGeneration.state === "running" || storedGeneration.state === "succeeded") {
void monitorTourGeneration(storedGeneration);
} else if (storedGeneration.state === "failed") {
- setError(storedGeneration.error ?? "Wingdiff could not generate this guided tour.");
+ setGenerationError(storedGeneration.error ?? "Wingdiff could not generate this guided tour.");
}
const restoredScope = storedProgress.activeScope === "update" && !updateContext
? "full"
@@ -379,6 +383,16 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
if (view === "tour") tourCanvasRef.current?.scrollTo({ top: 0 });
}, [activeIndex, reviewScope, view]);
+ function setError(message: string | null) {
+ setErrorMessage(message);
+ setGenerationFailed(false);
+ }
+
+ function setGenerationError(message: string) {
+ setErrorMessage(message);
+ setGenerationFailed(true);
+ }
+
async function generateTour() {
if (!activeProvider?.configured) {
setModelPickerOpen(true);
@@ -391,7 +405,8 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
setGenerationStatus(status);
void monitorTourGeneration(status);
} catch (caught) {
- setError(caught instanceof Error ? caught.message : "Wingdiff could not generate this guided tour.");
+ if (caught instanceof TourGenerationFailedError) setGenerationError(caught.message);
+ else setError(`Wingdiff could not start this guided tour: ${caught instanceof Error ? caught.message : "Unknown error"}`);
} finally {
setGenerationStarting(false);
}
@@ -416,7 +431,8 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
} catch (caught) {
if (caught instanceof DOMException && caught.name === "AbortError") return;
const detail = caught instanceof Error || caught instanceof DOMException ? caught.message : String(caught ?? "Unknown error");
- setError(`Wingdiff could not generate this guided tour: ${detail}`);
+ if (caught instanceof TourGenerationFailedError) setGenerationError(detail);
+ else setError(`Wingdiff could not generate this guided tour: ${detail}`);
} finally {
if (generationAbort.current === controller) generationAbort.current = null;
}
@@ -789,7 +805,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
{ setView("browse"); setMobileRouteOpen(false); }} onReview={() => { setView("review"); setMobileRouteOpen(false); }} onSelect={(index) => { selectStop(index); setMobileRouteOpen(false); }} onSummary={() => { setView("summary"); setMobileRouteOpen(false); }} statuses={statuses} stops={stops} />
comment.stopId === activeStop.id).length} headSha={metadata.head.sha} onAsk={(prompt) => { setDrawerOpen(true); if (prompt) void askQuestion(prompt); }} onComment={() => openComment(false)} onEvidence={setActiveEvidenceId} onFindingComment={() => openComment(true)} onFlag={toggleFlag} onNavigate={navigateStop} onSelectLine={selectLine} onUnderstood={markUnderstood} selection={selection} status={statuses[activeStop.id] ?? "unseen"} stop={activeStop} totalStops={stops.length} />
:
- {view === "summary" ? { selectStop(activeIndex); setView("tour"); }} onBrowse={() => setView("browse")} onCheckUpdates={() => void checkForUpdates()} onComplete={() => void completeReview()} onGenerate={() => void previewGenerationContext()} onScope={selectScope} onSelectStop={(index) => { selectStop(index); setView("tour"); }} refreshing={refreshing} reviewScope={reviewScope} scopedEvidence={scopedEvidence} session={session} statuses={statuses} stops={stops} tourLoading={tourLoading} update={update} /> : view === "browse" ? setView("summary")} scope={reviewScope} session={session} /> : void allowPublicationRetry()} onBack={() => setView(generated ? "tour" : "summary")} onDisposition={(event) => { setDisposition(event); void persistReviewDraft(reviewSummary, event); }} onPublish={(acknowledged) => void publishReviewToGitHub(acknowledged)} onRemoveComment={(id) => void removeComment(id)} onSave={() => void persistReviewDraft()} onSummary={setReviewSummary} publication={publication} publishError={publicationError} publishing={publishingReview} saving={savingReview} statuses={statuses} stops={stops} submission={submission} summary={reviewSummary} />}
+ {view === "summary" ? { selectStop(activeIndex); setView("tour"); }} onBrowse={() => setView("browse")} onCheckUpdates={() => void checkForUpdates()} onComplete={() => void completeReview()} onGenerate={() => void previewGenerationContext()} onModel={() => setModelPickerOpen(true)} onScope={selectScope} onSelectStop={(index) => { selectStop(index); setView("tour"); }} refreshing={refreshing} reviewScope={reviewScope} scopedEvidence={scopedEvidence} session={session} statuses={statuses} stops={stops} tourLoading={tourLoading} update={update} /> : view === "browse" ? setView("summary")} scope={reviewScope} session={session} /> : void allowPublicationRetry()} onBack={() => setView(generated ? "tour" : "summary")} onDisposition={(event) => { setDisposition(event); void persistReviewDraft(reviewSummary, event); }} onPublish={(acknowledged) => void publishReviewToGitHub(acknowledged)} onRemoveComment={(id) => void removeComment(id)} onSave={() => void persistReviewDraft()} onSummary={setReviewSummary} publication={publication} publishError={publicationError} publishing={publishingReview} saving={savingReview} statuses={statuses} stops={stops} submission={submission} summary={reviewSummary} />}
}
{modelPickerOpen && setModelPickerOpen(false)} onSelection={setModelSelection} providers={providers} selection={modelSelection} />}
{contextPreviewOpen && contextManifest && activeProvider && setContextPreviewOpen(false)} onConfirm={() => void confirmGenerationContext()} onExclusions={setExclusionText} provider={activeProvider} saving={contextSaving} />}
@@ -798,7 +814,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
;
}
-function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completing, error, generated, generating, generationStatus, modelReady, notice, onBegin, onBrowse, onCheckUpdates, onComplete, onGenerate, onScope, onSelectStop, refreshing, reviewScope, scopedEvidence, session, statuses, stops, tourLoading, update }: {
+function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completing, error, generated, generating, generationFailed, generationStatus, modelReady, notice, onBegin, onBrowse, onCheckUpdates, onComplete, onGenerate, onModel, onScope, onSelectStop, refreshing, reviewScope, scopedEvidence, session, statuses, stops, tourLoading, update }: {
activeModel: string;
baselineCheckpoint: ReviewCheckpoint | null;
checkpoint: ReviewCheckpoint | null;
@@ -806,6 +822,7 @@ function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completi
error: string | null;
generated: GeneratedSessionTour | null;
generating: boolean;
+ generationFailed: boolean;
generationStatus: TourGenerationStatus | null;
modelReady: boolean;
notice: string | null;
@@ -814,6 +831,7 @@ function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completi
onCheckUpdates: () => void;
onComplete: () => void;
onGenerate: () => void;
+ onModel: () => void;
onScope: (scope: AcquiredScope) => void;
onSelectStop: (index: number) => void;
refreshing: boolean;
@@ -858,7 +876,7 @@ function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completi
> : <>
Evidence ready
The pull request is pinned and ready for a guided review
{tourLoading ? "Checking" : "Not analyzed"}{session.evidence.files.length} changed file{session.evidence.files.length === 1 ? "" : "s"} passed anchor validation.Generate a semantic route with {activeModel}, or inspect the diff directly.
{metadata.body && }
- {error && {error}
}
+ {error && {error}{generationFailed && }
}
{generating ? `Building ${generationStatus?.scope === "update" ? "update" : "guided"} tour` : modelReady ? activeModel : "Choose a configured model"}{generating && generationStatus?.state === "running" ? `${formatElapsedTime(generationStatus.elapsedMs)} elapsed${generationStatus.timeoutMs ? ` · ${formatElapsedTime(generationStatus.timeoutMs)} limit` : ""} · status checked every second` : "Analysis stays inside the local Wingdiff process"}
>}
{notice && {notice}
}
@@ -975,7 +993,7 @@ function ReviewApp({ onHome }: { onHome: () => void }) {
const [question, setQuestion] = useState("");
const [answering, setAnswering] = useState(false);
const [providers, setProviders] = useState(FALLBACK_PROVIDERS);
- const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION);
+ const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION, restoreModelSelection);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [theme, setTheme] = usePersistentState<"dark" | "light">("wingdiff:theme", "dark");
const [reviewSummary, setReviewSummary] = useState(
@@ -1002,15 +1020,7 @@ function ReviewApp({ onHome }: { onHome: () => void }) {
const controller = new AbortController();
fetchProviders(controller.signal).then((availableProviders) => {
setProviders(availableProviders);
- setModelSelection((current) => {
- const currentProvider = availableProviders.find((provider) => provider.id === current.provider);
- if (currentProvider?.configured) return current;
- const codex = availableProviders.find((provider) => provider.id === "codex" && provider.configured);
- const model = codex?.models.find((candidate) => candidate.id === DEFAULT_SELECTION.model) ?? codex?.models[0];
- return codex && model
- ? { provider: codex.id, model: model.id, reasoningEffort: model.defaultEffort }
- : current;
- });
+ setModelSelection((current) => preferredAvailableSelection(availableProviders, current));
}).catch(() => undefined);
return () => controller.abort();
}, []);
@@ -1501,14 +1511,6 @@ function updateNotebookEntry(
setEntries((current) => current.map((entry) => entry.id === id ? { ...entry, ...patch } : entry));
}
-function preferredAvailableSelection(providers: ProviderDefinition[], current: ModelSelection): ModelSelection {
- const currentProvider = providers.find((provider) => provider.id === current.provider);
- if (currentProvider?.configured && currentProvider.models.some((model) => model.id === current.model)) return current;
- const provider = providers.find((candidate) => candidate.configured);
- const model = provider?.models[0];
- return provider && model ? { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort } : current;
-}
-
function checkoutMessage(preparation: TargetPreparation): string {
const checkout = preparation.environment.checkout;
if (checkout.status === "matched") return "Current checkout matches";
@@ -1528,8 +1530,10 @@ function AuthorMarkdown({ fallback, source }: { fallback?: string; source: strin
return Formatting description…
}>;
}
-function usePersistentState(key: string, fallback: T) {
- const [value, setValue] = useState(() => { try { const stored = window.localStorage.getItem(key); return stored ? JSON.parse(stored) as T : fallback; } catch { return fallback; } });
+function restoreModelSelection(stored: ModelSelection) { return normalizeSelection(FALLBACK_PROVIDERS, stored); }
+
+function usePersistentState(key: string, fallback: T, restore: (stored: T) => T = (stored) => stored) {
+ const [value, setValue] = useState(() => { try { const stored = window.localStorage.getItem(key); return stored ? restore(JSON.parse(stored) as T) : fallback; } catch { return fallback; } });
useEffect(() => { try { window.localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage can be unavailable */ } }, [key, value]);
return [value, setValue] as const;
}
diff --git a/apps/web/src/ai.test.ts b/apps/web/src/ai.test.ts
index 3467514..e72d762 100644
--- a/apps/web/src/ai.test.ts
+++ b/apps/web/src/ai.test.ts
@@ -1,19 +1,60 @@
import { describe, expect, it } from "vitest";
-import { DEFAULT_SELECTION, FALLBACK_PROVIDERS, selectedModel } from "./ai";
+import { DEFAULT_SELECTION, FALLBACK_PROVIDERS, normalizeSelection, preferredAvailableSelection, selectedModel } from "./ai";
+import type { ModelSelection } from "./types";
describe("AI model selection", () => {
- it("defaults to the balanced model through the local Codex CLI", () => {
- expect(DEFAULT_SELECTION).toEqual({ provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" });
+ it("lets the local Codex CLI choose the account's supported default", () => {
+ expect(DEFAULT_SELECTION).toEqual({ provider: "codex", model: "codex-default", reasoningEffort: "medium" });
expect(selectedModel(FALLBACK_PROVIDERS, DEFAULT_SELECTION).badge).toBe("Recommended");
});
it("keeps CLI, API, and Claude options in the same catalog", () => {
expect(FALLBACK_PROVIDERS.map((provider) => provider.id)).toEqual(["codex", "openai", "anthropic"]);
- expect(FALLBACK_PROVIDERS.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toContain("gpt-5.3-codex");
+ expect(FALLBACK_PROVIDERS.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual(["codex-default"]);
+ expect(FALLBACK_PROVIDERS.find((provider) => provider.id === "openai")?.models.map((model) => model.id)).toContain("gpt-5.3-codex");
expect(FALLBACK_PROVIDERS.find((provider) => provider.id === "anthropic")?.models.map((model) => model.id)).toContain("claude-sonnet-4-6");
});
- it("resolves duplicate model ids within the selected provider", () => {
- expect(selectedModel(FALLBACK_PROVIDERS, { ...DEFAULT_SELECTION, provider: "openai" }).provider).toBe("openai");
+ it("resolves named API models within the selected provider", () => {
+ expect(selectedModel(FALLBACK_PROVIDERS, { provider: "openai", model: "gpt-6-sol", reasoningEffort: "medium" }).provider).toBe("openai");
+ });
+
+ it("migrates a saved named Codex selection to the account default", () => {
+ const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: provider.id === "codex" }));
+ expect(preferredAvailableSelection(providers, {
+ provider: "codex",
+ model: "gpt-6-sol",
+ reasoningEffort: "medium",
+ })).toEqual(DEFAULT_SELECTION);
+ });
+
+ it("migrates a saved named Codex selection even when no provider is configured", () => {
+ const saved: ModelSelection = { provider: "codex", model: "gpt-5.3-codex", reasoningEffort: "xhigh" };
+ expect(preferredAvailableSelection(FALLBACK_PROVIDERS, saved)).toEqual(DEFAULT_SELECTION);
+ expect(normalizeSelection(FALLBACK_PROVIDERS, saved)).toEqual(DEFAULT_SELECTION);
+ });
+
+ it("stays on the current provider when its saved model is no longer offered", () => {
+ const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: true }));
+ expect(preferredAvailableSelection(providers, { provider: "openai", model: "gpt-4-retired", reasoningEffort: "medium" }))
+ .toEqual({ provider: "openai", model: "gpt-6-sol", reasoningEffort: "medium" });
+ });
+
+ it("resets a reasoning effort the saved model does not support", () => {
+ const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: true }));
+ expect(preferredAvailableSelection(providers, { provider: "openai", model: "gpt-6-astra", reasoningEffort: "none" }))
+ .toEqual({ provider: "openai", model: "gpt-6-astra", reasoningEffort: "high" });
+ });
+
+ it("keeps a valid selection untouched and replaces an unreadable one", () => {
+ const saved: ModelSelection = { provider: "openai", model: "gpt-6-luna", reasoningEffort: "low" };
+ expect(normalizeSelection(FALLBACK_PROVIDERS, saved)).toBe(saved);
+ expect(normalizeSelection(FALLBACK_PROVIDERS, null as unknown as ModelSelection)).toEqual(DEFAULT_SELECTION);
+ });
+
+ it("moves to a configured provider when the saved one is not set up", () => {
+ const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: provider.id === "anthropic" }));
+ expect(preferredAvailableSelection(providers, DEFAULT_SELECTION))
+ .toEqual({ provider: "anthropic", model: "claude-sonnet-4-6", reasoningEffort: "medium" });
});
});
diff --git a/apps/web/src/ai.ts b/apps/web/src/ai.ts
index cee76d1..eb88dd4 100644
--- a/apps/web/src/ai.ts
+++ b/apps/web/src/ai.ts
@@ -8,10 +8,17 @@ import type {
export const DEFAULT_SELECTION: ModelSelection = {
provider: "codex",
- model: "gpt-6-sol",
+ model: "codex-default",
reasoningEffort: "medium",
};
+const OPENAI_MODELS: ModelDefinition[] = [
+ model("gpt-6-sol", "GPT-6 Sol", "OpenAI", "Balanced reasoning, latency, and cost for everyday code review.", "API", ["none", "low", "medium", "high", "xhigh", "max"], "medium"),
+ model("gpt-6-astra", "GPT-6 Astra", "OpenAI", "Highest capability for architectural and high-risk reviews.", "Deep review", ["low", "medium", "high", "xhigh", "max"], "high"),
+ model("gpt-6-luna", "GPT-6 Luna", "OpenAI", "Fast, cost-efficient investigation for routine questions.", "Fast", ["none", "low", "medium", "high", "xhigh", "max"], "low"),
+ model("gpt-5.3-codex", "GPT-5.3-Codex", "OpenAI API", "Deprecated Codex model available through direct API authentication only.", "Deprecated", ["low", "medium", "high", "xhigh"], "medium"),
+];
+
export const FALLBACK_PROVIDERS: ProviderDefinition[] = [
{
id: "codex",
@@ -20,7 +27,9 @@ export const FALLBACK_PROVIDERS: ProviderDefinition[] = [
transport: "cli",
setupCommand: "codex login",
setupDescription: "Install Codex CLI and sign in with your ChatGPT account.",
- models: openAIModels("codex", "Recommended"),
+ models: [
+ model("codex-default", "Account default", "Codex", "Let Codex choose the default model supported by your signed-in account.", "Recommended", ["low", "medium", "high"], "medium", "codex"),
+ ],
},
{
id: "openai",
@@ -29,7 +38,7 @@ export const FALLBACK_PROVIDERS: ProviderDefinition[] = [
transport: "api",
setupCommand: "OPENAI_API_KEY",
setupDescription: "Set an OpenAI API key in your shell or local .env file.",
- models: openAIModels("openai", "API"),
+ models: OPENAI_MODELS,
},
{
id: "anthropic",
@@ -151,13 +160,21 @@ export function selectedModel(providers: ProviderDefinition[], selection: ModelS
?? FALLBACK_PROVIDERS[0]!.models[0]!;
}
-function openAIModels(provider: "codex" | "openai", solBadge: string): ModelDefinition[] {
- return [
- model("gpt-6-sol", "GPT-6 Sol", "OpenAI", "Balanced reasoning, latency, and cost for everyday code review.", solBadge, ["none", "low", "medium", "high", "xhigh", "max"], "medium", provider),
- model("gpt-6-astra", "GPT-6 Astra", "OpenAI", "Highest capability for architectural and high-risk reviews.", "Deep review", ["low", "medium", "high", "xhigh", "max"], "high", provider),
- model("gpt-6-luna", "GPT-6 Luna", "OpenAI", "Fast, cost-efficient investigation for routine questions.", "Fast", ["none", "low", "medium", "high", "xhigh", "max"], "low", provider),
- model("gpt-5.3-codex", "GPT-5.3-Codex", "Codex", "Codex-tuned model for agentic coding and code investigation.", "Codex", ["low", "medium", "high", "xhigh"], "medium", provider),
- ];
+export function normalizeSelection(providers: ProviderDefinition[], current: ModelSelection): ModelSelection {
+ const provider = providers.find((candidate) => candidate.id === current?.provider);
+ if (!provider) return DEFAULT_SELECTION;
+ const model = provider.models.find((candidate) => candidate.id === current.model) ?? provider.models[0];
+ if (!model) return current;
+ if (model.id === current.model && model.reasoningEfforts.includes(current.reasoningEffort)) return current;
+ return { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort };
+}
+
+export function preferredAvailableSelection(providers: ProviderDefinition[], current: ModelSelection): ModelSelection {
+ const normalized = normalizeSelection(providers, current);
+ if (providers.some((provider) => provider.id === normalized.provider && provider.configured)) return normalized;
+ const provider = providers.find((candidate) => candidate.configured);
+ const model = provider?.models[0];
+ return provider && model ? { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort } : normalized;
}
function model(
diff --git a/apps/web/src/session.ts b/apps/web/src/session.ts
index a6e9b1b..98ee3e3 100644
--- a/apps/web/src/session.ts
+++ b/apps/web/src/session.ts
@@ -277,7 +277,7 @@ export async function startSessionTourGeneration(
signal,
});
const body = await response.json() as { generation?: TourGenerationStatus; error?: string };
- if (!response.ok || !body.generation) throw new Error(body.error ?? "Wingdiff could not start this guided tour.");
+ if (!response.ok || !body.generation) throw new TourGenerationFailedError(body.error ?? "Wingdiff could not start this guided tour.");
return body.generation;
}
@@ -288,6 +288,13 @@ export async function fetchTourGenerationStatus(id: string, signal?: AbortSignal
return body.generation;
}
+export class TourGenerationFailedError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "TourGenerationFailedError";
+ }
+}
+
export async function waitForSessionTourGeneration(
id: string,
initial: TourGenerationStatus,
@@ -298,7 +305,7 @@ export async function waitForSessionTourGeneration(
let generation = initial;
while (true) {
onStatus(generation);
- if (generation.state === "failed") throw new Error(generation.error ?? "Wingdiff could not generate this guided tour.");
+ if (generation.state === "failed") throw new TourGenerationFailedError(generation.error ?? "Wingdiff could not generate this guided tour.");
if (generation.state === "idle" || !generation.scope) throw new Error("Guided-tour generation is no longer running.");
if (generation.state === "succeeded") {
const tour = await fetchSessionTour(id, generation.scope, signal);
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 7a72701..91b03ac 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -111,7 +111,7 @@ code, kbd { font-family: var(--mono); }
.launcher-footer { display: flex; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 0 18px; color: var(--text-4); border-top: 1px solid var(--border-soft); font-size: 8px; }.launcher-footer span { display: flex; align-items: center; gap: 6px; }.launcher-footer code { font-size: 8px; }
.session-state { display: grid; min-height: 100%; place-content: center; justify-items: center; padding: 40px; text-align: center; background: radial-gradient(circle at 50% 40%, var(--accent-soft), transparent 28%), var(--canvas); }.session-state h1 { margin: 18px 0 8px; font-size: 26px; font-weight: 500; letter-spacing: -.035em; }.session-state p { max-width: 500px; margin: 0 0 20px; color: var(--text-3); font-size: 11px; line-height: 1.55; }
.acquired-shell { height: 100%; }.acquired-canvas { height: calc(100% - 62px); }.acquired-github-link { text-decoration: none; }.acquired-evidence-ready .summary-verdict { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 30%, var(--border)); background: var(--blue-soft); }.acquired-description { grid-template-columns: 1fr; }.check-badge.is-failed { color: var(--coral); border-color: color-mix(in srgb, var(--coral) 30%, var(--border)); background: var(--coral-soft); }.check-badge.is-failed strong { color: var(--coral); }
-.acquired-workspace { height: calc(100% - 62px); }.acquired-start-actions { display: flex; align-items: center; gap: 8px; }.acquired-generation-error { margin-top: 14px; justify-content: flex-start; }.acquired-prompt { display: grid; width: 100%; grid-template-columns: 23px 1fr; padding: 10px 7px; gap: 7px; color: var(--text-3); border: 0; border-bottom: 1px solid var(--border-soft); background: transparent; cursor: pointer; font: inherit; font-size: 9px; line-height: 1.45; text-align: left; }.acquired-prompt:hover { color: var(--text); background: var(--surface-2); }.acquired-prompt > span { color: var(--text-4); font-family: var(--mono); font-size: 7px; }
+.acquired-workspace { height: calc(100% - 62px); }.acquired-start-actions { display: flex; align-items: center; gap: 8px; }.acquired-generation-error { margin-top: 14px; justify-content: flex-start; }.acquired-generation-error > span { line-height: 1.45; }.acquired-generation-error .button { margin-left: auto; flex: 0 0 auto; }.acquired-prompt { display: grid; width: 100%; grid-template-columns: 23px 1fr; padding: 10px 7px; gap: 7px; color: var(--text-3); border: 0; border-bottom: 1px solid var(--border-soft); background: transparent; cursor: pointer; font: inherit; font-size: 9px; line-height: 1.45; text-align: left; }.acquired-prompt:hover { color: var(--text); background: var(--surface-2); }.acquired-prompt > span { color: var(--text-4); font-family: var(--mono); font-size: 7px; }
.acquired-scope { display: flex; width: fit-content; margin-top: 20px; padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: var(--canvas-soft); }.acquired-scope button { display: flex; min-height: 34px; align-items: center; padding: 0 11px; gap: 8px; color: var(--text-4); border: 0; border-radius: 6px; background: transparent; cursor: pointer; font-size: 9px; }.acquired-scope button.is-active { color: var(--text); background: var(--surface-3); box-shadow: inset 0 0 0 1px var(--border); }.acquired-scope b { display: grid; min-width: 18px; height: 18px; place-items: center; color: var(--text-3); border-radius: 5px; background: var(--surface); font-size: 7px; }
.checkpoint-card { display: flex; margin-top: 12px; align-items: center; justify-content: space-between; padding: 14px 16px; gap: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }.checkpoint-card > div { display: flex; min-width: 0; align-items: center; gap: 11px; }.checkpoint-card strong, .checkpoint-card div span { display: block; }.checkpoint-card strong { font-size: 10px; }.checkpoint-card div div > span { margin-top: 3px; color: var(--text-4); font-size: 8px; }.checkpoint-card .button { flex: 0 0 auto; }.review-notice { display: flex; margin-top: 12px; align-items: center; padding: 10px 12px; gap: 7px; color: var(--green); border: 1px solid color-mix(in srgb, var(--green) 25%, var(--border)); border-radius: 9px; background: var(--green-soft); font-size: 9px; }
diff --git a/e2e/reviewer-flow.spec.ts b/e2e/reviewer-flow.spec.ts
index 9678c49..513802d 100644
--- a/e2e/reviewer-flow.spec.ts
+++ b/e2e/reviewer-flow.spec.ts
@@ -9,7 +9,7 @@ test("reviews a real-session fixture from privacy preview through an anchored dr
const contextDialog = page.getByRole("dialog", { name: "Model context preview" });
await expect(contextDialog).toBeVisible();
- await expect(contextDialog.getByText("Codex CLI · GPT-6 Sol", { exact: true })).toBeVisible();
+ await expect(contextDialog.getByText("Codex CLI · Account default", { exact: true })).toBeVisible();
await expect(contextDialog.getByText("1 sent · 0 excluded")).toBeVisible();
await contextDialog.getByText("Exact context preview").click();
await expect(contextDialog.getByText("return redis.incr(key)")).toBeVisible();
@@ -156,7 +156,7 @@ async function mockReviewApi(page: Page, options: { failFirstPublish?: boolean }
return route.fulfill({
status: 200,
contentType: "text/event-stream",
- body: `data: ${JSON.stringify({ type: "delta", delta: "Redis INCR is atomic for concurrent callers." })}\n\ndata: ${JSON.stringify({ type: "done", provider: "codex", model: "gpt-6-sol" })}\n\n`,
+ body: `data: ${JSON.stringify({ type: "delta", delta: "Redis INCR is atomic for concurrent callers." })}\n\ndata: ${JSON.stringify({ type: "done", provider: "codex", model: "codex-default" })}\n\n`,
});
}
return json(route, { error: `Unhandled test route: ${method} ${path}` }, 500);
@@ -186,7 +186,7 @@ const session = {
status: "ready", createdAt: now, updatedAt: now,
};
const tour = {
- sessionId: session.id, scope: "full", selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" }, baseSha, headSha, createdAt: now, updatedAt: now,
+ sessionId: session.id, scope: "full", selection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" }, baseSha, headSha, createdAt: now, updatedAt: now,
tour: { summary: "The counter now uses one atomic Redis operation.", findingRevisions: [], stops: [{ id: "atomic-counter", title: "Counter updates become atomic", summary: "One Redis operation replaces the read/write pair.", purpose: "Verify concurrent behavior.", anchorIds: ["file-counter", "line_new-counter"], claims: [{ text: "The new path calls Redis INCR.", kind: "fact", confidence: "high", anchorIds: ["line_new-counter"] }], prompts: ["Can concurrent callers lose increments?"] }] },
anchors: [{ id: "file-counter", path: "src/counter.ts", kind: "file" }, ...diffLines.map((line) => ({ id: `line_${line.fingerprint}`, path: "src/counter.ts", kind: line.kind, content: line.content, ...(line.oldLine ? { oldLine: line.oldLine } : {}), ...(line.newLine ? { newLine: line.newLine } : {}) }))],
};
@@ -199,5 +199,5 @@ const manifest = {
};
const providers = [{
id: "codex", name: "Codex CLI", configured: true, transport: "cli", setupCommand: "codex login", setupDescription: "Sign in.",
- models: [{ id: "gpt-6-sol", provider: "codex", name: "GPT-6 Sol", family: "OpenAI", description: "Balanced review.", badge: "Recommended", reasoningEfforts: ["medium"], defaultEffort: "medium" }],
+ models: [{ id: "codex-default", provider: "codex", name: "Account default", family: "Codex", description: "Uses the signed-in account default.", badge: "Recommended", reasoningEfforts: ["medium"], defaultEffort: "medium" }],
}];
From 28e3810c8a447414ab6ea4c86a555324e059e7e9 Mon Sep 17 00:00:00 2001
From: Cody Hart <157865967+HartBrook@users.noreply.github.com>
Date: Wed, 7 Oct 2026 15:27:08 -0400
Subject: [PATCH 2/3] fix: harden guided review recovery
---
apps/server/src/providers/tourPrompt.test.ts | 3 +++
apps/server/src/providers/tourPrompt.ts | 23 +++++++++++++++++++-
apps/web/src/App.tsx | 9 ++++----
apps/web/src/session.test.ts | 7 ++++++
apps/web/src/session.ts | 7 ++++++
5 files changed, 44 insertions(+), 5 deletions(-)
diff --git a/apps/server/src/providers/tourPrompt.test.ts b/apps/server/src/providers/tourPrompt.test.ts
index 71d07fe..70aaa81 100644
--- a/apps/server/src/providers/tourPrompt.test.ts
+++ b/apps/server/src/providers/tourPrompt.test.ts
@@ -30,6 +30,9 @@ describe("tour generation prompt", () => {
expect(prompt).toContain("");
expect(prompt).toContain("");
expect(prompt).toContain("a2\tnew:8");
+ expect(prompt).toContain("");
+ expect(prompt).toContain("- src/store.ts: include at least one of [a2] in a stop.anchorIds array");
+ expect(prompt).toContain("verify that every required-coverage row is represented");
expect(prompt).not.toContain("line_def");
expect(TOUR_INSTRUCTIONS).toContain("untrusted data");
expect(TOUR_INSTRUCTIONS).toContain("Avoid introductions, conclusions, praise, filler");
diff --git a/apps/server/src/providers/tourPrompt.ts b/apps/server/src/providers/tourPrompt.ts
index 526cb95..3afec32 100644
--- a/apps/server/src/providers/tourPrompt.ts
+++ b/apps/server/src/providers/tourPrompt.ts
@@ -106,7 +106,28 @@ export function tourJsonSchema(input: TourGenerationInput) {
}
export function buildTourRequestPrompt(input: TourGenerationInput): string {
- return `${buildTourPrompt(input)}\n\nCreate the guided review tour. Use null for finding when the evidence does not support a concrete issue.`;
+ return `${buildTourPrompt(input)}\n\n${coverageChecklist(input)}\n\nCreate the guided review tour. Before returning JSON, verify that every required-coverage row is represented in at least one stop.anchorIds array. Use null for finding when the evidence does not support a concrete issue.`;
+}
+
+function coverageChecklist(input: TourGenerationInput): string {
+ const anchorsByPath = new Map();
+ for (const anchor of input.anchors) {
+ const current = anchorsByPath.get(anchor.path) ?? [];
+ current.push(anchor);
+ anchorsByPath.set(anchor.path, current);
+ }
+
+ const rows = input.fileAnchorIds.map((fileAnchorId) => {
+ const file = input.anchors.find((anchor) => anchor.id === fileAnchorId);
+ if (!file) throw new Error(`File anchor ${fileAnchorId} is missing from the tour input.`);
+ const changedLineIds = (anchorsByPath.get(file.path) ?? [])
+ .filter((anchor) => anchor.kind === "addition" || anchor.kind === "deletion")
+ .map((anchor) => anchor.id);
+ const required = changedLineIds.length ? changedLineIds : [fileAnchorId];
+ return `- ${file.path}: include at least one of [${required.join(", ")}] in a stop.anchorIds array`;
+ });
+
+ return `\n${rows.join("\n")}\n`;
}
export function parseStructuredJson(text: string): unknown {
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index a41cff2..1b2b76b 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -59,6 +59,7 @@ import {
startSessionTourGeneration,
updateInvestigationEntry,
TourGenerationFailedError,
+ generationErrorNeedsModelSetup,
waitForSessionTourGeneration,
type AcquiredReviewSession,
type FindingCheckpoint,
@@ -388,9 +389,9 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
setGenerationFailed(false);
}
- function setGenerationError(message: string) {
+ function setGenerationError(message: string, modelSetupRecommended = generationErrorNeedsModelSetup(message)) {
setErrorMessage(message);
- setGenerationFailed(true);
+ setGenerationFailed(modelSetupRecommended);
}
async function generateTour() {
@@ -405,7 +406,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
setGenerationStatus(status);
void monitorTourGeneration(status);
} catch (caught) {
- if (caught instanceof TourGenerationFailedError) setGenerationError(caught.message);
+ if (caught instanceof TourGenerationFailedError) setGenerationError(caught.message, caught.modelSetupRecommended);
else setError(`Wingdiff could not start this guided tour: ${caught instanceof Error ? caught.message : "Unknown error"}`);
} finally {
setGenerationStarting(false);
@@ -431,7 +432,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
} catch (caught) {
if (caught instanceof DOMException && caught.name === "AbortError") return;
const detail = caught instanceof Error || caught instanceof DOMException ? caught.message : String(caught ?? "Unknown error");
- if (caught instanceof TourGenerationFailedError) setGenerationError(detail);
+ if (caught instanceof TourGenerationFailedError) setGenerationError(detail, caught.modelSetupRecommended);
else setError(`Wingdiff could not generate this guided tour: ${detail}`);
} finally {
if (generationAbort.current === controller) generationAbort.current = null;
diff --git a/apps/web/src/session.test.ts b/apps/web/src/session.test.ts
index 7631fe5..1f54025 100644
--- a/apps/web/src/session.test.ts
+++ b/apps/web/src/session.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
evidenceBlocks,
formatElapsedTime,
+ generationErrorNeedsModelSetup,
generatedTourStops,
revisionStopIndex,
type AcquiredReviewSession,
@@ -9,6 +10,12 @@ import {
} from "./session";
describe("acquired session evidence", () => {
+ it("recommends model setup only for provider and authentication failures", () => {
+ expect(generationErrorNeedsModelSetup("Codex authentication is missing or expired. Run `codex login`, then restart Wingdiff.")).toBe(true);
+ expect(generationErrorNeedsModelSetup("Codex's account-default model is not available with this ChatGPT account.")).toBe(true);
+ expect(generationErrorNeedsModelSetup("Generated tour does not cite changed lines in src/counter.test.ts.")).toBe(false);
+ });
+
it("formats generation elapsed time for polling feedback", () => {
expect(formatElapsedTime(999)).toBe("0s");
expect(formatElapsedTime(65_900)).toBe("1m 5s");
diff --git a/apps/web/src/session.ts b/apps/web/src/session.ts
index 98ee3e3..13f3e17 100644
--- a/apps/web/src/session.ts
+++ b/apps/web/src/session.ts
@@ -289,12 +289,19 @@ export async function fetchTourGenerationStatus(id: string, signal?: AbortSignal
}
export class TourGenerationFailedError extends Error {
+ readonly modelSetupRecommended: boolean;
+
constructor(message: string) {
super(message);
this.name = "TourGenerationFailedError";
+ this.modelSetupRecommended = generationErrorNeedsModelSetup(message);
}
}
+export function generationErrorNeedsModelSetup(message: string): boolean {
+ return /(?:authentication is (?:missing|expired)|not (?:configured|available) with (?:this|the current) (?:ChatGPT account|Codex sign-in)|not supported when using Codex with a ChatGPT account|\bcodex login\b|\b(?:OPENAI|ANTHROPIC)_API_KEY\b)/i.test(message);
+}
+
export async function waitForSessionTourGeneration(
id: string,
initial: TourGenerationStatus,
From 192e12e798cc992821c8dc4e79dc8394b935ab38 Mon Sep 17 00:00:00 2001
From: Cody Hart <157865967+HartBrook@users.noreply.github.com>
Date: Thu, 8 Oct 2026 10:59:48 -0400
Subject: [PATCH 3/3] test: add opt-in live review smoke
---
README.md | 24 ++++++
e2e-live/reviewer-live.spec.ts | 134 +++++++++++++++++++++++++++++++++
package.json | 2 +
playwright.live.config.ts | 18 +++++
4 files changed, 178 insertions(+)
create mode 100644 e2e-live/reviewer-live.spec.ts
create mode 100644 playwright.live.config.ts
diff --git a/README.md b/README.md
index 12162fb..f4d965d 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,30 @@ npm run test:e2e
The tests enforce the product's grounding contract: claims must resolve to real evidence, generated tours must cover every included changed file, diff ranges must be internally consistent, revision coverage must partition the full fixture tour, findings must remain attached to known stops, and staged comments must match the pinned GitHub diff side and line fingerprint. The browser suite drives a mocked real-PR session through context approval, generation, investigation, visible-code comment drafting, persistence, and navigation home.
+### Local live smoke tests
+
+The opt-in live suite exercises the production build with your existing `gh` and
+`codex` logins. It is separate from `npm run test:e2e`, refuses to run in CI,
+uses an isolated temporary database, and never opens the publish flow.
+
+```bash
+# Fast GitHub acquisition and rendering check; infers the PR for this branch.
+npm run test:e2e:live:acquire
+
+# Full acquisition plus real Codex guided-review generation.
+npm run test:e2e:live
+
+# Target another PR or checkout explicitly.
+WINGDIFF_LIVE_PR=https://github.com/owner/repository/pull/123 npm run test:e2e:live
+WINGDIFF_LIVE_CHECKOUT=/path/to/checkout WINGDIFF_LIVE_PR=owner/repository#123 npm run test:e2e:live
+```
+
+Run `gh auth status --hostname github.com` and `codex login status` if the
+preflight fails. Set `WINGDIFF_LIVE_KEEP_DATA=1` to retain the temporary
+database for debugging; otherwise it is deleted even after a failure. Failure
+traces and screenshots stay under `test-results/live/` and may contain source
+from the tested pull request, so keep those artifacts local.
+
## Keyboard shortcuts
| Key | Action |
diff --git a/e2e-live/reviewer-live.spec.ts b/e2e-live/reviewer-live.spec.ts
new file mode 100644
index 0000000..806d465
--- /dev/null
+++ b/e2e-live/reviewer-live.spec.ts
@@ -0,0 +1,134 @@
+import { execFile as execFileCallback } from "node:child_process";
+import type { Server } from "node:http";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { expect, test, type Page } from "@playwright/test";
+
+const execFile = promisify(execFileCallback);
+const checkout = path.resolve(process.env.WINGDIFF_LIVE_CHECKOUT ?? process.cwd());
+
+let dataDirectory = "";
+let target = "";
+let sessionId = "";
+let running: { server: Server; url: string; authToken: string } | undefined;
+
+test.describe.serial("local live review", () => {
+ test.beforeAll(async () => {
+ if (process.env.CI) throw new Error("Live account tests are local-only and must never run in CI.");
+ await requireLogin("gh", ["auth", "status", "--hostname", "github.com"], "GitHub CLI is not authenticated. Run: gh auth login");
+
+ target = process.env.WINGDIFF_LIVE_PR?.trim() || await currentPullRequest();
+ dataDirectory = await mkdtemp(path.join(tmpdir(), "wingdiff-live-e2e-"));
+
+ const { loadWingdiffEnvironment } = await import("../apps/server/dist/environment.js");
+ const { startWingdiffServer } = await import("../apps/server/dist/server.js");
+ loadWingdiffEnvironment();
+ const environment = {
+ ...process.env,
+ NODE_ENV: "production",
+ WINGDIFF_DATA_DIR: dataDirectory,
+ WINGDIFF_PORT: "0",
+ };
+ running = await startWingdiffServer({ cwd: checkout, development: false, environment, port: 0 });
+ });
+
+ test.afterAll(async () => {
+ if (running) await closeServer(running.server);
+ if (!dataDirectory) return;
+ if (process.env.WINGDIFF_LIVE_KEEP_DATA === "1") {
+ process.stdout.write(`Kept live-test data at ${dataDirectory}\n`);
+ return;
+ }
+ await rm(dataDirectory, { recursive: true, force: true });
+ });
+
+ test("@live-acquire acquires and renders the current pull request", async ({ page }) => {
+ test.setTimeout(3 * 60_000);
+ await openReview(page);
+
+ await expect(page.locator(".model-button")).toContainText("Codex CLI");
+ await expect(page.locator(".model-button")).toContainText("Account default");
+ await expect(page.getByRole("button", { name: "Generate guided review" })).toBeVisible();
+ await expect(page.getByText(/changed files? passed anchor validation/i)).toBeVisible();
+ });
+
+ test("@live-generate generates and opens a grounded guided review", async ({ page }) => {
+ test.setTimeout(12 * 60_000);
+ await requireLogin(process.env.WINGDIFF_CODEX_BIN || "codex", ["login", "status"], "Codex CLI is not authenticated. Run: codex login");
+ await openReview(page);
+
+ await page.getByRole("button", { name: "Generate guided review" }).click();
+ const context = page.getByRole("dialog", { name: "Model context preview" });
+ await expect(context).toContainText("Codex CLI · Account default");
+ await expect(context).toContainText(/\d+ sent · \d+ excluded/i);
+ await context.getByRole("button", { name: "Generate with this context" }).click();
+
+ await waitForButtonOrError(page, "Start review", 11 * 60_000);
+ await expect(page.getByText(/\d+ review stops?/i)).toBeVisible();
+ await page.getByRole("button", { name: "Start review" }).click();
+ await expect(page.locator(".stop-header h1")).toBeVisible();
+ await expect(page.getByRole("navigation", { name: "Review route" })).toBeVisible();
+ });
+});
+
+async function openReview(page: Page) {
+ if (sessionId) {
+ await page.goto(authorizedUrl({ session: sessionId }));
+ await waitForButtonOrError(page, "Generate guided review", 60_000);
+ return;
+ }
+
+ await page.goto(authorizedUrl({ target }));
+ await waitForButtonOrError(page, "Start review", 60_000);
+ await page.getByRole("button", { name: "Start review" }).click();
+ await waitForButtonOrError(page, "Generate guided review", 2 * 60_000);
+ sessionId = new URL(page.url()).searchParams.get("session") ?? "";
+ if (!sessionId) throw new Error("Wingdiff acquired the pull request without opening a persisted review session.");
+}
+
+function authorizedUrl(parameters: { target?: string; session?: string }) {
+ if (!running) throw new Error("The live Wingdiff server did not start.");
+ const url = new URL(running.url);
+ if (parameters.target) url.searchParams.set("target", parameters.target);
+ if (parameters.session) url.searchParams.set("session", parameters.session);
+ url.searchParams.set("wingdiff_token", running.authToken);
+ return url.toString();
+}
+
+async function waitForButtonOrError(page: Page, buttonName: string, timeout: number) {
+ const outcome = await Promise.race([
+ page.getByRole("button", { name: buttonName, exact: true }).waitFor({ state: "visible", timeout }).then(() => ({ kind: "ready" as const })),
+ page.getByRole("alert").first().waitFor({ state: "visible", timeout }).then(async () => ({
+ kind: "error" as const,
+ message: (await page.getByRole("alert").first().innerText()).trim(),
+ })),
+ ]);
+ if (outcome.kind === "error") throw new Error(`Wingdiff live flow failed: ${outcome.message}`);
+}
+
+async function requireLogin(command: string, arguments_: string[], setupMessage: string) {
+ try {
+ await execFile(command, arguments_, { cwd: checkout, timeout: 10_000 });
+ } catch {
+ throw new Error(setupMessage);
+ }
+}
+
+async function currentPullRequest() {
+ try {
+ const { stdout } = await execFile("gh", ["pr", "view", "--json", "url", "--jq", ".url"], { cwd: checkout, timeout: 30_000 });
+ const url = stdout.trim();
+ if (url) return url;
+ } catch {
+ // Fall through to the actionable error below.
+ }
+ throw new Error("No pull request is associated with the current branch. Set WINGDIFF_LIVE_PR to a pull request URL.");
+}
+
+function closeServer(server: Server) {
+ return new Promise((resolve, reject) => {
+ server.close((error) => error ? reject(error) : resolve());
+ });
+}
diff --git a/package.json b/package.json
index c9176e3..56073a1 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,8 @@
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"test:e2e": "playwright test",
+ "test:e2e:live": "npm run build && playwright test --config playwright.live.config.ts",
+ "test:e2e:live:acquire": "npm run build && playwright test --config playwright.live.config.ts --grep @live-acquire",
"test:package": "node scripts/smoke-package.mjs",
"prepack": "npm run build"
},
diff --git a/playwright.live.config.ts b/playwright.live.config.ts
new file mode 100644
index 0000000..bc6952f
--- /dev/null
+++ b/playwright.live.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e-live",
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ timeout: 12 * 60_000,
+ reporter: "line",
+ outputDir: "test-results/live",
+ use: {
+ ...devices["Desktop Chrome"],
+ actionTimeout: 30_000,
+ navigationTimeout: 60_000,
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ },
+});