Skip to content
Open
47 changes: 47 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2105,13 +2105,60 @@ pub fn extract_model_config_options(result: &serde_json::Value) -> Vec<serde_jso
.unwrap_or_default()
}

/// Extract `configOptions` entries relevant to agent capability tracking from a
/// `session/new` result.
///
/// Returns entries with `category == "model"` (for model catalog/validation, existing
/// callers) **and** `category == "thought_level"` (so the pool-level capability cache
/// can populate `valid_values` and the harness `invalid_value` guard runs in production).
///
/// Stored in `AgentModelCapabilities::config_options_raw`; `extract_model_config_options`
/// is kept as a separate helper for callers that only need the model category.
pub fn extract_agent_config_options(result: &serde_json::Value) -> Vec<serde_json::Value> {
result["configOptions"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|opt| {
matches!(
opt.get("category").and_then(|c| c.as_str()),
Some("model") | Some("thought_level")
)
})
.cloned()
.collect()
})
.unwrap_or_default()
}

/// Extract `SessionModelState` (unstable path) from a `session/new` result.
///
/// Returns the `models` object if present: `{ currentModelId, availableModels: [...] }`.
pub fn extract_model_state(result: &serde_json::Value) -> Option<serde_json::Value> {
result.get("models").cloned()
}

/// B5: 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 `set_idle_agent_effort` can forward the real id.
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
35 changes: 35 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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 @@ -140,6 +145,7 @@ impl PermissionMode {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::Default => "default",
Self::Auto => "auto",
Self::AcceptEdits => "acceptEdits",
Self::DontAsk => "dontAsk",
Self::Plan => "plan",
Expand Down Expand Up @@ -418,6 +424,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 @@ -527,6 +541,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>,
Expand Down Expand Up @@ -1088,6 +1107,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 @@ -1475,6 +1495,7 @@ mod tests {
agent_owner: None,
no_base_prompt: false,
base_prompt_content: None,
effort_level: None,
}
}

Expand Down Expand Up @@ -2263,6 +2284,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::DontAsk.as_wire_str(), "dontAsk");
assert_eq!(PermissionMode::Plan.as_wire_str(), "plan");
Expand All @@ -2271,15 +2293,28 @@ channels = "ALL"
#[test]
fn test_permission_mode_is_default() {
assert!(PermissionMode::Default.is_default());
assert!(!PermissionMode::Auto.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::DontAsk), "dontAsk");
assert_eq!(format!("{}", PermissionMode::Default), "default");
assert_eq!(format!("{}", PermissionMode::Auto), "auto");
}

#[test]
Expand Down
Loading
Loading