Files
buzz/desktop/src/features/agents/lib/liveSwitchOutcome.ts
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

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;
}