fix: effort startup default via ACP + honor CLAUDE_CONFIG_DIR for .claude.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>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-04 20:25:18 -04:00
parent c1397bf7dd
commit 51918328cb
8 changed files with 239 additions and 46 deletions

View File

@@ -429,6 +429,14 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_MODEL")]
pub model: Option<String>,
/// Persisted effort level value (e.g. "high", "medium", "low") to apply via
/// `session/set_config_option` at the first session creation. The configId is
/// resolved from the adapter's advertised `thought_level` capability — not
/// hardcoded. Non-fatal: if the adapter does not advertise `thought_level`,
/// the value is silently ignored and the persisted effort is not overwritten.
#[arg(long, env = "BUZZ_ACP_EFFORT_LEVEL")]
pub effort_level: Option<String>,
/// Title for the agent's ACP sessions, passed out-of-band in `session/new`
/// `_meta`. Adapters that recognize it name the session after this value;
/// others ignore it. Never enters the prompt.
@@ -539,6 +547,11 @@ pub struct Config {
pub memory_enabled: bool,
/// Desired LLM model ID. Applied after every `session_new_full()`.
pub model: Option<String>,
/// Persisted effort level value (e.g. "high", "medium", "low"). Resolved into a
/// real `desired_effort` at the first session creation by pairing with the
/// adapter's advertised `thought_level` configId. Non-fatal when absent or
/// when the adapter does not advertise `thought_level`.
pub effort_level: Option<String>,
/// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`.
/// `None` when unset or when the configured value sanitized to empty.
pub session_title: Option<String>,
@@ -1100,6 +1113,7 @@ impl Config {
typing_enabled: !args.no_typing,
memory_enabled: args.memory && !args.no_memory,
model,
effort_level: args.effort_level,
session_title: args
.session_title
.as_deref()

View File

@@ -1908,6 +1908,7 @@ async fn tokio_main() -> Result<()> {
desired_model: config.model.clone(),
model_overridden: false,
desired_effort: None,
startup_effort: config.effort_level.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
@@ -3905,6 +3906,12 @@ struct PoolStartup {
extra_env: Vec<(String, String)>,
has_generated_codex_config: bool,
model: Option<String>,
/// Persisted effort value to apply at the first session creation by pairing
/// with the adapter's advertised 'thought_level' configId. Carried from
/// (the env var). The configId
/// is unknown until capabilities arrive at session creation; it is never
/// hardcoded in the harness.
startup_effort: Option<String>,
observer: Option<observer::ObserverHandle>,
}
@@ -3917,6 +3924,7 @@ impl PoolStartup {
extra_env: config.persona_env_vars.clone(),
has_generated_codex_config: config.has_generated_codex_config,
model: config.model.clone(),
startup_effort: config.effort_level.clone(),
observer,
}
}
@@ -3985,6 +3993,7 @@ async fn initialize_agent_pool(
desired_model: startup.model.clone(),
model_overridden: false,
desired_effort: None,
startup_effort: startup.startup_effort.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
@@ -5439,6 +5448,7 @@ mod error_outcome_emission_tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
// Error branches under test never read this; 1 is the legacy
@@ -6878,6 +6888,7 @@ mod control_result_tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "test".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -6996,6 +7007,7 @@ mod control_result_tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "test".into(),
goose_system_prompt_supported: None,
protocol_version: 2,

View File

@@ -171,6 +171,12 @@ pub struct OwnedAgent {
/// `config_id` is the adapter's actual id from `AgentModelCapabilities::thought_level_config_id`;
/// it is set here by `set_idle_agent_effort` and never hardcoded in the harness.
pub desired_effort: Option<(String, String)>,
/// Startup effort value from `BUZZ_ACP_EFFORT_LEVEL` (carried from the Desktop
/// record). At the first session creation, if `desired_effort` is None, this
/// value is paired with the capabilities-derived `thought_level` configId to
/// form `desired_effort`. Non-fatal when absent or when the adapter does not
/// advertise `thought_level`.
pub startup_effort: Option<String>,
/// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`).
pub agent_name: String,
/// Whether Goose accepted its custom system-prompt method. `None` probes on
@@ -225,6 +231,26 @@ impl OwnedAgent {
self.goose_system_prompt_supported,
)
}
/// B5 startup-default: arms `desired_effort` from `startup_effort` + capabilities.
///
/// Called once at first session creation after capabilities are populated.
/// No-op when `desired_effort` is already set (live pick takes precedence),
/// when `startup_effort` is absent, or when the adapter does not advertise
/// a `thought_level` configId for the current model.
pub(crate) fn resolve_startup_effort(&mut self) {
if self.desired_effort.is_none() {
if let Some(ref value) = self.startup_effort.clone() {
if let Some(config_id) = self
.model_capabilities
.as_ref()
.and_then(|c| c.thought_level_config_id.clone())
{
self.desired_effort = Some((config_id, value.clone()));
}
}
}
}
}
/// Pool of agents with take-and-return ownership semantics.
@@ -1017,6 +1043,11 @@ async fn create_session_and_apply_model(
});
}
// B5 startup-default: arm desired_effort from startup_effort + capabilities.
// No-op when desired_effort already set (live pick takes precedence) or when
// the adapter does not advertise thought_level for this model.
agent.resolve_startup_effort();
// Apply desired_model if set, matching against the fresh session/new response.
// Track whether the switch succeeded so session_config_captured reflects
// the post-switch state (not the pre-switch desired state).
@@ -6115,6 +6146,7 @@ mod tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -6174,6 +6206,7 @@ mod tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -7220,6 +7253,7 @@ mod effort_tests {
desired_model: None,
model_overridden: false,
desired_effort: None,
startup_effort: None,
agent_name: "test".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
@@ -7243,4 +7277,107 @@ mod effort_tests {
"desired_effort must be queued"
);
}
// ── resolve_startup_effort ────────────────────────────────────────────────
/// Helper to build a minimal `OwnedAgent` for `resolve_startup_effort` tests.
/// The bash subprocess is needed for `AcpClient` construction; none of the
/// tests below send any ACP messages.
async fn make_agent_for_startup_effort(
startup_effort: Option<&str>,
thought_level_config_id: Option<&str>,
desired_effort: Option<(&str, &str)>,
) -> OwnedAgent {
let acp = AcpClient::spawn(
"bash",
&["-c".to_string(), "sleep 10".to_string()],
&[],
false,
)
.await
.expect("failed to spawn test agent");
OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: thought_level_config_id.map(|id| AgentModelCapabilities {
config_options_raw: vec![],
available_models_raw: None,
thought_level_config_id: Some(id.to_string()),
}),
desired_model: None,
model_overridden: false,
desired_effort: desired_effort.map(|(id, v)| (id.to_string(), v.to_string())),
startup_effort: startup_effort.map(str::to_string),
agent_name: "test".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
}
}
/// `resolve_startup_effort` arms `desired_effort` from `startup_effort` and the
/// capabilities-derived `thought_level` configId on the first session creation.
#[tokio::test]
async fn test_resolve_startup_effort_arms_desired_effort_from_startup_value() {
let mut agent = make_agent_for_startup_effort(Some("high"), Some("tlevel-id"), None).await;
agent.resolve_startup_effort();
assert_eq!(
agent
.desired_effort
.as_ref()
.map(|(id, v)| (id.as_str(), v.as_str())),
Some(("tlevel-id", "high")),
"desired_effort must be armed from startup_effort + thought_level configId"
);
}
/// `resolve_startup_effort` is a no-op when `desired_effort` is already set
/// (a live user pick must not be overridden by the startup default).
#[tokio::test]
async fn test_resolve_startup_effort_does_not_override_live_pick() {
let mut agent = make_agent_for_startup_effort(
Some("high"),
Some("tlevel-id"),
Some(("tlevel-id", "low")), // live pick already present
)
.await;
agent.resolve_startup_effort();
assert_eq!(
agent
.desired_effort
.as_ref()
.map(|(id, v)| (id.as_str(), v.as_str())),
Some(("tlevel-id", "low")),
"live desired_effort must not be overridden by startup default"
);
}
/// `resolve_startup_effort` is a no-op when no `startup_effort` was provided
/// (agent record had no persisted effort level).
#[tokio::test]
async fn test_resolve_startup_effort_noop_when_startup_effort_absent() {
let mut agent = make_agent_for_startup_effort(None, Some("tlevel-id"), None).await;
agent.resolve_startup_effort();
assert!(
agent.desired_effort.is_none(),
"desired_effort must stay None when no startup_effort set"
);
}
/// `resolve_startup_effort` is a no-op when the adapter has no `thought_level`
/// configId (model does not support thinking effort).
#[tokio::test]
async fn test_resolve_startup_effort_noop_when_model_lacks_thought_level() {
let mut agent = make_agent_for_startup_effort(
Some("high"),
None, // no thought_level_config_id → not supported
None,
)
.await;
agent.resolve_startup_effort();
assert!(
agent.desired_effort.is_none(),
"desired_effort must stay None when adapter does not advertise thought_level"
);
}
}

View File

@@ -13,9 +13,9 @@ use crate::{
},
},
current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key,
known_acp_runtime, load_managed_agents, load_personas, save_managed_agents,
sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime,
ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES,
known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env,
save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig,
KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES,
},
};
@@ -295,30 +295,29 @@ pub async fn get_agent_config_surface(
let session_cache = state.get_session_cache(&runtime_key);
let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
// #3493: for claude agents, resolve the settings.json path from the agent's
// effective CLAUDE_CONFIG_DIR env var (if set), falling back to ~/.claude/.
// We never provision this dir ourselves — we only respect what the user configured.
let claude_config_dir: Option<std::path::PathBuf> = if runtime_meta
.is_some_and(|m| m.id == "claude")
{
// Look up CLAUDE_CONFIG_DIR from the effective agent env (record overrides
// persona overrides global) — matching the precedence order at spawn.
let personas_ref = &personas;
let persona = record
.persona_id
.as_deref()
.and_then(|pid| personas_ref.iter().find(|p| p.id == pid));
let global_ref = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
record
.env_vars
.get("CLAUDE_CONFIG_DIR")
.or_else(|| persona.and_then(|p| p.env_vars.get("CLAUDE_CONFIG_DIR")))
.or_else(|| global_ref.env_vars.get("CLAUDE_CONFIG_DIR"))
.map(std::path::PathBuf::from)
} else {
None
};
// #3493: for claude agents, resolve the settings.json and .claude.json paths
// from the agent's effective CLAUDE_CONFIG_DIR env var (if set), falling
// back to ~/.claude/ and ~/.claude.json. We never provision this dir
// ourselves — we only respect what the user configured.
//
// Use resolve_effective_agent_env so the lookup covers all tiers (baked
// floor → definition → global → persona → record) and cannot diverge from
// what the spawned process actually sees.
let claude_config_dir: Option<std::path::PathBuf> =
if runtime_meta.is_some_and(|m| m.id == "claude") {
let effective_env = resolve_effective_agent_env(
&record,
&personas,
runtime_meta,
&global,
);
effective_env
.env
.get("CLAUDE_CONFIG_DIR")
.map(std::path::PathBuf::from)
} else {
None
};
Ok(resolve_config_surface(
record,
@@ -540,8 +539,10 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<
///
/// B5: called by the TypeScript observer when `session/set_config_option` for
/// the "effort" config option receives a positive acknowledgement. The record
/// is updated in-place and persisted; the next spawn will apply this value via
/// `session/set_config_option` at session creation (in `create_session_and_apply_model`).
/// is updated in-place and persisted. At the next spawn the Desktop injects
/// `BUZZ_ACP_EFFORT_LEVEL` so the harness applies this value via
/// `session/set_config_option` at session creation, pairing with the
/// adapter-advertised `thought_level` configId discovered from capabilities.
///
/// `effort_level` is the acknowledged value. Pass `None` to clear the
/// canonical effort (reverts to adapter default on next spawn).

View File

@@ -1,23 +1,29 @@
use super::types::{ExtensionEntry, RuntimeFileConfig};
/// Read Claude Code config from `settings.json` and `~/.claude.json`.
/// Read Claude Code config from `settings.json` and `.claude.json`.
///
/// `config_dir` — when `Some`, reads `settings.json` from that directory
/// (the agent's effective `CLAUDE_CONFIG_DIR`) instead of `~/.claude/`.
/// MCP servers are always read from `~/.claude.json` regardless of
/// `CLAUDE_CONFIG_DIR` — Claude Code does not remap the global MCP config
/// file via that variable.
/// `config_dir` — when `Some`, reads both `settings.json` and `.claude.json`
/// from that directory (the agent's effective `CLAUDE_CONFIG_DIR`).
/// Defaults to `~/.claude/settings.json` and `~/.claude.json` when `None`.
///
/// Both files are resolved from the same directory: the claude 2.1.x binary
/// resolves `.claude.json` as `join(process.env.CLAUDE_CONFIG_DIR || homedir(),
/// ".claude.json")`, mirroring the `settings.json` resolver. A user-set
/// `CLAUDE_CONFIG_DIR` therefore remaps both files — honoring only
/// `settings.json` would misrepresent the agent's actual MCP config.
pub(super) fn read_config_file(config_dir: Option<&std::path::Path>) -> Option<RuntimeFileConfig> {
let home = dirs::home_dir()?;
// #3493: honor user-set CLAUDE_CONFIG_DIR for settings.json path.
// #3493: honor user-set CLAUDE_CONFIG_DIR for both settings.json and
// .claude.json — the binary resolves both relative to CLAUDE_CONFIG_DIR.
// Panel reflects the actual config the agent reads.
let settings_path = config_dir
.map(|d| d.join("settings.json"))
.unwrap_or_else(|| home.join(".claude").join("settings.json"));
// MCP config is always ~/ relative — CLAUDE_CONFIG_DIR does not affect it.
let mcp_path = home.join(".claude.json");
let mcp_path = config_dir
.map(|d| d.join(".claude.json"))
.unwrap_or_else(|| home.join(".claude.json"));
let settings = read_json_file(&settings_path);
let mcp_config = read_json_file(&mcp_path);

View File

@@ -154,7 +154,8 @@ pub(crate) fn read_config_surface(
let config_file_path = runtime_meta
.and_then(|m| m.config_file_path)
.map(resolve_tilde);
let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime);
let mcp_config_file_path =
runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, claude_config_dir));
let extensions = file_config.extensions.clone();
let sources = ConfigSourceReport {
@@ -211,12 +212,28 @@ pub(crate) fn read_config_surface(
}
}
fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String> {
fn mcp_config_file_path_for_runtime(
runtime: &KnownAcpRuntime,
claude_config_dir: Option<&std::path::Path>,
) -> Option<String> {
match runtime.id {
"goose" => {
super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned())
}
"claude" => dirs::home_dir().map(|h| h.join(".claude.json").to_string_lossy().into_owned()),
// #3493: the claude 2.1.x binary resolves .claude.json as
// join(CLAUDE_CONFIG_DIR || homedir(), ".claude.json"), so the MCP
// config file moves with a user-set CLAUDE_CONFIG_DIR.
"claude" => Some(
claude_config_dir
.map(|d| d.join(".claude.json"))
.unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join(".claude.json"))
.unwrap_or_default()
})
.to_string_lossy()
.into_owned(),
),
"codex" => {
super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned())
}

View File

@@ -760,6 +760,12 @@ pub fn spawn_agent_child(
} else {
command.env_remove("BUZZ_ACP_MODEL");
}
// B5: carry persisted effort; harness resolves thought_level configId at first session.
if let Some(ref effort) = record.effort_level {
command.env("BUZZ_ACP_EFFORT_LEVEL", effort);
} else {
command.env_remove("BUZZ_ACP_EFFORT_LEVEL");
}
// Session title for the harness to pass out-of-band on `session/new`. The
// adapter names the session after it; it never reaches the prompt, so this
// is display metadata only. The spawn-config snapshot records the same
@@ -837,10 +843,9 @@ pub fn spawn_agent_child(
command.env(key, value);
}
// A1: for local claude agents, ANTHROPIC_MODEL is the single startup model
// authority. BUZZ_ACP_MODEL is removed so the harness never sees two
// simultaneous model authorities (BUZZ_ACP_MODEL is for live ACP switches
// only; ANTHROPIC_MODEL locks the session model in the adapter env).
// A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority.
// BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env
// would be ambiguous).
if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude")
{
apply_claude_model_env(&mut command, effective_model.as_deref());

View File

@@ -439,7 +439,8 @@ pub struct ManagedAgentRecord {
/// deserialize as `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_mesh: Option<RelayMeshConfig>,
/// Canonical Claude Code effort level; seeded into the per-agent `settings.json` at spawn.
/// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn
/// so the harness applies it via `session/set_config_option` at session creation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort_level: Option<String>,
}