From aba09438a070b0fec5c5a2b22530598988f802a2 Mon Sep 17 00:00:00 2001 From: Johann Drews <37542352+johanndrews@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:39:35 +0200 Subject: [PATCH] feat(cli): add /new to start a fresh session without restarting (#10767) --- crates/goose-cli/src/session/completion.rs | 13 ++ crates/goose-cli/src/session/input.rs | 12 ++ crates/goose-cli/src/session/mod.rs | 197 +++++++++++++++++++++ 3 files changed, 222 insertions(+) diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index cf3b545dc..e8e2dfc87 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -244,6 +244,7 @@ impl GooseCompleter { "/mode".to_string(), "/model".to_string(), "/recipe".to_string(), + "/new".to_string(), ]; commands.extend( list_commands() @@ -694,6 +695,18 @@ mod tests { assert_eq!(candidates.len(), 0); } + #[test] + fn test_complete_slash_commands_new() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + let (pos, candidates) = completer.complete_slash_commands("/new").unwrap(); + assert_eq!(pos, 0); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].display, "/new"); + assert_eq!(candidates[0].replacement, "/new "); + } + #[test] fn test_complete_model_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 09955ee28..d8a1138c6 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -27,6 +27,7 @@ pub enum InputResult { Plan(PlanCommandOptions), EndPlan, Clear, + New, Recipe(Option), Compact, ToggleFullToolOutput, @@ -239,6 +240,7 @@ fn handle_slash_command(input: &str) -> Option { const CMD_PLAN: &str = "/plan"; const CMD_ENDPLAN: &str = "/endplan"; const CMD_CLEAR: &str = "/clear"; + const CMD_NEW: &str = "/new"; const CMD_RECIPE: &str = "/recipe"; const CMD_COMPACT: &str = "/compact"; const CMD_SUMMARIZE_DEPRECATED: &str = "/summarize"; @@ -335,6 +337,7 @@ fn handle_slash_command(input: &str) -> Option { } s if s == CMD_ENDPLAN => Some(InputResult::EndPlan), s if s == CMD_CLEAR => Some(InputResult::Clear), + s if s == CMD_NEW => Some(InputResult::New), s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s), s if s == CMD_COMPACT => Some(InputResult::Compact), // Match "/skills" exactly or "/skills " with args - avoids matching e.g. "/skillsextra" @@ -492,6 +495,7 @@ fn help_text() -> String { /skills - List available skills or enable skills by name (usage: /skills [...]) /? or /help - Display this help message /clear - Clears the current chat history +/new - Start a fresh session in this process, keeping the current provider, model and extensions Navigation: Enter - Send message @@ -667,6 +671,14 @@ mod tests { assert!(handle_slash_command("/unknown").is_none()); } + #[test] + fn test_handle_slash_command_new() { + assert!(matches!( + handle_slash_command("/new"), + Some(InputResult::New) + )); + } + #[test] fn help_lists_builtin_agent_commands() { let help = help_text(); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 3f269c33c..9ebeb8b6c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -685,6 +685,10 @@ impl CliSession { history.save(editor); self.handle_clear().await?; } + InputResult::New => { + history.save(editor); + self.handle_new().await?; + } InputResult::PromptCommand(opts) => { history.save(editor); self.handle_prompt_command(opts).await?; @@ -1080,6 +1084,92 @@ impl CliSession { Ok(()) } + async fn handle_new(&mut self) -> Result<()> { + let provider = self.agent.provider().await?; + if provider.manages_own_context() { + output::render_error(&format!( + "Starting a new session is not supported for provider '{}' because it manages its own conversation context.", + provider.get_name() + )); + return Ok(()); + } + + let new_session_id = match self.prepare_successor_session().await { + Ok(id) => id, + Err(e) => { + output::render_error(&format!("Failed to start a new session: {}", e)); + return Ok(()); + } + }; + + let extension_configs = self.agent.get_extension_configs().await; + + self.agent + .emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id) + .await; + + self.agent.discard_pending_steers(&self.session_id).await; + + self.session_id = new_session_id; + self.messages.clear(); + self.run_mode = RunMode::Normal; + self.agent.set_goal(None).await; + self.agent.set_grind(None).await; + + if let Err(e) = self + .agent + .update_goose_mode(self.agent.goose_mode().await, &self.session_id) + .await + { + output::render_error(&format!("Failed to apply the current mode: {}", e)); + } + + if !extension_configs.is_empty() { + output::goose_mode_message("Restarting extensions for the new session..."); + } + + // MCP clients pin themselves to the first session id they see a request for, so + // extensions must be torn down and re-added under the new session id. + for name in self.agent.list_extensions().await { + if let Err(e) = self.agent.remove_extension(&name, &self.session_id).await { + output::render_extension_error(&name, &e.to_string()); + } + } + + let mut unavailable = Vec::new(); + for config in extension_configs { + let name = config.name(); + if let Err(e) = self.agent.add_extension(config, &self.session_id).await { + output::render_extension_error(&name, &e.to_string()); + unavailable.push(name); + } + } + + if let Err(e) = self.update_completion_cache().await { + output::render_error(&format!("Failed to refresh completions: {}", e)); + } + + let mut started = format!("Started a new session ยท {}\n", self.session_id); + if !unavailable.is_empty() { + started.push_str(&format!( + "Continuing without these extensions: {}\n", + unavailable.join(", ") + )); + } + output::render_message(&Message::assistant().with_text(started), self.debug); + Ok(()) + } + + async fn prepare_successor_session(&self) -> Result { + let session_manager = &self.agent.config.session_manager; + let old_session = session_manager.get_session(&self.session_id, false).await?; + let new_session_id = + create_successor_session(session_manager, &old_session, self.agent.goose_mode().await) + .await?; + self.agent.persist_extension_state(&new_session_id).await?; + Ok(new_session_id) + } + async fn handle_recipe(&mut self, filepath_opt: Option) { println!("{}", console::style("Generating Recipe").green()); @@ -1987,6 +2077,40 @@ impl CliSession { } } +async fn create_successor_session( + session_manager: &SessionManager, + old_session: &goose::session::Session, + goose_mode: GooseMode, +) -> Result { + let new_session = session_manager + .create_session( + old_session.working_dir.clone(), + "CLI Session".to_string(), + old_session.session_type, + goose_mode, + ) + .await?; + + let mut builder = session_manager + .update(&new_session.id) + .recipe(old_session.recipe.clone()) + .user_recipe_values(old_session.user_recipe_values.clone()); + + if let Some(provider_name) = old_session.provider_name.clone() { + builder = builder.provider_name(provider_name); + } + if let Some(model_config) = old_session.model_config.clone() { + builder = builder.model_config(model_config); + } + if let Some(project_id) = old_session.project_id.clone() { + builder = builder.project_id(Some(project_id)); + } + + builder.apply().await?; + + Ok(new_session.id) +} + fn message_has_text(message: &Message) -> bool { message.content.iter().any( |content| matches!(content, MessageContent::Text(text) if !text.text.trim().is_empty()), @@ -2955,4 +3079,77 @@ mod tests { expected ); } + + #[tokio::test] + async fn new_session_inherits_provider_model_and_working_dir() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let old = sm + .create_session( + temp_dir.path().to_path_buf(), + "CLI Session".to_string(), + goose::session::SessionType::User, + GooseMode::Auto, + ) + .await + .unwrap(); + + sm.update(&old.id) + .provider_name("anthropic") + .model_config(goose_providers::model::ModelConfig::new("test-model")) + .accumulated_usage(goose_providers::conversation::token_usage::Usage::new( + Some(100), + Some(50), + Some(150), + )) + .apply() + .await + .unwrap(); + + sm.add_message(&old.id, &Message::user().with_text("hello")) + .await + .unwrap(); + + let mut extension_data = goose::session::ExtensionData::new(); + extension_data.set_extension_state("test", "v0", serde_json::json!("marker")); + sm.update(&old.id) + .extension_data(extension_data) + .apply() + .await + .unwrap(); + + let old = sm.get_session(&old.id, false).await.unwrap(); + + let new_id = create_successor_session(&sm, &old, GooseMode::Chat) + .await + .unwrap(); + + assert_ne!(new_id, old.id); + + let new_session = sm.get_session(&new_id, true).await.unwrap(); + assert_eq!(new_session.provider_name, old.provider_name); + assert_eq!( + new_session.model_config.as_ref().map(|m| &m.model_name), + old.model_config.as_ref().map(|m| &m.model_name) + ); + assert_eq!(new_session.goose_mode, GooseMode::Chat); + assert_eq!(new_session.working_dir, old.working_dir); + assert_eq!(new_session.session_type, old.session_type); + assert!(new_session.conversation.unwrap().messages().is_empty()); + assert_eq!(new_session.usage.total_tokens, None); + assert_eq!(old.accumulated_usage.total_tokens, Some(150)); + assert_eq!(new_session.accumulated_usage.total_tokens, None); + + let reloaded_old = sm.get_session(&old.id, true).await.unwrap(); + let old_messages = reloaded_old.conversation.unwrap().messages().to_vec(); + assert_eq!(old_messages.len(), 1); + assert_eq!(old_messages[0].as_concat_text(), "hello"); + assert_eq!( + reloaded_old + .extension_data + .get_extension_state("test", "v0"), + Some(&serde_json::json!("marker")) + ); + } }