Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DEVIN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 32 additions & 33 deletions desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import {
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import {
getDefaultConfigOnboardingRuntimes,
getReadyOnboardingRuntimes,
getVisibleOnboardingRuntimes,
} from "./onboardingRuntimeSelection";
import type { DefaultConfigStepActions } from "./types";

Expand Down Expand Up @@ -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<BakedEnvEntry[]>([]);
const coalescerRef = React.useRef<{
enqueue: (value: GlobalAgentConfig) => void;
Expand Down Expand Up @@ -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 } =
Expand All @@ -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);
Expand All @@ -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 (
<section className="w-full space-y-4 text-left text-sm">
Expand Down Expand Up @@ -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
/>
Expand Down Expand Up @@ -292,12 +291,12 @@ export function DefaultConfigStep({
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-title font-normal text-foreground">
Configure your default model settings
Choose how your agents run
</h1>
<p className="mx-auto mt-3 max-w-[440px] text-sm leading-5 text-foreground/80">
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.
</p>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
getDefaultConfigOnboardingRuntimes,
getReadyOnboardingRuntimes,
getVisibleOnboardingRuntimes,
runtimeIsReadyForOnboarding,
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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"],
);
});
47 changes: 40 additions & 7 deletions desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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(
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion desktop/src/features/onboarding/welcomeGuide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading