Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2182,6 +2182,28 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option<serde_json::Val
result.get("models").cloned()
}

/// Extract the `configId` for the `thought_level` category option from a
/// `session/new` result, if the adapter advertised one.
///
/// Claude Code's adapter uses `category: "thought_level"` in its `configOptions`.
/// The configId is adapter-defined (e.g. `"effort"` on claude-agent-acp) and must
/// not be hardcoded in the harness — this function discovers it at session time so
/// the spawn-scoped effort application forwards the adapter's real id. Accepts both
/// `configId` (ACP spec) and `id` (claude-agent-acp), matching the model-switch path.
pub fn extract_thought_level_config_id(result: &serde_json::Value) -> Option<String> {
let arr = result["configOptions"].as_array()?;
for opt in arr {
if opt.get("category").and_then(|c| c.as_str()) == Some("thought_level") {
let config_id = opt
.get("configId")
.or_else(|| opt.get("id"))
.and_then(|v| v.as_str())?;
return Some(config_id.to_string());
}
}
None
}

/// Match a desired model ID against a fresh `session/new` response.
///
/// Returns the correct ACP method to call, or `None` if no match.
Expand Down Expand Up @@ -2751,6 +2773,54 @@ mod tests {
assert!(super::extract_model_state(&result).is_none());
}

#[test]
fn extract_thought_level_config_id_finds_config_id() {
let result = serde_json::json!({
"sessionId": "sess-1",
"configOptions": [
{ "configId": "model", "category": "model" },
{
"configId": "effort",
"category": "thought_level",
"options": [{ "value": "high" }, { "value": "low" }]
}
]
});
assert_eq!(
super::extract_thought_level_config_id(&result).as_deref(),
Some("effort")
);
}

#[test]
fn extract_thought_level_config_id_falls_back_to_id_key() {
let result = serde_json::json!({
"configOptions": [
{ "id": "effort", "category": "thought_level" }
]
});
assert_eq!(
super::extract_thought_level_config_id(&result).as_deref(),
Some("effort")
);
}

#[test]
fn extract_thought_level_config_id_none_without_category() {
let result = serde_json::json!({
"configOptions": [
{ "configId": "model", "category": "model" }
]
});
assert!(super::extract_thought_level_config_id(&result).is_none());
}

#[test]
fn extract_thought_level_config_id_none_without_config_options() {
let result = serde_json::json!({ "sessionId": "sess-1" });
assert!(super::extract_thought_level_config_id(&result).is_none());
}

#[test]
fn resolve_prefers_stable_over_unstable() {
let result = serde_json::json!({
Expand Down
38 changes: 38 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ pub enum PermissionMode {
/// Agent default — permission requests per tool call.
#[value(alias = "default")]
Default,
/// Auto mode — fully autonomous execution; model-gated (requires a model
/// that supports `supportsAutoMode`). Degrades gracefully to `default`
/// when the session's active model does not support it.
#[value(alias = "auto")]
Auto,
/// Auto-approve file edits, still ask for other tools.
#[value(alias = "acceptEdits")]
AcceptEdits,
Expand All @@ -144,6 +149,7 @@ impl PermissionMode {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::Default => "default",
Self::Auto => "auto",
Self::AcceptEdits => "acceptEdits",
Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
Expand Down Expand Up @@ -423,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.
Expand Down Expand Up @@ -540,6 +554,12 @@ 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"). Held as a
/// per-worker spawn-scoped value and applied 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>,
Expand Down Expand Up @@ -1105,6 +1125,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()
Expand Down Expand Up @@ -1480,6 +1501,7 @@ mod tests {
typing_enabled: true,
memory_enabled: true,
model: None,
effort_level: None,
session_title: None,
permission_mode: PermissionMode::BypassPermissions,
respond_to: RespondTo::Anyone,
Expand Down Expand Up @@ -2298,6 +2320,7 @@ channels = "ALL"
#[test]
fn test_permission_mode_wire_strings() {
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
assert_eq!(PermissionMode::Auto.as_wire_str(), "auto");
assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits");
assert_eq!(
PermissionMode::BypassPermissions.as_wire_str(),
Expand All @@ -2310,19 +2333,32 @@ channels = "ALL"
#[test]
fn test_permission_mode_is_default() {
assert!(PermissionMode::Default.is_default());
assert!(!PermissionMode::Auto.is_default());
assert!(!PermissionMode::BypassPermissions.is_default());
assert!(!PermissionMode::AcceptEdits.is_default());
assert!(!PermissionMode::DontAsk.is_default());
assert!(!PermissionMode::Plan.is_default());
}

#[test]
fn test_permission_mode_auto_degrades_to_default_when_unsupported() {
// The wire string is "auto" — the adapter handles graceful downgrade
// to "default" when the active model does not support Auto mode.
// Verify only that the wire string is correct and distinct from "default".
let auto = PermissionMode::Auto;
assert_eq!(auto.as_wire_str(), "auto");
assert_ne!(auto.as_wire_str(), "default");
assert!(!auto.is_default());
}

#[test]
fn test_permission_mode_display() {
assert_eq!(
format!("{}", PermissionMode::BypassPermissions),
"bypassPermissions"
);
assert_eq!(format!("{}", PermissionMode::Default), "default");
assert_eq!(format!("{}", PermissionMode::Auto), "auto");
}

#[test]
Expand Down Expand Up @@ -2360,6 +2396,7 @@ channels = "ALL"
use clap::ValueEnum;
let cases = [
("default", PermissionMode::Default),
("auto", PermissionMode::Auto),
("accept-edits", PermissionMode::AcceptEdits),
("bypass-permissions", PermissionMode::BypassPermissions),
("dont-ask", PermissionMode::DontAsk),
Expand All @@ -2382,6 +2419,7 @@ channels = "ALL"
use clap::ValueEnum;
let cases = [
("default", PermissionMode::Default),
("auto", PermissionMode::Auto),
("acceptEdits", PermissionMode::AcceptEdits),
("bypassPermissions", PermissionMode::BypassPermissions),
("dontAsk", PermissionMode::DontAsk),
Expand Down
30 changes: 28 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,13 @@ fn handle_switch_model_control(
tracing::warn!("observer switch_model control frame missing modelId");
return;
};
// Opaque per-pick correlator, echoed on every result frame so the Desktop
// can ignore a replayed result for an earlier pick. Optional: absent on
// older Desktop clients, in which case the frames simply carry no id.
let request_id = payload
.get("requestId")
.and_then(|value| value.as_str())
.map(str::to_string);

// A turn is in flight for this channel iff a task_map entry exists. The
// agent is moved out of the pool during a turn, so the control oneshot is
Expand All @@ -1352,15 +1359,18 @@ fn handle_switch_model_control(
if signal_in_flight_task(
pool,
channel_id,
ControlSignal::SwitchModel(model_id.to_string()),
ControlSignal::SwitchModel {
model_id: model_id.to_string(),
request_id: request_id.clone(),
},
) {
"sent"
} else {
"turn_ending"
}
} else {
// Idle path: validate against the cached catalog before invalidating.
match pool.switch_idle_agent_model(channel_id, model_id) {
match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) {
IdleSwitchResult::Switched => "switched",
IdleSwitchResult::UnsupportedModel => "unsupported_model",
IdleSwitchResult::NoIdleAgent => "no_active_turn",
Expand All @@ -1381,6 +1391,9 @@ fn handle_switch_model_control(
"type": "switch_model",
"status": status,
"modelId": model_id,
// Echo the correlator on the immediate ack so a `sent` /
// `turn_ending` / idle-path terminal frame matches the pick.
"requestId": request_id,
}),
);
}
Expand Down Expand Up @@ -2478,6 +2491,9 @@ async fn tokio_main() -> Result<()> {
model_capabilities: None,
desired_model: config.model.clone(),
model_overridden: false,
desired_model_request_id: None,
desired_model_pending_ack: false,
startup_effort: config.effort_level.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
Expand Down Expand Up @@ -4701,6 +4717,7 @@ struct PoolStartup {
extra_env: Vec<(String, String)>,
has_generated_codex_config: bool,
model: Option<String>,
effort_level: Option<String>,
observer: Option<observer::ObserverHandle>,
}

Expand All @@ -4713,6 +4730,7 @@ impl PoolStartup {
extra_env: config.persona_env_vars.clone(),
has_generated_codex_config: config.has_generated_codex_config,
model: config.model.clone(),
effort_level: config.effort_level.clone(),
observer,
}
}
Expand Down Expand Up @@ -4780,6 +4798,9 @@ async fn initialize_agent_pool(
model_capabilities: None,
desired_model: startup.model.clone(),
model_overridden: false,
desired_model_request_id: None,
desired_model_pending_ack: false,
startup_effort: startup.effort_level.clone(),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
Expand Down Expand Up @@ -7139,6 +7160,7 @@ mod build_mcp_servers_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
effort_level: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
Expand Down Expand Up @@ -7362,6 +7384,7 @@ mod error_outcome_emission_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
effort_level: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
Expand Down Expand Up @@ -7408,6 +7431,9 @@ mod error_outcome_emission_tests {
model_capabilities: None,
desired_model: None,
model_overridden: false,
desired_model_request_id: None,
desired_model_pending_ack: false,
startup_effort: None,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
// Error branches under test never read this; 1 is the legacy
Expand Down
Loading
Loading