feat(cli): add /new to start a fresh session without restarting (#10767)

This commit is contained in:
Johann Drews
2026-08-10 21:39:35 +02:00
committed by GitHub
parent e36c21b9be
commit aba09438a0
3 changed files with 222 additions and 0 deletions
@@ -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();
+12
View File
@@ -27,6 +27,7 @@ pub enum InputResult {
Plan(PlanCommandOptions),
EndPlan,
Clear,
New,
Recipe(Option<String>),
Compact,
ToggleFullToolOutput,
@@ -239,6 +240,7 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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<InputResult> {
}
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 [<name>...])
/? 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();
+197
View File
@@ -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<String> {
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<String>) {
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<String> {
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"))
);
}
}