mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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>
294 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|