From a926e6c10d021ed82d121d0538a0b46a2b124976 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Tue, 21 Apr 2026 22:59:47 -0700 Subject: [PATCH 1/2] feat: add /model slash command to CLI for session model switching Add a /model command that lets users view or switch the active model within a CLI session without restarting. The command: - /model (no args): displays the current model and provider - /model : switches to the given model, keeping the same provider Includes guard rails for ACP providers and providers that manage their own context. Carries over temperature and toolshim settings when switching. Adds tab-completion stub, input parsing, and unit tests. Signed-off-by: Bradley Axen --- Cargo.lock | 1 + crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/session/completion.rs | 25 +++- crates/goose-cli/src/session/input.rs | 32 +++++ crates/goose-cli/src/session/mod.rs | 129 +++++++++++++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bb980f3b41a7..bf223d4454fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4477,6 +4477,7 @@ dependencies = [ "comfy-table", "console 0.16.3", "dotenvy", + "env-lock", "etcetera 0.11.0", "futures", "goose", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 7c801cdf6f1e..9e7192d4ac23 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -92,6 +92,7 @@ native-tls = [ ] [dev-dependencies] +env-lock.workspace = true tempfile = { workspace = true } test-case = { workspace = true } tokio = { workspace = true } diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 9b766f36c916..10e113ab1f48 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -129,7 +129,6 @@ impl GooseCompleter { let skills = list_installed_skills(Some(&cwd)); let skill_names: Vec = skills.iter().map(|s| s.name.clone()).collect(); - // Complete the last letter being typed (e.g. "/skills coding in") let last = line.rsplit_once(' ').map_or("", |(_, w)| w); let pos = line.len() - last.len(); @@ -146,6 +145,11 @@ impl GooseCompleter { Ok((pos, candidates)) } + /// Complete model names for the /model command. + fn complete_model_names(&self, line: &str) -> Result<(usize, Vec)> { + Ok((line.len(), vec![])) + } + /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { // Define available slash commands @@ -160,6 +164,7 @@ impl GooseCompleter { "/prompts", "/prompt", "/mode", + "/model", "/recipe", "/skills", ]; @@ -396,6 +401,10 @@ impl Completer for GooseCompleter { } } + if line.starts_with("/model") { + return self.complete_model_names(line); + } + if line.starts_with("/mode") { return self.complete_mode_flags(line); } @@ -574,6 +583,20 @@ mod tests { assert_eq!(candidates.len(), 0); } + #[test] + fn test_complete_model_names() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + let (pos, candidates) = completer.complete_model_names("/model ").unwrap(); + assert_eq!(pos, "/model ".len()); + assert!(candidates.is_empty()); + + let (pos, candidates) = completer.complete_model_names("/model gpt").unwrap(); + assert_eq!(pos, "/model gpt".len()); + assert!(candidates.is_empty()); + } + #[test] fn test_complete_prompt_names() { let cache = create_test_cache(); diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 2641a77b725e..3d64ebb8ffc2 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -20,6 +20,7 @@ pub enum InputResult { ListPrompts(Option), PromptCommand(PromptCommandOptions), GooseMode(String), + Model(Option), Plan(PlanCommandOptions), EndPlan, Clear, @@ -198,6 +199,8 @@ fn handle_slash_command(input: &str) -> Option { const CMD_EXTENSION: &str = "/extension "; const CMD_BUILTIN: &str = "/builtin "; const CMD_MODE: &str = "/mode "; + const CMD_MODEL: &str = "/model"; + const CMD_MODEL_WITH_SPACE: &str = "/model "; const CMD_PLAN: &str = "/plan"; const CMD_ENDPLAN: &str = "/endplan"; const CMD_CLEAR: &str = "/clear"; @@ -263,6 +266,19 @@ fn handle_slash_command(input: &str) -> Option { s if s.starts_with(CMD_MODE) => Some(InputResult::GooseMode( s.get(CMD_MODE.len()..).unwrap_or("").to_string(), )), + s if s == CMD_MODEL => Some(InputResult::Model(None)), + s if s.starts_with(CMD_MODEL_WITH_SPACE) => { + let model = s + .get(CMD_MODEL_WITH_SPACE.len()..) + .unwrap_or("") + .trim() + .to_string(); + if model.is_empty() { + Some(InputResult::Model(None)) + } else { + Some(InputResult::Model(Some(model))) + } + } s if s.starts_with(CMD_PLAN) => { parse_plan_command(s.get(CMD_PLAN.len()..).unwrap_or("").trim().to_string()) } @@ -419,6 +435,7 @@ fn print_help() { /prompts [--extension ] - List all available prompts, optionally filtered by extension /prompt [--info] [key=value...] - Get prompt info or execute a prompt /mode - Set the goose mode to use ({modes}) +/model [name] - Show the current model, or switch models for this session while keeping the same provider /plan - Enters 'plan' mode with optional message. Create a plan based on the current messages and asks user if they want to act on it. If user acts on the plan, goose mode is set to 'auto' and returns to 'normal' goose mode. To warm up goose before using '/plan', we recommend setting '/mode approve' & putting appropriate context into goose. @@ -518,6 +535,21 @@ mod tests { panic!("Expected AddBuiltin"); } + // Test model command + assert!(matches!( + handle_slash_command("/model"), + Some(InputResult::Model(None)) + )); + assert!(matches!( + handle_slash_command("/model "), + Some(InputResult::Model(None)) + )); + if let Some(InputResult::Model(Some(model))) = handle_slash_command("/model gpt-4.1") { + assert_eq!(model, "gpt-4.1"); + } else { + panic!("Expected Model"); + } + // Test unknown commands assert!(handle_slash_command("/unknown").is_none()); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 493c54481343..16ee1e6dc887 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -614,6 +614,10 @@ impl CliSession { history.save(editor); self.handle_goose_mode(&mode).await?; } + InputResult::Model(model) => { + history.save(editor); + self.handle_model(model.as_deref()).await?; + } InputResult::Plan(options) => { self.handle_plan_mode(options).await?; } @@ -801,6 +805,71 @@ impl CliSession { Ok(()) } + async fn handle_model(&self, model: Option<&str>) -> Result<()> { + let provider = self.agent.provider().await?; + let current_provider_name = provider.get_name().to_string(); + let current_model_config = provider.get_model_config(); + let current_model_name = current_model_config.model_name.clone(); + + if model.is_none() { + output::goose_mode_message(&format!( + "Current session model: '{}' (provider '{}')", + current_model_name, current_provider_name + )); + return Ok(()); + } + + let model_name = model.unwrap_or_default().trim(); + if model_name.is_empty() { + output::render_error("Model name cannot be empty"); + return Ok(()); + } + + if current_provider_name.ends_with("-acp") { + output::render_error( + "Session model switching is not supported for ACP providers in the CLI.", + ); + return Ok(()); + } + + if provider.manages_own_context() { + output::render_error(&format!( + "Session model switching is not supported for provider '{}' because it manages its own conversation context.", + current_provider_name + )); + return Ok(()); + } + + if model_name == current_model_name { + output::goose_mode_message(&format!( + "Session already using model '{}' for provider '{}'", + current_model_name, current_provider_name + )); + return Ok(()); + } + + let new_model_config = + build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; + + let extensions = self.agent.get_extension_configs().await; + let new_provider = + goose::providers::create(¤t_provider_name, new_model_config, extensions) + .await + .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; + + self.agent + .update_provider(new_provider, &self.session_id) + .await?; + + let mode = self.agent.goose_mode().await; + self.agent.update_goose_mode(mode, &self.session_id).await?; + output::goose_mode_message(&format!( + "Session model switched from '{}' to '{}' for provider '{}'", + current_model_name, model_name, current_provider_name + )); + Ok(()) + } + async fn handle_plan_mode(&mut self, options: input::PlanCommandOptions) -> Result<()> { self.run_mode = RunMode::Plan; output::render_enter_plan_mode(); @@ -2063,11 +2132,28 @@ fn format_elapsed_time(duration: std::time::Duration) -> String { } } +fn build_switched_model_config( + provider_name: &str, + model_name: &str, + current_model_config: &goose::model::ModelConfig, +) -> Result { + goose::model::ModelConfig::new(model_name) + .map(|config| { + config + .with_canonical_limits(provider_name) + .with_temperature(current_model_config.temperature) + .with_toolshim(current_model_config.toolshim) + .with_toolshim_model(current_model_config.toolshim_model.clone()) + }) + .map_err(|e| anyhow::anyhow!("Failed to create model configuration: {e}")) +} + #[cfg(test)] mod tests { use super::*; use goose::agents::extension::Envs; use goose::config::ExtensionConfig; + use std::collections::HashMap; use std::time::Duration; use test_case::test_case; @@ -2191,6 +2277,49 @@ mod tests { assert!(CliSession::parse_stdio_extension("").is_err()); } + #[test] + fn test_build_switched_model_config_rebuilds_target_model_settings() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + + let current_model_config = goose::model::ModelConfig { + model_name: "gpt-4o".to_string(), + context_limit: Some(128_000), + temperature: Some(0.25), + max_tokens: Some(16_384), + toolshim: true, + toolshim_model: Some("qwen2.5-coder".to_string()), + fast_model_config: None, + request_params: Some(HashMap::from([( + "anthropic_beta".to_string(), + serde_json::json!(["output-128k-2025-02-19"]), + )])), + reasoning: Some(false), + }; + + let switched = + build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); + let expected = goose::model::ModelConfig::new_or_fail("gpt-5.4") + .with_canonical_limits("openai") + .with_temperature(Some(0.25)) + .with_toolshim(true) + .with_toolshim_model(Some("qwen2.5-coder".to_string())); + + assert_eq!(switched.model_name, expected.model_name); + assert_eq!(switched.context_limit, expected.context_limit); + assert_eq!(switched.max_tokens, expected.max_tokens); + assert_eq!(switched.request_params, expected.request_params); + assert_eq!(switched.reasoning, expected.reasoning); + assert_eq!(switched.temperature, Some(0.25)); + assert!(switched.toolshim); + assert_eq!(switched.toolshim_model.as_deref(), Some("qwen2.5-coder")); + } + #[test] fn test_split_quoted_windows_paths() { assert_eq!( From 6d86558ffebb01d95186aeae5188226b0cd83015 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 26 May 2026 15:13:58 -0400 Subject: [PATCH 2/2] Fix /model no-op guard to consider thinking effort ModelConfig::new normalizes reasoning-effort suffixes (e.g. gpt-5.4-high becomes model gpt-5.4 with thinking_effort=high in request_params). The previous guard compared the raw requested string to the current model name, so a session on gpt-5.4-high would treat '/model gpt-5.4' as a no-op even though the effective config should change. Base the no-op decision on the built target config: compare both model_name and thinking_effort. Signed-off-by: Douwe Osinga --- crates/goose-cli/src/session/mod.rs | 35 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6ae54bdd9eb1..cd7e63b74098 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -834,7 +834,12 @@ impl CliSession { return Ok(()); } - if model_name == current_model_name { + let new_model_config = + build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; + + if new_model_config.model_name == current_model_config.model_name + && new_model_config.thinking_effort() == current_model_config.thinking_effort() + { output::goose_mode_message(&format!( "Session already using model '{}' for provider '{}'", current_model_name, current_provider_name @@ -842,9 +847,6 @@ impl CliSession { return Ok(()); } - let new_model_config = - build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; - let extensions = self.agent.get_extension_configs().await; let new_provider = goose::providers::create(¤t_provider_name, new_model_config, extensions) @@ -2348,6 +2350,31 @@ mod tests { assert_eq!(switched.toolshim_model.as_deref(), Some("qwen2.5-coder")); } + #[test] + fn test_build_switched_model_config_detects_effort_suffix_change() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ("GOOSE_THINKING_EFFORT", None::<&str>), + ]); + + let current = + goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai"); + assert_eq!(current.model_name, "gpt-5.4"); + assert_eq!( + current.thinking_effort(), + Some(goose::model::ThinkingEffort::High) + ); + + let switched = build_switched_model_config("openai", "gpt-5.4", ¤t).unwrap(); + + assert_eq!(switched.model_name, current.model_name); + assert_ne!(switched.thinking_effort(), current.thinking_effort()); + } + #[test] fn test_split_command_args_windows_paths() { assert_eq!(