Skip to content

fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR - #4557

Open
wpfleger96 wants to merge 24 commits into
mainfrom
duncan/claude-config-gaps
Open

fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR#4557
wpfleger96 wants to merge 24 commits into
mainfrom
duncan/claude-config-gaps

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes five agent-config gaps for Claude Code managed agents in Buzz Desktop.

What this changes

A1 — Model authority for local and remote Claude spawns

apply_claude_model_env in runtime.rs sets ANTHROPIC_MODEL from the agent's effective model and removes BUZZ_ACP_MODEL from the child env (Desktop owns model selection; ACP is the live-switch channel only). Remote Claude deploys add ANTHROPIC_MODEL to policy_env via agents_deploy.rs; BUZZ_ACP_MODEL is never injected remotely. Contract tests cover both the local inject-and-remove path and the remote Claude/non-Claude cases.

B5 — Effort end-to-end via ACP

Pool-level desired effort. A single AgentPool::desired_effort is the sole authority. set_pool_effort stores the value and invalidates all idle sessions; clear_pool_effort resets to None and also invalidates. Every try_claim copies the pool's current value onto the checked-out agent so all workers converge immediately — busy workers apply it at their next session.

Provisional picks with commit/rollback. AgentPool::committed_effort tracks the last adapter-confirmed value. A pick is provisional until create_session_and_apply_model resolves: on ok → commit committed_effort; on failure → roll back desired_effort to committed_effort so the failed candidate is never recopied by try_claim or retried. A monotonic effort_generation counter lets return_agent distinguish the current pick from superseded ones — stale-generation results are discarded without touching committed state.

Startup default. record.effort_level is injected as BUZZ_ACP_EFFORT_LEVEL at local spawn (runtime.rs) and into policy_env at remote deploy (agents_deploy.rs). The harness reads it into PoolStartup.startup_effort; resolve_startup_effort() arms desired_effort after capabilities arrive at the first session creation, using the capabilities-derived thought_level_config_id. return_agent propagates a startup-resolved effort back to pool level only when no live pick or clear has ever been made (effort_ever_picked = false), preventing resurrection of a cleared value.

Canonical effort wins over user env (env-authority contract). record.effort_level (the canonical persisted value) wins over any user-supplied BUZZ_ACP_EFFORT_LEVEL in agent definition/global/persona env, both locally and remotely. Locally: apply_effort_env() is applied after the descriptor.env loop in runtime.rs, mirroring the A1 model-authority pattern — a canonical Some value always overwrites any conflicting env entry. Remotely: agents_deploy.rs strips BUZZ_ACP_EFFORT_LEVEL from launch.env when a canonical value is present, then places the canonical value in policy_env (tier 1, always wins over launch.env tier 2). When record.effort_level is None, user env passthrough is preserved — it merely seeds startup effort with no canonical value to contradict.

Tri-state capability model. EffortCapabilityState { Unknown | Supported { config_id, valid_values } | Unsupported } replaces the former populate-once Option<PoolEffortCapabilities> + capabilities_ever_discovered bool pair. Capabilities are re-derived from every session/new response (not just the first) and after every apply_model_switch (which resets agent.model_capabilities to None so return_agent writes Unknown rather than stale-Supported). The handle_set_config_option_control classifier is now a three-way dispatch: Supported → validate + store, Unsupported → reject with unsupported_model status (capability loss surfaced immediately, no silent queue), Unknown → pre-discovery trust path (category field). On the switch path, session_config_captured emits null for configOptions so Desktop clears its picker state rather than caching pre-switch data.

Two-phase acks for both pick and clear. handle_set_config_option_control:

  • Non-empty valid value → pending_session immediate ack (pool stored); final ok/failure arrives pre-prompt via pool.resolve_effort_report (PoolEvent::EffortReport arm in the main loop) after session_set_config_option resolves.
  • Empty value (Auto/clear) → pending_session immediate ack (pool cleared to None); final cleared ack arrives pre-prompt via the same PoolEvent::EffortReport path when the session runs without an effort override.
  • Value not in adapter-advertised options → invalid_value ack; pool unchanged.

Per-request nonce correlation. The Desktop generates a crypto.randomUUID() nonce per pick/clear and passes it in the control frame. The harness echoes it in all acks (immediate and final). awaitEffortOutcome uses the nonce as the primary correlation key, rejecting acks where frame.nonce !== nonce; this prevents a stale ack from a same-value pick arriving late or from a superseded request settling the wrong promise. The observer persistence gate also checks the registered nonce, so late acks (e.g. after the 8s timeout) cannot overwrite a newer persisted value.

Observer persistence. dispatchControlResult persists on ok + thought_level (final applied) or cleared + thought_level (final clear confirmed); all other statuses (including pending_session and failure) do not persist. The nonce gate adds a second layer: mismatched nonces are silently dropped.

EffortPicker. Subscribes to control_result before sending (no dropped acks), awaits the correlated final result via awaitEffortOutcome with an 8-second timeout, surfaces pending_session / failure / invalid_value status messages, and invalidates both managedAgentsQueryKey and agentConfigSurfaceQueryKey on ok/cleared. Options come from the adapter-advertised effortOptions (exported through RuntimeConfigSurface; extract_agent_config_options retains both "model" and "thought_level" entries so the pool-level capability state and the harness invalid_value guard work in production); falls back to low/medium/high for older adapters.

PermissionMode Auto

config.rs adds Auto to the PermissionMode enum and tests coverage in crates/buzz-acp.

#3493 — Respect user-set CLAUDE_CONFIG_DIR

config_bridge resolves both settings.json and .claude.json panel paths from the agent's effective env via resolve_effective_agent_env (full tier chain: baked floor → definition env → global → persona → record). mcp_config_file_path_for_runtime honors the resolved custom dir; empty/blank values are treated as unset (matching Claude's CLAUDE_CONFIG_DIR || homedir() semantics). agent_config.rs routes through the same effective-env path so the panel and the process cannot diverge on which dir is active. AgentConfigPanel shows a Keychain-logout caveat when a custom dir is in effect (Claude behavior, not Buzz's).

Scope explicitly excluded

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

Closes #2692, #2884, #3493

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 3, 2026 16:25
@wpfleger96 wpfleger96 changed the title feat(managed-agents): isolate Claude Code agent config per-agent root feat(managed-agents): claude config gaps — isolation, model authority, PermissionMode Auto, effort persistence, bridge path fix Aug 3, 2026
@wpfleger96 wpfleger96 changed the title feat(managed-agents): claude config gaps — isolation, model authority, PermissionMode Auto, effort persistence, bridge path fix feat(managed-agents): Claude Code agent config isolation (B1–B8 + A1/A7 + Thufir invariants) Aug 3, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/claude-config-gaps branch from d3662b8 to 952e5c4 Compare August 4, 2026 23:38
…ect CLAUDE_CONFIG_DIR

Fix five gaps in Claude Code agent configuration in Buzz Desktop.
Buzz sets config via env vars at spawn and ACP messages at runtime;
file layout on disk stays the owner's.

Model (fixes #2692): ANTHROPIC_MODEL is injected at local claude spawn
as the single startup model authority. BUZZ_ACP_MODEL is removed from
the spawned env to prevent two simultaneous model authorities. Remote
claude deploys receive ANTHROPIC_MODEL in policy_env, never BUZZ_ACP_MODEL.

PermissionMode Auto (fixes #2884): adds the Auto variant to PermissionMode
with wire string "auto" and tests. The adapter handles graceful downgrade
when the active model does not support it.

Effort end-to-end via ACP (B5): EffortPicker in the config panel discovers
the thought_level configId from the session cache (never hardcoded) and
calls set_config_option. The harness verifies the configId, forwards to
the adapter, and emits an ack carrying category: "thought_level" only on
a real forward. The observer persists the canonical value only on ok+category.
At next session creation, desired_effort is applied via session_set_config_option
so the persisted default takes effect on first turn after a restart.

Honest acks: no fabricated ok anywhere. Synthetic acks (unknown configIds)
carry no category so the observer cannot persist them.

the agent's effective CLAUDE_CONFIG_DIR env var (record > persona > global),
falling back to ~/.claude/ when unset. MCP config stays at ~/.claude.json
regardless (CLAUDE_CONFIG_DIR does not remap the global MCP config file).
The panel shows a Keychain caveat note when a custom dir is active: Claude
keys its login to the config-dir path, so a custom dir creates a fresh
Keychain namespace and the agent needs re-authentication unless the user
also manages CLAUDE_SECURESTORAGE_CONFIG_DIR.

Closes #2692, #2884, #3493

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/claude-config-gaps branch from 952e5c4 to c1397bf Compare August 4, 2026 23:39
@wpfleger96 wpfleger96 changed the title feat(managed-agents): Claude Code agent config isolation (B1–B8 + A1/A7 + Thufir invariants) fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR Aug 4, 2026
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 9 commits August 4, 2026 20:26
…aude.json

Finding 1 — effort startup-default glue:
- Add BUZZ_ACP_EFFORT_LEVEL CLI arg + env var to buzz-acp config
- Add startup_effort to PoolStartup and OwnedAgent structs
- Extract OwnedAgent::resolve_startup_effort() method that arms
  desired_effort from startup_effort + capabilities-derived configId
- Call resolve_startup_effort() at first session creation after
  capabilities are populated (replaces inline block)
- Inject BUZZ_ACP_EFFORT_LEVEL at spawn in runtime.rs from record.effort_level
- Fix stale doc comment in types.rs (was settings.json seeding language)

Finding 2 — .claude.json path honors CLAUDE_CONFIG_DIR:
- Fix claude.rs read_config_file: resolve .claude.json relative to
  config_dir when set, same as settings.json (binary does the same)
- Fix reader.rs mcp_config_file_path_for_runtime to accept and use
  claude_config_dir for the claude case
- Fix agent_config.rs CLAUDE_CONFIG_DIR lookup to use
  resolve_effective_agent_env instead of hand-rolled record chain
  that skipped definition-env tier and baked floor

Tests added:
- resolve_startup_effort arms desired_effort from startup_effort + configId
- resolve_startup_effort does not override live pick
- resolve_startup_effort is no-op when startup_effort absent
- resolve_startup_effort is no-op when model lacks thought_level

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three test-mod Config{} literals in buzz-acp were missing the new
effort_level field, causing compilation failures under --all-targets
(Windows CI caught this; desktop gates run a separate workspace).
Add effort_level: None to all three.

Run cargo fmt --all and cargo fmt --manifest-path desktop/src-tauri/Cargo.toml
to fix the rustfmt diffs caught by CI Rust Lint and desktop-tauri-fmt-check.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…emote parity, option validation

Replace per-worker desired_effort/startup_effort with ONE pool-level
desired_effort (AgentPool::desired_effort). Closes I-2 (queued-as-ok),
I-3 (first-idle-only), and I-5 (spawn-drift) together.

Rust (crates/buzz-acp):
- AgentPool gains desired_effort field; set_idle_agent_effort replaced
  by set_pool_effort (stores pool-level, invalidates all idle sessions,
  returns Stored { invalidated }) and clear_pool_effort (sets None,
  invalidates all idle).
- try_claim always copies pool.desired_effort onto the claimed agent.
- return_agent propagates startup-resolved effort back to pool level.
- create_session_and_apply_model emits honest final control_result ack
  (ok/failure) after the real ACP call; no pre-persist on queued state.
- handle_set_config_option_control: clear path emits "cleared"; non-empty
  path emits "pending_session"; I-7 validates value against adapter-
  advertised options and emits "invalid_value" on mismatch.
- M-1: restore damaged PoolStartup doc comment.
- Tests: set_pool_effort_stores_and_invalidates, multi-worker convergence,
  clear_pool_effort, test_b5_empty_value_emits_cleared_ack,
  test_b5_invalid_value_emits_invalid_value_ack_and_does_not_update_pool.

TypeScript (desktop/src):
- effortOutcome.ts: awaitEffortOutcome helper — subscribes before send,
  awaits correlated final result (ok/failure/invalid_value/cleared), falls
  back to pending_session on timeout.
- effortOutcome.test.mjs: 13 tests covering all statuses, correlation,
  cleanup, and deferred-path (pending_session → final ok).
- EffortPicker: uses awaitEffortOutcome; empty value = clear (I-1);
  effortOptions from adapter (I-7); surfaces pending_session / failure /
  invalid_value status messages; invalidates queries on ok/cleared.
- observerRelayStore: persist on ok+thought_level (final applied ack) OR
  cleared+thought_level (Auto clear); skip all other statuses.
- types.ts: SetConfigOptionResult named type; effortOptions field on
  RuntimeConfigSurface.

agents_deploy.rs (I-4): project record.effort_level → BUZZ_ACP_EFFORT_LEVEL
into remote policy_env, mirroring local spawn; positive/negative tests.

config_bridge (M-2/M-3): direct test for mcp_config_file_path_for_runtime
with custom CLAUDE_CONFIG_DIR; treat empty/blank CLAUDE_CONFIG_DIR as unset
in agent_config.rs (matches Claude's || homedir() semantics).

reader.rs: effort_options populated from session cache for claude runtime.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
V-1 (clear resurrection prevention): add effort_ever_picked flag to
AgentPool. return_agent propagates a startup-resolved desired_effort to
pool level ONLY when no live pick/clear has ever been made. A user clear
sets effort_ever_picked=true; subsequent worker returns can no longer
resurrect the cleared value.

V-2 (all-busy capability loss): add PoolEffortCapabilities cache and
capabilities_ever_discovered flag to AgentPool. Pool-level cache is
written at return_agent (refreshed from the returning worker's
capabilities) and via notify_capabilities_discovered. The
handle_set_config_option_control path reads from the cache instead of
scanning idle agent slots, so picks and clears are never silently dropped
when all workers are checked out.

V-3 (busy-worker session convergence): return_agent compares the worker's
checkout snapshot (desired_effort) against the current pool value. If they
differ (a pick or clear arrived while the worker was busy), the worker's
sessions are invalidated so the next try_claim creates a fresh session
under the current pool value.

Tests added (pool.rs effort_tests):
- test_v1_clear_while_busy_return_does_not_resurrect_cleared_effort
- test_v2_pick_while_all_busy_is_stored_not_dropped
- test_v3_busy_worker_sessions_invalidated_on_return_after_pick
- test_startup_effort_propagates_to_pool_on_first_return_when_no_live_pick

Existing tests updated (lib.rs control_result_tests, pool.rs effort_tests):
four tests that construct AgentPool::from_slots with agents carrying
model_capabilities now also call notify_capabilities_discovered to
populate the pool-level cache, matching production behavior where the
cache is written at return_agent time.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…scovery window

Before this fix, a pick or clear sent during the first turn (before any
worker returns and populates the pool capability cache) fell through to
the synthetic-ok branch. `capabilities_ever_discovered` was false,
`effort_capabilities.config_id` was None, and `is_thought_level` was
false: the harness emitted a fabricated ok with no category, the value
was never stored, and the EffortPicker reported success with nothing
applied or persisted.

Fix:

- Desktop sends `category: "thought_level"` on all effort frames
  (sendSetConfigOption gains an optional category param; EffortPicker
  passes it). The harness uses this as the trust signal in the
  pre-discovery window (case D).

- Harness (lib.rs): adds case D — `!capabilities_ever_discovered &&
  frame_category == "thought_level" && configId != "unknown"` — to
  the `is_thought_level` check. Picks and clears in this window are
  stored and acked `pending_session`/`cleared` rather than synthetic ok.

- pool.rs: removes the `NoCatalog` guard from `set_pool_effort` and the
  `NoCatalog` variant entirely. The caller already gates on
  `is_thought_level`; `set_pool_effort` always stores. Removes the
  unreachable `NoCatalog => pending_session` match arm from the handler.

- `notify_capabilities_discovered` moved to `#[cfg(test)]` with an
  honest doc. In production the cache is written only at `return_agent`.
  All false doc claims (pool.rs:264, 306; lib.rs:1020-21) corrected.

- Tests: two new case-D tests in lib.rs
  (`test_b5_pre_discovery_pick_with_category_stores_and_emits_pending_session`,
  `test_b5_pre_discovery_clear_with_category_emits_cleared_not_synthetic_ok`);
  existing NoCatalog tests rewritten to match new semantics (always stores).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ation (IMPORTANT 1/2/3)

Addresses all three IMPORTANT findings from Thufir Pass 2 plus the extraction
MINOR that feeds harness-side invalid_value validation in production.

IMPORTANT 1 — provisional pick + rollback:
- Added committed_effort field to AgentPool as the last adapter-confirmed
  baseline. Pending picks are provisional until the ACP call resolves.
- return_agent: generation-matched Applied → commit; Failed → rollback to
  committed_effort so the failed candidate is never recopied by try_claim.
- Stale-generation results (superseded by newer pick/clear) are discarded
  entirely — no commit, no rollback, pool unchanged.

IMPORTANT 2 — per-request nonce correlation:
- AgentPool.effort_generation incremented on every pick/clear; carried on
  checked-out agents as desired_effort_gen. Pool echoes it as a nonce field
  (from pending_effort_nonce, set from the Desktop's crypto.randomUUID() nonce)
  in all immediate and final acks.
- Desktop: sendSetConfigOption gains optional nonce param; AgentConfigPanel
  generates crypto.randomUUID() per request and registers it before awaiting.
- awaitEffortOutcome: nonce is the primary correlation key; rejects acks where
  frame.nonce !== nonce even if configId and value match (stale same-value picks).
- observerRelayStore: persistence gate checks ackNonce === registered before
  persisting ok/cleared; backwards-compat — acks without nonce always pass.
- Four new nonce correlation tests in effortOutcome.test.mjs.

IMPORTANT 3 — two-phase clear:
- handle_set_config_option_control: empty value now emits pending_session
  (non-terminal) instead of terminal cleared. Pool is cleared immediately so
  future sessions run without effort, but confirmation waits for adapter.
- create_session_and_apply_model: new else-if branch — desired_effort=None but
  desired_effort_gen set → pending clear; emits final cleared ack with nonce+
  category after the session creates without effort override. Observer persists
  null only on this final cleared.
- Tests updated: test_b5_empty_value_emits_pending_session_ack and
  test_b5_pre_discovery_clear_with_category_emits_pending_session_not_synthetic_ok.

MINOR — extract_agent_config_options (feeds harness invalid_value production):
- New function in acp.rs that retains both category=="model" and
  category=="thought_level" entries from session/new configOptions.
- AgentModelCapabilities.config_options_raw now populated via this function so
  the pool-level capability cache includes thought_level valid_values in
  production (not just tests). The harness invalid_value guard now runs for
  real adapter picks.

MINOR — picker query invalidation:
- AgentConfigPanel: on ok/cleared outcome, invalidates agentConfigSurfaceQueryKey
  (source of currentEffort) in addition to managedAgentsQueryKey so the panel
  reflects the new committed effort immediately.

MINOR — restore damaged doc comment:
- lib.rs test_b5_real_forward_ack_includes_thought_level_category: restored the
  middle line of the three-line doc comment that was dropped in a prior commit.

Pool tests: 4 new (test_failure_rolls_back_desired_effort_to_committed,
test_applied_commits_desired_effort_to_committed,
test_stale_gen_failure_does_not_rollback_pending_pick,
test_cleared_commits_none_to_committed_effort).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F-1: gate resolve_startup_effort on desired_effort_gen.is_none()

resolve_startup_effort re-armed desired_effort from startup_effort
whenever it was None, which could not distinguish 'never picked' from
'user just cleared'. A post-clear checkout carries desired_effort=None
and desired_effort_gen=Some(N); the old guard would re-arm the startup
value, fire the apply branch instead of the cleared branch, and emit an
ok ack carrying the clear's nonce — causing the observer to persist the
old value over the user's explicit clear, with perpetual V-3 churn.

Fix: add && self.desired_effort_gen.is_none() so the startup-seeding
path is only taken when no live pick/clear has ever been stored (gen
never set). Startup seeding (gen None) still works; post-clear (gen
Some) falls through to the cleared branch as intended.

New test: test_resolve_startup_effort_noop_after_live_clear_gen_is_some

F-2: emit real configId in final cleared ack (pool.rs:1465)

The cleared ack hardcoded "effort" as the configId. awaitEffortOutcome
checks frame.configId !== configId before the nonce, so any adapter
whose thought_level configId differs would leave the clear promise
unsettled and fall to the 8s timeout.

Fix: read the configId from agent.model_capabilities (populated just
above at line 1336), falling back to "effort" when capabilities are
not yet populated (pre-discovery clear path).

F-3: box PoolEvent::Wake large variant (lib.rs:1910)

AgentPool grew past clippy's large_enum_variant threshold after the
committed_effort, nonce, and capability-cache fields were added.
CI Rust Lint and Windows Rust both failed on this branch at 333362a.

Fix: Box<Result<AgentPool, String>> in the Wake variant; box on
construction, unbox on match.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Resolve two conflicts:

1. crates/buzz-acp/src/config.rs: PermissionMode::BypassPermissions removed
   by main (#4609, security fix). Branch added PermissionMode::Auto and
   effort_level. Resolution: keep Auto and effort_level additions; drop
   BypassPermissions (aligns with #4609's intent that the variant cannot
   be restored by configuration). One test assertion for
   BypassPermissions.is_default() removed accordingly.

2. desktop/src-tauri/src/commands/agents_deploy.rs: branch added claude
   B2/I-4 tests (ANTHROPIC_MODEL routing, BUZZ_ACP_EFFORT_LEVEL); main
   added OpenClaw parallelism-cap tests via #4019. Resolution: keep both
   test sets; fix trailing blank line introduced by merge tool.

Also: desktop/src-tauri/src/managed_agents/parallelism.rs (new in main
via #4019) constructs ManagedAgentRecord in tests without the
effort_level field added by this branch. Added effort_level: None to the
test helper to satisfy the struct initializer.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Conflict resolution and compile fixes:
- tests.rs: take main's side (fixture extracted to test_fixtures.rs by #4053),
  preserve our comment reword on marker_entry_is_namespaced_by_instance_id
- test_fixtures.rs: auto-merged as main's version; add effort_level: None
  (same class as last round's parallelism.rs fix)

No other ManagedAgentRecord literals required patching (cargo check clean).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Aug 6, 2026
Add the Auto variant to PermissionMode (wire string 'auto'; #4557 adds the
same variant from the claude-config arc — this commit establishes the
contradiction logic ahead of that merge so the rebase is mechanical).

Auto mode = fully autonomous execution; model-gated (requires
supportsAutoMode); the adapter self-approves all tool calls internally
and never emits session/request_permission.

Mode matrix:
- allow + auto → compatible (transmit as-is; both want unattended approval)
- ask   + auto → startup error (card never fires — ask becomes a dead letter)
- reject + auto → startup error (inverted-security worst case: policy says
                  deny while adapter silently auto-approves everything)

Tests: 4 new pinned tests (allow+auto ok, ask+auto error, reject+auto error,
wire string correct). Total: 724 passing.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the request nonce in invalid_value acknowledgements — crates/buzz-acp/src/lib.rs:1320

The validation-rejection branch returns before the common acknowledgement builder adds nonce. Desktop always supplies a nonce and awaitEffortOutcome rejects every acknowledgement whose nonce does not match, so a genuinely invalid selection is ignored until the 8-second timeout and then presented as “Applies at next session,” even though the pool was deliberately left unchanged. Add the incoming nonce to this early acknowledgement (and cover the real nonce-bearing invalid-value path rather than only nonce-free fixtures).

[P2] Seed committed_effort when the startup effort is successfully applied — crates/buzz-acp/src/pool.rs:791

A persisted startup effort is applied by create_session_and_apply_model, but its checkout has desired_effort_gen == None; return_agent copies it into desired_effort and never records it in committed_effort. If the user subsequently selects another valid effort and the adapter rejects/times out, the failure path rolls desired_effort back to committed_effort, which is still None, instead of restoring the previously confirmed startup value. Record a successful startup application as the committed baseline (or initialize the pool baseline from the persisted value once capabilities confirm it), and add a startup-success → live-pick-failure regression.

[P2] Serialize generation resolution across parallel workers — crates/buzz-acp/src/pool.rs:805

Every worker checked out for the same generation independently emits a terminal acknowledgement and independently commits or rolls back the shared pool state. With parallelism >1, if worker B fails and returns first it rolls desired_effort back; if worker A then returns success, it updates only committed_effort and leaves desired_effort rolled back. The observer can persist A's ok, while future checkouts receive the old/default value. The reverse return order produces a different result, so pool state is scheduler-dependent. Resolve each generation once with explicit aggregate semantics (and emit one terminal outcome), or ensure a later same-generation success restores desired_effort consistently; cover both return orders with two workers.

Validation at exact PR HEAD b7c99fb94808cf3b07bb7540121d8eb4513578dd: cargo test -p buzz-acp --lib passed (723 tests); node --test src/features/agents/lib/effortOutcome.test.mjs passed (15 tests). These paths are not covered by the current suite.

Duncan and others added 10 commits August 6, 2026 18:02
P2-1 — invalid_value ack echoes nonce (lib.rs)
The early-return rejection branch built its own ack before the common
nonce-attaching builder ran. The parsed nonce was in scope but never
added. Desktop's awaitEffortOutcome nonce guard rejects a nonce-less
invalid_value ack, causing an 8 s timeout instead of an immediate
rejection. Fix: attach nonce in the early-return branch when present.
New test: test_b5_invalid_value_ack_echoes_nonce.

P2-2 — committed_effort seeded on startup application (pool.rs)
try_claim hands out desired_effort_gen = None for startup agents, so
return_agent's generation-matched commit block never fires. A later
rejected live pick rolled back to None instead of the confirmed startup
value. Fix: when the V-1 startup propagation block runs and the result
is Applied, seed committed_effort alongside desired_effort. Regression
test: test_startup_success_seeds_committed_effort_for_rollback.

P2-3 — same-generation resolution order-independent (pool.rs)
With parallelism > 1, multiple workers carry the same generation.
Applied wrote only committed_effort; Failed rolled back only
desired_effort; each emitted a terminal ack. Pool state and the ack
stream both depended on return order. Fix: first terminal result for a
generation emits the ack and marks the gen resolved
(last_acked_effort_gen); subsequent same-gen returns update pool state
(commit/rollback) but emit no ack. Semantics documented at the
resolution site. Two-worker tests: test_p2_3_fail_then_success and
test_p2_3_success_then_fail.

P3 fold-in — stale capability cache cleared on model swap (pool.rs)
When a returning agent had populated capabilities but no thought_level
configId (model swapped to a non-effort model), the pool-level
effort_capabilities cache retained stale valid_values. Fix: clear the
cache in the else-if branch; capabilities_ever_discovered stays true
so case C (workers busy) remains active.
New test: test_p3_capability_cache_cleared_on_non_effort_model_return.

P3 fold-in — dead case-C branch resolved (lib.rs)
After the model-swap clear, capabilities_ever_discovered = true AND
effort_capabilities.config_id = None is now a reachable state (all
workers checked out after a model swap), making all_busy_with_known_caps
genuine. No code change — the earlier capability-cache clear made this
case reachable; updated comment confirms it.

P3 fold-in — nonce-gate bypass closed (observerRelayStore.ts)
A nonce-less ok/cleared ack bypassed the stale-result guard when a
nonce had previously been registered. Fix: once a nonce is registered
for an agent, only acks with a matching nonce pass; a missing ack-nonce
is treated as non-matching. Pre-any-pick startup path preserved: when no
nonce has ever been registered, a nonce-less ack still passes through.
Also: resetAgentObserverStore now clears currentEffortNonce so test
isolation is not broken by residual nonce state.
New test file: observerRelayNonceGate.test.mjs (7 cases: startup path,
registered path, mismatch, bypass fix, per-agent isolation, reset).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main:
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ust ack

Unify commit/rollback and terminal ack under a single first-terminal-wins
gate. Previously the two blocks ran independently: the commit/rollback
block executed for every same-gen return while only the ack block was
gated on last_acked_effort_gen, producing order-dependent pool state.

New semantics: the first same-gen return whose result is terminal
(Applied / Failed / Cleared) performs the commit-or-rollback AND emits
the ack AND marks the generation resolved. Later same-gen returns touch
neither state nor the ack stream — the Desktop-visible ack and the
pool state it reflects are resolved atomically by the same return.

V-3 discarded-failed special case: a second-returning Failed worker may
carry desired_effort == pool.desired_effort (values match after the
first worker Applied and committed). V-3's equality check would skip
invalidation, but that worker's live session ran at DEFAULT effort —
stale. Force-invalidate when a same-gen return is discarded and the
worker's result was Failed, regardless of the value comparison.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F2 (pre-prompt pool-authority ack): the EffortReport channel/resolve_effort_report
seam is now fully wired and the P2-3 tests updated to match. Previously the tests
still called return_agent expecting it to emit acks, but acks moved to
resolve_effort_report (PoolEvent::EffortReport arm) which fires pre-prompt.
Fix: update test_applied/cleared/failure/p2_3 to call pool.resolve_effort_report
with a real observer before return_agent; add #[allow(clippy::too_many_arguments)]
on create_session_and_apply_model and run_prompt_task (effort_report_tx is the
8th arg).

F1 (stale-gen force-invalidation): code was correct at tip; add three regression
tests: rapid high->medium->high value-match stranding, repeated-clear None==None
hole, and stale-gen-Failed DEFAULT-effort session surviving on value match.

F3 (durable nonce registration): registerEffortNonce now also writes to
localStorage (key: buzz:effort-nonce:<normalized-pubkey>). loadNoncesFromStorage
runs at module init and after every resetAgentObserverStore, so the nonce survives
Desktop restart and community-switch. _testClearEffortNonceStorage test helper
exposes full logout cleanup. Tests updated with localStorage stub + two new cases:
reset-then-replay (post-reset ack accepted) and full-reset (startup path restored).

Minor 1: save worker_b.index before return in test_p2_3_success_then_fail and
assert pool.agents_mut()[worker_b_index] — the old iter().flatten().next() read
slot 0 (worker A), making the discarded_failed invalidation assertion vacuous.

Minor 2: extract effortNonceMatches() pure predicate shared by dispatchControlResult
and _testNonceGate; delete the duplicate inline logic from _testNonceGate.

Minor 3: update five stale doc-comment references that still named
create_session_and_apply_model as the ack source (lib.rs, pool.rs x2,
observerRelayStore.ts, effortOutcome.ts) to point at pool.resolve_effort_report
/ PoolEvent::EffortReport.

PR body: add tri-state Unknown|Supported|Unsupported capability representation as
tracked follow-up in the Scope explicitly excluded section.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the three-condition discarded_failed predicate (which required
last_acked_effort_gen == Some(g) — false in the return→report race) with
a single unconditional rule: any returned worker whose last_effort_result
is Some(Failed) invalidates its sessions regardless of generation state,
value equality, or whether resolve_effort_report has already run.

Rationale: a Failed application means session_set_config_option rejected
the pick; the live session ran at the agent default, not the requested
value. The session is stale in both orderings — no gate state is needed.
Worst case is one redundant session re-creation when the rollback target
equals the default.

Two new regression tests cover both orderings:
- test_failed_worker_return_before_report_invalidates_session (the race):
  return_agent runs while generation is unresolved; session must still be
  invalidated; pre-fix this test fails at the sessions.is_empty() assert.
- test_failed_worker_report_before_return_invalidates_session (normal path):
  resolve_effort_report fires first (pre-prompt); return_agent follows;
  session still invalidated; no second ack emitted.

Both tests assert: failed session empty, exactly one terminal failure ack
with the correct nonce, pool state (desired=low/committed=low) matching ack.

F1 tests extended through resolution: after the invalidation assert, the
high→medium→high and repeated-clear tests now perform the next claim and
simulate a resolve_effort_report, asserting exactly one terminal ack
carrying the newest nonce and persistence-eligible pool state.

Minor 2: fix 4 remaining stale doc-comment references in pool.rs (~1282,
~1378) and lib.rs (~8145, ~8470) that still named create_session_and_apply_
model as the final-ack source; updated to PoolEvent::EffortReport →
resolve_effort_report.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…motely

Fixes P2-1: BUZZ_ACP_EFFORT_LEVEL was written before descriptor.env, so
a user-supplied value in agent/persona/global env silently overrode the
canonical persisted effort_level. The panel and persistence continued to
report the canonical value while the harness started at the user-supplied
one — the same silent-divergence class the PR exists to eliminate.

Local fix (runtime.rs): extract apply_effort_env() and call it after the
descriptor.env loop, mirroring the A1 model-authority pattern exactly.
When effort_level is Some, the canonical value overwrites any user-supplied
entry. When None, the call is a no-op so user env from the loop survives
as legitimate startup seeding (no canonical to contradict).

Remote fix (agents_deploy.rs): when a canonical effort_level is present,
strip BUZZ_ACP_EFFORT_LEVEL from launch.env before serialisation. In the
k8s three-tier model, tier 2 (launch.env) overwrites tier 1 (policy_env)
via later-wins — so the key must be absent from tier 2 whenever a
canonical value is already authoritative in tier 1. The strip is done on
the Desktop side, preserving the k8s engine's declared semantics rather
than adding a special case to build_env.

Tests: three local tests via apply_effort_env() in claude_config/tests.rs
(collision canonical wins, basic injection, None passthrough preserved)
and two remote tests in agents_deploy.rs (collision strips tier-2 entry,
None lets user entry survive in launch.env).

Ownership: desktop/src-tauri/src/managed_agents/runtime.rs,
claude_config/{mod,tests}.rs, commands/agents_deploy.rs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gaps

* origin/main: (26 commits)
  fix(sdk): preserve self-mention p tags in message and forum event builders (#4975)
  bump @tauri-apps/cli to ~2.11.4 to fix linux app icon issue (#4858)
  feat(desktop): adding rich link previews to messages (#3818)
  fix(buzz-agent): Responses reasoning summary, Anthropic display:summarized, ACP v2 messageId (#5195)
  fix(desktop): retain distinct agent instances in autocomplete (#5202)
  fix(desktop): defer channel visibility change to Save (#5203)
  feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073)
  refactor(cli): replace probe/decider/detail split with single typed extractor (#5191)
  fix(desktop): drop unhandled rejection from throwing window.Notification (#5143)
  fix(desktop): fence localStorage SecurityError from killing the React tree (#5142)
  fix(desktop): make terminal output selectable (#4980)
  fix(desktop): use WEBKIT_DMABUF_RENDERER_FORCE_SHM for NVIDIA/AppImage (#3654) (#4505)
  Make public starter channels best effort (#5192)
  Mobile: add anchored reaction popover (#5025)
  feat(mobile): add bee pull-to-refresh (#5059)
  Remove agent creation success modal (#5063)
  fix(buzz-agent): escalate LLM timeouts per retry and log per-call latency (#5130)
  fix(agent): resolve oauth cache home cross-platform (#5151)
  Improve video review readiness and controls (#5161)
  Polish advanced agent setup and Welcome composer (#4926)
  ...

Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…ches

Replace the populate-once PoolEffortCapabilities cache + capabilities_ever_discovered
bool pair with a three-state EffortCapabilityState enum:

  Unknown  — no session has returned capabilities yet (pre-first-session window,
             or capabilities reset after a model switch)
  Supported { config_id, valid_values }  — current model advertises thought_level
  Unsupported  — current model confirmed to not have thought_level

The old model populated capabilities once (on the first session/new) and never
refreshed them. After a live model switch — which resets agent.model_capabilities to
None to discard the pre-switch snapshot — return_agent wrote Unknown/kept stale state,
so handle_set_config_option_control kept validating against the old model's option set
and session_config_captured emitted pre-switch configOptions to Desktop.

Changes:

pool.rs:
- EffortCapabilityState enum replaces PoolEffortCapabilities + capabilities_ever_discovered
- return_agent derives tri-state from the returning worker's model_capabilities on
  every return (not just the first), so the pool state always reflects the current model
- AgentPool initialises with Unknown; struct fields updated accordingly

pool.rs (create_session_and_apply_model):
- Capabilities are populated from EVERY session/new response, not just when None
- After a successful apply_model_switch, agent.model_capabilities is reset to None so
  return_agent writes Unknown rather than stale-Supported

pool.rs (session_config_captured):
- On the switch path, configOptions emitted as null so Desktop clears its picker
  state rather than caching pre-switch capability data

lib.rs (handle_set_config_option_control):
- Four-way A/B/C/D classifier replaced with a three-way tri-state dispatch
- Supported(config_id matches) → validate + store (case A)
- Supported(config_id no-match) → synthetic ok, non-effort (case B)
- Unsupported → reject with unsupported_model status (eliminates case-C
  occupancy inference: capability loss is now surfaced immediately)
- Unknown → pre-discovery trust path: category=thought_level stores + pending_session

pool.rs field comment:
- last_acked_effort_gen comment updated to name resolve_effort_report as the sole
  ack authority (Thufir Pass 3 non-blocking MINOR)

Tests (lib.rs control_result_tests):
- pool_with_capabilities_via_return helper drives the PRODUCTION return_agent path,
  not the test-only notify_capabilities_discovered shortcut
- test_tristate_switch_effort_to_no_effort_rejects_pick: effort→no-effort switch
  writes Unsupported; subsequent pick is rejected with unsupported_model status
- test_tristate_switch_no_effort_to_effort_accepts_pick: no-effort→effort switch
  writes Supported with new configId; pick validates and stores correctly
- test_tristate_switch_new_options_validates_against_new_snapshot: both models
  Supported with different option sets; stale value rejected, new value accepted
- test_tristate_unknown_after_switch_uses_pre_discovery_trust_path: capabilities=None
  on return writes Unknown; pre-discovery category trust path remains active

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gaps

* origin/main: (35 commits)
  feat(desktop): time-based sweep for stale localStorage caches (#5453)
  ci(release): gate OSS desktop auto-update promotion (#5398)
  fix(release): pin desktop PR operations to block/buzz (#5212)
  fix(search): surface exact short profile names (#5480)
  Reduce repeated ACP session context (#5423)
  feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
  fix(desktop): resolve overlapping member mentions (#5225)
  chore(deps): update react monorepo (#4441)
  ci(security): allow retired relay pool advisory (#5404)
  chore(deps): update dependency @tanstack/react-virtual to v3.14.9 (#4439)
  chore(deps): update all non-major dependencies (#3049)
  chore(deps): update rust crate anyhow to v1.0.104 (#4447)
  chore(deps): update rust crate arc-swap to v1.9.2 (#4448)
  chore(deps): update rust crate async-trait to v0.1.91 (#4458)
  chore(deps): update rust crate diffy to v0.5.1 (#4466)
  chore(deps): update rust crate async-compression to v0.4.43 (#4456)
  chore(deps): update rust crate clap to v4.6.6 (#4465)
  fix(desktop): preserve Welcome banner dismissal (#5406)
  fix(agent): retry LLM completion on malformed 2xx JSON body (#5351)
  fix(desktop): welcome banner overlap and missing dismiss control (#5330)
  ...

# Conflicts:
#	crates/buzz-acp/src/config.rs

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
#5423 (Reduce repeated ACP session context) added four lifecycle tests
that construct OwnedAgent and call run_prompt_task with the pre-effort
shape. The tri-state effort work on this branch added five fields to
OwnedAgent and an effort_report_tx parameter to run_prompt_task, so the
textual merge compiled the library but broke these cfg(test) targets.

Add the five effort fields (all None, matching every other test-side
construction) and thread an effort_report_tx into each call, mirroring
the production call site in lib.rs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/claude-config-gaps branch from 49177be to 756ed48 Compare August 10, 2026 18:27
Duncan and others added 4 commits August 10, 2026 16:16
…d_model

A successful Claude model switch discarded the RPC result and reset
model_capabilities to None, so the switched session never yielded a
post-switch snapshot: the effort picker stayed empty (Unknown) and
resolve_startup_effort armed against the pre-switch default model.

apply_model_switch now returns the RPC value. On a switch that echoes
authoritative configOptions (claude-agent-acp 0.60.0 rebuilds
session.configOptions on a model change), model_capabilities, the
session_config_captured cache, and startup-effort resolution all
converge on the target model. Startup effort resolves after the switch.
When the adapter returns no options (or an application-level failure
returns Null), only that fallback drops to Unknown. The pool moves to
Unknown at switch dispatch on both idle and busy paths so a concurrent
effort pick can't validate against the outgoing model's snapshot.

Desktop treated the harness unsupported_model effort rejection as
non-terminal, timing out to "Applies at next session" — the opposite of
a capability rejection. awaitEffortOutcome now settles it immediately
and EffortPicker renders a direct model-capability error.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…cted switch outcome

Finding A: clear model_capabilities in SwitchModel worker handler so
return_agent preserves Unknown instead of restoring the outgoing model's
stale Supported snapshot. The pool was already set to Unknown in
handle_switch_model_control; the worker's own cleared field prevents the
cancelled return from overwriting it before the requeued switch lands.

Finding B: introduce ModelSwitchOutcome enum (Applied/Rejected) to replace
the overloaded Value::Null sentinel. On Rejected, the pre-switch capabilities
and cache are preserved (session is still on default model), switch_succeeded
is false, and a terminal control_result failure is emitted so Desktop
ModelPicker rejects the live pick immediately. The optionless-success and
rejection paths are now distinct: optionless success drops to Unknown, a
rejection preserves existing capabilities.

Two lifecycle regressions added (no manual capability injection):
- test_busy_switch_clears_capabilities_so_return_preserves_unknown: starts
  Supported, simulates SwitchModel handler clearing caps, calls return_agent,
  asserts Unknown preserved.
- test_switch_rpc_rejection_preserves_pre_switch_capabilities: scripted ACP
  returning a JSON-RPC error from the switch RPC; asserts pre-switch caps
  intact, modelOverridden false, control_result failure emitted.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n_prompt_task

The Finding A regression previously hand-built a worker with cleared
capabilities and called return_agent directly, so deleting the
production fix line left it green — it proved a state transition the
handler does not generate. Rewrite it to run run_prompt_task with a
scripted ACP that holds the prompt open until session/cancel, delivering
ControlSignal::SwitchModel over the biased select! so the real handler
clears model_capabilities. Seed channel_info from cache and point the
REST client at a fast local stub returning [] so the profile lookup does
not retry twice against a dead base_url (~13s -> ~0.5s). Also document
the multi-worker tri-state coarseness at the return_agent refresh block
(bounded by the ratified F1/F2 effort-rejection machinery; no switch
epoch added) and correct the rejection test's closing comment.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The Rust pool now emits control_result { type: "switch_model", status:
"failure" } when an adapter explicitly rejects a live model switch, but
awaitLiveSwitchOutcome treated only unsupported_model as failure — every
other status, including the new failure, counted toward success and
resolved "ok". ModelPicker then toasted "Model switched for this
session." on a switch that never happened.

Add a distinct terminal "failed" outcome that fail-fasts on a single
failure frame, kept separate from "unsupported" because the user-facing
causes differ (model unavailable vs adapter refused). ModelPicker shows
a direct failure toast and skips onModelChanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Claude Code agents ignore the model set in Buzz Desktop — model picker persists but nothing applies it at spawn

2 participants