diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 1fd2e4bcab..2c20e18776 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -77,6 +77,11 @@ function AgentDefaultsSection({ initialDraftRef.current?.isCustomModelEditing ?? false, ); const [bakedEnv, setBakedEnv] = React.useState([]); + const [configLoadError, setConfigLoadError] = React.useState(false); + const [configLoadNonce, retryConfigLoad] = React.useReducer( + (nonce: number) => nonce + 1, + 0, + ); const configRef = React.useRef( initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, ); @@ -86,7 +91,9 @@ function AgentDefaultsSection({ React.useEffect(() => { let unmounted = false; - async function loadDefaults() { + async function loadDefaults(_retryAttempt: number) { + setIsLoading(true); + setConfigLoadError(false); const [configResult, bakedEnvResult] = await Promise.allSettled([ getGlobalAgentConfig(), getBakedBuildEnv(), @@ -94,25 +101,27 @@ function AgentDefaultsSection({ if (unmounted) return; - if ( - initialDraftRef.current === null && - configResult.status === "fulfilled" - ) { - configRef.current = configResult.value; - setConfig(configResult.value); - } if (bakedEnvResult.status === "fulfilled") { setBakedEnv(bakedEnvResult.value); } + if (configResult.status === "rejected") { + setConfigLoadError(true); + setIsLoading(false); + return; + } + if (initialDraftRef.current === null) { + configRef.current = configResult.value; + setConfig(configResult.value); + } setIsLoading(false); } - void loadDefaults(); + void loadDefaults(configLoadNonce); return () => { unmounted = true; }; - }, []); + }, [configLoadNonce]); const effectiveReadyRuntimeIds = React.useMemo( () => @@ -147,6 +156,7 @@ function AgentDefaultsSection({ const configSurfaceLoading = isLoading || runtimesQuery.isLoading; const configSurfaceError = + configLoadError || runtimesQuery.isError || (!configSurfaceLoading && effectiveReadyRuntimeIds.length > 0 && @@ -212,12 +222,14 @@ function AgentDefaultsSection({ // configIsValid comes from AgentConfigFields' onValidityChange and // covers model + provider credentials — a harness selection alone is // not a working default (e.g. buzz-agent with no provider configured). - canComplete: selectedRuntimeId.length > 0 && configIsValid, + canComplete: + !configLoadError && selectedRuntimeId.length > 0 && configIsValid, commit: commitPersistence, }); }, [ commitPersistence, configIsValid, + configLoadError, onPersistenceStateChange, selectedRuntimeId, ]); @@ -233,6 +245,20 @@ function AgentDefaultsSection({ Loading… + ) : configLoadError ? ( +
+

+ Couldn't load your existing default harness. Try again. +

+ +
) : configSurfaceError ? (

Couldn't load harness settings. Go back and try again. diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 2658850c75..5216bfefa4 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -62,8 +62,7 @@ export type ProfileStepActions = { export type SetupStepActions = { back: () => void; - next: (runtimeId: string) => void; - skip: () => void; + next: (readyRuntimeIds: readonly string[]) => void; navigateToAgentSettings?: () => void; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index af17af4f75..85f46f25d5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -537,6 +537,8 @@ type E2eConfig = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; /** Explicit owner-only agent-access capability; independent of baked defaults. */ ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ @@ -13153,6 +13155,8 @@ export function maybeInstallE2eTauriMocks() { return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null; } case "get_global_agent_config": { + const readError = activeConfig?.mock?.globalAgentConfigErrors?.shift(); + if (readError) throw new Error(readError); // Return the mutable persisted mock value, seeded from the test config. return ( mockGlobalAgentConfig ?? { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3a15d21767..293e7e7362 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -491,6 +491,102 @@ test("defaults renders only fields supported by the selected harness", async ({ ).toHaveCount(0); }); +test("failed config read blocks Next, preserves existing config, and retry succeeds", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + provider: "anthropic", + model: "claude-sonnet-4", + preferred_runtime: "claude", + }, + globalAgentConfigErrors: ["temporary config read failure", null], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + await expect( + page.getByText("Couldn't load your existing default harness. Try again."), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await page.getByTestId("onboarding-finish").click({ force: true }); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + let saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + model: "claude-sonnet-4", + preferred_runtime: "claude", + provider: "anthropic", + }); + + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + await page.getByTestId("onboarding-finish").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + + saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + }, + model: null, + preferred_runtime: "codex", + provider: null, + }); + expect(saved?.env_vars).not.toHaveProperty("BUZZ_AGENT_THINKING_EFFORT"); +}); + test("defaults hides model when optional harness has empty discovery", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b176..493017582d 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -497,6 +497,8 @@ type MockBridgeOptions = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record<