Skip to content
Open
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
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