From 4277bb39530d857adad502704478501e9447749e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:45:05 -0700 Subject: [PATCH 01/11] Hide bundled harnesses from onboarding --- .../src/features/onboarding/ui/SetupStep.tsx | 80 +++---------------- .../ui/onboardingRuntimeSelection.test.mjs | 21 ++--- .../ui/onboardingRuntimeSelection.ts | 22 ++--- 3 files changed, 23 insertions(+), 100 deletions(-) diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 9307e4dc99..07b488e695 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -1,27 +1,27 @@ import * as React from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { AlertTriangle, Check, ExternalLink } from "lucide-react"; +import { Check } from "lucide-react"; import { useAcpAuthMethodsQuery, useAcpRuntimesQuery, useConnectAcpRuntimeMutation, useInstallAcpRuntimeMutation, - useGitBashPrerequisiteQuery, } from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; import { cn } from "@/shared/lib/cn"; -import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee"; import { Spinner } from "@/shared/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { + ONBOARDING_RUNTIME_ORDER, runtimeCanAdvanceOnboarding, runtimeCanBeSelected, + runtimeIsOnboardingChoice, } from "./onboardingRuntimeSelection"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip"; @@ -609,62 +609,6 @@ function RuntimeCard({ ); } -function GitBashPrerequisiteCard() { - const query = useGitBashPrerequisiteQuery(); - const prerequisite = query.data; - if (!prerequisite) return null; - - return ( -
-
- {prerequisite.available ? ( - - ) : ( - - )} -

Git Bash

- {prerequisite.available ? ( - - Installed - - ) : null} -
- {prerequisite.available ? ( -

- {prerequisite.path} -

- ) : ( - <> -

- Required for buzz-agent shell tools on Windows. -

-

- {prerequisite.installHint} -

- - - )} -
- ); -} - function RuntimeProvidersLoadingState() { return (
{ - const leftIndex = runtimeOrder.indexOf(left.id); - const rightIndex = runtimeOrder.indexOf(right.id); - return ( - (leftIndex === -1 ? runtimeOrder.length : leftIndex) - - (rightIndex === -1 ? runtimeOrder.length : rightIndex) + const orderedItems = items + .filter((runtime) => runtimeIsOnboardingChoice(runtime.id)) + .sort( + (left, right) => + ONBOARDING_RUNTIME_ORDER.indexOf(left.id) - + ONBOARDING_RUNTIME_ORDER.indexOf(right.id), ); - }); const installMutation = useInstallAcpRuntimeMutation(); const selectedRuntimeIdSet = React.useMemo( () => new Set(selectedRuntimeIds), @@ -782,9 +724,7 @@ function RuntimeProvidersSection({
- - - {items.length > 0 ? ( + {orderedItems.length > 0 ? (
Agent harnesses {orderedItems.map((runtime) => ( diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index 046cca93a0..74b1465328 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -6,6 +6,7 @@ import { getPreferredRuntimeIdForSelection, runtimeCanAdvanceOnboarding, runtimeCanBeSelected, + runtimeIsOnboardingChoice, runtimeSelectionNeedsDefaultModelConfig, runtimeSelectionNeedsDefaultsStep, } from "./onboardingRuntimeSelection.ts"; @@ -41,26 +42,18 @@ test("known onboarding harnesses can be selected regardless of setup state", () true, ); } +}); - for (const id of ["buzz-agent", "goose"]) { - assert.equal( - runtimeCanBeSelected(runtime(id, "available", "not_applicable")), - true, - ); +test("Buzz, Goose, and unknown runtimes are not onboarding choices", () => { + for (const id of ["buzz-agent", "goose", "custom"]) { + assert.equal(runtimeIsOnboardingChoice(id), false); assert.equal( - runtimeCanBeSelected(runtime(id, "not_installed", "not_applicable")), - true, + runtimeCanBeSelected(runtime(id, "available", "logged_in")), + false, ); } }); -test("unknown runtimes are not onboarding choices", () => { - assert.equal( - runtimeCanBeSelected(runtime("custom", "available", "logged_in")), - false, - ); -}); - test("selected runtimes can advance only after setup is complete", () => { assert.equal( runtimeCanAdvanceOnboarding(runtime("claude", "available", "logged_in")), diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 3ff78e9b5f..b69ce127ae 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,14 +1,13 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = [ - "claude", - "codex", - "goose", - "buzz-agent", -]; +export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; const KNOWN_ONBOARDING_RUNTIME_IDS = new Set(ONBOARDING_RUNTIME_ORDER); +export function runtimeIsOnboardingChoice(runtimeId: string) { + return KNOWN_ONBOARDING_RUNTIME_IDS.has(runtimeId); +} + export function runtimeUsesDefaultModelConfig(runtimeId: string) { return runtimeId === "buzz-agent" || runtimeId === "goose"; } @@ -47,16 +46,7 @@ export function runtimeSelectionNeedsDefaultsStep( } export function runtimeCanBeSelected(runtime: AcpRuntimeCatalogEntry) { - if (KNOWN_ONBOARDING_RUNTIME_IDS.has(runtime.id)) return true; - if (runtime.availability !== "available") return false; - if (runtime.id === "claude" || runtime.id === "codex") { - return ( - runtime.authStatus.status === "logged_in" || - runtime.authStatus.status === "not_applicable" || - runtime.authStatus.status === "logged_out" - ); - } - return runtime.id === "buzz-agent" || runtime.id === "goose"; + return runtimeIsOnboardingChoice(runtime.id); } export function runtimeCanAdvanceOnboarding(runtime: AcpRuntimeCatalogEntry) { From 2fc936e4603917bc6ae58c9a3f422f2e5e5bacc0 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:16:27 -0700 Subject: [PATCH 02/11] Detect harness readiness during onboarding --- desktop/src/features/agents/AGENTS.md | 8 +- .../onboarding/ui/DefaultConfigStep.tsx | 92 ++-- .../onboarding/ui/MachineOnboardingFlow.tsx | 84 +--- .../src/features/onboarding/ui/SetupStep.tsx | 399 ++++-------------- .../ui/onboardingRuntimeSelection.test.mjs | 134 ++---- .../ui/onboardingRuntimeSelection.ts | 63 +-- 6 files changed, 187 insertions(+), 593 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 180253440b..ff1ab998a3 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -54,8 +54,12 @@ with a TypeScript lookup table or an id comparison in a component. flags were deliberately killed in #2148 (`CANONICAL_CONFIG_BEHAVIORS`). Surface differences are expressed via the `disclosure` preset, not new boolean props. -7. **Onboarding does not change.** `onboarding-agent-defaults.spec.ts` is the - acceptance gate for anything touching the shared renderer. +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`. + `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything + touching this flow or the shared renderer. ## The tests that enforce this diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 68a01f053d..daec2e0cff 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -25,13 +25,13 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { ONBOARDING_RUNTIME_ORDER } from "./onboardingRuntimeSelection"; +import { getReadyOnboardingRuntimes } from "./onboardingRuntimeSelection"; import type { DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { actions: DefaultConfigStepActions; direction: OnboardingTransitionDirection; - selectedRuntimeIds: readonly string[]; + readyRuntimeIds: readonly string[]; }; function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { @@ -39,27 +39,12 @@ function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { return runtime.id === "buzz-agent" ? "Buzz" : runtime.label; } -function sortSelectedRuntimes( - runtimes: readonly AcpRuntimeCatalogEntry[], - selectedRuntimeIds: readonly string[], -) { - const selectedRuntimeIdSet = new Set(selectedRuntimeIds); - return runtimes - .filter((runtime) => selectedRuntimeIdSet.has(runtime.id)) - .sort((left, right) => { - const leftIndex = ONBOARDING_RUNTIME_ORDER.indexOf(left.id); - const rightIndex = ONBOARDING_RUNTIME_ORDER.indexOf(right.id); - return ( - (leftIndex === -1 ? ONBOARDING_RUNTIME_ORDER.length : leftIndex) - - (rightIndex === -1 ? ONBOARDING_RUNTIME_ORDER.length : rightIndex) - ); - }); -} - function AgentDefaultsSection({ - selectedRuntimeIds, + onHasDefaultRuntimeChange, + readyRuntimeIds, }: { - selectedRuntimeIds: readonly string[]; + onHasDefaultRuntimeChange: (hasDefaultRuntime: boolean) => void; + readyRuntimeIds: readonly string[]; }) { const runtimesQuery = useAcpRuntimesQuery(); const [config, setConfig] = @@ -115,31 +100,38 @@ function AgentDefaultsSection({ }; }, []); - const selectedRuntimes = React.useMemo( - () => sortSelectedRuntimes(runtimesQuery.data ?? [], selectedRuntimeIds), - [runtimesQuery.data, selectedRuntimeIds], + const readyRuntimeIdSet = React.useMemo( + () => new Set(readyRuntimeIds), + [readyRuntimeIds], ); - const selectedRuntime = React.useMemo(() => { - const preferredRuntime = selectedRuntimes.find( - (runtime) => runtime.id === config.preferred_runtime, - ); - return preferredRuntime ?? selectedRuntimes[0]; - }, [config.preferred_runtime, selectedRuntimes]); - const selectedRuntimeId = - selectedRuntime?.id ?? config.preferred_runtime ?? ""; + const readyRuntimes = React.useMemo( + () => + getReadyOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => + readyRuntimeIdSet.has(runtime.id), + ), + [readyRuntimeIdSet, runtimesQuery.data], + ); + const selectedRuntime = React.useMemo( + () => + readyRuntimes.find((runtime) => runtime.id === config.preferred_runtime), + [config.preferred_runtime, readyRuntimes], + ); + const selectedRuntimeId = selectedRuntime?.id ?? ""; const configSurfaceLoading = isLoading || runtimesQuery.isLoading; + + React.useEffect(() => { + onHasDefaultRuntimeChange(selectedRuntimeId.length > 0); + }, [onHasDefaultRuntimeChange, selectedRuntimeId]); const configSurfaceError = runtimesQuery.isError || - (!configSurfaceLoading && - selectedRuntimeIds.length > 0 && - !selectedRuntime); + (!configSurfaceLoading && readyRuntimeIds.length > 0 && !selectedRuntime); const harnessOptions = React.useMemo( () => - selectedRuntimes.map((runtime) => ({ + readyRuntimes.map((runtime) => ({ label: formatHarnessLabel(runtime), value: runtime.id, })), - [selectedRuntimes], + [readyRuntimes], ); const handleHarnessChange = React.useCallback( @@ -153,22 +145,6 @@ function AgentDefaultsSection({ [config], ); - React.useEffect(() => { - if (isLoading || !selectedRuntimeId) return; - if (config.preferred_runtime === selectedRuntimeId) return; - - // The user can go Back, change which harnesses are selected, then return to - // this page without using this page's own harness dropdown. Reconcile that - // effective harness change through the same reset path so a Codex model - // never survives into Claude Code as a custom model (or vice versa). - handleHarnessChange(selectedRuntimeId); - }, [ - config.preferred_runtime, - handleHarnessChange, - isLoading, - selectedRuntimeId, - ]); - return (
{configSurfaceLoading ? ( @@ -236,8 +212,10 @@ function AgentDefaultsSection({ export function DefaultConfigStep({ actions, direction, - selectedRuntimeIds, + readyRuntimeIds, }: DefaultConfigStepProps) { + const [hasDefaultRuntime, setHasDefaultRuntime] = React.useState(false); + return (
- +
@@ -266,6 +247,7 @@ export function DefaultConfigStep({ {methodsQuery.error instanceof Error ? ( { - event.stopPropagation(); - void runtimesQuery.refetch(); - }} - type="button" - variant="ghost" - > - {runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"} - - ); - } - - if (installError && runtime.canAutoInstall) { - return ( - - ); - } - - if (runtimeIsInstalled(runtime) || installSuccess) { + if (runtimeIsReadyForOnboarding(runtime)) { return ( - INSTALLED + READY - {runtimeIsInstalled(runtime) ? ( - - ) : ( -

Setup completed.

- )} +
); } + if ( + runtime.availability === "available" && + runtime.authStatus.status === "unknown" + ) { + return ( + + ); + } + + const installLabel = installError ? "RETRY INSTALL" : "INSTALL"; if (runtime.canAutoInstall) { return ( ); } return ( ); } @@ -519,52 +420,30 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { function RuntimeCard({ installError, - installSuccess, isInstalling, onInstall, - onSelect, - onToggle, runtime, - selected, - setupFlashToken, }: { installError: string | null; - installSuccess: boolean; isInstalling: boolean; onInstall: () => void; - onSelect: () => void; - onToggle: () => void; runtime: AcpRuntimeCatalogEntry; - selected: boolean; - setupFlashToken: number; }) { - const isAvailable = runtime.availability === "available" || installSuccess; - const canSelect = runtimeCanBeSelected(runtime); + const isAvailable = runtime.availability === "available"; + const isReady = runtimeIsReadyForOnboarding(runtime); return ( { - if (event.target !== event.currentTarget) return; - if (canSelect && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - onToggle(); - } - }} - role="checkbox" - tabIndex={canSelect ? 0 : -1} variant="textured" > - +
@@ -575,12 +454,9 @@ function RuntimeCard({
{!isAvailable && runtimeDetailText(runtime) ? (

@@ -630,54 +506,17 @@ function RuntimeProvidersLoadingState() { function RuntimeProvidersSection({ installResults, onInstallResultsChange, - onSelectedRuntimeIdsChange, runtimeProviders, - setupFlashToken, - setupRequiredRuntimeIds, - selectedRuntimeIds, }: { installResults: InstallResultsState; onInstallResultsChange: React.Dispatch< React.SetStateAction >; - onSelectedRuntimeIdsChange: (runtimeIds: readonly string[]) => void; runtimeProviders: SetupStepState["runtimeProviders"]; - setupFlashToken: number; - setupRequiredRuntimeIds: readonly string[]; - selectedRuntimeIds: readonly string[]; }) { const { errorMessage, isChecking, items } = runtimeProviders; - const orderedItems = items - .filter((runtime) => runtimeIsOnboardingChoice(runtime.id)) - .sort( - (left, right) => - ONBOARDING_RUNTIME_ORDER.indexOf(left.id) - - ONBOARDING_RUNTIME_ORDER.indexOf(right.id), - ); + const orderedItems = getVisibleOnboardingRuntimes(items); const installMutation = useInstallAcpRuntimeMutation(); - const selectedRuntimeIdSet = React.useMemo( - () => new Set(selectedRuntimeIds), - [selectedRuntimeIds], - ); - const setupRequiredRuntimeIdSet = React.useMemo( - () => new Set(setupRequiredRuntimeIds), - [setupRequiredRuntimeIds], - ); - - function handleRuntimeToggle(runtimeId: string) { - if (selectedRuntimeIdSet.has(runtimeId)) { - onSelectedRuntimeIdsChange( - selectedRuntimeIds.filter((selectedId) => selectedId !== runtimeId), - ); - return; - } - onSelectedRuntimeIdsChange([...selectedRuntimeIds, runtimeId]); - } - - function handleRuntimeSelect(runtimeId: string) { - if (selectedRuntimeIdSet.has(runtimeId)) return; - onSelectedRuntimeIdsChange([...selectedRuntimeIds, runtimeId]); - } function handleInstall(runtimeId: string) { onInstallResultsChange((current) => ({ @@ -710,45 +549,30 @@ function RuntimeProvidersSection({

- Use the models that fit the task + Set up your agent harnesses

- - Connect your model providers here. Each agent can use the one that’s - best for their work. - - - Choose at least one to start using Buzz. - + Buzz detected the harnesses available on this machine. Install or sign + in to at least one to continue.

{orderedItems.length > 0 ? ( -
- Agent harnesses +
{orderedItems.map((runtime) => ( handleInstall(runtime.id)} - onSelect={() => handleRuntimeSelect(runtime.id)} - onToggle={() => handleRuntimeToggle(runtime.id)} runtime={runtime} - selected={selectedRuntimeIdSet.has(runtime.id)} - setupFlashToken={ - setupRequiredRuntimeIdSet.has(runtime.id) - ? setupFlashToken - : 0 - } /> ))} -
+
) : isChecking ? ( ) : errorMessage ? null : ( @@ -756,8 +580,8 @@ function RuntimeProvidersSection({ className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground" data-testid="onboarding-acp-empty" > - No compatible agent runtimes detected yet. You can finish setup now - and come back later in Settings > Agents. + No supported agent harnesses were detected yet. Install Claude Code + or Codex, then check again.

)} @@ -774,57 +598,22 @@ function RuntimeProvidersSection({ function SetupStepContent({ actions, direction, - isSelectionSaving, - onSelectedRuntimeIdsChange, - selectionError, - selectedRuntimeIds, + onReadyRuntimeIdsChange, state, }: SetupStepContentProps) { const { runtimeProviders } = state; const [installResults, setInstallResults] = React.useState({}); - const [setupFlashToken, setSetupFlashToken] = React.useState(0); - const [setupRequiredHintKey, setSetupRequiredHintKey] = React.useState< - string | null - >(null); - const runtimeById = React.useMemo( + const readyRuntimeIds = React.useMemo( () => - new Map(runtimeProviders.items.map((runtime) => [runtime.id, runtime])), + getReadyOnboardingRuntimes(runtimeProviders.items).map( + (runtime) => runtime.id, + ), [runtimeProviders.items], ); - const setupRequiredRuntimeIds = React.useMemo( - () => - selectedRuntimeIds.filter((runtimeId) => { - const runtime = runtimeById.get(runtimeId); - if (!runtime) return false; - return !runtimeCanAdvanceOnboarding(runtime); - }), - [runtimeById, selectedRuntimeIds], - ); - const hasSetupRequiredSelection = setupRequiredRuntimeIds.length > 0; - const setupRequiredRuntimeIdsKey = setupRequiredRuntimeIds.join("\0"); - const showSetupRequiredHint = - hasSetupRequiredSelection && - setupRequiredHintKey === setupRequiredRuntimeIdsKey; - React.useEffect(() => { - if ( - setupRequiredHintKey !== null && - setupRequiredHintKey !== setupRequiredRuntimeIdsKey - ) { - setSetupRequiredHintKey(null); - } - }, [setupRequiredHintKey, setupRequiredRuntimeIdsKey]); - - function handleNext() { - if (selectedRuntimeIds.length === 0 || isSelectionSaving) return; - if (hasSetupRequiredSelection) { - setSetupRequiredHintKey(setupRequiredRuntimeIdsKey); - setSetupFlashToken((current) => current + 1); - return; - } - actions.next(); - } + onReadyRuntimeIdsChange(readyRuntimeIds); + }, [onReadyRuntimeIdsChange, readyRuntimeIds]); return ( - {selectionError ? ( -

- {selectionError} -

- ) : null} - {hasSetupRequiredSelection && showSetupRequiredHint ? ( -

- Please finish set up -

- ) : null} {methodsQuery.error instanceof Error ? ( { onReadyRuntimeIdsChange(readyRuntimeIds); - }, [onReadyRuntimeIdsChange, readyRuntimeIds]); + }, [onReadyRuntimeIdsChange, readyRuntimeIdsKey]); return ( actions.next(readyRuntimeIds)} type="button" > Next diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 149e453b65..9b2c4be8d2 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -58,7 +58,7 @@ export type ProfileStepActions = { export type SetupStepActions = { back: () => void; - next: () => void; + next: (readyRuntimeIds: readonly string[]) => void; }; export type DefaultConfigStepActions = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f324b9370c..1ea8998313 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -158,6 +158,12 @@ type E2eConfig = { archived_at?: string | null; }; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; + /** Catalog returned after a successful mocked install. */ + acpRuntimesCatalogAfterInstall?: RawAcpRuntimeCatalogEntry[]; + /** Catalog responses after install for testing later sign-in completion. */ + acpRuntimesCatalogAfterInstallSequence?: RawAcpRuntimeCatalogEntry[][]; + /** Catalog responses for successive discovery calls. The final response repeats. */ + acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; @@ -6785,6 +6791,9 @@ function withMockRuntimeConfigMetadata( }; } +let runtimeCatalogDiscoveryCount = 0; +let mockInstallCompleted = false; + async function handleDiscoverAcpRuntimes( config: E2eConfig | undefined, ): Promise { @@ -6795,6 +6804,26 @@ async function handleDiscoverAcpRuntimes( }); } + const afterInstallSequence = + config?.mock?.acpRuntimesCatalogAfterInstallSequence; + if (mockInstallCompleted && afterInstallSequence?.length) { + const index = Math.min( + runtimeCatalogDiscoveryCount, + afterInstallSequence.length - 1, + ); + runtimeCatalogDiscoveryCount += 1; + return afterInstallSequence[index].map(withMockRuntimeConfigMetadata); + } + const afterInstall = config?.mock?.acpRuntimesCatalogAfterInstall; + if (mockInstallCompleted && afterInstall) { + return afterInstall.map(withMockRuntimeConfigMetadata); + } + const sequence = config?.mock?.acpRuntimesCatalogSequence; + if (sequence && sequence.length > 0) { + const index = Math.min(runtimeCatalogDiscoveryCount, sequence.length - 1); + runtimeCatalogDiscoveryCount += 1; + return sequence[index].map(withMockRuntimeConfigMetadata); + } const configured = config?.mock?.acpRuntimesCatalog; if (configured) { return configured.map(withMockRuntimeConfigMetadata); @@ -6940,12 +6969,16 @@ async function handleInstallAcpRuntime( if (sequence && sequence.length > 0) { const idx = Math.min(installCallCount, sequence.length - 1); installCallCount++; - return sequence[idx]; + const result = sequence[idx]; + if (result.success) mockInstallCompleted = true; + return result; } const configured = config?.mock?.installAcpRuntimeResult; if (configured) { + if (configured.success) mockInstallCompleted = true; return configured; } + mockInstallCompleted = true; return { success: true, steps: [ diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 4d1a293ecf..76e978602c 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1,15 +1,11 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; -import { waitForAnimations } from "../helpers/animations"; import { passThroughBackupStep } from "../helpers/onboarding"; -const SHOTS = "test-results/screenshots-onboarding"; - -function availableRuntime( +function runtime( id: "buzz-agent" | "claude" | "codex" | "goose", - authStatus: - | { status: "config_invalid"; diagnostic: string } - | { status: "logged_in" | "logged_out" | "not_applicable" | "unknown" }, + availability: string, + authStatus: Record, overrides: Record = {}, ) { return { @@ -18,19 +14,19 @@ function availableRuntime( id === "buzz-agent" ? "Buzz Agent" : id === "claude" - ? "Claude" + ? "Claude Code" : id === "codex" ? "Codex" : "Goose", avatar_url: "", - availability: "available", - command: id, - binary_path: `/usr/local/bin/${id}`, + availability, + command: availability === "available" ? id : null, + binary_path: availability === "available" ? `/usr/local/bin/${id}` : null, default_args: [], mcp_command: null, - install_hint: "", + install_hint: `Install ${id}`, install_instructions_url: "https://example.com", - can_auto_install: false, + can_auto_install: true, underlying_cli_path: null, node_required: false, auth_status: authStatus, @@ -39,7 +35,6 @@ function availableRuntime( }; } -/** Drive to the harness setup page (page 3) via the full onboarding flow. */ async function navigateToSetupPage( page: Parameters[0], ) { @@ -48,154 +43,91 @@ async function navigateToSetupPage( await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); } -/** Drive to the default config page (page 4), past the harness page. */ -async function navigateToConfigPage( - page: Parameters[0], -) { - await navigateToSetupPage(page); - await page.getByTestId("onboarding-runtime-buzz-agent").click(); - await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); - await page.getByTestId("onboarding-setup-next").click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); -} - -async function chooseConfigDropdownOption( - page: Parameters[0], - triggerTestId: string, - value: string, -) { - await page.getByTestId(triggerTestId).click(); - await page.getByTestId(`${triggerTestId}-option-${value || "empty"}`).click(); -} - -async function readSavedConfig(page: Parameters[0]) { - return await page.evaluate(() => - ( +async function readSavedRuntime(page: Parameters[0]) { + return await page.evaluate(async () => { + const result = await ( window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload: unknown, - ) => Promise<{ - model?: string | null; - preferred_runtime?: string | null; - }>; + ) => Promise<{ preferred_runtime?: string | null }>; } - ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), - ); -} - -async function readSavedRuntime(page: Parameters[0]) { - const savedConfig = await readSavedConfig(page); - return savedConfig?.preferred_runtime ?? null; + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + return result?.preferred_runtime ?? null; + }); } -test("requires a runtime selection and routes Buzz Agent to config", async ({ +test("setup shows only Claude Code and Codex as detected harnesses", async ({ page, }) => { await installMockBridge( page, { acpRuntimesCatalog: [ - availableRuntime("buzz-agent", { status: "not_applicable" }), + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime("goose", "available", { status: "not_applicable" }), + runtime("codex", "available", { status: "logged_in" }), + runtime("claude", "available", { status: "logged_in" }), ], - setGlobalAgentConfigDelayMs: 200, }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); await navigateToSetupPage(page); - const next = page.getByTestId("onboarding-setup-next"); - const card = page.getByTestId("onboarding-runtime-buzz-agent"); - await expect(next).toBeDisabled(); - await expect(card.getByText("Preferred")).toHaveCount(0); - - await card.click(); - await expect(next).toHaveText("Next"); - await expect(card).toHaveAttribute("aria-checked", "true"); - await expect(next).toBeEnabled(); - await next.click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - expect(await readSavedRuntime(page)).toBe("buzz-agent"); + 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.getByRole("checkbox")).toHaveCount(0); }); -test("rapid harness toggles serialize the prioritized preferred runtime", async ({ +test("ready state is detected, checked, and enables Next without persisting a default", async ({ page, }) => { await installMockBridge( page, { acpRuntimesCatalog: [ - availableRuntime("goose", { status: "not_applicable" }), - availableRuntime("buzz-agent", { status: "not_applicable" }), + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_out" }), ], - setGlobalAgentConfigDelayMs: 200, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - await page.getByTestId("onboarding-runtime-goose").click(); - await page.getByTestId("onboarding-runtime-buzz-agent").click(); - const next = page.getByTestId("onboarding-setup-next"); - await expect(next).toBeDisabled(); - await page.waitForTimeout(300); - await expect(next).toBeDisabled(); - await expect(next).toBeEnabled({ timeout: 700 }); - expect(await readSavedRuntime(page)).toBe("goose"); -}); - -test("authenticated Claude saves the selected runtime and routes to defaults", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [availableRuntime("claude", { status: "logged_in" })], }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); await navigateToSetupPage(page); - await page.getByTestId("onboarding-runtime-claude").click(); - await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); - 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", + await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText( + "READY", ); - // Claude Code effort is real, but it is exposed as a Claude ACP-native - // config option, not Buzz Agent's generic effort env var. Hide the generic - // control until the config core can render that native option. await expect( - page.getByTestId("global-agent-thinking-effort-select"), + page.getByTestId("onboarding-runtime-checkmark-claude"), + ).toBeVisible(); + await expect( + page.getByTestId("onboarding-runtime-checkmark-codex"), ).toHaveCount(0); - expect(await readSavedRuntime(page)).toBe("claude"); + await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); + expect(await readSavedRuntime(page)).toBeNull(); }); -test("successful Claude sign-in selects the card without extra status copy", async ({ +test("sign in stays pending until catalog detection confirms Ready", async ({ page, }) => { + const loggedOut = runtime("claude", "available", { status: "logged_out" }); + const loggedIn = runtime("claude", "available", { status: "logged_in" }); await installMockBridge( page, { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - ], + acpRuntimesCatalogSequence: [[loggedOut], [loggedOut], [loggedIn]], acpAuthMethods: { claude: { methods: [ { - id: "claude-subscription", - name: "Claude Subscription", - description: null, - type: "terminal", - }, - { - id: "anthropic-console", - name: "Anthropic Console", + id: "subscription", + name: "Claude.ai subscription", description: null, type: "terminal", }, @@ -207,53 +139,40 @@ test("successful Claude sign-in selects the card without extra status copy", asy ); await page.goto("/"); await navigateToSetupPage(page); - await waitForAnimations(page); - - const card = page.getByTestId("onboarding-runtime-claude"); - await expect( - card.getByRole("button", { name: "Claude Subscription" }), - ).toHaveCount(0); - await expect( - card.getByRole("button", { name: "Anthropic Console" }), - ).toHaveCount(0); - await expect( - card.getByRole("button", { name: "Log in to Claude" }), - ).toHaveCount(0); - await expect( - card.getByTestId("onboarding-runtime-instructions-claude"), - ).toBeVisible(); - await card.getByTestId("onboarding-runtime-instructions-claude").click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - await expect(card.getByText("Preferred")).toHaveCount(0); - await expect( - page.getByTestId("onboarding-runtime-checkmark-claude"), - ).toHaveCSS("opacity", "1"); + const signIn = page.getByRole("button", { name: "Sign in to Claude Code" }); + await expect(signIn).toHaveText("SIGN IN"); + await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled(); + await signIn.click(); + await expect(signIn).toHaveText("CHECKING…"); + await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled(); + await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText( + "READY", + { timeout: 5_000 }, + ); await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); - expect(await readSavedRuntime(page)).toBe("claude"); }); -test("successful Codex sign-in hides API key auth and selects the card", async ({ - page, -}) => { +test("install transitions through Sign in to Ready", async ({ page }) => { + const notInstalled = runtime("claude", "adapter_missing", { + status: "unknown", + }); + const loggedOut = runtime("claude", "available", { status: "logged_out" }); + const loggedIn = runtime("claude", "available", { status: "logged_in" }); await installMockBridge( page, { - acpRuntimesCatalog: [availableRuntime("codex", { status: "logged_out" })], + acpRuntimesCatalog: [notInstalled], + acpRuntimesCatalogAfterInstallSequence: [[loggedOut], [loggedIn]], + installAcpRuntimeDelayMs: 500, acpAuthMethods: { - codex: { + claude: { methods: [ { - id: "api-key", - name: "Use API key", - description: null, - type: "input", - }, - { - id: "chat-gpt", - name: "Sign in with ChatGPT", + id: "subscription", + name: "Claude.ai subscription", description: null, - type: "browser", + type: "terminal", }, ], }, @@ -263,340 +182,54 @@ test("successful Codex sign-in hides API key auth and selects the card", async ( ); await page.goto("/"); await navigateToSetupPage(page); - await waitForAnimations(page); - - const card = page.getByTestId("onboarding-runtime-codex"); - await expect(card.getByRole("button", { name: "Use API key" })).toHaveCount( - 0, - ); - await expect( - card.getByRole("button", { name: "Sign in with ChatGPT" }), - ).toHaveCount(0); - - await expect(card.getByRole("button", { name: "Log in" })).toHaveCount(0); - await expect( - card.getByTestId("onboarding-runtime-instructions-codex"), - ).toBeVisible(); - await card.getByTestId("onboarding-runtime-instructions-codex").click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - await expect(card.getByText("Preferred")).toHaveCount(0); - await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); - expect(await readSavedRuntime(page)).toBe("codex"); -}); - -test("setup-needed runtimes remain selectable", async ({ page }) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - availableRuntime("codex", { status: "logged_out" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - await expect(page.getByTestId("onboarding-runtime-claude")).toHaveAttribute( - "aria-disabled", - "false", - ); - await expect(page.getByTestId("onboarding-runtime-codex")).toHaveAttribute( - "aria-disabled", - "false", - ); - await page.getByTestId("onboarding-runtime-claude").click(); - await expect(page.getByTestId("onboarding-runtime-claude")).toHaveAttribute( - "aria-checked", - "true", - ); - await page.getByTestId("onboarding-runtime-codex").click(); - await expect(page.getByTestId("onboarding-runtime-codex")).toHaveAttribute( - "aria-checked", - "true", - ); -}); - -test("Next flashes selected setup-needed runtimes instead of advancing", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - availableRuntime("buzz-agent", { status: "not_applicable" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const next = page.getByTestId("onboarding-setup-next"); - const claudeCard = page.getByTestId("onboarding-runtime-claude"); - const buzzCard = page.getByTestId("onboarding-runtime-buzz-agent"); - const claudeSetup = claudeCard.getByTestId( - "onboarding-runtime-instructions-claude", - ); - - await claudeCard.click(); - await buzzCard.click(); - await expect(claudeCard).toHaveAttribute("aria-checked", "true"); - await expect(buzzCard).toHaveAttribute("aria-checked", "true"); - await expect(next).toHaveAttribute("data-soft-disabled", "true"); - await expect(next).toBeEnabled(); - await expect(page.getByTestId("onboarding-setup-next-hint")).toHaveCount(0); - await expect(next).toHaveCSS("cursor", "default"); - - await next.click(); - await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); - await expect(page.getByTestId("onboarding-page-config")).toHaveCount(0); - await expect(page.getByTestId("onboarding-setup-next-hint")).toHaveText( - "Please finish set up", - ); - await expect(claudeSetup).toHaveAttribute("data-setup-flash", "true"); - await expect( - page.getByTestId("onboarding-runtime-installed-buzz-agent"), - ).not.toHaveAttribute("data-setup-flash", "true"); -}); - -test("logged-out CLI runtimes can be selected and keep setup visible", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - availableRuntime("codex", { status: "logged_out" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - for (const runtimeId of ["claude", "codex"]) { - const card = page.getByTestId(`onboarding-runtime-${runtimeId}`); - await expect( - card.getByTestId(`onboarding-runtime-instructions-${runtimeId}`), - ).toBeVisible(); - await expect( - page.getByTestId(`onboarding-runtime-installed-${runtimeId}`), - ).toHaveCount(0); - - await card.click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - await expect( - card.getByTestId(`onboarding-runtime-instructions-${runtimeId}`), - ).toBeVisible(); - await expect( - page.getByTestId(`onboarding-runtime-installed-${runtimeId}`), - ).toHaveCount(0); - } -}); - -test("runtime cards use the selected onboarding order", async ({ page }) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("buzz-agent", { status: "not_applicable" }), - availableRuntime("goose", { status: "not_applicable" }), - availableRuntime("codex", { status: "logged_in" }), - availableRuntime("claude", { status: "logged_in" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const runtimeOrder = await page - .getByRole("checkbox") - .evaluateAll((cards) => - cards.map((card) => card.getAttribute("data-testid")), - ); - expect(runtimeOrder).toEqual([ - "onboarding-runtime-claude", - "onboarding-runtime-codex", - "onboarding-runtime-goose", - "onboarding-runtime-buzz-agent", - ]); - await expect( - page.getByTestId("onboarding-runtime-buzz-agent").getByRole("heading", { - name: "Buzz", - }), - ).toBeVisible(); -}); - -test("runtime cards allow multiple harness selections", async ({ page }) => { - await installMockBridge(page, undefined, { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }); - await page.goto("/"); - await navigateToSetupPage(page); - - const next = page.getByTestId("onboarding-setup-next"); - const gooseCard = page.getByTestId("onboarding-runtime-goose"); - const buzzCard = page.getByTestId("onboarding-runtime-buzz-agent"); - - await expect(next).toBeDisabled(); - - await gooseCard.click(); - await expect(gooseCard).toHaveAttribute("aria-checked", "true"); - await expect(next).toBeEnabled(); - - await buzzCard.click(); - await expect(gooseCard).toHaveAttribute("aria-checked", "true"); - await expect(buzzCard).toHaveAttribute("aria-checked", "true"); - await expect(next).toBeEnabled(); - - await gooseCard.click(); - await expect(gooseCard).toHaveAttribute("aria-checked", "false"); - await expect(buzzCard).toHaveAttribute("aria-checked", "true"); - await expect(next).toBeEnabled(); - expect(await readSavedRuntime(page)).toBe("buzz-agent"); -}); - -test("multiple CLI harnesses route to default harness selection", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_in" }), - availableRuntime("codex", { status: "logged_in" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - await page.getByTestId("onboarding-runtime-claude").click(); - await page.getByTestId("onboarding-runtime-codex").click(); - await page.getByTestId("onboarding-setup-next").click(); + const install = page.getByTestId("onboarding-runtime-install-claude"); + await expect(install).toHaveText("INSTALL"); + await install.click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Claude", - ); - await chooseConfigDropdownOption( - page, - "global-agent-default-harness", - "codex", - ); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Codex", + const signIn = page.getByRole("button", { name: "Sign in to Claude Code" }); + await expect(signIn).toHaveText("SIGN IN"); + await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled(); + await signIn.click(); + await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText( + "READY", + { timeout: 5_000 }, ); - await expect.poll(() => readSavedRuntime(page)).toBe("codex"); - await expect(page.getByTestId("global-agent-provider")).toHaveCount(0); - const modelSelect = page.getByTestId("global-agent-model"); - await expect(modelSelect).toBeVisible(); - // Codex model ids can encode effort (for example, `gpt-5.5[low]`). Until the - // config core models Codex-native options directly, don't show Buzz Agent's - // generic effort field beside Codex's model catalog. - await expect( - page.getByTestId("global-agent-thinking-effort-select"), - ).toHaveCount(0); - await expect(modelSelect).toHaveText("Default model (gpt-5.5[high])"); - await modelSelect.click(); await expect( - page.getByTestId("global-agent-model-option-gpt-5.5[low]"), + page.getByTestId("onboarding-runtime-checkmark-claude"), ).toBeVisible(); - await page.keyboard.press("Escape"); -}); - -test("changing setup-page harness clears an incompatible saved model", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_in" }), - availableRuntime("codex", { status: "logged_in" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - await page.getByTestId("onboarding-runtime-codex").click(); - await page.getByTestId("onboarding-setup-next").click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - - await chooseConfigDropdownOption(page, "global-agent-model", "gpt-5.5[low]"); - await expect(page.getByTestId("global-agent-model")).toHaveText( - "gpt-5.5[low]", - ); - - await page.getByTestId("onboarding-back").click(); - await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); - await page.getByTestId("onboarding-runtime-codex").click(); - await page.getByTestId("onboarding-runtime-claude").click(); - 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", - ); - await expect(page.getByTestId("global-agent-model")).not.toHaveText( - "Custom model...", - ); - await expect(page.getByLabel("Custom model ID")).toHaveCount(0); - await expect - .poll(async () => (await readSavedConfig(page))?.model) - .toBeNull(); - await expect.poll(() => readSavedRuntime(page)).toBe("claude"); }); -test("selecting all harnesses prioritizes Claude for defaults", async ({ +test("defaults includes every ready visible harness and persists the user's choice there", async ({ page, }) => { await installMockBridge( page, { acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_in" }), - availableRuntime("codex", { status: "logged_in" }), - availableRuntime("goose", { status: "not_applicable" }), - availableRuntime("buzz-agent", { status: "not_applicable" }), + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime("goose", "available", { status: "not_applicable" }), + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), ], globalAgentConfig: { - env_vars: { - OPENAI_COMPAT_API_KEY: "sk-test", - }, + env_vars: {}, provider: null, model: null, + preferred_runtime: null, }, }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); await navigateToSetupPage(page); - - for (const runtimeId of ["claude", "codex", "goose", "buzz-agent"]) { - await page.getByTestId(`onboarding-runtime-${runtimeId}`).click(); - await expect( - page.getByTestId(`onboarding-runtime-${runtimeId}`), - ).toHaveAttribute("aria-checked", "true"); - } - - await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - expect(await readSavedRuntime(page)).toBe("claude"); - const harnessSelect = page.getByTestId("global-agent-default-harness"); - await expect(harnessSelect).toHaveText("Claude"); - await harnessSelect.click(); + 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 expect( page.getByTestId("global-agent-default-harness-option-claude"), ).toBeVisible(); @@ -605,888 +238,12 @@ test("selecting all harnesses prioritizes Claude for defaults", async ({ ).toBeVisible(); await expect( page.getByTestId("global-agent-default-harness-option-goose"), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId("global-agent-default-harness-option-buzz-agent"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-default-harness-option-codex"), - ).toHaveCSS("opacity", "1"); + ).toHaveCount(0); await page.getByTestId("global-agent-default-harness-option-codex").click(); - await expect(harnessSelect).toHaveAttribute("data-value", "codex"); + await expect(harness).toHaveText("Codex"); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); await expect.poll(() => readSavedRuntime(page)).toBe("codex"); - await expect(page.getByTestId("global-agent-provider")).toHaveCount(0); - await expect(page.getByTestId("global-agent-model")).toBeVisible(); - await expect( - page.getByTestId("global-agent-thinking-effort-select"), - ).toHaveCount(0); - - await chooseConfigDropdownOption( - page, - "global-agent-default-harness", - "goose", - ); - await expect(harnessSelect).toHaveAttribute("data-value", "goose"); - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - await expect(page.getByTestId("global-agent-model")).toHaveAttribute( - "data-value", - "gpt-5.5", - ); - await page.getByTestId("global-agent-model").click(); - await expect(page.getByTestId("global-agent-model-option-gpt-5.5")).toHaveCSS( - "color", - "rgb(0, 0, 0)", - ); - await expect(page.getByTestId("global-agent-model-option-gpt-5.5")).toHaveCSS( - "opacity", - "1", - ); - await page.keyboard.press("Escape"); - - await chooseConfigDropdownOption( - page, - "global-agent-default-harness", - "buzz-agent", - ); - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - await expect(page.getByTestId("global-agent-model")).toHaveAttribute( - "data-value", - "gpt-5.5", - ); -}); - -for (const authStatus of [ - { status: "logged_out" as const }, - { status: "unknown" as const }, - { status: "config_invalid" as const, diagnostic: "Fix Claude config" }, -]) { - test(`Claude ${authStatus.status} state can be selected before setup`, async ({ - page, - }) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [availableRuntime("claude", authStatus)], - acpAuthMethods: { - claude: { - methods: [ - { - id: "login", - name: "Sign in", - description: null, - type: "terminal", - }, - ], - }, - }, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const card = page.getByTestId("onboarding-runtime-claude"); - await expect(card).toHaveAttribute("aria-disabled", "false"); - const setupButton = card.getByTestId( - "onboarding-runtime-instructions-claude", - ); - if (authStatus.status === "logged_out") { - await expect(setupButton).toBeVisible(); - await expect( - card.getByRole("button", { name: "Log in to Claude" }), - ).toHaveCount(0); - await setupButton.click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - } else if (authStatus.status === "unknown") { - const error = card.getByRole("status", { - name: /Status unavailable/, - }); - await expect(error).toBeVisible(); - await expect(error).toHaveCSS("font-size", "12px"); - await expect(error).toHaveCSS("position", "absolute"); - await error.hover(); - await expect(page.getByRole("tooltip")).toHaveText( - "Couldn’t verify authentication.", - ); - await expect( - card.getByRole("button", { name: "Check Claude again" }), - ).toHaveText("CHECK AGAIN"); - await card.click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - } else { - const error = card.getByRole("status", { - name: /Configuration invalid/, - }); - await expect(error).toBeVisible(); - await expect(error).toHaveCSS("font-size", "12px"); - await expect(error).toHaveCSS("position", "absolute"); - await error.hover(); - await expect(page.getByRole("tooltip")).toHaveText( - "Check this runtime’s configuration and try again.", - ); - await expect(page.getByRole("tooltip")).not.toContainText( - "Fix Claude config", - ); - await card.click(); - await expect(card).toHaveAttribute("aria-checked", "true"); - } - }); -} - -test("setup cards only show checks after user selection", async ({ page }) => { - await installMockBridge(page, undefined, { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }); - await page.goto("/"); - - await navigateToSetupPage(page); - - const gooseCard = page.getByTestId("onboarding-runtime-goose"); - const gooseCheck = page.getByTestId("onboarding-runtime-check-goose"); - const gooseCheckmark = page.getByTestId("onboarding-runtime-checkmark-goose"); - - await expect(gooseCard).toHaveAttribute("aria-checked", "false"); - await expect(gooseCheckmark).toHaveCSS("opacity", "0"); - await expect(gooseCheck).toHaveCSS("opacity", "0"); - - await gooseCard.hover(); - await expect(gooseCheck).toHaveCSS("opacity", "1"); - await expect(gooseCheckmark).toHaveCSS("opacity", "0"); - - await gooseCard.click(); - await expect(gooseCard).toHaveAttribute("aria-checked", "true"); - await expect(gooseCheckmark).toHaveCSS("opacity", "1"); - await expect( - page.getByTestId("onboarding-runtime-installed-goose"), - ).toHaveText("INSTALLED"); -}); - -test("unavailable sign-in options use the compact error and tooltip pattern", async ({ - page, -}) => { - const discoveryError = "Auth discovery failed with sensitive details"; - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - ], - acpAuthMethodsError: discoveryError, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const card = page.getByTestId("onboarding-runtime-claude"); - const setupButton = card.getByTestId( - "onboarding-runtime-instructions-claude", - ); - const error = card.getByRole("status", { name: /Sign-in unavailable/ }); - await expect(error).toBeVisible(); - await expect(error).toHaveCSS("font-size", "12px"); - await expect(error).toHaveCSS("position", "absolute"); - await expect(error).toHaveCSS("white-space", "nowrap"); - await expect(error).not.toHaveAttribute("title"); - await error.hover(); - await expect(page.getByRole("tooltip")).toHaveText( - "Couldn’t load sign-in options.", - ); - await expect(page.getByRole("tooltip")).not.toContainText(discoveryError); - await expect(setupButton).toBeVisible(); - await expect(setupButton).toHaveText("SET UP"); -}); - -test("failed sign-in uses the compact error and tooltip pattern", async ({ - page, -}) => { - const connectionError = "Terminal launch failed with sensitive details"; - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("claude", { status: "logged_out" }), - ], - acpAuthMethods: { - claude: { - methods: [ - { - id: "login", - name: "Sign in", - description: null, - type: "terminal", - }, - ], - }, - }, - connectAcpRuntimeError: connectionError, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const card = page.getByTestId("onboarding-runtime-claude"); - const setupButton = card.getByTestId( - "onboarding-runtime-instructions-claude", - ); - const heading = card.getByRole("heading", { name: "Claude" }); - const headingTopBefore = await heading.evaluate( - (element) => element.getBoundingClientRect().top, - ); - const setupTopBefore = await setupButton.evaluate( - (element) => element.getBoundingClientRect().top, - ); - - await setupButton.click(); - - const error = card.getByRole("status", { name: /Sign-in failed/ }); - await expect(error).toBeVisible(); - await expect(error).toHaveCSS("font-size", "12px"); - await expect(error).toHaveCSS("position", "absolute"); - await expect(error).toHaveCSS("white-space", "nowrap"); - await expect(error).not.toHaveAttribute("title"); - await error.hover(); - await expect(page.getByRole("tooltip")).toHaveText( - "Couldn’t start sign-in. Try again.", - ); - await expect(page.getByRole("tooltip")).not.toContainText(connectionError); - await page.keyboard.press("Escape"); - await expect(page.getByRole("tooltip")).toBeHidden(); - await error.focus(); - const tooltip = page.getByRole("tooltip"); - await expect(tooltip).toHaveText("Couldn’t start sign-in. Try again."); - await expect(error).toHaveAccessibleName( - "Sign-in failed. Couldn’t start sign-in. Try again.", - ); - const [cardBox, setupBox, tooltipBox] = await Promise.all([ - card.boundingBox(), - setupButton.boundingBox(), - tooltip.boundingBox(), - ]); - expect(cardBox).not.toBeNull(); - expect(setupBox).not.toBeNull(); - expect(tooltipBox).not.toBeNull(); - expect(tooltipBox?.y).toBeGreaterThanOrEqual( - (cardBox?.y ?? 0) + (cardBox?.height ?? 0), - ); - expect(tooltipBox?.y).toBeGreaterThanOrEqual( - (setupBox?.y ?? 0) + (setupBox?.height ?? 0), - ); - await expect(setupButton).toBeVisible(); - expect( - await heading.evaluate((element) => element.getBoundingClientRect().top), - ).toBe(headingTopBefore); - expect( - await setupButton.evaluate( - (element) => element.getBoundingClientRect().top, - ), - ).toBe(setupTopBefore); -}); - -test("failed install pins a single-line 12px error without moving card content", async ({ - page, -}) => { - const installError = "Install already in progress with additional details"; - await installMockBridge( - page, - { - installAcpRuntimeResult: { - success: false, - steps: [ - { - step: "Install adapter", - command: "npm install adapter", - success: false, - stdout: "", - stderr: installError, - exit_code: 1, - }, - ], - }, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - - const card = page.getByTestId("onboarding-runtime-claude"); - const setupButton = page.getByTestId("onboarding-runtime-install-claude"); - const heading = card.getByRole("heading", { name: "Claude" }); - const headingTopBefore = await heading.evaluate( - (element) => element.getBoundingClientRect().top, - ); - const detail = card.getByText("CLI detected; ACP adapter missing."); - const detailTopBefore = await detail.evaluate( - (element) => element.getBoundingClientRect().top, - ); - const setupTopBefore = await setupButton.evaluate( - (element) => element.getBoundingClientRect().top, - ); - - await setupButton.click(); - - const error = page.getByTestId("onboarding-runtime-error-claude"); - await expect(error).toBeVisible(); - await expect(error).toHaveText(/Setup failed/); - await expect(error).not.toHaveAttribute("title"); - await expect(setupButton).toBeVisible(); - await expect(setupButton).toHaveText("SET UP"); - await expect(error).toHaveCSS("font-size", "12px"); - await expect(error).toHaveCSS("position", "absolute"); - await expect(error).toHaveCSS("white-space", "nowrap"); - await expect(error.locator("span")).toHaveCSS("text-overflow", "ellipsis"); - await error.hover(); - await expect(page.getByRole("tooltip")).toHaveText( - "Setup couldn’t be completed. Try again.", - ); - await expect(page.getByRole("tooltip")).not.toContainText(installError); - expect( - await heading.evaluate((element) => element.getBoundingClientRect().top), - ).toBe(headingTopBefore); - expect( - await detail.evaluate((element) => element.getBoundingClientRect().top), - ).toBe(detailTopBefore); - expect( - await setupButton.evaluate( - (element) => element.getBoundingClientRect().top, - ), - ).toBe(setupTopBefore); -}); - -test("successful install still waits for refreshed runtime readiness", async ({ - page, -}) => { - await installMockBridge(page, undefined, { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }); - await page.goto("/"); - - await navigateToSetupPage(page); - - const claudeCard = page.getByTestId("onboarding-runtime-claude"); - const setupButton = page.getByTestId("onboarding-runtime-install-claude"); - await expect(setupButton).toHaveText("SET UP"); - await expect(claudeCard).toHaveAttribute("aria-checked", "false"); - - await setupButton.focus(); - await page.keyboard.press("Enter"); - await expect( - page.getByTestId("onboarding-runtime-installed-claude"), - ).toHaveText("INSTALLED"); - const next = page.getByTestId("onboarding-setup-next"); - await expect(next).toBeEnabled(); - await expect(next).toHaveAttribute("data-soft-disabled", "true"); - await next.click(); - await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); - await expect(page.getByTestId("onboarding-page-config")).toHaveCount(0); - await expect(page.getByTestId("onboarding-setup-next-hint")).toHaveText( - "Please finish set up", - ); -}); - -test("config page shows Agent defaults form", async ({ page }) => { - await installMockBridge(page, undefined, { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }); - await page.goto("/"); - - await navigateToConfigPage(page); - - // The defaults form is the page's content; no readiness badge is shown. - await expect(page.locator("#global-agent-default-harness")).toBeVisible(); - await expect(page.getByTestId("global-agent-default-harness")).toHaveText( - "Buzz", - ); - await expect(page.getByText("Default harness")).toBeVisible(); - await expect(page.getByText("Provider", { exact: true })).toBeVisible(); - await expect(page.locator("#global-agent-provider")).toBeVisible(); - await expect(page.locator("#global-agent-model")).toBeVisible(); - await expect(page.getByText("Default LLM provider")).toHaveCount(0); - await expect(page.getByText("Select provider")).toHaveCount(0); - await expect(page.getByTestId("global-agent-provider")).toHaveText( - "Select a provider", - ); - const modelSelect = page.getByTestId("global-agent-model"); - const effortSelect = page.getByTestId("global-agent-thinking-effort-select"); - await expect(modelSelect).toHaveText("Select a model"); - await expect(modelSelect).toBeDisabled(); - await expect(effortSelect).toHaveText("Select effort level"); - await expect(effortSelect).toBeDisabled(); - await page.getByTestId("global-agent-provider").click(); - await expect( - page.getByTestId("global-agent-provider-option-anthropic"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-provider-option-openai"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-provider-option-__custom_provider__"), - ).toHaveCount(0); - await page.keyboard.press("Escape"); - await expect(page.getByText("Applies to all agents")).toHaveCount(0); - await expect(page.getByLabel("OpenAI API Key")).toHaveCount(0); - await expect(effortSelect).toBeVisible(); - await expect( - page.getByText( - "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.", - { exact: true }, - ), - ).toBeVisible(); - await expect(page.getByTestId("agent-readiness-badge")).toHaveCount(0); - - await waitForAnimations(page); - const configPage = page.locator('[data-testid="onboarding-page-config"]'); - await configPage.screenshot({ - path: `${SHOTS}/04-config-defaults-form.png`, - }); -}); - -test("config page gates stale saved model and effort until provider selection", async ({ - page, -}) => { - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { - BUZZ_AGENT_THINKING_EFFORT: "high", - OPENAI_COMPAT_API_KEY: "sk-test", - }, - provider: null, - model: "claude-sonnet-4-6", - }, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - const modelSelect = page.getByTestId("global-agent-model"); - const effortSelect = page.getByTestId("global-agent-thinking-effort-select"); - await expect(page.getByTestId("global-agent-provider")).toHaveText( - "Select a provider", - ); - await expect(modelSelect).toHaveText("Select a model"); - await expect(modelSelect).toBeDisabled(); - await expect(effortSelect).toHaveText("Select effort level"); - await expect(effortSelect).toBeDisabled(); - - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - - await expect(modelSelect).toBeEnabled(); - await expect(modelSelect).toHaveAttribute("data-value", "gpt-5.5"); - await expect(effortSelect).toBeEnabled(); - await expect(effortSelect).toHaveText("Select effort level"); -}); - -test("config page model dropdown filters options via search", async ({ - page, -}) => { - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { OPENAI_COMPAT_API_KEY: "sk-test" }, - provider: "openai", - model: null, - }, - }, - { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - const modelSelect = page.getByTestId("global-agent-model"); - await expect(modelSelect).toBeEnabled(); - await modelSelect.click(); - const search = page.getByTestId("global-agent-model-search"); - await expect(search).toBeVisible(); - await expect( - page.getByTestId("global-agent-model-option-gpt-5.5"), - ).toBeVisible(); - await search.fill("mini"); - await expect( - page.getByTestId("global-agent-model-option-gpt-5.4-mini"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-model-option-gpt-5.5"), - ).toHaveCount(0); - await search.fill("zzz-no-such-model"); - await expect(page.getByText("No matches")).toBeVisible(); - await search.fill(""); - await page.getByTestId("global-agent-model-option-gpt-5.4-nano").click(); - await expect(modelSelect).toHaveAttribute("data-value", "gpt-5.4-nano"); -}); - -test("config page defaults model after provider selection", async ({ - page, -}) => { - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { OPENAI_COMPAT_API_KEY: "sk-test" }, - provider: null, - model: null, - }, - }, - { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - const modelSelect = page.getByTestId("global-agent-model"); - const effortSelect = page.getByTestId("global-agent-thinking-effort-select"); - await expect(modelSelect).toHaveText("Select a model"); - await expect(modelSelect).toBeDisabled(); - await expect(effortSelect).toBeDisabled(); - - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - - await expect(page.getByTestId("global-agent-provider")).toHaveAttribute( - "data-value", - "openai", - ); - await expect(modelSelect).toBeEnabled(); - await expect(modelSelect).toHaveAttribute("data-value", "gpt-5.5"); - await expect(modelSelect).toHaveText("gpt-5.5"); - await expect(effortSelect).toBeEnabled(); - await effortSelect.click(); - await expect( - page.getByTestId("global-agent-thinking-effort-select-option-none"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-thinking-effort-select-option-minimal"), - ).toHaveCount(0); - await expect( - page.getByTestId("global-agent-thinking-effort-select-option-max"), - ).toHaveCount(0); - await page.keyboard.press("Escape"); - await modelSelect.click(); - await expect( - page.getByTestId("global-agent-model-option-__custom_model__"), - ).toHaveCount(0); - await page.keyboard.press("Escape"); -}); - -test("config page waits for baked defaults before showing provider dropdown", async ({ - page, -}) => { - await installMockBridge( - page, - { - bakedBuildEnv: [ - { key: "BUZZ_AGENT_PROVIDER", masked: false, value: "anthropic" }, - { key: "BUZZ_AGENT_MODEL", masked: false, value: "claude-sonnet-4" }, - { key: "ANTHROPIC_API_KEY", masked: true, value: "••••••" }, - ], - bakedBuildEnvDelayMs: 1_000, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - await expect(page.getByText("Loading…")).toBeVisible(); - const providerSelect = page.getByTestId("global-agent-provider"); - await expect(providerSelect).toHaveText("Anthropic"); - await expect(page.getByText("Select provider")).toHaveCount(0); -}); - -test("setup page shows provider discovery loading state", async ({ page }) => { - await installMockBridge( - page, - { acpRuntimesDelayMs: 1_000 }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToSetupPage(page); - - await expect(page.getByTestId("onboarding-runtime-loading")).toBeVisible(); - await expect(page.getByText("Finding your providers...")).toBeVisible(); - - await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible(); - await expect(page.getByTestId("onboarding-runtime-loading")).toHaveCount(0); -}); - -test("config page stays compact when Buzz Agent model config is available", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("buzz-agent", { status: "not_applicable" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - // The compact onboarding form stays bare; readiness copy belongs in Settings. - await expect( - page.getByText("You can finish now and configure agents later in Settings"), - ).toHaveCount(0); - - await waitForAnimations(page); - const configPage = page.locator('[data-testid="onboarding-page-config"]'); - await configPage.screenshot({ - path: `${SHOTS}/05-config-compact.png`, - }); -}); - -test("Finish button is always enabled on config page regardless of readiness", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("buzz-agent", { status: "not_applicable" }), - ], - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - const finishBtn = page.getByTestId("onboarding-finish"); - await expect(finishBtn).toBeVisible(); - await expect(finishBtn).toBeEnabled(); -}); - -test("community setup back button returns to agent defaults", async ({ - page, -}) => { - await installMockBridge(page, undefined, { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }); - await page.goto("/"); - - await navigateToConfigPage(page); - await page.getByTestId("onboarding-finish").click(); - - await expect(page.getByText("Join or create a community")).toBeVisible(); - - await page.getByTestId("welcome-setup-back").click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); -}); - -// --------------------------------------------------------------------------- -// B1 regression: rapid consecutive edits must not lose the later change -// --------------------------------------------------------------------------- - -test("Goose config page discovers models through the selected Goose runtime", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("goose", { status: "not_applicable" }), - ], - globalAgentConfig: { - env_vars: { - OPENAI_COMPAT_API_KEY: "sk-test", - }, - provider: null, - model: null, - }, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - await page.getByTestId("onboarding-runtime-goose").click(); - await page.getByTestId("onboarding-setup-next").click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - await page.getByTestId("global-agent-model").click(); - await expect( - page.getByTestId("global-agent-model-option-gpt-5.5"), - ).toBeVisible(); - await page.keyboard.press("Escape"); -}); - -test("Goose provider dropdown offers setup providers before credentials exist", async ({ - page, -}) => { - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("goose", { status: "not_applicable" }), - ], - globalAgentConfig: { - env_vars: {}, - provider: null, - model: null, - }, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - await navigateToSetupPage(page); - await page.getByTestId("onboarding-runtime-goose").click(); - await page.getByTestId("onboarding-setup-next").click(); - await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - - await page.getByTestId("global-agent-provider").click(); - await expect( - page.getByTestId("global-agent-provider-option-anthropic"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-provider-option-openai"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-provider-option-relay-mesh"), - ).toHaveCount(0); -}); - -test("compact default config still persists rapid provider edits", async ({ - page, -}) => { - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { - ANTHROPIC_API_KEY: "sk-test", - OPENAI_COMPAT_API_KEY: "sk-test", - }, - provider: null, - model: null, - }, - }, - { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }, - ); - await page.goto("/"); - await navigateToConfigPage(page); - - const providerSelect = page.getByTestId("global-agent-provider"); - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - await expect(providerSelect).toHaveAttribute("data-value", "openai"); - - await chooseConfigDropdownOption( - page, - "global-agent-provider", - "openai-compat", - ); - await expect(providerSelect).toHaveAttribute("data-value", "openai-compat"); - - await chooseConfigDropdownOption(page, "global-agent-provider", "anthropic"); - await expect(providerSelect).toHaveAttribute("data-value", "anthropic"); - - await expect(page.getByLabel("Anthropic API Key")).toBeVisible(); - await expect(page.getByLabel("OpenAI API Key")).toHaveCount(0); - await expect(page.getByLabel("Value for DATABRICKS_HOST")).toHaveCount(0); -}); - -test("compact default config keeps credential-backed providers after selecting Buzz", async ({ - page, -}) => { - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { - ANTHROPIC_API_KEY: "sk-test", - OPENAI_COMPAT_API_KEY: "sk-test", - }, - provider: null, - model: null, - }, - }, - { - skipCommunitySeed: true, - skipOnboardingSeed: true, - }, - ); - await page.goto("/"); - await navigateToConfigPage(page); - - const providerSelect = page.getByTestId("global-agent-provider"); - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - await expect(providerSelect).toHaveAttribute("data-value", "openai"); - - await chooseConfigDropdownOption(page, "global-agent-provider", "relay-mesh"); - await expect(providerSelect).toHaveAttribute("data-value", "relay-mesh"); - - await providerSelect.click(); - await expect( - page.getByTestId("global-agent-provider-option-anthropic"), - ).toBeVisible(); - await expect( - page.getByTestId("global-agent-provider-option-openai"), - ).toBeVisible(); - await page.getByTestId("global-agent-provider-option-anthropic").click(); - await expect(providerSelect).toHaveAttribute("data-value", "anthropic"); -}); - -test("rapid consecutive provider changes both survive — later change wins", async ({ - page, -}) => { - // Hold each set_global_agent_config request for 300 ms so the test can - // make a second edit before the first response arrives. - await installMockBridge( - page, - { - acpRuntimesCatalog: [ - availableRuntime("buzz-agent", { status: "not_applicable" }), - ], - globalAgentConfig: { - env_vars: { - ANTHROPIC_API_KEY: "sk-test", - OPENAI_COMPAT_API_KEY: "sk-test", - }, - provider: null, - model: null, - }, - setGlobalAgentConfigDelayMs: 300, - }, - { skipCommunitySeed: true, skipOnboardingSeed: true }, - ); - await page.goto("/"); - - await navigateToConfigPage(page); - - const providerSelect = page.getByTestId("global-agent-provider"); - await expect(providerSelect).toBeVisible(); - - // First edit: select OpenAI — save starts, held open for 300 ms. - await chooseConfigDropdownOption(page, "global-agent-provider", "openai"); - - // Second edit before first response: select Anthropic. The coalescer must - // persist this as the trailing save, and it must survive in the UI. - await chooseConfigDropdownOption(page, "global-agent-provider", "anthropic"); - - // Wait long enough for both saves to complete (2 × 300 ms + margin). - await page.waitForTimeout(800); - - // The final provider shown must be Anthropic — neither save must overwrite - // the later optimistic state with a stale response. - await expect(providerSelect).toHaveAttribute("data-value", "anthropic"); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 71bb7ba6e3..a4c29957a1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -144,6 +144,12 @@ type MockBridgeOptions = { archived_at?: string | null; }>; acpRuntimesCatalog?: Record[]; + /** Catalog returned after a successful mocked install. */ + acpRuntimesCatalogAfterInstall?: Record[]; + /** Catalog responses after install for testing later sign-in completion. */ + acpRuntimesCatalogAfterInstallSequence?: Record[][]; + /** Catalog responses for successive discovery calls. The final response repeats. */ + acpRuntimesCatalogSequence?: Record[][]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; From bee05883ed3e52c2044ccd477e5cacef92632d75 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:36:01 -0700 Subject: [PATCH 04/11] Trust onboarding readiness handoff --- desktop/src/features/onboarding/ui/DefaultConfigStep.tsx | 6 ++++-- desktop/tests/e2e/onboarding-agent-defaults.spec.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 20b55c1b89..5fb7fc35f9 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -25,7 +25,7 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { getReadyOnboardingRuntimes } from "./onboardingRuntimeSelection"; +import { getVisibleOnboardingRuntimes } from "./onboardingRuntimeSelection"; import type { DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { @@ -104,9 +104,11 @@ function AgentDefaultsSection({ () => new Set(readyRuntimeIds), [readyRuntimeIds], ); + // Setup already confirmed readiness. Re-filter only for onboarding + // visibility here; a transient auth recheck must not invalidate that handoff. const readyRuntimes = React.useMemo( () => - getReadyOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => + getVisibleOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => readyRuntimeIdSet.has(runtime.id), ), [readyRuntimeIdSet, runtimesQuery.data], diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 76e978602c..aeed5ed48e 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -200,7 +200,7 @@ test("install transitions through Sign in to Ready", async ({ page }) => { ).toBeVisible(); }); -test("defaults includes every ready visible harness and persists the user's choice there", async ({ +test("defaults trusts setup readiness and persists the user's visible harness choice", async ({ page, }) => { await installMockBridge( From f3be77543454e140ae23809364d63c2e521d5ad1 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:43:52 -0700 Subject: [PATCH 05/11] Hide onboarding readiness checkmark --- desktop/src/features/onboarding/ui/SetupStep.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 80b9444c9a..b1ebb126af 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -72,7 +72,9 @@ function RuntimeReadinessIndicator({ runtime: AcpRuntimeCatalogEntry; ready: boolean; }) { - if (!ready) return null; + // Checkmark temporarily hidden; flip to true to restore it. + const showReadinessCheckmark = false; + if (!ready || !showReadinessCheckmark) return null; return ( Date: Tue, 21 Jul 2026 10:44:50 -0700 Subject: [PATCH 06/11] Update onboarding screenshot heading --- desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 569327cdb8..dbb8a92a1f 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -67,7 +67,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await page.getByTestId("onboarding-next").click(); await expect( - page.getByRole("heading", { name: "Use the models that fit the task" }), + page.getByRole("heading", { name: "Set up your agent harnesses" }), ).toBeVisible(); await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` }); From f9dcc596f44753666afd83cece91443484a42716 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:55:59 -0700 Subject: [PATCH 07/11] Update readiness tests for hidden checkmark --- desktop/tests/e2e/onboarding-agent-defaults.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index aeed5ed48e..d118f51ddc 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -84,7 +84,7 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByRole("checkbox")).toHaveCount(0); }); -test("ready state is detected, checked, and enables Next without persisting a default", async ({ +test("ready state is detected and enables Next without persisting a default", async ({ page, }) => { await installMockBridge( @@ -105,7 +105,7 @@ test("ready state is detected, checked, and enables Next without persisting a de ); await expect( page.getByTestId("onboarding-runtime-checkmark-claude"), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId("onboarding-runtime-checkmark-codex"), ).toHaveCount(0); @@ -197,7 +197,7 @@ test("install transitions through Sign in to Ready", async ({ page }) => { ); await expect( page.getByTestId("onboarding-runtime-checkmark-claude"), - ).toBeVisible(); + ).toHaveCount(0); }); test("defaults trusts setup readiness and persists the user's visible harness choice", async ({ From 0bff8bb77a8a968eab20066639007b8aef879c50 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:01:39 -0700 Subject: [PATCH 08/11] Stop completed sign-in polling --- .../src/features/onboarding/ui/SetupStep.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index b1ebb126af..6111589cef 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -110,6 +110,15 @@ function RuntimeStatus({ const connectMutation = useConnectAcpRuntimeMutation(); const runtimesQuery = useAcpRuntimesQuery(); const [isWaitingForSignIn, setIsWaitingForSignIn] = React.useState(false); + const [didSignInCheckTimeOut, setDidSignInCheckTimeOut] = + React.useState(false); + const isReady = runtimeIsReadyForOnboarding(runtime); + + React.useEffect(() => { + if (!isWaitingForSignIn || !isReady) return; + setIsWaitingForSignIn(false); + setDidSignInCheckTimeOut(false); + }, [isReady, isWaitingForSignIn]); React.useEffect(() => { if (!isWaitingForSignIn) return; @@ -119,6 +128,7 @@ function RuntimeStatus({ }, 2_000); const timeout = window.setTimeout(() => { setIsWaitingForSignIn(false); + setDidSignInCheckTimeOut(true); }, 120_000); return () => { @@ -143,6 +153,12 @@ function RuntimeStatus({ className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40" data-testid={`onboarding-runtime-instructions-${runtime.id}`} onClick={() => { + if (didSignInCheckTimeOut) { + setDidSignInCheckTimeOut(false); + setIsWaitingForSignIn(true); + void runtimesQuery.refetch(); + return; + } if (!authMethod) { void methodsQuery.refetch(); return; @@ -160,7 +176,11 @@ function RuntimeStatus({ type="button" variant="ghost" > - {isWaitingForSignIn ? "CHECKING…" : "SIGN IN"} + {isWaitingForSignIn + ? "CHECKING…" + : didSignInCheckTimeOut + ? "CHECK AGAIN" + : "SIGN IN"} {methodsQuery.error instanceof Error ? ( Date: Tue, 21 Jul 2026 11:03:32 -0700 Subject: [PATCH 09/11] Auto-select sole onboarding harness --- .../onboarding/ui/DefaultConfigStep.tsx | 18 ++++++++-- .../e2e/onboarding-agent-defaults.spec.ts | 35 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 5fb7fc35f9..bdc5eb9e82 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -121,9 +121,6 @@ function AgentDefaultsSection({ const selectedRuntimeId = selectedRuntime?.id ?? ""; const configSurfaceLoading = isLoading || runtimesQuery.isLoading; - React.useEffect(() => { - onHasDefaultRuntimeChange(selectedRuntimeId.length > 0); - }, [onHasDefaultRuntimeChange, selectedRuntimeId]); const configSurfaceError = runtimesQuery.isError || (!configSurfaceLoading && @@ -149,6 +146,21 @@ function AgentDefaultsSection({ [config], ); + React.useEffect(() => { + if (configSurfaceLoading || selectedRuntimeId) return; + if (readyRuntimes.length !== 1) return; + handleHarnessChange(readyRuntimes[0].id); + }, [ + configSurfaceLoading, + handleHarnessChange, + readyRuntimes, + selectedRuntimeId, + ]); + + React.useEffect(() => { + onHasDefaultRuntimeChange(selectedRuntimeId.length > 0); + }, [onHasDefaultRuntimeChange, selectedRuntimeId]); + return (
{configSurfaceLoading ? ( diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index d118f51ddc..44bba460c2 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -200,7 +200,40 @@ test("install transitions through Sign in to Ready", async ({ page }) => { ).toHaveCount(0); }); -test("defaults trusts setup readiness and persists the user's visible harness choice", async ({ +test("defaults auto-selects the only ready visible harness", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime("goose", "available", { status: "not_applicable" }), + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_out" }), + ], + 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(); + + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + await expect.poll(() => readSavedRuntime(page)).toBe("claude"); +}); + +test("defaults requires a choice when multiple visible harnesses are ready", async ({ page, }) => { await installMockBridge( From 75599d3e363eaa4dbe03c318424e4f3b2e680139 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:25:58 -0700 Subject: [PATCH 10/11] Harden onboarding defaults persistence --- desktop/src/features/agents/AGENTS.md | 3 +- .../onboarding/ui/DefaultConfigStep.tsx | 59 +++- .../onboarding/ui/saveCoalescer.test.mjs | 41 +++ .../features/onboarding/ui/saveCoalescer.ts | 49 ++- .../e2e/onboarding-agent-defaults.spec.ts | 285 ++++++++++++++++++ 5 files changed, 422 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index ff1ab998a3..cde3ad4b80 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -68,7 +68,8 @@ with a TypeScript lookup table or an id comparison in a component. - `ui/agentConfigFieldsContract.test.mjs` — canonical behaviors + disclosure presets. If this fails, you probably reintroduced a per-surface flag. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior - pin (35 tests). + acceptance coverage for readiness, failure states, defaults, navigation, and + persistence races. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. ## Keep this file true diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index bdc5eb9e82..b1a536ee4d 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -40,10 +40,13 @@ function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { } function AgentDefaultsSection({ - onHasDefaultRuntimeChange, + onPersistenceStateChange, readyRuntimeIds, }: { - onHasDefaultRuntimeChange: (hasDefaultRuntime: boolean) => void; + onPersistenceStateChange: (state: { + canComplete: boolean; + flush: () => Promise; + }) => void; readyRuntimeIds: readonly string[]; }) { const runtimesQuery = useAcpRuntimesQuery(); @@ -55,8 +58,10 @@ function AgentDefaultsSection({ const [bakedEnv, setBakedEnv] = React.useState([]); const coalescerRef = React.useRef<{ enqueue: (value: GlobalAgentConfig) => void; + flush: () => Promise; cancel: () => void; } | null>(null); + const [isSaving, setIsSaving] = React.useState(false); React.useEffect(() => { let unmounted = false; @@ -87,7 +92,9 @@ function AgentDefaultsSection({ // set_global_agent_config returns a save result (config + restart // counts); the coalescer round-trips the persisted config only. async (next) => (await setGlobalAgentConfig(next)).config, - () => undefined, // saving state not surfaced in this autosave UX + (saving) => { + if (!unmounted) setIsSaving(saving); + }, (saved) => { if (!unmounted) setConfig(saved); }, @@ -157,9 +164,16 @@ function AgentDefaultsSection({ selectedRuntimeId, ]); + const flushPersistence = React.useCallback( + () => coalescerRef.current?.flush() ?? Promise.resolve(), + [], + ); React.useEffect(() => { - onHasDefaultRuntimeChange(selectedRuntimeId.length > 0); - }, [onHasDefaultRuntimeChange, selectedRuntimeId]); + onPersistenceStateChange({ + canComplete: selectedRuntimeId.length > 0 && !isSaving, + flush: flushPersistence, + }); + }, [flushPersistence, isSaving, onPersistenceStateChange, selectedRuntimeId]); return (
@@ -230,7 +244,26 @@ export function DefaultConfigStep({ direction, readyRuntimeIds, }: DefaultConfigStepProps) { - const [hasDefaultRuntime, setHasDefaultRuntime] = React.useState(false); + const [persistenceState, setPersistenceState] = React.useState<{ + canComplete: boolean; + flush: () => Promise; + }>({ canComplete: false, flush: () => Promise.resolve() }); + const [completionError, setCompletionError] = React.useState( + null, + ); + const [isCompleting, setIsCompleting] = React.useState(false); + + const handleComplete = React.useCallback(async () => { + setIsCompleting(true); + setCompletionError(null); + try { + await persistenceState.flush(); + actions.complete(); + } catch { + setCompletionError("Couldn't save your default harness. Try again."); + setIsCompleting(false); + } + }, [actions, persistenceState]); return (
+ {completionError ? ( +

+ {completionError} +

+ ) : null}
@@ -263,8 +304,8 @@ export function DefaultConfigStep({