Files
buzz/desktop/src/features/agents/ui/ModelPicker.tsx
Will Pfleger 50a71137e6 feat(managed-agents): close five Claude Code agent-config gaps (#4557)
Closes five Claude Code agent-config gaps in Buzz Desktop, split into
three commits that share the spawn-time and live-switch surfaces.

## Config isolation, model authority, and Auto mode (#2692, #2884,
#3493)

- **`CLAUDE_CONFIG_DIR` isolation (#3493).** `config_bridge` resolves
both `settings.json` and `.claude.json` panel paths from the agent's
effective env (`resolve_effective_agent_env` — baked floor → definition
→ global → persona → record), so the panel reads the same directory the
agent runs against. `mcp_config_file_path_for_runtime` honors a custom
dir; empty/blank is treated as unset, matching Claude's
`CLAUDE_CONFIG_DIR || homedir()` semantics. `AgentConfigPanel` shows a
Keychain caveat when a custom dir is active (a custom dir maps to a
fresh Keychain namespace unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is
also set).
- **Single startup model authority (#2692).** `ANTHROPIC_MODEL` is the
sole startup model authority for Claude. Local spawns write
`ANTHROPIC_MODEL` and strip `BUZZ_ACP_MODEL` so the harness never sees
two authorities; remote deploys send `ANTHROPIC_MODEL` in `policy_env`
instead of `BUZZ_ACP_MODEL`. Non-Claude runtimes are unchanged.
- **`PermissionMode::Auto` (#2884).** Wire string `"auto"`, model-gated,
degrades to the agent default when the active model doesn't advertise
it.

## Thinking effort end-to-end for local Claude agents

Effort flows from the running session's discovered `thought_level`
config option through the config surface to a local-only write control
and a read-only two-facts display.

- **Reader.** Discovers the `thought_level` config option from the
session cache (never hardcoded) and populates `effort_config_id` /
`effort_options` on `RuntimeConfigSurface`. The canonical effort tier
orders record env > `record.effort_level` (`BuzzExplicit`) > ACP >
persona > global > definition > file, so the panel shows the effort the
next spawn will launch with while `resolve_with_override` surfaces the
running ACP value as the struck-through override — neither masks the
other silently.
- **Write control.** `persist_agent_effort_level` is a direct-write
Tauri setter (writes `record.effort_level` + `updated_at`,
`save_managed_agents`) that rejects non-local backends — remote effort
is set at deploy time via `policy_env`. `EffortPickerField` mounts in
`AgentInstanceEditDialog` beside the Model block, gated on a local
backend **AND** a discovered `effortConfigId`. It persists directly and
invalidates the config surface, mirroring the
`setManagedAgentAutoRestart` standalone-setter precedent, so the frozen
`UpdateManagedAgentInput` shape stays frozen.
- **Display.** The read-only configured-vs-current two-facts display is
the `thinkingEffort` normalized field in `AgentConfigPanel`, fed by the
reader's canonical tier ordering.
- `buzz-acp` applies the startup effort env at session start.

## Distinguish a rejected model switch from silent success

A live model switch collapsed adapter rejection into success:
`apply_model_switch` returned `Ok` on both an accepted switch and an
application-level refusal, so the caller cached pre-switch capabilities
as if they described the target model and Desktop reported the pick as
landed.

- `ModelSwitchOutcome::{Applied(Value),Rejected}`. Transport-class
errors still propagate as `Err` (respawn the poisoned stdio); an
application-level refusal is now `Rejected`.
- The caller drives everything off `post_switch_snapshot`: `Applied`
refreshes `model_capabilities` from the target model's echoed
`configOptions` (or drops to `None` when none are echoed, so a
pre-switch snapshot is never mistaken for the target's); `Rejected`
preserves pre-switch caps and emits a `failure` `control_result`. Effort
resolution and the session-config capture read the post-switch snapshot
so they converge on the model the session actually runs;
`modelOverridden` is gated on `switch_succeeded`.
- `liveSwitchOutcome.ts` gains a distinct `"failed"` outcome for the
adapter `failure` frame and treats the busy-path `"sent"` ack as
provisional — it never counts toward success. Success is confirmed only
by a real positive terminal frame (the busy-path deferred apply emits a
correlated `switched` `control_result` when it lands), and the fallback
timeout resolves an honest `"pending"` (accepted, apply deferred), never
a false `"ok"`. `ModelPicker` surfaces a distinct toast per outcome —
failed, unsupported, and pending.

## Scope explicitly excluded

Per-agent config dir provisioning, `CLAUDE_SECURESTORAGE_CONFIG_DIR`
sentinel injection, `settings.json` projection, protected-key stripping,
MCP inheritance, spawn serialization, and the `last_spawn_warnings`
surface are absent from this diff. Silent-fallback machinery for
non-Claude runtimes (#2265/#4004) is a tracked follow-up.

## Sanctioned follow-ups

- **Live mid-conversation effort switching.** Effort is spawn-scoped
only in this PR: the worker reads `BUZZ_ACP_EFFORT_LEVEL` once and
applies it at session creation. The live effort-switch machinery
(mid-conversation effort RPC + ack frame) was deliberately removed and
is archived on `archive/claude-config-gaps-live-effort` for a future
plan-gated revival.
- **Idle-path late model-switch rejection is unobservable.** An idle
switch acks `switched` immediately after catalog validation, but the
real `set_config_option` runs at the next session creation — potentially
much later — so a rejection there is not surfaced back to the picker
(holding a subscription that long is not sensible). Pre-existing,
catalog-gated behavior; a durable fix is a tracked follow-up.

Closes #2692, #2884, #3493

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-18 16:29:28 -04:00

294 lines
11 KiB
TypeScript

import { ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { Spinner } from "@/shared/ui/spinner";
import React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types";
import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri";
import { switchManagedAgentModel } from "@/shared/api/agentControl";
import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome";
import { subscribeControlResults } from "@/features/agents/observerRelayStore";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import {
useAgentConfigSurface,
managedAgentsQueryKey,
} from "@/features/agents/hooks";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
export function ModelPicker({
agent,
onModelChanged,
}: {
agent: ManagedAgent;
onModelChanged?: () => void;
}) {
const [modelsData, setModelsData] =
React.useState<AgentModelsResponse | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [needsRestart, setNeedsRestart] = React.useState(false);
const [hasRequestedModels, setHasRequestedModels] = React.useState(false);
const { data: configSurface } = useAgentConfigSurface(agent.pubkey);
const queryClient = useQueryClient();
const isRunning = agent.status === "running" || agent.status === "deployed";
const activeTurns = useActiveAgentTurns(agent.pubkey);
// A live switch rides the agent's running session(s) instead of persisting a
// new default. It applies only to a persona-linked running agent with at
// least one active turn — those are the channels the desktop can name in the
// `switch_model` frame (the ModelPicker has no other channel context). The
// harness then routes each named channel itself: a channel still mid-turn
// cancel-switch-requeues; one that finished between send and receipt takes
// the idle invalidate-and-reapply path. A persona-linked agent that is
// running but wholly idle has no nameable channel here, so it falls through
// to persisting the default (the only reachable lever from this surface).
const isLiveSwitch =
agent.personaId !== null && isRunning && activeTurns.length > 0;
const fetchModels = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getAgentModels(agent.pubkey);
setModelsData(data);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [agent.pubkey]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open || loading || modelsData) {
return;
}
setHasRequestedModels(true);
void fetchModels();
},
[fetchModels, loading, modelsData],
);
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
const displayLabel = agent.model
? resolveModelLabel(agent.model, null, agent.provider)
: modelsData?.agentDefaultModel
? `${resolveModelLabel(modelsData.agentDefaultModel, null, agent.provider)} (default)`
: hasRequestedModels && loading
? "Loading..."
: "Auto";
// Provenance label shown only for post-spawn agents where the model origin
// is known from the config surface and the source is not a user-explicit
// Buzz setting (which is already self-evident from the picker state).
const modelOriginLabel = React.useMemo(() => {
const origin = configSurface?.normalized.model?.origin;
if (!origin || origin === "buzzExplicit") return null;
const labels: Record<string, string> = {
acpNativeRead: "from ACP",
acpConfigOption: "from ACP config",
envVar: "from env",
configFile: "from config file",
personaDefault: "template default",
runtimeOverride: "live override",
};
return labels[origin] ?? null;
}, [configSurface]);
// Send a live `switch_model` frame to each channel the agent is working in
// and wait for the harness to acknowledge. A single `unsupported_model`
// (model unavailable) or `failure` (adapter refused) result rejects the whole
// pick immediately. The busy-path `sent` ack is provisional (the adapter
// isn't consulted until the requeued session); success is confirmed only by a
// real positive terminal frame from every channel, and if none arrives before
// the timeout the pick resolves `"pending"` (accepted, apply deferred).
const sendLiveSwitch = React.useCallback(
(modelId: string) => {
const channelIds = activeTurns.map((turn) => turn.channelId);
// Opaque per-pick correlator. The harness echoes it on the immediate ack
// and the late terminal frame, so a five-minute reconnect replay of an
// earlier pick's result cannot settle this one.
const requestId = crypto.randomUUID();
return awaitLiveSwitchOutcome({
requestId,
channelIds,
subscribe: (listener) =>
subscribeControlResults(agent.pubkey, listener),
sendSwitches: async () => {
await Promise.all(
channelIds.map((channelId) =>
switchManagedAgentModel(
agent.pubkey,
channelId,
modelId,
requestId,
),
),
);
},
// No positive terminal in time: resolve `"pending"`. The override still
// rides the requeued/next session; we just can't confirm synchronously,
// and must not claim a success that hasn't happened.
scheduleTimeout: (onTimeout) => {
const timeout = window.setTimeout(onTimeout, 8_000);
return () => window.clearTimeout(timeout);
},
});
},
[activeTurns, agent.pubkey],
);
const handleModelChange = async (modelId: string) => {
setSaving(true);
setError(null);
try {
if (isLiveSwitch) {
const outcome = await sendLiveSwitch(modelId);
if (outcome === "unsupported") {
toast.error("That model isn't available for this agent.");
return;
}
if (outcome === "failed") {
toast.error(
"Couldn't switch models — the agent kept its current model.",
);
return;
}
if (outcome === "not_delivered") {
// The switch never reached a session: the turn was already ending, or
// no active turn remained by the time the harness received it. Nothing
// was applied and nothing rides a later session — tell the truth.
toast.error(
"Couldn't switch models — the agent wasn't running a turn to switch.",
);
return;
}
if (outcome === "pending") {
// The switch was accepted but its apply is deferred to the next
// session (the agent is mid-turn) and didn't confirm before the
// fallback timeout. Tell the truth instead of claiming success.
toast.info(
"Model switch pending — applies when the current turn finishes.",
);
onModelChanged?.();
return;
}
toast.success("Model switched for this session.");
onModelChanged?.();
return;
}
// Non-live path (idle, stopped, or non-persona): persist the default.
await updateManagedAgent({
pubkey: agent.pubkey,
model: modelId === modelsData?.agentDefaultModel ? null : modelId,
});
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
if (isRunning) {
setNeedsRestart(true);
}
onModelChanged?.();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
return (
<span className="inline-flex items-center gap-1.5">
<DropdownMenu modal={false} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={saving}
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{displayLabel}</span>
{modelOriginLabel ? (
<span className="shrink-0 text-2xs text-muted-foreground/70">
({modelOriginLabel})
</span>
) : null}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 min-w-48 overflow-y-auto"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{loading ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading models...
</div>
) : error ? (
<div className="space-y-2 px-3 py-2 text-sm">
<p className="text-destructive">Failed to load models.</p>
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => {
setHasRequestedModels(true);
void fetchModels();
}}
type="button"
>
Retry
</button>
</div>
) : !modelsData ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
Open to load available models.
</div>
) : !modelsData.supportsSwitching ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{agent.model ? (
<>
<p className="font-medium text-foreground">
{resolveModelLabel(agent.model, null, agent.provider)}
</p>
<p className="mt-0.5 text-xs">
This runtime does not support switching models.
</p>
</>
) : (
"This agent uses the runtime's default model."
)}
</div>
) : (
<DropdownMenuRadioGroup
onValueChange={handleModelChange}
value={currentValue}
>
{modelsData.models.map((model) => (
<DropdownMenuRadioItem key={model.id} value={model.id}>
{resolveModelLabel(model.id, model.name, agent.provider)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
{needsRestart ? (
<span className="text-2xs text-warning">restart to apply</span>
) : null}
</span>
);
}