Files
buzz/desktop/tests/e2e/agent-numeric-tuning.spec.ts
985cdcc6ea feat(agents): model-tuning parity in global Agent Defaults editor (#4578)
## Overview

The global Agent Defaults surface (Settings card, defaults modal,
onboarding) exposed structured controls for Effort but left Max Output
Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs
had structured numeric fields but only for `isBuzzAgentRuntime` —
incorrectly excluding Goose. This PR unifies numeric-tuning capability
across all surfaces, fixes a pre-existing dual-editor defect, and adds
full test coverage.

## What changed

### Phase 1 — Catalog projection

- Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs`
(`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere).
- Project all three numeric env-var fields (`max_tokens_env_var`,
`context_limit_env_var`, `max_rounds_env_var`) end-to-end:
`AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`,
`RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in
`tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`).

### Phase 2 — Field model

- `deriveAgentConfigFieldModel` now derives `maxOutputTokens` /
`contextLimit` / `maxRounds` descriptors from catalog-projected fields.
- `structuredEnvKeys(descriptors)` — exported helper that takes the
**rendered** descriptor set (not the whole model). Hidden keys follow
what is actually rendered per surface: global hides effort + all three
numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides
effort + three numeric keys; per-agent Goose hides only its two numeric
keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row
per-agent because no effort control renders there.

### Phase 3 — UI

- Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as
a shared descriptor-driven component (`descriptors`, `envVars`,
`inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima:
`NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1,
`maxRounds`: 0) applied to `<input min>`.
- **Global surface** (`AgentConfigFields.tsx`): deduplicate the
previously duplicated Advanced env-editor block; render
`NumericTuningFields` below the env editor when descriptors exist;
`hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys`
so structured keys are never double-rendered. Under 1000 lines.
- **Per-agent surfaces** (`EditAgentAdvancedFields`,
`PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the
numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from
`agentConfigCore`; hidden keys come from
`structuredEnvKeys(numericDescriptors)` — the same rendered descriptor
set, no local rebuilding (fixes pre-existing dual-editor defect).
Catalog status carried as `RuntimeCatalogStatus` (`loading | ready |
error`); both error and loading withhold structured controls and leave
saved values visible as generic rows, making error distinguishable from
"runtime not capable" (`ready` + no runtime).
- **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`,
callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?:
"loading" | "ready" | "error"` (replaces separate
`runtimesLoading`/`runtimesError` booleans); all call sites —
`AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`,
`UserProfilePersonaDialogs` — compute and pass the status.

### Phase 4 — Tests

- `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows,
value, requiredKeys, hiddenKeys) => Record<string, string>` helper for
isolation testing.
- **17 new node tests** in `agentConfigCore.test.mjs`:
`deriveNumericDescriptors` (all three fields, partial, undefined
runtime, matches field-model subset); `structuredEnvKeys` per surface
including discriminating Goose per-agent effort-key invariant;
`NUMERIC_KIND_MIN` values.
- **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key
preserved through generic row edits; runtime-switch then generic edit
(derives both descriptor sets, asserts new-runtime hidden key survives
`buildRecord` via `hiddenKeys` and old-runtime key survives via generic
rows); baked numeric key excluded via `filterBakedGenericRows` with
`numericTuningPlaceholder` assertion; clearing a structured override —
`numericTuningPlaceholder` verifies placeholder text.
- **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to
smoke project `testMatch`): global numeric fields visible for
buzz-agent; global: non-capable runtime hides numeric controls; Goose
per-agent shows `Inherit (16384)` after saving global value through the
UI; delayed catalog: saved values visible as generic rows while loading
then structured controls appear after settle; failed catalog: saved
values remain visible as generic rows (never the "unsupported" empty
state).

## Result

- buzz-agent global defaults: Max output tokens, Context limit, Max
rounds as structured inputs with `Inherit (N)` placeholders from baked
env.
- Goose global defaults: Max output tokens, Context limit as structured
inputs.
- A Goose global value surfaces as `Inherit (<value>)` in the per-agent
Goose edit dialog.
- No structured key is editable in two places on any surface; no
persisted key has zero editors.
- No `runtime.id === "buzz-agent"` comparison decides numeric-field
visibility anywhere — capability flows catalog →
`AcpRuntimeCatalogEntry` → field model → UI.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-03 18:09:24 -04:00

373 lines
13 KiB
TypeScript

/**
* Playwright regression tests for the numeric tuning fields (max output tokens,
* context limit, max rounds) on both the global Agent Defaults surface and the
* per-agent Advanced section.
*
* Covers:
* 1. Global defaults Advanced shows numeric inputs for buzz-agent.
* 2. Global defaults Advanced hides numeric inputs for non-capable runtimes.
* 3. Per-agent Goose: saving a max-tokens value globally surfaces as
* Inherit (<value>) placeholder in the per-agent edit dialog.
* 4. Delayed catalog: while loading, saved tuning env vars stay visible
* as generic rows (not silently dropped); structured controls appear
* once the catalog settles.
* 5. Failed catalog: when discovery errors, saved tuning env vars remain
* visible as generic rows (never the "unsupported" empty state).
*/
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
// ── Helpers ────────────────────────────────────────────────────────────────
async function openAiDefaultsSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(".animate-spin").first()).not.toBeVisible({
timeout: 5_000,
});
}
async function openEditAgentDialog(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: `${agentName} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
}
// ── Tests ──────────────────────────────────────────────────────────────────
test("global_advanced_buzz_agent_shows_all_numeric_controls", async ({
page,
}) => {
// The mock bridge's withMockRuntimeConfigMetadata injects the numeric env var
// fields for buzz-agent. When buzz-agent is selected and Advanced is opened,
// all three numeric inputs must be visible.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: null,
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
auth_status: { status: "not_applicable" },
},
],
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
});
await openAiDefaultsSettings(page);
// Open the Advanced section. The settings card uses disclosure="full" (no
// animation wrapper), so we click the toggle and wait for content directly.
await page.getByTestId("global-agent-advanced-toggle").click();
// All three numeric inputs must be present for buzz-agent.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
await expect(page.getByTestId("numeric-context-limit-input")).toBeVisible();
await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible();
});
test("global_advanced_non_capable_runtime_hides_numeric_controls", async ({
page,
}) => {
// Claude has no numeric tuning env vars (contextLimitEnvVar = null etc.).
// After selecting Claude, the Advanced section must show no numeric inputs.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "available",
command: "/usr/local/bin/claude-agent",
binary_path: "/usr/local/bin/claude-agent",
default_args: ["acp"],
mcp_command: null,
install_hint: "Install via npm.",
install_instructions_url: "https://example.com",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
auth_status: { status: "logged_in" },
},
],
globalAgentConfig: {
env_vars: {},
provider: null,
model: null,
preferred_runtime: "claude",
},
});
await openAiDefaultsSettings(page);
await page.getByTestId("global-agent-advanced-toggle").click();
// No numeric inputs must render for a non-capable runtime.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
await expect(page.getByTestId("numeric-context-limit-input")).toHaveCount(0);
await expect(page.getByTestId("numeric-max-rounds-input")).toHaveCount(0);
});
test("goose_per_agent_advanced_max_tokens_shows_inherited_global_placeholder", async ({
page,
}) => {
// Save GOOSE_MAX_TOKENS = 16384 in the global Agent Defaults settings via
// the UI, then open a Goose agent's edit dialog. The max-output-tokens input
// must show "Inherit (16384)" — the globally-saved value surfaced via the
// inherited placeholder.
await installMockBridge(page, {
globalAgentConfig: {
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" },
provider: "anthropic",
model: "claude-opus-4-5",
preferred_runtime: "goose",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "goose",
status: "stopped",
channelNames: ["agents"],
},
],
});
// Step 1: open global defaults, expand Advanced, enter the max-tokens value.
await openAiDefaultsSettings(page);
await page.getByTestId("global-agent-advanced-toggle").click();
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
await page.getByTestId("numeric-max-output-tokens-input").click();
await page
.getByTestId("numeric-max-output-tokens-input")
.pressSequentially("16384");
// Blur to ensure React's change event fires for the number input.
await page.keyboard.press("Tab");
// Step 2: save the global defaults.
await expect(page.getByRole("button", { name: "Save defaults" })).toBeEnabled(
{ timeout: 5_000 },
);
await page.getByRole("button", { name: "Save defaults" }).click();
// Wait for the save to complete: the button returns to disabled (dirty resets).
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeDisabled({ timeout: 5_000 });
// Step 3: navigate back and open the per-agent edit dialog for the Goose
// agent. We use the app's Back link rather than page.goto("/") to preserve
// the in-memory mock state (page.goto causes a full reload that resets it).
await page.getByRole("button", { name: "Back to app" }).click();
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: "Tyler Agent agent profile",
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
// Wait for the provider field — signals the catalog and dialog have settled.
await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({
timeout: 10_000,
});
// Open the Advanced section.
await page.getByRole("button", { name: "Advanced", exact: true }).click();
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
// The placeholder must reflect the globally-saved value.
await expect(
page.getByTestId("numeric-max-output-tokens-input"),
).toHaveAttribute("placeholder", "Inherit (16384)");
});
test("delayed_catalog_per_agent_saved_tuning_values_visible_then_structured_controls_appear", async ({
page,
}) => {
// Scenario: catalog takes 5 seconds to respond (simulates slow discovery).
// The per-agent edit dialog opens. While the catalog is still in flight,
// saved tuning env vars must not be dropped from view — they appear as
// generic env rows (no hiddenKeys applied yet). Once the catalog settles,
// the structured numeric controls replace the generic rows.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: null,
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
auth_status: { status: "not_applicable" },
},
],
// 5-second delay: generous enough that the dialog opens and Advanced is
// expanded while the catalog query is still in-flight (navigation takes
// ~1-2 s), but short enough to keep the test under 30 s.
acpRuntimesDelayMs: 5000,
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "buzz-agent",
status: "stopped",
channelNames: ["agents"],
envVars: {
BUZZ_AGENT_MAX_OUTPUT_TOKENS: "4096",
BUZZ_AGENT_MAX_ROUNDS: "25",
},
},
],
});
await openEditAgentDialog(page, "Tyler Agent");
// Open Advanced before the catalog has settled (the dialog opens quickly;
// the catalog query fires when the dialog opens and takes ~5 seconds).
await page.getByRole("button", { name: "Advanced", exact: true }).click();
// While loading: structured numeric controls must NOT be visible yet —
// the catalog-settling gate withholds them.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
// The saved tuning env vars must be visible as generic rows (not hidden)
// while the catalog hasn't settled: BUZZ_AGENT_MAX_OUTPUT_TOKENS and
// BUZZ_AGENT_MAX_ROUNDS should appear in the env-vars editor.
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]',
),
).toBeVisible();
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]',
),
).toBeVisible();
// After the catalog settles (allow up to 8 s — 5 s delay + margin):
// structured controls appear, replacing the generic rows.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 8_000 },
);
await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible({
timeout: 8_000,
});
});
test("failed_catalog_per_agent_saved_tuning_values_remain_visible_as_generic_rows", async ({
page,
}) => {
// Scenario: catalog discovery fails (network error / IPC rejection).
// The per-agent edit dialog opens. The saved tuning env vars must remain
// visible as generic rows — the error state must never produce the
// "unsupported" no-controls state that would hide persisted values.
await installMockBridge(page, {
acpRuntimesError: true,
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "buzz-agent",
status: "stopped",
channelNames: ["agents"],
envVars: {
BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192",
BUZZ_AGENT_MAX_ROUNDS: "10",
},
},
],
});
await openEditAgentDialog(page, "Tyler Agent");
// Open Advanced after a brief wait (query has had time to fail).
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Advanced", exact: true }).click();
// Structured numeric controls must NOT render (catalog errored — no runtime).
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
// Saved tuning values must still be visible as generic env rows — the error
// state must never hide persisted values with no editor to replace them.
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]',
),
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]',
),
).toBeVisible();
});