feat: add /model slash command to CLI for session model switching (#8747)

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Bradley Axen
2026-05-27 06:38:59 -07:00
committed by GitHub
parent 794402d932
commit d90b349a69
5 changed files with 214 additions and 1 deletions
Generated
+1
View File
@@ -4605,6 +4605,7 @@ dependencies = [
"comfy-table",
"console",
"dotenvy",
"env-lock",
"etcetera 0.11.0",
"futures",
"goose",
+1
View File
@@ -108,6 +108,7 @@ native-tls = [
]
[dev-dependencies]
env-lock.workspace = true
tempfile = { workspace = true }
test-case = { workspace = true }
tokio = { workspace = true }
+24 -1
View File
@@ -129,7 +129,6 @@ impl GooseCompleter {
let skills = list_installed_skills(Some(&cwd));
let skill_names: Vec<String> = skills.iter().map(|s| s.name.clone()).collect();
// Complete the last letter being typed (e.g. "/skills coding in<tab>")
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<Pair>)> {
Ok((line.len(), vec![]))
}
/// Complete slash commands
fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
// 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();
+32
View File
@@ -20,6 +20,7 @@ pub enum InputResult {
ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions),
GooseMode(String),
Model(Option<String>),
Plan(PlanCommandOptions),
EndPlan,
Clear,
@@ -196,6 +197,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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";
@@ -261,6 +264,19 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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())
}
@@ -399,6 +415,7 @@ fn print_help() {
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
/mode <name> - 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 <message_text> - 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.
@@ -498,6 +515,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());
}
+156
View File
@@ -608,6 +608,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?;
}
@@ -795,6 +799,73 @@ 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(());
}
let new_model_config =
build_switched_model_config(&current_provider_name, model_name, &current_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
));
return Ok(());
}
let extensions = self.agent.get_extension_configs().await;
let new_provider =
goose::providers::create(&current_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();
@@ -2091,11 +2162,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> {
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;
@@ -2219,6 +2307,74 @@ 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", &current_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_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", &current).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!(