Skip to content
Open
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
17 changes: 6 additions & 11 deletions desktop/src/features/agents/lib/agentConfigCore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => {
});
});

test("Goose exposes provider, model, and its real effort application key", () => {
test("Goose omits effort until native persistence and options are wired", () => {
const model = deriveAgentConfigFieldModel({
config,
runtime: runtime("goose", {
Expand All @@ -80,17 +80,12 @@ test("Goose exposes provider, model, and its real effort application key", () =>
});

assert.equal(
field(model, "effort").optionSource,
"legacyProviderModelCatalog",
model.fields.some((item) => item.kind === "effort"),
false,
);
assert.deepEqual(field(model, "effort").currentPersistence, {
kind: "envVar",
key: "BUZZ_AGENT_THINKING_EFFORT",
});
assert.deepEqual(field(model, "effort").targetApplication, {
kind: "envVar",
key: "GOOSE_THINKING_EFFORT",
});
assert.deepEqual(model.omissions, [
{ kind: "effort", reason: "pendingNativePersistence" },
]);
});

test("Claude models effort as a deferred native ACP option", () => {
Expand Down
14 changes: 8 additions & 6 deletions desktop/src/features/agents/lib/agentConfigCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export type AgentConfigFieldDescriptor =

export type AgentConfigOmission = {
kind: "effort";
reason: "ownedByModelId" | "unsupportedByHarness";
reason:
| "ownedByModelId"
| "pendingNativePersistence"
| "unsupportedByHarness";
};

/**
Expand Down Expand Up @@ -203,13 +206,10 @@ export function deriveAgentConfigFieldModel({
value: config.model,
});

if (runtime?.thinkingEnvVar) {
if (runtime?.id === "buzz-agent" && runtime.thinkingEnvVar) {
fields.push({
kind: "effort",
optionSource:
runtime.id === "buzz-agent"
? "buzzAgentCatalog"
: "legacyProviderModelCatalog",
optionSource: "buzzAgentCatalog",
currentPersistence: {
kind: "envVar",
key: BUZZ_AGENT_THINKING_EFFORT,
Expand All @@ -218,6 +218,8 @@ export function deriveAgentConfigFieldModel({
render: "control",
value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT),
});
} else if (runtime?.id === "goose" && runtime.thinkingEnvVar) {
omissions.push({ kind: "effort", reason: "pendingNativePersistence" });
} else if (runtime?.id === "claude") {
fields.push({
kind: "effort",
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/features/agents/ui/bakedEnvHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ export function getBakedModelInheritLabel(bakedModelId: string): string {
return `Inherit build default (${bakedModelId})`;
}

/** Resolve baked provider/model values only from this runtime's catalog keys. */
export function getRuntimeBakedDefaults(
bakedEnv: readonly BakedEnvEntry[],
runtime:
| { id: string; modelEnvVar: string | null; providerEnvVar: string | null }
| undefined,
globalEnv: Readonly<Record<string, string>>,
): { model: string | null; provider: string | null } {
const provider = runtime?.providerEnvVar
? resolveInheritedDefault(null, bakedEnv, runtime.providerEnvVar).value
: "";
const directModel = runtime?.modelEnvVar
? resolveInheritedDefault(null, bakedEnv, runtime.modelEnvVar).value
: "";
const model =
directModel ||
(runtime?.id === "buzz-agent"
? getGlobalModelFallback(bakedEnv, provider, globalEnv)
: null);
return { model: model || null, provider: provider || null };
}

function providerModelEnvKey(provider: string): string | null {
switch (provider.trim().toLowerCase()) {
case "databricks":
Expand Down
48 changes: 37 additions & 11 deletions desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ function AgentDefaultsSection({
initialDraftRef.current?.isCustomModelEditing ?? false,
);
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
const [configLoadError, setConfigLoadError] = React.useState(false);
const [configLoadNonce, retryConfigLoad] = React.useReducer(
(nonce: number) => nonce + 1,
0,
);
const configRef = React.useRef<GlobalAgentConfig>(
initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG,
);
Expand All @@ -86,33 +91,37 @@ function AgentDefaultsSection({
React.useEffect(() => {
let unmounted = false;

async function loadDefaults() {
async function loadDefaults(_retryAttempt: number) {
setIsLoading(true);
setConfigLoadError(false);
const [configResult, bakedEnvResult] = await Promise.allSettled([
getGlobalAgentConfig(),
getBakedBuildEnv(),
]);

if (unmounted) return;

if (
initialDraftRef.current === null &&
configResult.status === "fulfilled"
) {
configRef.current = configResult.value;
setConfig(configResult.value);
}
if (bakedEnvResult.status === "fulfilled") {
setBakedEnv(bakedEnvResult.value);
}
if (configResult.status === "rejected") {
setConfigLoadError(true);
setIsLoading(false);
return;
}
if (initialDraftRef.current === null) {
configRef.current = configResult.value;
setConfig(configResult.value);
}
setIsLoading(false);
}

void loadDefaults();
void loadDefaults(configLoadNonce);

return () => {
unmounted = true;
};
}, []);
}, [configLoadNonce]);

const effectiveReadyRuntimeIds = React.useMemo(
() =>
Expand Down Expand Up @@ -147,6 +156,7 @@ function AgentDefaultsSection({
const configSurfaceLoading = isLoading || runtimesQuery.isLoading;

const configSurfaceError =
configLoadError ||
runtimesQuery.isError ||
(!configSurfaceLoading &&
effectiveReadyRuntimeIds.length > 0 &&
Expand Down Expand Up @@ -212,12 +222,14 @@ function AgentDefaultsSection({
// configIsValid comes from AgentConfigFields' onValidityChange and
// covers model + provider credentials — a harness selection alone is
// not a working default (e.g. buzz-agent with no provider configured).
canComplete: selectedRuntimeId.length > 0 && configIsValid,
canComplete:
!configLoadError && selectedRuntimeId.length > 0 && configIsValid,
commit: commitPersistence,
});
}, [
commitPersistence,
configIsValid,
configLoadError,
onPersistenceStateChange,
selectedRuntimeId,
]);
Expand All @@ -233,6 +245,20 @@ function AgentDefaultsSection({
<Spinner className="h-4 w-4 border-2" />
Loading…
</div>
) : configLoadError ? (
<div className="flex flex-col items-center gap-3 py-4 text-center text-sm">
<p className="text-destructive">
Couldn't load your existing default harness. Try again.
</p>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
disabled={isLoading}
onClick={retryConfigLoad}
type="button"
>
{isLoading ? "Checking…" : "Try again"}
</Button>
</div>
) : configSurfaceError ? (
<p className="py-4 text-center text-sm text-destructive">
Couldn't load harness settings. Go back and try again.
Expand Down
53 changes: 53 additions & 0 deletions desktop/src/features/onboarding/ui/HarnessChoiceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type * as React from "react";

import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Card, type CardProps } from "@/shared/ui/card";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";

/**
* Setup-page width that mirrors one column of the untouched chooser grid:
* the onboarding frame is min(viewport - page padding, 65rem), with three
* 1rem gaps divided across four columns. Do not apply this to the chooser —
* its grid remains the source of truth.
*/
export const HARNESS_SETUP_CARD_WIDTH_CLASS =
"w-full max-w-[288px] md:w-[calc((min(100vw-2rem,65rem)-3rem)/4)]";

type HarnessChoiceCardProps = Omit<CardProps, "variant"> & {
runtime: AcpRuntimeCatalogEntry;
};

/**
* The single visual for an onboarding harness card — icon + name on the
* textured surface. The chooser renders it interactive; the setup page
* renders it static. Both pages keep identical size and content.
*/
export function HarnessChoiceCard({
className,
runtime,
...cardProps
}: HarnessChoiceCardProps): React.JSX.Element {
return (
<Card
className={cn(
// Height compresses on short windows (800×500 minimum) so the grid +
// docked footer fit without clipping. The textured variant's
// min-height floor must drop with it (see card-texture.css — compact
// cards opt in via the variable). Chooser and setup page share this
// class, keeping the card-travel transition dimension-stable.
"h-[180px] w-full select-none items-center px-3 py-1.5 text-center [--buzz-card-textured-min-height:180px] [@media(max-height:560px)]:h-[120px] [@media(max-height:560px)]:[--buzz-card-textured-min-height:120px]",
className,
)}
variant="textured"
{...cardProps}
>
<div className="flex min-w-0 flex-col items-center gap-3">
<RuntimeIcon className="h-8 w-8" runtime={runtime} />
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
{getRuntimeDisplayLabel(runtime)}
</h2>
</div>
</Card>
);
}
4 changes: 4 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ type E2eConfig = {
model: string | null;
preferred_runtime?: string | null;
};
/** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */
globalAgentConfigErrors?: (string | null)[];
/** Explicit owner-only agent-access capability; independent of baked defaults. */
ownerOnlyAccessBuild?: boolean;
/** File-layer config returned by runtime id. */
Expand Down Expand Up @@ -13153,6 +13155,8 @@ export function maybeInstallE2eTauriMocks() {
return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null;
}
case "get_global_agent_config": {
const readError = activeConfig?.mock?.globalAgentConfigErrors?.shift();
if (readError) throw new Error(readError);
// Return the mutable persisted mock value, seeded from the test config.
return (
mockGlobalAgentConfig ?? {
Expand Down
2 changes: 1 addition & 1 deletion desktop/tests/e2e/harness-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a

// Now on the setup page.
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
page.getByRole("heading", { name: "Choose your default harness" }),
).toBeVisible({ timeout: 10_000 });

// Click the "More harnesses" link — fires navigateToAgentSettings.
Expand Down
96 changes: 96 additions & 0 deletions desktop/tests/e2e/onboarding-agent-defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,102 @@ test("defaults renders only fields supported by the selected harness", async ({
).toHaveCount(0);
});

test("failed config read blocks Next, preserves existing config, and retry succeeds", async ({
page,
}) => {
await installMockBridge(
page,
{
acpRuntimesCatalog: [
runtime("claude", "available", { status: "logged_in" }),
runtime("codex", "available", { status: "logged_in" }),
],
globalAgentConfig: {
env_vars: {
ANTHROPIC_API_KEY: "sk-existing",
BUZZ_AGENT_THINKING_EFFORT: "high",
},
provider: "anthropic",
model: "claude-sonnet-4",
preferred_runtime: "claude",
},
globalAgentConfigErrors: ["temporary config read failure", null],
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
await page.getByTestId("onboarding-setup-next").click();

await expect(
page.getByText("Couldn't load your existing default harness. Try again."),
).toBeVisible();
await expect(page.getByTestId("onboarding-finish")).toBeDisabled();
await page.getByTestId("onboarding-finish").click({ force: true });
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();

let saved = await page.evaluate(async () => {
return await (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload: unknown,
) => Promise<{
env_vars: Record<string, string>;
model: string | null;
preferred_runtime: string | null;
provider: string | null;
}>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null);
});
expect(saved).toMatchObject({
env_vars: {
ANTHROPIC_API_KEY: "sk-existing",
BUZZ_AGENT_THINKING_EFFORT: "high",
},
model: "claude-sonnet-4",
preferred_runtime: "claude",
provider: "anthropic",
});

await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
"Claude Code",
);
const harness = page.getByTestId("global-agent-default-harness");
await harness.click();
await page.getByTestId("global-agent-default-harness-option-codex").click();
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
await page.getByTestId("onboarding-finish").click();
await expect(page.getByText("Join or create a community")).toBeVisible();

saved = await page.evaluate(async () => {
return await (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload: unknown,
) => Promise<{
env_vars: Record<string, string>;
model: string | null;
preferred_runtime: string | null;
provider: string | null;
}>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null);
});
expect(saved).toMatchObject({
env_vars: {
ANTHROPIC_API_KEY: "sk-existing",
},
model: null,
preferred_runtime: "codex",
provider: null,
});
expect(saved?.env_vars).not.toHaveProperty("BUZZ_AGENT_THINKING_EFFORT");
});

test("defaults hides model when optional harness has empty discovery", async ({
page,
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
page.getByRole("heading", { name: "Choose your default harness" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
Expand Down
Loading
Loading