Skip to content
Draft
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
139 changes: 133 additions & 6 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1816,22 +1816,89 @@ pub enum ModelSwitchMethod {
SetModel { model_id: String },
}

/// Extract `configOptions` entries with `category == "model"` from a `session/new` result.
/// Extract model and thought-level `configOptions` from a `session/new` result.
///
/// Returns the raw JSON array entries. Each entry has `configId`, `displayName`,
/// `options: [{ value, displayName }]`, etc.
pub fn extract_model_config_options(result: &serde_json::Value) -> Vec<serde_json::Value> {
/// These are the harness-native choices Buzz can render and apply. Other
/// categories (appearance, permissions, etc.) remain owned by the harness.
pub fn extract_agent_config_options(result: &serde_json::Value) -> Vec<serde_json::Value> {
result["configOptions"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|opt| opt.get("category").and_then(|c| c.as_str()) == Some("model"))
.filter(|opt| {
matches!(
opt.get("category").and_then(|c| c.as_str()),
Some("model" | "thought_level")
)
})
.cloned()
.collect()
})
.unwrap_or_default()
}

/// Return the harness-native thought-level config ID when the requested value
/// is advertised for this session.
pub fn resolve_effort_config_option<'a>(
result: &'a serde_json::Value,
desired: &str,
) -> Option<&'a str> {
result
.get("configOptions")?
.as_array()?
.iter()
.find(|option| {
option.get("category").and_then(|value| value.as_str()) == Some("thought_level")
&& option
.get("options")
.and_then(|values| values.as_array())
.is_some_and(|values| {
values.iter().any(|value| {
value.get("value").and_then(|value| value.as_str()) == Some(desired)
})
})
})
.and_then(|option| option.get("id").or_else(|| option.get("configId")))
.and_then(|value| value.as_str())
}

/// Merge refreshed options into the session catalog without dropping options
/// omitted from a partial `set_config_option` response.
pub fn merge_config_options(session: &mut serde_json::Value, refreshed: &serde_json::Value) {
let Some(refreshed_options) = refreshed
.get("configOptions")
.and_then(|value| value.as_array())
else {
return;
};
let Some(session_options) = session
.get_mut("configOptions")
.and_then(|value| value.as_array_mut())
else {
session["configOptions"] = serde_json::Value::Array(refreshed_options.clone());
return;
};
for option in refreshed_options {
let id = option.get("id").or_else(|| option.get("configId"));
if let Some(existing) = session_options
.iter_mut()
.find(|existing| existing.get("id").or_else(|| existing.get("configId")) == id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The comparison here treats two absent IDs as equal (None == None). That means the first existing ID-less/malformed option can be overwritten by every unrelated ID-less refreshed option. Please merge only when the refreshed option has a concrete non-empty id/configId; otherwise append it or ignore it defensively. A regression test with two distinct ID-less options would pin this down.

{
*existing = option.clone();
} else {
session_options.push(option.clone());
}
}
}

/// Extract only model-category `configOptions` from a `session/new` result.
pub fn extract_model_config_options(result: &serde_json::Value) -> Vec<serde_json::Value> {
extract_agent_config_options(result)
.into_iter()
.filter(|opt| opt.get("category").and_then(|c| c.as_str()) == Some("model"))
.collect()
}

/// Extract `SessionModelState` (unstable path) from a `session/new` result.
///
/// Returns the `models` object if present: `{ currentModelId, availableModels: [...] }`.
Expand All @@ -1852,7 +1919,11 @@ pub fn resolve_model_switch_method(
// 1. Search stable configOptions for a "model"-category entry whose
// options contain a value matching desired_model.
for config_opt in extract_model_config_options(session_new_result) {
let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) {
let config_id = match config_opt
.get("id")
.or_else(|| config_opt.get("configId"))
.and_then(|v| v.as_str())
{
Some(id) => id,
None => continue,
};
Expand Down Expand Up @@ -2349,6 +2420,62 @@ mod tests {
assert_eq!(opts[0]["configId"].as_str(), Some("model"));
}

#[test]
fn extract_agent_config_options_keeps_native_effort() {
let result = serde_json::json!({
"configOptions": [
{ "id": "model", "category": "model" },
{
"id": "thinking_effort",
"category": "thought_level",
"currentValue": "medium",
"options": [{ "value": "low", "name": "Low" }]
},
{ "id": "theme", "category": "appearance" }
]
});
let opts = super::extract_agent_config_options(&result);
assert_eq!(opts.len(), 2);
assert_eq!(opts[1]["id"].as_str(), Some("thinking_effort"));
}

#[test]
fn resolve_effort_config_option_requires_an_advertised_value() {
let result = serde_json::json!({
"configOptions": [{
"id": "thinking_effort",
"category": "thought_level",
"options": [{ "value": "low" }, { "value": "high" }]
}]
});
assert_eq!(
super::resolve_effort_config_option(&result, "high"),
Some("thinking_effort")
);
assert_eq!(super::resolve_effort_config_option(&result, "max"), None);
}

#[test]
fn merge_config_options_preserves_unmentioned_categories() {
let mut session = serde_json::json!({
"configOptions": [
{ "id": "model", "category": "model", "currentValue": "old" },
{ "id": "mode", "category": "mode", "currentValue": "auto" }
]
});
let refreshed = serde_json::json!({
"configOptions": [
{ "id": "model", "category": "model", "currentValue": "new" },
{ "id": "thinking_effort", "category": "thought_level" }
]
});
super::merge_config_options(&mut session, &refreshed);
let options = session["configOptions"].as_array().unwrap();
assert_eq!(options.len(), 3);
assert_eq!(options[0]["currentValue"], "new");
assert_eq!(options[1]["id"], "mode");
}

#[test]
fn extract_model_config_options_empty_when_no_config_options() {
let result = serde_json::json!({ "sessionId": "sess-1" });
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ pub struct ModelsArgs {
#[command(flatten)]
pub agent: AuthAgentArgs,

/// Select this model before returning model-specific config options.
#[arg(long)]
pub model: Option<String>,

/// Output structured JSON instead of human-readable text.
#[arg(long)]
pub json: bool,
Expand Down Expand Up @@ -423,6 +427,11 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_MODEL")]
pub model: Option<String>,

/// Desired native effort value. Applied to each new ACP session through
/// the harness's `thought_level` config option.
#[arg(long, env = "BUZZ_ACP_EFFORT")]
pub effort: Option<String>,

/// Permission mode for agents that support `session/set_config_option`
/// with `configId: "mode"` (e.g. `claude-agent-acp`).
///
Expand Down Expand Up @@ -518,6 +527,8 @@ pub struct Config {
pub memory_enabled: bool,
/// Desired LLM model ID. Applied after every `session_new_full()`.
pub model: Option<String>,
/// Desired harness-native effort value. Applied after session creation.
pub effort: Option<String>,
/// Permission mode to apply after session creation. `Default` = skip.
pub permission_mode: PermissionMode,
/// Inbound author gate mode.
Expand Down Expand Up @@ -989,6 +1000,7 @@ impl Config {
typing_enabled: !args.no_typing,
memory_enabled: args.memory && !args.no_memory,
model,
effort: args.effort,
permission_mode: args.permission_mode,
respond_to: args.respond_to,
respond_to_allowlist,
Expand Down Expand Up @@ -1361,6 +1373,7 @@ mod tests {
typing_enabled: true,
memory_enabled: true,
model: None,
effort: None,
permission_mode: PermissionMode::BypassPermissions,
respond_to: RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
Expand Down
39 changes: 35 additions & 4 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,7 @@ async fn tokio_main() -> Result<()> {
channel_info: channel_info_map,
context_message_limit: config.context_message_limit,
max_turns_per_session: config.max_turns_per_session,
effort: config.effort.clone(),
permission_mode: config.permission_mode,
agent_keys: config.keys.clone(),
agent_owner_pubkey: startup_owner
Expand Down Expand Up @@ -3538,7 +3539,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
/// Flow: spawn → initialize → session/new → print models → shutdown.
/// No relay connection, no MCP servers, no subscriptions. ~2-5s total.
async fn run_models(args: ModelsArgs) -> Result<()> {
use acp::{extract_model_config_options, extract_model_state};
use acp::{extract_agent_config_options, extract_model_state};

let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args);
let cwd = std::env::current_dir()
Expand Down Expand Up @@ -3566,7 +3567,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
})
.await;

let (init_result, session_resp) = match protocol_result {
let (init_result, mut session_resp) = match protocol_result {
Ok(Ok(tuple)) => tuple,
Ok(Err(e)) => {
client.shutdown().await;
Expand Down Expand Up @@ -3594,8 +3595,36 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
.and_then(|v| v.as_str())
.unwrap_or("unknown");

// Extract model info from session/new response.
let config_options = extract_model_config_options(&session_resp.raw);
// Select the requested model first so the returned thought-level choices
// belong to that model. Goose returns the refreshed configOptions in the
// set_config_option response; other harnesses may return an empty object.
if let Some(model) = args.model.as_deref() {
if let Some(acp::ModelSwitchMethod::ConfigOption { config_id, .. }) =
acp::resolve_model_switch_method(&session_resp.raw, model)
{
let response = tokio::time::timeout(
MODELS_TIMEOUT,
client.session_set_config_option(&session_resp.session_id, &config_id, model),
)
.await;
match response {
Ok(Ok(response)) => {
if response.get("configOptions").is_some() {
session_resp.raw = response;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This replaces the entire session/new result with a set_config_option response. ACP config-option updates may be partial, so this can discard models, session metadata, and config categories omitted from the update; extract_model_state below then silently loses the unstable catalog. Please keep the original response and merge only refreshed configOptions (using a corrected merge_config_options).

}
}
Ok(Err(error)) => eprintln!(
"warning: model-specific option discovery failed: {error}; using default options"
),
Err(_) => eprintln!(
"warning: model-specific option discovery timed out; using default options"
),
}
}
}

// Extract model and effort info from the authoritative session response.
let config_options = extract_agent_config_options(&session_resp.raw);
let model_state = extract_model_state(&session_resp.raw);

if args.json {
Expand Down Expand Up @@ -4173,6 +4202,7 @@ mod build_mcp_servers_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
effort: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: std::collections::HashSet::new(),
Expand Down Expand Up @@ -4338,6 +4368,7 @@ mod error_outcome_emission_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
effort: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
Expand Down
Loading
Loading