diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..06e6c02acb 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -67,7 +67,11 @@ with a TypeScript lookup table or an id comparison in a component. 7. **Onboarding setup detects readiness; it does not select defaults.** The setup page derives visible and ready harnesses from the runtime catalog and only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`. + sole onboarding surface that chooses and persists `preferred_runtime`, and + its Finish gate consumes the shared renderer's `onValidityChange` signal — + a harness selection alone does not complete onboarding when the harness + requires provider/model/credential config (e.g. buzz-agent with no + provider). Baked build env and runtime-file config satisfy the gate. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 90f8b22d62..50887f08aa 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -68,6 +68,7 @@ function AgentDefaultsSection({ cancel: () => void; } | null>(null); const [isSaving, setIsSaving] = React.useState(false); + const [configIsValid, setConfigIsValid] = React.useState(false); React.useEffect(() => { let unmounted = false; @@ -187,10 +188,19 @@ function AgentDefaultsSection({ ); React.useEffect(() => { onPersistenceStateChange({ - canComplete: selectedRuntimeId.length > 0 && !isSaving, + // 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 && !isSaving, flush: flushPersistence, }); - }, [flushPersistence, isSaving, onPersistenceStateChange, selectedRuntimeId]); + }, [ + configIsValid, + flushPersistence, + isSaving, + onPersistenceStateChange, + selectedRuntimeId, + ]); return (
@@ -239,6 +249,7 @@ function AgentDefaultsSection({ }} onCustomModelEditingChange={setIsCustomModelEditing} onIsCustomProviderChange={setIsCustomProvider} + onValidityChange={setConfigIsValid} placeholderClassName="text-foreground/70" runtimeFileConfig={runtimeFileConfig} selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95" diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 288ca30259..cad680c48d 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -615,7 +615,7 @@ function RuntimeProvidersSection({
{orderedItems.length > 0 ? ( -
+
{orderedItems.map((runtime) => ( { +test("all bundled harnesses are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); - assert.equal(runtimeIsVisibleInOnboarding("goose"), false); - assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); + assert.equal(runtimeIsVisibleInOnboarding("goose"), true); + assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), true); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); }); @@ -30,7 +30,7 @@ test("visible onboarding runtimes use the product order", () => { assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["claude", "codex", "goose", "buzz-agent"], ); }); @@ -55,16 +55,17 @@ test("readiness requires an available and authenticated runtime", () => { ); }); -test("ready onboarding runtimes exclude hidden ready harnesses", () => { +test("ready onboarding runtimes exclude unknown and non-ready harnesses", () => { const runtimes = [ runtime("goose", "available", "not_applicable"), runtime("codex", "available", "logged_out"), runtime("buzz-agent", "available", "not_applicable"), runtime("claude", "available", "logged_in"), + runtime("custom", "available", "not_applicable"), ]; assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude"], + ["claude", "goose", "buzz-agent"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..cd491dfcc5 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,11 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = [ + "claude", + "codex", + "goose", + "buzz-agent", +]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index d007bd6118..1c3e86f133 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -57,9 +57,7 @@ async function readSavedRuntime(page: Parameters[0]) { }); } -test("setup shows only Claude Code and Codex as detected harnesses", async ({ - page, -}) => { +test("setup shows all bundled harnesses as detected", async ({ page }) => { await installMockBridge( page, { @@ -77,10 +75,8 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible(); - await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0); - await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toHaveCount( - 0, - ); + await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible(); + await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toBeVisible(); await expect(page.getByRole("checkbox")).toHaveCount(0); }); @@ -559,8 +555,8 @@ test("defaults auto-selects the only ready visible harness", async ({ page, { acpRuntimesCatalog: [ - runtime("buzz-agent", "available", { status: "not_applicable" }), - runtime("goose", "available", { status: "not_applicable" }), + runtime("buzz-agent", "not_installed", { status: "not_applicable" }), + runtime("goose", "not_installed", { status: "not_applicable" }), runtime("claude", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_out" }), ], @@ -660,10 +656,10 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy ).toBeVisible(); await expect( page.getByTestId("global-agent-default-harness-option-goose"), - ).toHaveCount(0); + ).toBeVisible(); await expect( page.getByTestId("global-agent-default-harness-option-buzz-agent"), - ).toHaveCount(0); + ).toBeVisible(); await page.getByTestId("global-agent-default-harness-option-codex").click(); await expect(harness).toHaveText("Codex"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); @@ -836,3 +832,89 @@ test("concurrent installs each keep their own state — one fails, one succeeds" }); expect(hasHorizontalOverflow).toBe(false); }); + +test("Finish stays disabled until a provider-required harness is fully configured", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + ], + discoverAgentModels: { + models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }], + supportsSwitching: true, + }, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + // buzz-agent auto-selects as the only ready harness, but with no provider + // configured the default is not launchable — Finish must be gated. + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Buzz", + ); + const finish = page.getByTestId("onboarding-finish"); + await expect(finish).toBeDisabled(); + + // Configure provider + credential; model resolves via discovery/fallback. + await page.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await page.getByTestId("persona-provider-api-key").fill("sk-test-key"); + + await expect(finish).toBeEnabled(); + await finish.click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBe("buzz-agent"); +}); + +test("baked build config keeps Finish enabled without manual provider setup", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + ], + bakedBuildEnv: [ + { key: "BUZZ_AGENT_PROVIDER", masked: false, value: "databricks_v2" }, + { + key: "DATABRICKS_HOST", + masked: false, + value: "https://example.cloud.databricks.com", + }, + { key: "DATABRICKS_MODEL", masked: false, value: "baked-model" }, + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + // Internal builds bake provider/model/credentials — the gate must treat + // baked config as complete and never block Finish. + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Buzz", + ); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); +});