diff --git a/DEVIN.md b/DEVIN.md index 16fdc86657..bbd63df385 100644 --- a/DEVIN.md +++ b/DEVIN.md @@ -36,6 +36,16 @@ Buzz's generic `BUZZ_ACP_MODEL` bootstrap value from being passed to Devin; Devin's official ACP server owns model selection. Existing Claude, Codex, Goose, Buzz Agent, and custom-runtime bootstrap behavior is unchanged. +Runtime readiness no longer silently selects an external CLI as the global +agent default. First-run configuration explicitly explains that Fizz, Honey, +Bumble, and other unpinned personas inherit the selected default harness. It +offers Buzz's bundled agent alongside ready external runtimes, using +catalog-projected capability facts, so a builder may keep the starter personas +on Buzz Agent while creating a separate Devin-backed agent. Buzz Agent still +requires the builder to configure its own supported model provider and +credentials; the fork does not provide shared model access. Existing saved +defaults are preserved. + Devin's catalog entry disables the harness's historical permission auto-approval and enforces `default` mode. When Devin requests permission, Buzz presents the agent owner with exact per-request **Allow once** and diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..1fcd560d49 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`. It + also offers ready bundled harnesses (`requires_external_cli = false`) even + when their catalog entry is hidden from setup, and it requires an explicit + harness choice. This prevents installing or authenticating one external CLI + from silently making it the engine for every unpinned starter persona. `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..49b223c764 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -29,8 +29,8 @@ import { OnboardingSlideTransition, } from "./OnboardingSlideTransition"; import { + getDefaultConfigOnboardingRuntimes, getReadyOnboardingRuntimes, - getVisibleOnboardingRuntimes, } from "./onboardingRuntimeSelection"; import type { DefaultConfigStepActions } from "./types"; @@ -61,6 +61,7 @@ function AgentDefaultsSection({ const [isLoading, setIsLoading] = React.useState(true); const [isCustomProvider, setIsCustomProvider] = React.useState(false); const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + const [configIsValid, setConfigIsValid] = React.useState(false); const [bakedEnv, setBakedEnv] = React.useState([]); const coalescerRef = React.useRef<{ enqueue: (value: GlobalAgentConfig) => void; @@ -122,23 +123,23 @@ function AgentDefaultsSection({ ), [readyRuntimeIds, runtimesQuery.data], ); - const readyRuntimeIdSet = React.useMemo( - () => new Set(effectiveReadyRuntimeIds), - [effectiveReadyRuntimeIds], - ); - // Setup already confirmed readiness. Re-filter only for onboarding - // visibility here; a transient auth recheck must not invalidate that handoff. - const readyRuntimes = React.useMemo( + // Setup already confirmed external CLI readiness. The defaults page also + // offers ready bundled harnesses so selecting an external CLI does not + // silently make it the engine for every starter persona. + const selectableRuntimes = React.useMemo( () => - getVisibleOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => - readyRuntimeIdSet.has(runtime.id), + getDefaultConfigOnboardingRuntimes( + runtimesQuery.data ?? [], + effectiveReadyRuntimeIds, ), - [readyRuntimeIdSet, runtimesQuery.data], + [effectiveReadyRuntimeIds, runtimesQuery.data], ); const selectedRuntime = React.useMemo( () => - readyRuntimes.find((runtime) => runtime.id === config.preferred_runtime), - [config.preferred_runtime, readyRuntimes], + selectableRuntimes.find( + (runtime) => runtime.id === config.preferred_runtime, + ), + [config.preferred_runtime, selectableRuntimes], ); const selectedRuntimeId = selectedRuntime?.id ?? ""; const { data: runtimeFileConfig } = @@ -149,19 +150,20 @@ function AgentDefaultsSection({ runtimesQuery.isError || (!configSurfaceLoading && effectiveReadyRuntimeIds.length > 0 && - readyRuntimes.length === 0); + selectableRuntimes.length === 0); const harnessOptions = React.useMemo( () => - readyRuntimes.map((runtime) => ({ + selectableRuntimes.map((runtime) => ({ label: formatHarnessLabel(runtime), value: runtime.id, })), - [readyRuntimes], + [selectableRuntimes], ); const handleHarnessChange = React.useCallback( (runtimeId: string) => { const next = resetConfigForHarnessChange(config, runtimeId); + setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); setConfig(next); @@ -170,27 +172,22 @@ function AgentDefaultsSection({ [config], ); - React.useEffect(() => { - if (configSurfaceLoading || selectedRuntimeId) return; - if (readyRuntimes.length !== 1) return; - handleHarnessChange(readyRuntimes[0].id); - }, [ - configSurfaceLoading, - handleHarnessChange, - readyRuntimes, - selectedRuntimeId, - ]); - const flushPersistence = React.useCallback( () => coalescerRef.current?.flush() ?? Promise.resolve(), [], ); React.useEffect(() => { onPersistenceStateChange({ - canComplete: selectedRuntimeId.length > 0 && !isSaving, + canComplete: selectedRuntimeId.length > 0 && configIsValid && !isSaving, flush: flushPersistence, }); - }, [flushPersistence, isSaving, onPersistenceStateChange, selectedRuntimeId]); + }, [ + configIsValid, + flushPersistence, + isSaving, + onPersistenceStateChange, + selectedRuntimeId, + ]); return (
@@ -239,10 +236,12 @@ 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" disclosure="onboarding-essential" + key={selectedRuntimeId} unstyled useCustomSelect /> @@ -292,12 +291,12 @@ export function DefaultConfigStep({ >

- Configure your default model settings + Choose how your agents run

- This will be set as your default model configuration across Buzz. You - can always change this in your Settings or give specific agents a - different configuration. + This default harness powers Fizz, Honey, Bumble, and other agents set + to Runtime default. It uses the account and usage plan connected to + that harness. You can give any agent a different harness later.

diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index 56f85f14b1..dbc8d5e4d6 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + getDefaultConfigOnboardingRuntimes, getReadyOnboardingRuntimes, getVisibleOnboardingRuntimes, runtimeIsReadyForOnboarding, @@ -12,13 +13,18 @@ function runtime( id, availability, status, - { onboardingVisible = true, sortPriority = 100 } = {}, + { + onboardingVisible = true, + requiresExternalCli = true, + sortPriority = 100, + } = {}, ) { return { id, availability, authStatus: { status }, onboardingVisible, + requiresExternalCli, sortPriority, }; } @@ -114,3 +120,42 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => { ["claude"], ); }); + +test("default config offers a ready bundled harness alongside ready external CLIs", () => { + const runtimes = [ + runtime("buzz-agent", "available", "not_applicable", { + onboardingVisible: false, + requiresExternalCli: false, + sortPriority: 0, + }), + runtime("goose", "available", "not_applicable", { + onboardingVisible: false, + sortPriority: 10, + }), + runtime("devin", "available", "logged_in", { sortPriority: 20 }), + runtime("claude", "available", "logged_in", { sortPriority: 30 }), + ]; + + assert.deepEqual( + getDefaultConfigOnboardingRuntimes(runtimes, ["devin"]).map(({ id }) => id), + ["buzz-agent", "devin"], + ); +}); + +test("default config excludes unavailable bundled and unconfirmed external harnesses", () => { + const runtimes = [ + runtime("bundled-offline", "not_installed", "not_applicable", { + onboardingVisible: false, + requiresExternalCli: false, + }), + runtime("claude", "available", "logged_in"), + runtime("codex", "available", "logged_in"), + ]; + + assert.deepEqual( + getDefaultConfigOnboardingRuntimes(runtimes, ["claude"]).map( + ({ id }) => id, + ), + ["claude"], + ); +}); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index baef13ac34..0e9ec3020c 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,5 +1,17 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +function compareOnboardingRuntimes( + left: AcpRuntimeCatalogEntry, + right: AcpRuntimeCatalogEntry, +) { + return ( + left.sortPriority - right.sortPriority || + (left.displayLabel || left.label || left.id).localeCompare( + right.displayLabel || right.label || right.id, + ) + ); +} + export function runtimeIsVisibleInOnboarding(runtime: AcpRuntimeCatalogEntry) { return runtime.onboardingVisible; } @@ -17,13 +29,7 @@ export function getVisibleOnboardingRuntimes( ) { return runtimes .filter(runtimeIsVisibleInOnboarding) - .sort( - (left, right) => - left.sortPriority - right.sortPriority || - (left.displayLabel || left.label || left.id).localeCompare( - right.displayLabel || right.label || right.id, - ), - ); + .sort(compareOnboardingRuntimes); } export function getReadyOnboardingRuntimes( @@ -33,3 +39,30 @@ export function getReadyOnboardingRuntimes( runtimeIsReadyForOnboarding, ); } + +/** + * Runtime choices for the first-run default-harness step. + * + * The setup step intentionally shows only catalog entries marked for first-run + * discovery. The defaults step has a different job: it must also offer a + * bundled, ready harness so installing an external CLI does not silently make + * that CLI the default for Fizz, Honey, Bumble, and every other unpinned + * persona. + * + * Capability facts still come from the runtime catalog. A hidden entry is + * eligible here only when it is available, authenticated (when applicable), + * and does not require a separately installed CLI. + */ +export function getDefaultConfigOnboardingRuntimes( + runtimes: readonly AcpRuntimeCatalogEntry[], + readyRuntimeIds: readonly string[], +) { + const readyRuntimeIdSet = new Set(readyRuntimeIds); + return runtimes + .filter( + (runtime) => + (runtime.onboardingVisible && readyRuntimeIdSet.has(runtime.id)) || + (!runtime.requiresExternalCli && runtimeIsReadyForOnboarding(runtime)), + ) + .sort(compareOnboardingRuntimes); +} diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index b3def930f1..da14cc8bd9 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -156,7 +156,7 @@ test("starter persona activation is serialized to protect the shared store", asy assert.deepEqual(calls, ["builtin:fizz", "builtin:honey", "builtin:bumble"]); }); -test("all Welcome starters use the onboarding runtime preference", async () => { +test("all Welcome starters use the explicitly selected onboarding runtime preference", async () => { const claude = { id: "claude", label: "Claude", diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index fa8158c66d..288890dcf1 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -34,6 +34,7 @@ function runtime( install_hint: `Install ${id}`, install_instructions_url: "https://example.com", can_auto_install: true, + requires_external_cli: id !== "buzz-agent", underlying_cli_path: null, node_required: false, auth_status: authStatus, @@ -64,6 +65,17 @@ async function readSavedRuntime(page: Parameters[0]) { }); } +async function chooseDefaultHarness( + page: Parameters[0], + runtimeId: string, +) { + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page + .getByTestId(`global-agent-default-harness-option-${runtimeId}`) + .click(); +} + test("setup projects catalog visibility and ordering, including Devin", async ({ page, }) => { @@ -533,6 +545,8 @@ test("defaults waits for baked configuration before rendering fields", async ({ await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByText("Loading…")).toBeVisible(); + await expect(page.getByText("Loading…")).toHaveCount(0); + await chooseDefaultHarness(page, "claude"); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude Code", ); @@ -559,6 +573,7 @@ test("defaults renders only fields supported by the selected harness", async ({ await page.goto("/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); + await chooseDefaultHarness(page, "claude"); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude Code", @@ -599,6 +614,7 @@ test("defaults hides model when optional harness has empty discovery", async ({ await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + await chooseDefaultHarness(page, "claude"); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude Code", ); @@ -632,6 +648,7 @@ test("defaults keeps model control when optional harness discovery fails", async await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + await chooseDefaultHarness(page, "claude"); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude Code", ); @@ -659,7 +676,7 @@ test("defaults Back returns to harness setup", async ({ page }) => { await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); -test("defaults auto-selects the only ready visible harness", async ({ +test("defaults offers bundled Buzz Agent and requires an explicit harness choice", async ({ page, }) => { await installMockBridge( @@ -678,7 +695,7 @@ test("defaults auto-selects the only ready visible harness", async ({ { status: "not_applicable" }, { onboarding_visible: false }, ), - runtime("claude", "available", { status: "logged_in" }), + runtime("devin", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_out" }), ], globalAgentConfig: { @@ -695,9 +712,67 @@ test("defaults auto-selects the only ready visible harness", async ({ await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Claude Code", + const harness = page.getByTestId("global-agent-default-harness"); + await expect(harness).toHaveText("Select a harness"); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + expect(await readSavedRuntime(page)).toBeNull(); + await expect( + page.getByText( + "This default harness powers Fizz, Honey, Bumble, and other agents set to Runtime default.", + ), + ).toBeVisible(); + + await harness.click(); + await expect( + page.getByTestId("global-agent-default-harness-option-buzz-agent"), + ).toBeVisible(); + await expect( + page.getByTestId("global-agent-default-harness-option-devin"), + ).toBeVisible(); + await page + .getByTestId("global-agent-default-harness-option-buzz-agent") + .click(); + await expect(harness).toHaveText("Buzz"); + await expect(page.getByTestId("global-agent-provider")).toBeVisible(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-devin").click(); + await expect(harness).toHaveText("Devin"); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + await expect.poll(() => readSavedRuntime(page)).toBe("devin"); +}); + +test("defaults requires a choice when one external harness is ready", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + ], + 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(); + + const harness = page.getByTestId("global-agent-default-harness"); + await expect(harness).toHaveText("Select a harness"); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + expect(await readSavedRuntime(page)).toBeNull(); + + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-claude").click(); + await expect(harness).toHaveText("Claude Code"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); await expect.poll(() => readSavedRuntime(page)).toBe("claude"); }); @@ -790,7 +865,7 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy ).toHaveCount(0); 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(); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index dac0a68e60..f90f5ce294 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1223,12 +1223,15 @@ test("first-community shows the scenario cards for localhost", async ({ await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); await expect( page.getByRole("heading", { - name: "Configure your default model settings", + name: "Choose how your agents run", }), ).toBeVisible(); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Claude Code", - ); + const harness = page.getByTestId("global-agent-default-harness"); + await expect(harness).toHaveText("Select a harness"); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-claude").click(); + await expect(harness).toHaveText("Claude Code"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); });