Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/goose-cli/src/session/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ impl GooseCompleter {
"/mode".to_string(),
"/model".to_string(),
"/recipe".to_string(),
"/new".to_string(),
];
commands.extend(
list_commands()
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions crates/goose-cli/src/session/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub enum InputResult {
Plan(PlanCommandOptions),
EndPlan,
Clear,
New,
Recipe(Option<String>),
Compact,
ToggleFullToolOutput,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add /new coverage to the self-test recipe

Because this adds a user-visible CLI feature, leaving goose-self-test.yaml unchanged means the repo’s required feature-validation recipe will not exercise the new-session transition, such as creating a new id while clearing history/tokens and preserving provider/extensions. Please add a self-test scenario for /new before landing.

AGENTS.md reference: AGENTS.md:L71-L71

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/new is only reachable from the interactive loop: handle_slash_command is called from input::get_input, which has exactly one caller — run_interactive (session/mod.rs:573). The self-test runs via goose run --recipe, which takes the headless path (session/mod.rs:1378) and never reaches that code, so a scenario there could not exercise the transition.

That is also why the recipe covers no slash command today — /clear, /model and /compact are all absent. It exercises agent and tool capabilities, not the interactive CLI. Happy to add coverage if you would rather have it, but it would need a PTY-driven session rather than a recipe step.

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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
197 changes: 197 additions & 0 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -1070,6 +1074,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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be emitted from somewhere inside the agent already when a session ends?

.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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset managed-context providers on /new

When the current provider manages its own context, /new only swaps Goose's session id and clears self.messages, leaving the existing provider instance and its upstream conversation id intact. For example, AcpProvider::stream still prompts self.acp_session_id(), and GeminiCliProvider resumes its cached cli_session_id with -r (crates/goose/src/acp/provider.rs:482, crates/goose/src/providers/gemini_cli.rs:68-111), so after /new users of ACP or gemini-cli continue the old upstream conversation even though the CLI reports a fresh session.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed for ACP, gemini-cli and claude-code (manages_own_context() == true). Swapping goose's session id would have left AcpProvider's session.id and GeminiCliProvider's OnceLock<cli_session_id> pointing at the old upstream conversation.

Rather than recreating the provider mid-session, /new now declines for those providers, matching how /model already refuses to switch when the provider manages its own context (session/mod.rs:898). Fixed in 649dc12.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate the mode after switching session ids

Switching self.session_id here without also calling agent.update_goose_mode(..., &new_session_id) leaves per-session provider state behind. This is observable with the Codex provider: it stores modes in mode_by_session and defaults missing session ids to GooseMode::Auto, which maps to --yolo, so a user who ran /mode approve or /mode chat before /new gets the new session executed under Auto even though the session row was created with the stricter mode.

Useful? React with 👍 / 👎.

self.messages.clear();
self.run_mode = RunMode::Normal;
Comment on lines +1104 to +1105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset goal/grind state for the fresh session

When /new is run while a /goal or /grind is still set, this only clears the visible conversation; the Agent's goal/grind fields are process state and survive the session id swap. The next turn in the new session can still receive the old objective nudge from the reply loop, so the supposedly fresh session continues the previous task until the user manually runs /goal off or /grind off; reset those fields here to match a real restart.

Useful? React with 👍 / 👎.

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());
Comment on lines +1132 to +1133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort /new when extension restart fails

With any configured extension whose process cannot be relaunched at /new time (for example a deleted binary, bad env, or a port already in use), add_extension returns Err here, but the code has already switched self.session_id, cleared the conversation, emitted SessionEnd, and still prints that the new session started. The new session therefore runs without an extension that was loaded in the old session, contrary to the command’s promise to keep extensions; fresh evidence is that the updated rebind loop renders the add error and continues instead of rolling back or aborting before announcing success.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed. /new deliberately does not abort: this matches session start, where builder.rs warns "Failed to start extension '' (), continuing without it" and starts the session anyway. Aborting is also more than it sounds at this point — the swap and teardown have already happened, so it would mean relaunching the extensions under the old id rather than just returning.

The misleading part was real, though. The confirmation now names what did not come back ("Continuing without these extensions: ..."), so it no longer reads as if everything was carried over. af42a49

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?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebind extensions before persisting the successor

Persisting the extension configs into the new row does not rebind or restart the already-running MCP clients. Those clients record the first session id they see and assert it never changes in GooseClient::set_session_id, while stdio extensions are also launched with AGENT_SESSION_ID from the old session; with any loaded MCP extension that has been listed or called before /new, the next tool/prompt/resource request under the new id can panic or continue operating against the old session instead of the fresh one.

Useful? React with 👍 / 👎.

Ok(new_session_id)
}

async fn handle_recipe(&mut self, filepath_opt: Option<String>) {
println!("{}", console::style("Generating Recipe").green());

Expand Down Expand Up @@ -1972,6 +2062,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()),
Expand Down Expand Up @@ -2853,4 +2977,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"))
);
}
}