diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 92159ff2754..ea35e517549 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -68,7 +68,7 @@ test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => { }); }); -test("Goose exposes provider, model, and its real effort application key", () => { +test("Goose omits effort until native persistence and options are wired", () => { const model = deriveAgentConfigFieldModel({ config, runtime: runtime("goose", { @@ -80,17 +80,12 @@ test("Goose exposes provider, model, and its real effort application key", () => }); assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", + model.fields.some((item) => item.kind === "effort"), + false, ); - assert.deepEqual(field(model, "effort").currentPersistence, { - kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", - }); - assert.deepEqual(field(model, "effort").targetApplication, { - kind: "envVar", - key: "GOOSE_THINKING_EFFORT", - }); + assert.deepEqual(model.omissions, [ + { kind: "effort", reason: "pendingNativePersistence" }, + ]); }); test("Claude models effort as a deferred native ACP option", () => { diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5a8b8cb1c37..a004f0d5774 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -92,7 +92,10 @@ export type AgentConfigFieldDescriptor = export type AgentConfigOmission = { kind: "effort"; - reason: "ownedByModelId" | "unsupportedByHarness"; + reason: + | "ownedByModelId" + | "pendingNativePersistence" + | "unsupportedByHarness"; }; /** @@ -203,13 +206,10 @@ export function deriveAgentConfigFieldModel({ value: config.model, }); - if (runtime?.thinkingEnvVar) { + if (runtime?.id === "buzz-agent" && runtime.thinkingEnvVar) { fields.push({ kind: "effort", - optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + optionSource: "buzzAgentCatalog", currentPersistence: { kind: "envVar", key: BUZZ_AGENT_THINKING_EFFORT, @@ -218,6 +218,8 @@ export function deriveAgentConfigFieldModel({ render: "control", value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), }); + } else if (runtime?.id === "goose" && runtime.thinkingEnvVar) { + omissions.push({ kind: "effort", reason: "pendingNativePersistence" }); } else if (runtime?.id === "claude") { fields.push({ kind: "effort", diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 40b33282566..b119407365d 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -58,6 +58,28 @@ export function getBakedModelInheritLabel(bakedModelId: string): string { return `Inherit build default (${bakedModelId})`; } +/** Resolve baked provider/model values only from this runtime's catalog keys. */ +export function getRuntimeBakedDefaults( + bakedEnv: readonly BakedEnvEntry[], + runtime: + | { id: string; modelEnvVar: string | null; providerEnvVar: string | null } + | undefined, + globalEnv: Readonly>, +): { model: string | null; provider: string | null } { + const provider = runtime?.providerEnvVar + ? resolveInheritedDefault(null, bakedEnv, runtime.providerEnvVar).value + : ""; + const directModel = runtime?.modelEnvVar + ? resolveInheritedDefault(null, bakedEnv, runtime.modelEnvVar).value + : ""; + const model = + directModel || + (runtime?.id === "buzz-agent" + ? getGlobalModelFallback(bakedEnv, provider, globalEnv) + : null); + return { model: model || null, provider: provider || null }; +} + function providerModelEnvKey(provider: string): string | null { switch (provider.trim().toLowerCase()) { case "databricks": diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 1fd2e4bcabd..2c20e187764 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/HarnessChoiceCard.tsx b/desktop/src/features/onboarding/ui/HarnessChoiceCard.tsx new file mode 100644 index 00000000000..8b484858117 --- /dev/null +++ b/desktop/src/features/onboarding/ui/HarnessChoiceCard.tsx @@ -0,0 +1,53 @@ +import type * as React from "react"; + +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Card, type CardProps } from "@/shared/ui/card"; +import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon"; + +/** + * Setup-page width that mirrors one column of the untouched chooser grid: + * the onboarding frame is min(viewport - page padding, 65rem), with three + * 1rem gaps divided across four columns. Do not apply this to the chooser — + * its grid remains the source of truth. + */ +export const HARNESS_SETUP_CARD_WIDTH_CLASS = + "w-full max-w-[288px] md:w-[calc((min(100vw-2rem,65rem)-3rem)/4)]"; + +type HarnessChoiceCardProps = Omit & { + runtime: AcpRuntimeCatalogEntry; +}; + +/** + * The single visual for an onboarding harness card — icon + name on the + * textured surface. The chooser renders it interactive; the setup page + * renders it static. Both pages keep identical size and content. + */ +export function HarnessChoiceCard({ + className, + runtime, + ...cardProps +}: HarnessChoiceCardProps): React.JSX.Element { + return ( + +

+ +

+ {getRuntimeDisplayLabel(runtime)} +

+
+ + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index af17af4f758..85f46f25d5f 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/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index c5b441ad21d..37b24727b85 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -676,7 +676,7 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a // Now on the setup page. await expect( - page.getByRole("heading", { name: "Set up your agent harnesses" }), + page.getByRole("heading", { name: "Choose your default harness" }), ).toBeVisible({ timeout: 10_000 }); // Click the "More harnesses" link — fires navigateToAgentSettings. diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3a15d21767d..293e7e7362c 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/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 1a426438ea9..a9a983c4877 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -127,7 +127,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); await page.getByTestId("onboarding-next").click(); await expect( - page.getByRole("heading", { name: "Set up your agent harnesses" }), + page.getByRole("heading", { name: "Choose your default harness" }), ).toBeVisible(); await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index b085e7dab69..c6afa12690b 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1587,6 +1587,12 @@ test("first-community shows the scenario cards for localhost", async ({ await installMockBridge( page, { + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "claude", + }, acpRuntimesCatalog: [ { id: "claude", @@ -1650,13 +1656,9 @@ test("first-community shows the scenario cards for localhost", async ({ await page.getByTestId("welcome-setup-back").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); await expect( - page.getByRole("heading", { - name: "Configure your default model settings", - }), + page.getByRole("heading", { name: "Set up Claude Code" }), ).toBeVisible(); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Claude Code", - ); + await expect(page.getByText(/Claude Code is ready/i)).toBeVisible(); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b1767..493017582d9 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<