From c42adfcbdd0f72b306bfee0171538270a2b5b6da Mon Sep 17 00:00:00 2001 From: Johann Drews Date: Tue, 28 Jul 2026 16:35:05 +0200 Subject: [PATCH 1/5] feat(cli): add /new to start a fresh session without restarting /clear wipes the conversation but keeps the same session id and the lifetime accumulated_* token counters, so unrelated tasks in one sitting all land under a single session unless the process is restarted. /new creates a new session in the running process and switches the live session over to it, carrying over what describes the process itself: provider, model config, goose mode, loaded extensions, working directory and recipe. The previous session stays on disk untouched, and its SessionEnd hook fires before the switch. The session id is only swapped once every fallible step has succeeded, so a failure leaves the running session usable. --- crates/goose-cli/src/session/completion.rs | 13 ++ crates/goose-cli/src/session/input.rs | 12 ++ crates/goose-cli/src/session/mod.rs | 132 +++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index cf3b545dc488..e8e2dfc87d99 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 09955ee28be9..d8a1138c61da 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 b31ad53bd68a..c9faa88398f9 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -665,6 +665,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?; @@ -1070,6 +1074,41 @@ impl CliSession { Ok(()) } + async fn handle_new(&mut self) -> Result<()> { + 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(()); + } + }; + + self.agent + .emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id) + .await; + + self.session_id = new_session_id; + self.messages.clear(); + self.run_mode = RunMode::Normal; + + output::render_message( + &Message::assistant() + .with_text(format!("Started a new session · {}\n", self.session_id)), + 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()); @@ -1972,6 +2011,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()), @@ -2853,4 +2926,63 @@ 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 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"); + } } From 649dc1293aa2c31bfbdbc9374e2d8d9cae07c39a Mon Sep 17 00:00:00 2001 From: Johann Drews Date: Tue, 28 Jul 2026 18:55:07 +0200 Subject: [PATCH 2/5] fix(cli): reject /new for providers that manage their own context ACP, claude-code and gemini-cli keep their upstream conversation inside the provider instance (AcpProvider::stream prompts self.acp_session_id(), GeminiCliProvider resumes its cached cli_session_id via -r), so swapping goose's session id alone would report a fresh session while the provider carried on with the old conversation. Refuse the command for those providers instead, matching how /model already declines to switch when manages_own_context() is true. --- crates/goose-cli/src/session/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index c9faa88398f9..105a09ce3161 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1075,6 +1075,15 @@ impl CliSession { } 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) => { From e48aaf7890d9afede44f75a4641a09ffc297546d Mon Sep 17 00:00:00 2001 From: Johann Drews Date: Tue, 28 Jul 2026 20:22:03 +0200 Subject: [PATCH 3/5] fix(cli): restart extensions and propagate mode on /new MCP clients pin themselves to the first session id they see a request for: McpClient::set_session_id asserts the id never changes, and every request routes through it, so swapping the session id panicked on the next tool call or prompt listing. The CLI lists extension prompts at startup, so every loaded extension is already pinned before the user types anything. /new now tears the extensions down and re-adds them under the new session id, which gives them fresh clients. Teardown happens after the swap so the previous session's extension_data is left intact, and it sends no MCP request of its own. It also propagates the current mode to the new session, so providers that track modes per session id (Codex keeps mode_by_session) do not fall back to their default for the fresh session. Every step after the swap reports failures instead of propagating them, so a failing extension leaves a usable session rather than tearing down the process. --- crates/goose-cli/src/session/mod.rs | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 105a09ce3161..983a298be7ad 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1092,6 +1092,8 @@ impl CliSession { } }; + let extension_configs = self.agent.get_extension_configs().await; + self.agent .emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id) .await; @@ -1100,6 +1102,37 @@ impl CliSession { self.messages.clear(); self.run_mode = RunMode::Normal; + 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()); + } + } + + 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()); + } + } + + if let Err(e) = self.update_completion_cache().await { + output::render_error(&format!("Failed to refresh completions: {}", e)); + } + output::render_message( &Message::assistant() .with_text(format!("Started a new session · {}\n", self.session_id)), @@ -2967,6 +3000,14 @@ mod tests { .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) @@ -2993,5 +3034,11 @@ mod tests { 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")) + ); } } From bc612f2b0a5429fafc4befaae02f6a8bb72f9581 Mon Sep 17 00:00:00 2001 From: Johann Drews Date: Tue, 28 Jul 2026 20:31:10 +0200 Subject: [PATCH 4/5] fix(cli): clear goal and grind when starting a new session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent keeps goal and grind in process-level fields, so they survive a session id swap and the reply loop would keep nudging the fresh session towards the previous objective. A real restart drops them, and /clear does not touch them either — only /goal off and /grind off do. --- crates/goose-cli/src/session/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 983a298be7ad..0aa235911f31 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1101,6 +1101,8 @@ impl CliSession { 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 From af42a49b6bccee84b31586d04beb622d307b9439 Mon Sep 17 00:00:00 2001 From: Johann Drews Date: Tue, 28 Jul 2026 20:42:21 +0200 Subject: [PATCH 5/5] fix(cli): name the extensions /new could not restart Report which extensions did not come back so the confirmation no longer reads like everything was carried over, matching how the builder warns and continues when an extension fails at startup. Also drop the old session's pending steers, which are keyed by session id and would otherwise never be delivered. --- crates/goose-cli/src/session/mod.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 0aa235911f31..0b2ea029282e 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1098,6 +1098,8 @@ impl CliSession { .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; @@ -1124,10 +1126,12 @@ impl CliSession { } } + 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); } } @@ -1135,11 +1139,14 @@ impl CliSession { output::render_error(&format!("Failed to refresh completions: {}", e)); } - output::render_message( - &Message::assistant() - .with_text(format!("Started a new session · {}\n", self.session_id)), - self.debug, - ); + 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(()) }