feat(cli): add /new to start a fresh session without restarting (#10767)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user