diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index b7bca5477b..886f184909 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -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 { +/// 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 { 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) + { + *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 { + 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: [...] }`. @@ -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, }; @@ -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" }); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index befb7aa6aa..33431b7a53 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -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, + /// Output structured JSON instead of human-readable text. #[arg(long)] pub json: bool, @@ -423,6 +427,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MODEL")] pub model: Option, + /// 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, + /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// @@ -518,6 +527,8 @@ pub struct Config { pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. pub model: Option, + /// Desired harness-native effort value. Applied after session creation. + pub effort: Option, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, /// Inbound author gate mode. @@ -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, @@ -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(), diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 808a34a793..31c10506b8 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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 @@ -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() @@ -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; @@ -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; + } + } + 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 { @@ -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(), @@ -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(), diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 3637869968..e98c09b5d4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -450,6 +450,8 @@ pub struct PromptContext { pub context_message_limit: u32, /// Max turns per session before proactive rotation. 0 = disabled. pub max_turns_per_session: u32, + /// Desired harness-native effort value to apply after session creation. + pub effort: Option, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, /// Agent identity — used to derive the NIP-AE conversation key at @@ -763,7 +765,7 @@ async fn create_session_and_apply_model( agent_canvas, ); - let resp = agent + let mut resp = agent .acp .session_new_full( &ctx.cwd, @@ -810,7 +812,11 @@ async fn create_session_and_apply_model( let switch_succeeded = if let Some(ref desired) = agent.desired_model { match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { - apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; + let switch_response = + apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; + if let Some(response) = switch_response.as_ref() { + crate::acp::merge_config_options(&mut resp.raw, response); + } true } None => { @@ -837,11 +843,37 @@ async fn create_session_and_apply_model( false }; - // Emit session config for desktop consumption (config bridge tier 1b). - // Emitted AFTER desired_model resolution so the desktop caches the - // post-switch state. modelOverridden reflects whether the switch actually - // applied — false on the unsupported arm so the panel doesn't show a - // stale override badge. + if let Some(effort) = ctx.effort.as_deref() { + if let Some(config_id) = crate::acp::resolve_effort_config_option(&resp.raw, effort) { + let result = tokio::time::timeout( + MODEL_SWITCH_TIMEOUT, + agent + .acp + .session_set_config_option(&resp.session_id, config_id, effort), + ) + .await; + match result { + Ok(Ok(response)) => { + crate::acp::merge_config_options(&mut resp.raw, &response); + } + Ok(Err(error)) => tracing::warn!( + target: "pool::effort", + "failed to apply effort {effort}: {error} — using harness default" + ), + Err(_) => tracing::warn!( + target: "pool::effort", + "effort apply timed out — using harness default" + ), + } + } else { + tracing::warn!( + target: "pool::effort", + "effort {effort} is not supported by the selected model — using harness default" + ); + } + } + + // Capture the final applied model and effort state for the desktop. agent.acp.observe( "session_config_captured", serde_json::json!({ @@ -876,7 +908,7 @@ async fn apply_model_switch( session_id: &str, desired: &str, method: &ModelSwitchMethod, -) -> Result<(), AcpError> { +) -> Result, AcpError> { let method_label = match method { ModelSwitchMethod::ConfigOption { config_id, .. } => { format!("configOption (configId={config_id})") @@ -901,11 +933,12 @@ async fn apply_model_switch( .await; match result { - Ok(Ok(_)) => { + Ok(Ok(response)) => { tracing::info!( target: "pool::model", "applied model {desired} via {method_label} on session {session_id}" ); + return Ok(Some(response)); } // Transport-class errors may have corrupted the stdio stream — propagate // so the caller can respawn the agent instead of reusing a poisoned one. @@ -937,7 +970,7 @@ async fn apply_model_switch( return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)); } } - Ok(()) + Ok(None) } /// Set the session permission mode via `session/set_config_option`. @@ -5239,6 +5272,7 @@ mod tests { channel_info: std::collections::HashMap::new(), context_message_limit: 0, max_turns_per_session: 0, + effort: None, permission_mode: PermissionMode::Default, agent_keys: agent_keys.clone(), agent_owner_pubkey: owner_pubkey, diff --git a/desktop/src-tauri/src/commands/agent_effort_options.rs b/desktop/src-tauri/src/commands/agent_effort_options.rs new file mode 100644 index 0000000000..adfe8c72f6 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_effort_options.rs @@ -0,0 +1,37 @@ +use crate::managed_agents::AgentEffortOption; + +pub(super) fn normalize_effort_options( + raw: &serde_json::Value, +) -> (Vec, Option) { + let effort_config = raw["stable"]["configOptions"] + .as_array() + .and_then(|options| { + options.iter().find(|option| { + option.get("category").and_then(|value| value.as_str()) == Some("thought_level") + }) + }); + let options = effort_config + .and_then(|option| option.get("options")) + .and_then(|options| options.as_array()) + .map(|options| { + options + .iter() + .filter_map(|option| { + let value = option.get("value")?.as_str()?.to_string(); + let label = option + .get("name") + .or_else(|| option.get("displayName")) + .and_then(|name| name.as_str()) + .unwrap_or(&value) + .to_string(); + Some(AgentEffortOption { value, label }) + }) + .collect() + }) + .unwrap_or_default(); + let current_value = effort_config + .and_then(|option| option.get("currentValue")) + .and_then(|value| value.as_str()) + .map(str::to_string); + (options, current_value) +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index c61170a4a1..9facd3296a 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -18,6 +18,7 @@ pub(super) async fn run_agent_models_command( // into the spawn_blocking closure and we still need the values to // scrub any user-supplied secrets that the child surfaces in stderr. let env_for_redaction = merged_env.clone(); + let model_for_probe = persisted_model.clone(); // Use spawn_blocking because the desktop Tauri crate doesn't enable // tokio's `process` feature. std::process::Command is synchronous @@ -34,6 +35,9 @@ pub(super) async fn run_agent_models_command( .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + if let Some(model) = model_for_probe.as_deref() { + cmd.arg("--model").arg(model); + } if let Some(meta) = known_acp_runtime(&agent_command) { for (key, value) in meta.default_env { if std::env::var(key).is_err() { diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 97772b12a5..3900f667a9 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -69,10 +69,7 @@ pub async fn get_agent_models( .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); - // ModelPicker can persist a selected model but not rewrite the saved - // provider/env snapshot, and runtime spawn reads that same snapshot. - // Discover models against the record snapshot so an out-of-date persona - // cannot offer models for a provider this agent will not launch with. + // Discover against the same saved snapshot runtime spawn uses. let discovery = saved_agent_model_discovery_config(record, &effective_command); ( @@ -171,6 +168,8 @@ pub struct DiscoverAgentModelsInput { #[serde(default)] pub provider: Option, #[serde(default)] + pub model: Option, + #[serde(default)] pub env_vars: BTreeMap, } @@ -219,9 +218,11 @@ pub async fn discover_agent_models( } let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars); let merged_env = discovery_env_with_baked_floor(merged_env); + // Native catalogs carry per-model options that provider APIs cannot. + let use_harness_catalog = + known_acp_runtime(agent_command).is_some_and(|runtime| runtime.supports_acp_native_config); - // Buzz shared compute discovery must not depend on the local OpenAI ingress: that - // client endpoint is started only after a live target is selected. + // Shared compute discovery cannot depend on the local OpenAI ingress. #[cfg(feature = "mesh-llm")] if input.provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) @@ -256,6 +257,8 @@ pub async fn discover_agent_models( agent_default_model: None, selected_model: None, supports_switching: true, + effort_options: Vec::new(), + effort_current_value: None, }); } #[cfg(not(feature = "mesh-llm"))] @@ -265,54 +268,60 @@ pub async fn discover_agent_models( return Err("Buzz shared compute is not available in this build".to_string()); } - if let Some(models) = discover_openai_compatible_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? - { - return Ok(models); - } + if !use_harness_catalog { + if let Some(models) = discover_openai_compatible_models( + &state.http_client, + input.provider.as_deref(), + &merged_env, + None, + ) + .await? + { + return Ok(models); + } - if let Some(models) = discover_anthropic_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? - { - return Ok(models); - } + if let Some(models) = discover_anthropic_models( + &state.http_client, + input.provider.as_deref(), + &merged_env, + None, + ) + .await? + { + return Ok(models); + } - if let Some(models) = discover_databricks_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? - { - return Ok(models); + if let Some(models) = discover_databricks_models( + &state.http_client, + input.provider.as_deref(), + &merged_env, + None, + ) + .await? + { + return Ok(models); + } } - run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await + run_agent_models_command( + resolved_acp, + resolved_agent, + agent_args, + input.model, + merged_env, + ) + .await } - #[derive(Debug, Deserialize)] struct OpenAiModelListResponse { data: Vec, } - #[derive(Debug, Deserialize)] struct OpenAiModelListItem { id: String, #[serde(default)] created: Option, } - fn is_openai_compatible_provider(provider: Option<&str>) -> bool { matches!( provider @@ -322,7 +331,6 @@ fn is_openai_compatible_provider(provider: Option<&str>) -> bool { Some("openai" | "openai-compat") ) } - #[cfg(test)] fn openai_compatible_models_url(env: &BTreeMap) -> String { let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL") @@ -531,6 +539,8 @@ async fn discover_openai_compatible_models( agent_default_model: None, selected_model, supports_switching: true, + effort_options: Vec::new(), + effort_current_value: None, })) } @@ -671,6 +681,8 @@ async fn discover_anthropic_models( agent_default_model: None, selected_model, supports_switching: true, + effort_options: Vec::new(), + effort_current_value: None, })) } @@ -763,6 +775,8 @@ async fn discover_databricks_models( agent_default_model: None, selected_model, supports_switching: true, + effort_options: Vec::new(), + effort_current_value: None, })) } @@ -1044,6 +1058,9 @@ pub(super) fn normalize_agent_models( let supports_switching = !models.is_empty(); + let (effort_options, effort_current_value) = + super::agent_effort_options::normalize_effort_options(raw); + AgentModelsResponse { agent_name, agent_version, @@ -1051,6 +1068,8 @@ pub(super) fn normalize_agent_models( agent_default_model, selected_model: persisted_model, supports_switching, + effort_options, + effort_current_value, } } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..f3a1868962 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -252,6 +252,34 @@ fn update_request_turn_timeout_parses_for_wire_compat() { assert_eq!(req.turn_timeout_seconds, Some(9999)); } +#[test] +fn normalize_agent_models_carries_native_effort_options() { + let raw = serde_json::json!({ + "agent": { "name": "goose", "version": "1" }, + "stable": { "configOptions": [ + { + "id": "model", + "category": "model", + "options": [{ "value": "m1", "name": "Model 1" }] + }, + { + "id": "thinking_effort", + "category": "thought_level", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" } + ] + } + ]} + }); + let response = super::normalize_agent_models(&raw, Some("m1".to_string())); + assert_eq!(response.effort_current_value.as_deref(), Some("medium")); + assert_eq!(response.effort_options.len(), 2); + assert_eq!(response.effort_options[0].value, "low"); + assert_eq!(response.effort_options[0].label, "Low"); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 3de7c68454..a65b41a6fd 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ mod agent_auth; mod agent_config; mod agent_discovery; +mod agent_effort_options; mod agent_logs; mod agent_metric_archive; mod agent_model_process; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 31b31e00d0..a959580578 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1841,9 +1841,20 @@ pub fn spawn_agent_child( &global.env_vars, &super::env_vars::live_persona_env(&personas, record.persona_id.as_deref()), ); - for (key, value) in super::env_vars::merged_user_env(&persona_over_global, &record.env_vars) { + let effective_user_env = + super::env_vars::merged_user_env(&persona_over_global, &record.env_vars); + for (key, value) in &effective_user_env { command.env(key, value); } + // Legacy Goose configs stored effort under Buzz Agent's key. Honor that + // choice at launch without rewriting storage; the next user save swaps it + // to the runtime's native key. + if let Some((thinking_key, effort)) = legacy_effort_spawn_bridge( + runtime_meta.and_then(|meta| meta.thinking_env_var), + &effective_user_env, + ) { + command.env(thinking_key, effort); + } // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. @@ -2064,6 +2075,19 @@ pub fn stop_managed_agent_process( Ok(()) } +pub(crate) fn legacy_effort_spawn_bridge<'a>( + thinking_env_var: Option<&'a str>, + effective_env: &'a std::collections::BTreeMap, +) -> Option<(&'a str, &'a str)> { + let thinking_key = thinking_env_var?; + if thinking_key == "BUZZ_AGENT_THINKING_EFFORT" || effective_env.contains_key(thinking_key) { + return None; + } + effective_env + .get("BUZZ_AGENT_THINKING_EFFORT") + .map(|effort| (thinking_key, effort.as_str())) +} + /// Returns the (key, value) env var pairs that should be forwarded to the /// agent process for model and provider selection. /// diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 06d3ac6db3..43d6fba087 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use crate::managed_agents::known_acp_runtime; // ── buffer_contains_identifier tests ──────────────────────────────────── @@ -309,7 +311,6 @@ fn persona_with_provider( use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; use crate::managed_agents::persona_events::persona_snapshot; -use std::collections::BTreeMap; /// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: /// snapshotted prompt/model/provider/source_version are pinned, with the @@ -498,7 +499,28 @@ fn non_persona_agent_never_drifts() { assert!(!orphaned); } -use super::runtime_metadata_env_vars; +use super::{legacy_effort_spawn_bridge, runtime_metadata_env_vars}; + +#[test] +fn legacy_goose_effort_is_applied_under_native_key() { + let env = BTreeMap::from([("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string())]); + assert_eq!( + legacy_effort_spawn_bridge(Some("GOOSE_THINKING_EFFORT"), &env), + Some(("GOOSE_THINKING_EFFORT", "high")) + ); +} + +#[test] +fn native_goose_effort_wins_over_legacy_value() { + let env = BTreeMap::from([ + ("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()), + ("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()), + ]); + assert_eq!( + legacy_effort_spawn_bridge(Some("GOOSE_THINKING_EFFORT"), &env), + None + ); +} #[test] fn runtime_metadata_env_vars_injects_model_and_provider() { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index f2d540a324..dac9783d77 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -663,6 +663,17 @@ pub struct AgentModelsResponse { pub selected_model: Option, /// Whether this agent supports model switching. pub supports_switching: bool, + /// Harness-native effort choices for the selected model. + pub effort_options: Vec, + /// Harness-native current/default effort for the selected model. + pub effort_current_value: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEffortOption { + pub value: String, + pub label: String, } /// A single model available from an agent. diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 180253440b..3c8b239379 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -28,17 +28,15 @@ with a TypeScript lookup table or an id comparison in a component. belongs in `deriveAgentConfigFieldModel` (once, with a named reason), never in a component. Components ask the field model what exists (`hasRenderableAgentConfigField`, `getRenderableEffortField`). -2. **Effort reads/writes go through the descriptor.** Use the effort - descriptor's `currentPersistence` key — never a raw - `BUZZ_AGENT_THINKING_EFFORT` literal in UI code. `currentPersistence` is - where the value lives *today*; `targetApplication` is how the harness - *should* receive it. They intentionally differ until PR 2.7 migrates - Goose/Claude — do not "fix" one to match the other without doing the - migration work. +2. **Effort reads/writes go through the descriptor.** Goose persists to + `GOOSE_THINKING_EFFORT`; legacy Buzz-key values are read until the next save + and bridged at spawn without double-writing. Claude persists through + `BUZZ_ACP_EFFORT` and applies through its native `thought_level` ACP option. + Harness-native option lists come from live ACP discovery, never Buzz Agent's + mirrored provider table. 3. **Field absence has a named reason, not a boolean.** Codex effort is - `ownedByModelId`; Claude effort is `deferredUntilNativeOptionsAvailable`. - New absences get new named reasons in `AgentConfigOmission` / - `render` — never a `showX` prop. + `ownedByModelId`. New absences get new named reasons in + `AgentConfigOmission` / `render` — never a `showX` prop. 4. **The clearing policy is the named types.** `onContextChange: "resetDependentValues"` (user changed harness/provider → dependent values reset everywhere) vs `onCatalogMismatch: "explainOnly" | "onboardingCleanup"` diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 62d8a61a6f..ad05a3c9aa 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts"; +import { + deriveAgentConfigFieldModel, + migrateLegacyEffortPersistence, +} from "./agentConfigCore.ts"; const config = { env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" }, @@ -60,7 +63,7 @@ test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => { }); }); -test("Goose exposes provider, model, and its real effort application key", () => { +test("Goose reads legacy effort but persists and applies its native key", () => { const model = deriveAgentConfigFieldModel({ config, runtime: runtime("goose", { @@ -71,21 +74,46 @@ test("Goose exposes provider, model, and its real effort application key", () => scope: "global", }); - assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", - ); + assert.equal(field(model, "effort").optionSource, "harnessNative"); assert.deepEqual(field(model, "effort").currentPersistence, { kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", + key: "GOOSE_THINKING_EFFORT", }); + assert.equal(field(model, "effort").value, "high"); assert.deepEqual(field(model, "effort").targetApplication, { kind: "envVar", key: "GOOSE_THINKING_EFFORT", }); }); -test("Claude models effort as a deferred native ACP option", () => { +test("Goose legacy effort migrates once without overwriting its native value", () => { + const legacyOnly = migrateLegacyEffortPersistence( + config, + "GOOSE_THINKING_EFFORT", + ); + assert.equal(legacyOnly.env_vars.GOOSE_THINKING_EFFORT, "high"); + assert.equal(legacyOnly.env_vars.BUZZ_AGENT_THINKING_EFFORT, undefined); + + const nativeWins = migrateLegacyEffortPersistence( + { + ...config, + env_vars: { + BUZZ_AGENT_THINKING_EFFORT: "high", + GOOSE_THINKING_EFFORT: "low", + }, + }, + "GOOSE_THINKING_EFFORT", + ); + assert.equal(nativeWins.env_vars.GOOSE_THINKING_EFFORT, "low"); + assert.equal(nativeWins.env_vars.BUZZ_AGENT_THINKING_EFFORT, undefined); + + assert.equal( + migrateLegacyEffortPersistence(config, "BUZZ_AGENT_THINKING_EFFORT"), + config, + ); +}); + +test("Claude renders and persists its native ACP effort option", () => { const model = deriveAgentConfigFieldModel({ config, runtime: runtime("claude"), @@ -96,12 +124,10 @@ test("Claude models effort as a deferred native ACP option", () => { model.fields.map((item) => item.kind), ["model", "effort"], ); - assert.equal( - field(model, "effort").render, - "deferredUntilNativeOptionsAvailable", - ); + assert.equal(field(model, "effort").render, "control"); assert.deepEqual(field(model, "effort").currentPersistence, { - kind: "unavailable", + kind: "envVar", + key: "BUZZ_ACP_EFFORT", }); assert.deepEqual(field(model, "effort").targetApplication, { kind: "acpConfigOption", diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5827aedfa7..67adbc6018 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -4,6 +4,8 @@ import type { } from "@/shared/api/types"; import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; +export const BUZZ_ACP_EFFORT = "BUZZ_ACP_EFFORT"; + export type AgentConfigScope = | "onboarding" | "global" @@ -86,6 +88,26 @@ function valueFromEnv(config: GlobalAgentConfig, key: string) { return config.env_vars[key]?.trim() || null; } +export function migrateLegacyEffortPersistence( + config: GlobalAgentConfig, + nativeThinkingKey: string | null | undefined, +): GlobalAgentConfig { + const legacyEffort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT]; + if ( + !nativeThinkingKey || + nativeThinkingKey === BUZZ_AGENT_THINKING_EFFORT || + legacyEffort === undefined + ) { + return config; + } + const envVars = { ...config.env_vars }; + if (envVars[nativeThinkingKey] === undefined) { + envVars[nativeThinkingKey] = legacyEffort; + } + delete envVars[BUZZ_AGENT_THINKING_EFFORT]; + return { ...config, env_vars: envVars }; +} + /** * Derives the harness-scoped field model consumed by agent config renderers. * @@ -128,32 +150,34 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { + const isBuzzAgent = runtime.thinkingEnvVar === BUZZ_AGENT_THINKING_EFFORT; + const currentValue = valueFromEnv(config, runtime.thinkingEnvVar); + const legacyGooseValue = isBuzzAgent + ? null + : valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT); fields.push({ kind: "effort", - optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + optionSource: isBuzzAgent ? "buzzAgentCatalog" : "harnessNative", currentPersistence: { kind: "envVar", - key: BUZZ_AGENT_THINKING_EFFORT, + key: runtime.thinkingEnvVar, }, targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, render: "control", - value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + value: currentValue ?? legacyGooseValue, }); } else if (runtime?.id === "claude") { fields.push({ kind: "effort", optionSource: "harnessNative", - currentPersistence: { kind: "unavailable" }, + currentPersistence: { kind: "envVar", key: BUZZ_ACP_EFFORT }, targetApplication: { kind: "acpConfigOption", id: "effort", category: "thought_level", }, - render: "deferredUntilNativeOptionsAvailable", - value: null, + render: "control", + value: valueFromEnv(config, BUZZ_ACP_EFFORT), }); } else { omissions.push({ diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 770c6fea90..ec0b6e25e3 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -19,6 +19,7 @@ import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor"; import { deriveAgentConfigFieldModel, getRenderableEffortField, + migrateLegacyEffortPersistence, hasRenderableAgentConfigField, } from "@/features/agents/lib/agentConfigCore"; import { @@ -148,7 +149,7 @@ export function AgentConfigFields({ config, isCustomModelEditing, isCustomProvider, - onConfigChange, + onConfigChange: onConfigChangeProp, onCustomModelEditingChange, onIsCustomProviderChange, onValidityChange, @@ -180,10 +181,21 @@ export function AgentConfigFields({ [config, disclosure, selectedRuntime], ); const effortField = getRenderableEffortField(fieldModel); + const usesHarnessNativeEffort = effortField?.optionSource === "harnessNative"; const effortPersistenceKey = effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; + const onConfigChange = React.useCallback( + (nextConfig: GlobalAgentConfig) => + onConfigChangeProp( + migrateLegacyEffortPersistence( + nextConfig, + selectedRuntime?.thinkingEnvVar, + ), + ), + [onConfigChangeProp, selectedRuntime?.thinkingEnvVar], + ); const bakedProvider = React.useMemo( () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, [bakedEnv], @@ -252,6 +264,8 @@ export function AgentConfigFields({ const { discoveredModelOptions, + effortOptions, + effortCurrentValue, modelDiscoveryLoading, modelDiscoveryStatus, } = usePersonaModelDiscovery({ @@ -260,6 +274,7 @@ export function AgentConfigFields({ modelFieldVisible: !dependentFieldsDisabled, open: true, provider: providerForDiscovery, + model: config.model ?? "", selectedRuntime, }); @@ -311,9 +326,7 @@ export function AgentConfigFields({ selectedRuntimeId, ]); - const currentEffortForAutoClear = effortPersistenceKey - ? (config.env_vars[effortPersistenceKey] ?? "") - : ""; + const currentEffortForAutoClear = effortField?.value ?? ""; // When the selected harness changes outside this component (Back → setup // page → choose a different harness → Next), the saved model can belong to @@ -333,6 +346,9 @@ export function AgentConfigFields({ const nextEnvVars = { ...config.env_vars }; if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + if (usesHarnessNativeEffort) { + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -343,6 +359,7 @@ export function AgentConfigFields({ onCustomModelEditingChange, healOnMount, effortPersistenceKey, + usesHarnessNativeEffort, ]); // Orphan-model clearing follows the mount-time healing policy above: the @@ -365,6 +382,9 @@ export function AgentConfigFields({ const nextEnvVars = { ...config.env_vars }; if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + if (usesHarnessNativeEffort) { + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -374,17 +394,25 @@ export function AgentConfigFields({ onConfigChange, onCustomModelEditingChange, effortPersistenceKey, + usesHarnessNativeEffort, ]); - const { validValues: effortValidForAutoClear } = getProviderEffortConfig( - config.provider ?? "", - config.model ?? "", - ); + const effortValidForAutoClear = usesHarnessNativeEffort + ? effortOptions.length > 0 + ? effortOptions.map((option) => option.value) + : currentEffortForAutoClear + ? [currentEffortForAutoClear] + : [] + : getProviderEffortConfig(config.provider ?? "", config.model ?? "") + .validValues; useEffortAutoClear({ currentEffort: currentEffortForAutoClear, effortValid: effortValidForAutoClear, onClear: () => { const nextEnvVars = { ...config.env_vars }; if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + if (usesHarnessNativeEffort) { + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } onConfigChange({ ...config, env_vars: nextEnvVars }); }, }); @@ -435,8 +463,14 @@ export function AgentConfigFields({ } function handleModelChange(value: string) { + const nextEnvVars = { ...config.env_vars }; + if (usesHarnessNativeEffort) { + if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } onConfigChange({ ...config, + env_vars: nextEnvVars, model: config.provider === "relay-mesh" ? value || "auto" : value || null, }); } @@ -491,20 +525,21 @@ export function AgentConfigFields({ return "Select a provider"; }, [bakedProvider, providerOptions]); - const implicitEffortProvider = - selectedRuntimeId === "claude" - ? "anthropic" - : selectedRuntimeId === "codex" - ? "openai" - : ""; - const effortProvider = providerFieldVisible - ? (config.provider ?? "") - : implicitEffortProvider; - const { validValues: effortValid, defaultValue: effortDefault } = - getProviderEffortConfig(effortProvider, config.model ?? ""); - const currentEffort = effortPersistenceKey - ? (config.env_vars[effortPersistenceKey] ?? "") - : ""; + const effortProvider = providerFieldVisible ? (config.provider ?? "") : ""; + const buzzEffortConfig = getProviderEffortConfig( + effortProvider, + config.model ?? "", + ); + const effortValid = usesHarnessNativeEffort + ? effortOptions.map((option) => option.value) + : buzzEffortConfig.validValues; + const effortDefault = usesHarnessNativeEffort + ? effortCurrentValue + : buzzEffortConfig.defaultValue; + const effortOptionLabels = Object.fromEntries( + effortOptions.map((option) => [option.value, option.label]), + ); + const currentEffort = effortField?.value ?? ""; const effortFieldVisible = showEffortField && effortField !== undefined; const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; @@ -688,6 +723,8 @@ export function AgentConfigFields({ } effortDefault={effortDefault} effortValid={effortValid} + optionLabels={effortOptionLabels} + optionValues={usesHarnessNativeEffort ? effortValid : undefined} fieldClassName={unstyled ? fieldClassName : undefined} htmlFor="global-agent-thinking-effort" inheritFallbackLabel={ @@ -705,6 +742,11 @@ export function AgentConfigFields({ if (effortPersistenceKey) nextEnvVars[effortPersistenceKey] = value; } + if ( + selectedRuntime?.thinkingEnvVar !== BUZZ_AGENT_THINKING_EFFORT + ) { + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } onConfigChange({ ...config, env_vars: nextEnvVars }); }} placeholderClassName={placeholderClassName} diff --git a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx index 938d5edf5b..aefbac3a2e 100644 --- a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx +++ b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx @@ -41,6 +41,8 @@ export function EffortSelectField({ emptyOptionLabel, effortDefault, effortValid, + optionLabels, + optionValues = BUZZ_AGENT_THINKING_EFFORT_VALUES, fieldClassName, htmlFor, inheritedEffort, @@ -64,6 +66,10 @@ export function EffortSelectField({ effortDefault: string | null; /** Valid effort values for this provider/model. */ effortValid: ReadonlyArray; + /** Harness-native labels keyed by effort value. */ + optionLabels?: Readonly>; + /** Ordered values to render; defaults to Buzz Agent's complete set. */ + optionValues?: ReadonlyArray; /** Optional class override for the field wrapper. */ fieldClassName?: string; /** `htmlFor` attribute for the label element. */ @@ -108,14 +114,16 @@ export function EffortSelectField({ : (inheritFallbackLabel ?? "Inherit"); const effortOptions: AgentDropdownOption[] = [ { label: emptyOptionLabel ?? inheritLabel, value: "" }, - ...BUZZ_AGENT_THINKING_EFFORT_VALUES.flatMap((v) => { + ...optionValues.flatMap((v) => { const isValid = (effortValid as readonly string[]).includes(v); if (!showUnavailableOptions && !isValid) return []; const isDefault = v === effortDefault; return [ { disabled: !isValid, - label: isDefault ? `${v} (default)` : v, + label: isDefault + ? `${optionLabels?.[v] ?? v} (default)` + : (optionLabels?.[v] ?? v), value: v, }, ]; diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts index c8f0b0f3bf..540c7fa4a5 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts @@ -88,6 +88,7 @@ export function usePersonaModelDiscovery({ modelFieldVisible, open, provider, + model = "", selectedRuntime, }: { envVars: EnvVarsValue; @@ -95,6 +96,7 @@ export function usePersonaModelDiscovery({ modelFieldVisible: boolean; open: boolean; provider: string; + model?: string; selectedRuntime: AcpRuntimeCatalogEntry | undefined; }) { const [modelDiscoveryData, setModelDiscoveryData] = @@ -115,6 +117,7 @@ export function usePersonaModelDiscovery({ const modelDiscoveryRequestRef = React.useRef(0); const trimmedProvider = provider.trim(); + const trimmedModel = model.trim(); const shouldDebounceModelDiscovery = providerRequiresExplicitModel(trimmedProvider); const discoveryAgentCommand = selectedRuntime?.command?.trim() @@ -147,12 +150,14 @@ export function usePersonaModelDiscovery({ agentCommand: discoveryAgentCommand, agentArgs: modelDiscoveryArgsKey, provider: trimmedProvider, + model: trimmedModel, envVars: modelDiscoveryEnvKey, }); }, [ canDiscoverModelOptions, discoveryAgentCommand, modelDiscoveryArgsKey, + trimmedModel, modelDiscoveryEnvKey, trimmedProvider, ]); @@ -207,6 +212,7 @@ export function usePersonaModelDiscovery({ agentCommand: activeAgentCommand, agentArgs: selectedRuntimeDefaultArgs ?? [], provider: trimmedProvider || undefined, + model: trimmedModel || undefined, envVars, }) .then((response) => { @@ -261,6 +267,7 @@ export function usePersonaModelDiscovery({ selectedRuntimeAvailability, selectedRuntimeDefaultArgs, shouldDebounceModelDiscovery, + trimmedModel, trimmedProvider, ]); @@ -290,6 +297,8 @@ export function usePersonaModelDiscovery({ return { discoveredModelOptions, + effortOptions: activeModelDiscoveryData?.effortOptions ?? [], + effortCurrentValue: activeModelDiscoveryData?.effortCurrentValue ?? null, modelDiscoveryLoading: modelDiscoveryPending, modelDiscoveryStatus: modelDiscoveryPending || discoveredModelOptions !== null diff --git a/desktop/src/shared/api/agentModels.ts b/desktop/src/shared/api/agentModels.ts index 892d2314f0..af320e4537 100644 --- a/desktop/src/shared/api/agentModels.ts +++ b/desktop/src/shared/api/agentModels.ts @@ -6,6 +6,7 @@ export type DiscoverAgentModelsInput = { agentCommand: string; agentArgs?: string[]; provider?: string; + model?: string; envVars?: Record; }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7834794d25..55448678f0 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -595,6 +595,12 @@ export type AgentModelsResponse = { agentDefaultModel: string | null; selectedModel: string | null; supportsSwitching: boolean; + effortOptions: AgentEffortOption[]; + effortCurrentValue: string | null; +}; +export type AgentEffortOption = { + value: string; + label: string; }; export type AgentModelInfo = { id: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9e0cac2b9e..b009dd502b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9919,6 +9919,18 @@ export function maybeInstallE2eTauriMocks() { : null, selectedModel: null, supportsSwitching: true, + effortOptions: + agentCommand.includes("goose") || agentCommand.includes("claude") + ? [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + ] + : [], + effortCurrentValue: + agentCommand.includes("goose") || agentCommand.includes("claude") + ? "medium" + : null, }; } case "get_agent_config_surface": {