mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +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>
156 lines
7.7 KiB
TypeScript
156 lines
7.7 KiB
TypeScript
import type { ControlResultFrame } from "@/shared/api/types";
|
|
|
|
/**
|
|
* Resolve the outcome of a live `switch_model` across one or more channels.
|
|
*
|
|
* A live switch fires a `switch_model` frame per active channel and learns each
|
|
* channel's result asynchronously over the observer relay. Two statuses
|
|
* fail-fast — any single frame rejects the whole pick immediately, without
|
|
* waiting for the other channels or the timeout:
|
|
* - `unsupported_model` → the target model isn't available for this agent.
|
|
* - `failure` → the adapter refused the switch (the session stays
|
|
* on its current model).
|
|
* Their causes differ, so they resolve to distinct outcomes (`"unsupported"`
|
|
* vs `"failed"`) the caller can message separately.
|
|
*
|
|
* `sent` is the busy-path PROVISIONAL ack: the switch was delivered to the
|
|
* in-flight turn, but the adapter isn't consulted until the requeued session
|
|
* runs. The real verdict lands later as a positive `switched` terminal (the
|
|
* deferred apply succeeded), a `failure`/`unsupported_model` frame (it didn't),
|
|
* so `sent` never settles the pick on its own — the subscription stays alive
|
|
* for the terminal frame.
|
|
*
|
|
* Success is only ever inferred from `switched` — the one status that means
|
|
* the model was APPLIED — which must arrive from every EXPECTED channel before
|
|
* resolving `"ok"`. The idle path emits it immediately; the busy path emits it
|
|
* when the requeued session applies the model. A busy turn routinely outlasts
|
|
* the fallback timeout, so the timeout NEVER resolves `"ok"` — it resolves
|
|
* `"pending"`: the switch was accepted and rides the requeued/next session, but
|
|
* we could not confirm the apply synchronously. The caller surfaces that
|
|
* truthfully rather than claiming a success that has not happened (and might
|
|
* yet be rejected).
|
|
*
|
|
* Two more statuses are non-delivery terminals — the harness never set the
|
|
* desired model and nothing applies later, so they can no more resolve `"ok"`
|
|
* than a `failure` can:
|
|
* - `turn_ending` → the control oneshot was already consumed (a prior
|
|
* cancel is ending the turn), so the switch can't land.
|
|
* - `no_active_turn` → neither an in-flight task nor an idle session-owning
|
|
* agent existed (a stale `activeTurns` snapshot between
|
|
* the picker read and the harness receipt).
|
|
* Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID
|
|
* ride the requeued session): here the switch never landed at all.
|
|
*
|
|
* Any other status — a `sent` provisional ack, or an unknown future status — is
|
|
* inert: it is never counted as success. A new producer status that should
|
|
* settle the pick must add its own explicit branch.
|
|
*
|
|
* Two identity guards keep a stale or replayed frame from settling the wrong
|
|
* pick. The observer relay requests a five-minute replay on reconnect, so an
|
|
* old `control_result` for an earlier switch can re-arrive mid-pick:
|
|
* - `requestId` — an opaque per-pick correlator the harness echoes on every
|
|
* frame. Frames without a matching id are ignored, so a replayed result for
|
|
* a prior operation (which carried a different id, or none) is inert.
|
|
* - `channelId` — every frame, positive OR negative, must name a channel in
|
|
* the EXPECTED set before it can settle anything. A misrouted `failure`
|
|
* from a foreign channel (or a frame with no channel) can no more fail the
|
|
* pick than a foreign `switched` can satisfy it. Positive terminals are
|
|
* then counted once per DISTINCT expected channel, not once per frame, so
|
|
* two copies of one channel's `switched` can't satisfy a two-channel pick.
|
|
*
|
|
* The counting lives here, isolated from React and the relay so it can be unit
|
|
* tested with synthetic frames and a fake clock. The caller injects the
|
|
* relay subscription, the per-channel sends, and the timeout scheduler.
|
|
*/
|
|
export async function awaitLiveSwitchOutcome({
|
|
requestId,
|
|
channelIds,
|
|
subscribe,
|
|
sendSwitches,
|
|
scheduleTimeout,
|
|
}: {
|
|
/** Opaque per-pick id; frames without this exact id are ignored. */
|
|
requestId: string;
|
|
/** Channels the switch was fired to — the distinct set to await. */
|
|
channelIds: readonly string[];
|
|
/** Register a control-result listener; returns an unsubscribe function. */
|
|
subscribe: (listener: (frame: ControlResultFrame) => void) => () => void;
|
|
/** Fire the per-channel `switch_model` sends. Resolves when all are sent. */
|
|
sendSwitches: () => Promise<void>;
|
|
/** Schedule the no-reply fallback; returns a cancel function. */
|
|
scheduleTimeout: (onTimeout: () => void) => () => void;
|
|
}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> {
|
|
const expected = new Set(channelIds);
|
|
const settled = new Promise<
|
|
"ok" | "unsupported" | "failed" | "not_delivered" | "pending"
|
|
>((resolve) => {
|
|
let unsubscribe = () => {};
|
|
let cancelTimeout = () => {};
|
|
const succeeded = new Set<string>();
|
|
const finish = (
|
|
outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending",
|
|
) => {
|
|
cancelTimeout();
|
|
unsubscribe();
|
|
resolve(outcome);
|
|
};
|
|
// No positive terminal in time: the switch was accepted but its deferred
|
|
// apply hasn't confirmed. Resolve indeterminate — never a false "ok".
|
|
cancelTimeout = scheduleTimeout(() => finish("pending"));
|
|
unsubscribe = subscribe((frame) => {
|
|
// Two identity guards run BEFORE any status handling, so they scope
|
|
// every decision — positive AND negative — to THIS pick's channels:
|
|
// - requestId: a replayed result for a prior operation carries a
|
|
// different id (or none) and is ignored.
|
|
// - channelId: the frame must name an EXPECTED channel. A negative
|
|
// frame (`failure`/`unsupported_model`) misrouted from a foreign
|
|
// channel, or carrying no channel at all, must not fail this pick
|
|
// any more than a foreign positive frame may satisfy it.
|
|
if (frame.type !== "switch_model" || frame.requestId !== requestId) {
|
|
return;
|
|
}
|
|
if (!frame.channelId || !expected.has(frame.channelId)) {
|
|
return;
|
|
}
|
|
if (frame.status === "unsupported_model") {
|
|
// Model unavailable — reject the whole pick immediately.
|
|
finish("unsupported");
|
|
return;
|
|
}
|
|
if (frame.status === "failure") {
|
|
// Adapter refused the switch — reject immediately. The session stays
|
|
// on its current model; distinct outcome so the caller can say why.
|
|
finish("failed");
|
|
return;
|
|
}
|
|
if (frame.status === "turn_ending" || frame.status === "no_active_turn") {
|
|
// Non-delivery terminal: the harness never set the desired model and
|
|
// nothing applies later (`turn_ending` = the control oneshot was
|
|
// already consumed; `no_active_turn` = no in-flight task and no idle
|
|
// session-owning agent). Fail-fast, distinct from `"pending"` — here
|
|
// the switch never landed at all.
|
|
finish("not_delivered");
|
|
return;
|
|
}
|
|
if (frame.status !== "switched") {
|
|
// Anything else — the provisional `sent` ack, or an unknown future
|
|
// status — is inert. Only `switched` (the model was APPLIED) counts.
|
|
// A busy `sent` is settled later by its own `switched`/`failure`
|
|
// terminal, or by the timeout resolving `"pending"`.
|
|
return;
|
|
}
|
|
// `switched` — the model was applied for this channel. Count each
|
|
// expected channel once: a duplicate frame for a channel already
|
|
// recorded (a replay, or a two-copy fan-out) is a no-op.
|
|
succeeded.add(frame.channelId);
|
|
if (succeeded.size >= expected.size) {
|
|
finish("ok");
|
|
}
|
|
});
|
|
});
|
|
|
|
await sendSwitches();
|
|
|
|
return settled;
|
|
}
|