From 77baa0f5014b5e9d99aab889e442de72ef0fa049 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Nov 2025 13:55:04 -0500 Subject: [PATCH 1/6] Add session fork feature for CLI and backend API --- crates/goose-cli/src/cli.rs | 69 ++++++++++++++++++--- crates/goose-cli/src/commands/bench.rs | 1 + crates/goose-cli/src/session/builder.rs | 5 ++ crates/goose-server/src/openapi.rs | 1 + crates/goose-server/src/routes/session.rs | 34 ++++++++++ crates/goose/src/session/session_manager.rs | 10 ++- ui/desktop/openapi.json | 41 ++++++++++++ ui/desktop/src/api/sdk.gen.ts | 9 ++- ui/desktop/src/api/types.gen.ts | 29 +++++++++ 9 files changed, 189 insertions(+), 10 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d774200..dbbfa0ddea8e 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -75,6 +75,7 @@ jsonl' -> '20250325_200615')." async fn get_or_create_session_id( identifier: Option, resume: bool, + fork: bool, no_session: bool, ) -> Result> { if no_session { @@ -82,7 +83,15 @@ async fn get_or_create_session_id( } let Some(id) = identifier else { - return if resume { + return if fork { + let sessions = SessionManager::list_sessions().await?; + let source_session = sessions + .first() + .ok_or_else(|| anyhow::anyhow!("No session found to fork"))?; + let new_name = format!("{} (fork)", source_session.name); + let forked_session = SessionManager::copy_session(&source_session.id, new_name).await?; + Ok(Some(forked_session.id)) + } else if resume { let sessions = SessionManager::list_sessions().await?; let session_id = sessions .first() @@ -101,9 +110,25 @@ async fn get_or_create_session_id( }; if let Some(session_id) = id.session_id { - Ok(Some(session_id)) + if fork { + let source_session = SessionManager::get_session(&session_id, false).await?; + let new_name = format!("{} (fork)", source_session.name); + let forked_session = SessionManager::copy_session(&session_id, new_name).await?; + Ok(Some(forked_session.id)) + } else { + Ok(Some(session_id)) + } } else if let Some(name) = id.name { - if resume { + if fork { + let sessions = SessionManager::list_sessions().await?; + let source_session = sessions + .into_iter() + .find(|s| s.name == name || s.id == name) + .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?; + let new_name = format!("{} (fork)", source_session.name); + let forked_session = SessionManager::copy_session(&source_session.id, new_name).await?; + Ok(Some(forked_session.id)) + } else if resume { let sessions = SessionManager::list_sessions().await?; let session_id = sessions .into_iter() @@ -132,7 +157,14 @@ async fn get_or_create_session_id( .and_then(|s| s.to_str()) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?; - Ok(Some(session_id)) + if fork { + let source_session = SessionManager::get_session(&session_id, false).await?; + let new_name = format!("{} (fork)", source_session.name); + let forked_session = SessionManager::copy_session(&session_id, new_name).await?; + Ok(Some(forked_session.id)) + } else { + Ok(Some(session_id)) + } } else { let session = SessionManager::create_session( std::env::current_dir()?, @@ -454,6 +486,15 @@ enum Command { )] resume: bool, + /// Fork a previous session (creates new session with copied history) + #[arg( + long, + requires = "resume", + help = "Fork a previous session (creates new session with copied history)", + long_help = "Create a new session by copying all messages from a previous session. Must be used with --resume. If --name or --session-id is provided, forks that specific session. Otherwise forks the most recently used session." + )] + fork: bool, + /// Show message history when resuming #[arg( long, @@ -904,6 +945,7 @@ pub async fn cli() -> anyhow::Result<()> { command, identifier, resume, + fork, history, debug, max_tool_repetitions, @@ -973,7 +1015,13 @@ pub async fn cli() -> anyhow::Result<()> { } None => { let session_start = std::time::Instant::now(); - let session_type = if resume { "resumed" } else { "new" }; + let session_type = if fork { + "forked" + } else if resume { + "resumed" + } else { + "new" + }; tracing::info!( counter.goose.session_starts = 1, @@ -993,12 +1041,14 @@ pub async fn cli() -> anyhow::Result<()> { } } - let session_id = get_or_create_session_id(identifier, resume, false).await?; + let session_id = + get_or_create_session_id(identifier, resume, fork, false).await?; // Run session command by default let mut session: crate::CliSession = build_session(SessionBuilderConfig { session_id, resume, + fork, no_session: false, extensions, remote_extensions, @@ -1205,11 +1255,13 @@ pub async fn cli() -> anyhow::Result<()> { } } - let session_id = get_or_create_session_id(identifier, resume, no_session).await?; + let session_id = + get_or_create_session_id(identifier, resume, false, no_session).await?; let mut session = build_session(SessionBuilderConfig { session_id, resume, + fork: false, no_session, extensions, remote_extensions, @@ -1393,11 +1445,12 @@ pub async fn cli() -> anyhow::Result<()> { Ok(()) } else { // Run session command by default - let session_id = get_or_create_session_id(None, false, false).await?; + let session_id = get_or_create_session_id(None, false, false, false).await?; let mut session = build_session(SessionBuilderConfig { session_id, resume: false, + fork: false, no_session: false, extensions: Vec::new(), remote_extensions: Vec::new(), diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index c0005fa5400c..527d8e73715f 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -36,6 +36,7 @@ pub async fn agent_generator( let base_session = build_session(SessionBuilderConfig { session_id: Some(session_id), resume: false, + fork: false, no_session: false, extensions: requirements.external, remote_extensions: requirements.remote, diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8c9fc966139a..7202bf1cf762 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -30,6 +30,8 @@ pub struct SessionBuilderConfig { pub session_id: Option, /// Whether to resume an existing session pub resume: bool, + /// Whether to fork an existing session (creates new session with copied history) + pub fork: bool, /// Whether to run without a session file pub no_session: bool, /// List of stdio extension commands to add @@ -79,6 +81,7 @@ impl Default for SessionBuilderConfig { SessionBuilderConfig { session_id: None, resume: false, + fork: false, no_session: false, extensions: Vec::new(), remote_extensions: Vec::new(), @@ -691,6 +694,7 @@ mod tests { let config = SessionBuilderConfig { session_id: None, resume: false, + fork: false, no_session: false, extensions: vec!["echo test".to_string()], remote_extensions: vec!["http://example.com".to_string()], @@ -745,6 +749,7 @@ mod tests { assert!(!config.interactive); assert!(!config.quiet); assert!(config.final_output_response.is_none()); + assert!(!config.fork); } #[tokio::test] diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 8870be0a73f8..a2951ef75c42 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -369,6 +369,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, super::routes::session::edit_message, + super::routes::session::fork_session, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 39cc203970e3..2a757fd9313f 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -390,6 +390,39 @@ async fn edit_message( } } +#[utoipa::path( + post, + path = "/sessions/{session_id}/fork", + responses( + (status = 200, description = "Session forked successfully", body = Session), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn fork_session(Path(session_id): Path) -> Result, StatusCode> { + let original_session = SessionManager::get_session(&session_id, false) + .await + .map_err(|e| { + tracing::error!("Failed to get session: {}", e); + StatusCode::NOT_FOUND + })?; + + let new_name = format!("{} (fork)", original_session.name); + + let forked_session = SessionManager::copy_session(&session_id, new_name) + .await + .map_err(|e| { + tracing::error!("Failed to copy session: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(forked_session)) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) @@ -404,5 +437,6 @@ pub fn routes(state: Arc) -> Router { put(update_session_user_recipe_values), ) .route("/sessions/{session_id}/edit_message", post(edit_message)) + .route("/sessions/{session_id}/fork", post(fork_session)) .with_state(state) } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b45c..8793b359ce21 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1239,12 +1239,20 @@ impl SessionStorage { ) .await?; - let builder = SessionUpdateBuilder::new(new_session.id.clone()) + let mut builder = SessionUpdateBuilder::new(new_session.id.clone()) .extension_data(original_session.extension_data) .schedule_id(original_session.schedule_id) .recipe(original_session.recipe) .user_recipe_values(original_session.user_recipe_values); + if let Some(provider_name) = original_session.provider_name { + builder = builder.provider_name(provider_name); + } + + if let Some(model_config) = original_session.model_config { + builder = builder.model_config(model_config); + } + self.apply_update(builder).await?; if let Some(conversation) = original_session.conversation { diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1507b4019a12..2a82da9c4739 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2124,6 +2124,47 @@ ] } }, + "/sessions/{session_id}/fork": { + "post": { + "tags": [ + "Session Management" + ], + "operationId": "fork_session", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session forked successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/{session_id}/name": { "put": { "tags": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 6f0e8186f1be..1990f3f7f12f 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -576,6 +576,13 @@ export const exportSession = (options: Opt }); }; +export const forkSession = (options: Options) => { + return (options.client ?? client).post({ + url: '/sessions/{session_id}/fork', + ...options + }); +}; + export const updateSessionName = (options: Options) => { return (options.client ?? client).put({ url: '/sessions/{session_id}/name', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 901e0c35890d..af1b91ea05f3 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -2637,6 +2637,35 @@ export type ExportSessionResponses = { export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; +export type ForkSessionData = { + body?: never; + path: { + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/fork'; +}; + +export type ForkSessionErrors = { + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ForkSessionResponses = { + /** + * Session forked successfully + */ + 200: Session; +}; + +export type ForkSessionResponse = ForkSessionResponses[keyof ForkSessionResponses]; + export type UpdateSessionNameData = { body: UpdateSessionNameRequest; path: { From b95d84049f90b3e65ab38601dd138e6433e0d6ce Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Nov 2025 13:55:29 -0500 Subject: [PATCH 2/6] Add session fork UI for desktop --- crates/goose-cli/src/cli.rs | 14 ++-- crates/goose-server/src/routes/session.rs | 13 +--- crates/goose/src/session/session_manager.rs | 68 +++++++++++++++++++ ui/desktop/src/components/ChatInput.tsx | 26 ++++++- .../components/sessions/SessionListView.tsx | 32 +++++++++ ui/desktop/src/hooks/useForkSession.ts | 36 ++++++++++ 6 files changed, 167 insertions(+), 22 deletions(-) create mode 100644 ui/desktop/src/hooks/useForkSession.ts diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index dbbfa0ddea8e..3ab83544137a 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -88,8 +88,7 @@ async fn get_or_create_session_id( let source_session = sessions .first() .ok_or_else(|| anyhow::anyhow!("No session found to fork"))?; - let new_name = format!("{} (fork)", source_session.name); - let forked_session = SessionManager::copy_session(&source_session.id, new_name).await?; + let forked_session = SessionManager::fork_session(&source_session.id).await?; Ok(Some(forked_session.id)) } else if resume { let sessions = SessionManager::list_sessions().await?; @@ -111,9 +110,7 @@ async fn get_or_create_session_id( if let Some(session_id) = id.session_id { if fork { - let source_session = SessionManager::get_session(&session_id, false).await?; - let new_name = format!("{} (fork)", source_session.name); - let forked_session = SessionManager::copy_session(&session_id, new_name).await?; + let forked_session = SessionManager::fork_session(&session_id).await?; Ok(Some(forked_session.id)) } else { Ok(Some(session_id)) @@ -125,8 +122,7 @@ async fn get_or_create_session_id( .into_iter() .find(|s| s.name == name || s.id == name) .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?; - let new_name = format!("{} (fork)", source_session.name); - let forked_session = SessionManager::copy_session(&source_session.id, new_name).await?; + let forked_session = SessionManager::fork_session(&source_session.id).await?; Ok(Some(forked_session.id)) } else if resume { let sessions = SessionManager::list_sessions().await?; @@ -158,9 +154,7 @@ async fn get_or_create_session_id( .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?; if fork { - let source_session = SessionManager::get_session(&session_id, false).await?; - let new_name = format!("{} (fork)", source_session.name); - let forked_session = SessionManager::copy_session(&session_id, new_name).await?; + let forked_session = SessionManager::fork_session(&session_id).await?; Ok(Some(forked_session.id)) } else { Ok(Some(session_id)) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 2a757fd9313f..9d11aead8bef 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -404,19 +404,10 @@ async fn edit_message( tag = "Session Management" )] async fn fork_session(Path(session_id): Path) -> Result, StatusCode> { - let original_session = SessionManager::get_session(&session_id, false) + let forked_session = SessionManager::fork_session(&session_id) .await .map_err(|e| { - tracing::error!("Failed to get session: {}", e); - StatusCode::NOT_FOUND - })?; - - let new_name = format!("{} (fork)", original_session.name); - - let forked_session = SessionManager::copy_session(&session_id, new_name) - .await - .map_err(|e| { - tracing::error!("Failed to copy session: {}", e); + tracing::error!("Failed to fork session: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 8793b359ce21..b9ae383871bf 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -314,6 +314,10 @@ impl SessionManager { .await } + pub async fn fork_session(session_id: &str) -> Result { + Self::instance().await?.fork_session(session_id).await + } + pub async fn truncate_conversation(session_id: &str, timestamp: i64) -> Result<()> { Self::instance() .await? @@ -1263,6 +1267,11 @@ impl SessionStorage { self.get_session(&new_session.id, true).await } + async fn fork_session(&self, session_id: &str) -> Result { + let original = self.get_session(session_id, false).await?; + self.copy_session(session_id, original.name).await + } + async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> { sqlx::query("DELETE FROM messages WHERE session_id = ? AND created_timestamp >= ?") .bind(session_id) @@ -1500,4 +1509,63 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } + + #[tokio::test] + async fn test_fork_session() { + const ORIGINAL_NAME: &str = "Original session"; + const USER_MESSAGE: &str = "test message"; + const ASSISTANT_MESSAGE: &str = "test response"; + + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_fork.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let original = storage + .create_session( + PathBuf::from("/tmp/test"), + ORIGINAL_NAME.to_string(), + SessionType::User, + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::User, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(USER_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(ASSISTANT_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + let forked = storage.fork_session(&original.id).await.unwrap(); + + assert_eq!(forked.name, ORIGINAL_NAME); + assert_ne!(forked.id, original.id); + assert_eq!(forked.working_dir, original.working_dir); + + let conversation = forked.conversation.unwrap(); + assert_eq!(conversation.messages().len(), 2); + assert_eq!(conversation.messages()[0].role, Role::User); + assert_eq!(conversation.messages()[1].role, Role::Assistant); + } } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 0cb0ea5e06aa..ec611cf82a4a 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react'; -import { Bug, FolderKey, ScrollText } from 'lucide-react'; +import { Bug, FolderKey, GitFork, ScrollText } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Button } from './ui/button'; import type { View } from '../utils/navigationUtils'; @@ -28,6 +28,7 @@ import MessageQueue from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; import { DiagnosticsModal } from './ui/DownloadDiagnostics'; import { Message } from '../api'; +import { useForkSession } from '../hooks/useForkSession'; interface QueuedMessage { id: string; @@ -140,6 +141,12 @@ export default function ChatInput({ const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const { forkAndOpenWindow, isForking } = useForkSession(); + + const handleForkSession = async () => { + if (!sessionId) return; + await forkAndOpenWindow(sessionId); + }; // Save queue state (paused/interrupted) to storage useEffect(() => { @@ -1531,6 +1538,23 @@ export default function ChatInput({ Generate diagnostics bundle )} + {sessionId && ( + + + + + Fork session in new window + + )} {sessionId && diagnosticsOpen && ( = React.memo( toast.success('Session exported successfully'); }, []); + const { forkAndOpenWindow } = useForkSession(); + + const handleForkSession = useCallback( + async (session: Session, e: React.MouseEvent) => { + e.stopPropagation(); + const forkedSession = await forkAndOpenWindow(session.id); + if (forkedSession) { + await loadSessions(); + } + }, + [forkAndOpenWindow, loadSessions] + ); + const handleImportClick = useCallback(() => { fileInputRef.current?.click(); }, []); @@ -505,12 +520,14 @@ const SessionListView: React.FC = React.memo( onEditClick, onDeleteClick, onExportClick, + onForkClick, onOpenInNewWindow, }: { session: Session; onEditClick: (session: Session) => void; onDeleteClick: (session: Session) => void; onExportClick: (session: Session, e: React.MouseEvent) => void; + onForkClick: (session: Session, e: React.MouseEvent) => void; onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void; }) { const handleEditClick = useCallback( @@ -547,6 +564,13 @@ const SessionListView: React.FC = React.memo( [onOpenInNewWindow, session] ); + const handleForkClick = useCallback( + (e: React.MouseEvent) => { + onForkClick(session, e); + }, + [onForkClick, session] + ); + return ( = React.memo( > + - - Fork session in new window - - )} {sessionId && diagnosticsOpen && ( = React.memo( setShowDeleteConfirmation(true); }, []); + const handleDuplicateSession = useCallback( + async (session: Session) => { + try { + await forkSession({ + path: { session_id: session.id }, + body: { truncate: false, copy: true }, + throwOnError: true, + }); + toast.success(`Session "${session.name}" duplicated successfully`); + await loadSessions(); + } catch (error) { + console.error('Error duplicating session:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to duplicate session: ${errorMessage}`); + } + }, + [loadSessions] + ); + const handleConfirmDelete = useCallback(async () => { if (!sessionToDelete) return; @@ -462,19 +481,6 @@ const SessionListView: React.FC = React.memo( toast.success('Session exported successfully'); }, []); - const { forkAndOpenWindow } = useForkSession(); - - const handleForkSession = useCallback( - async (session: Session, e: React.MouseEvent) => { - e.stopPropagation(); - const forkedSession = await forkAndOpenWindow(session.id); - if (forkedSession) { - await loadSessions(); - } - }, - [forkAndOpenWindow, loadSessions] - ); - const handleImportClick = useCallback(() => { fileInputRef.current?.click(); }, []); @@ -518,16 +524,16 @@ const SessionListView: React.FC = React.memo( const SessionItem = React.memo(function SessionItem({ session, onEditClick, + onDuplicateClick, onDeleteClick, onExportClick, - onForkClick, onOpenInNewWindow, }: { session: Session; onEditClick: (session: Session) => void; + onDuplicateClick: (session: Session) => void; onDeleteClick: (session: Session) => void; onExportClick: (session: Session, e: React.MouseEvent) => void; - onForkClick: (session: Session, e: React.MouseEvent) => void; onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void; }) { const handleEditClick = useCallback( @@ -538,6 +544,14 @@ const SessionListView: React.FC = React.memo( [onEditClick, session] ); + const handleDuplicateClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onDuplicateClick(session); + }, + [onDuplicateClick, session] + ); + const handleDeleteClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); // Prevent card click @@ -564,13 +578,6 @@ const SessionListView: React.FC = React.memo( [onOpenInNewWindow, session] ); - const handleForkClick = useCallback( - (e: React.MouseEvent) => { - onForkClick(session, e); - }, - [onForkClick, session] - ); - return ( = React.memo(