From 7c5db7d7cedfdae9113c3a2ab9a9c4f8366dc17a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 8 Oct 2025 16:18:53 -0400 Subject: [PATCH 01/16] Standardize on session name instead of description --- crates/goose-cli/src/cli.rs | 2 +- crates/goose-cli/src/commands/schedule.rs | 2 +- crates/goose-cli/src/commands/session.rs | 21 ++- crates/goose-cli/src/commands/web.rs | 4 +- crates/goose-cli/src/session/builder.rs | 9 +- crates/goose-server/src/routes/agent.rs | 4 +- crates/goose-server/src/routes/schedule.rs | 4 +- crates/goose-server/src/routes/session.rs | 2 +- crates/goose/src/agents/agent.rs | 4 +- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/context_mgmt/auto_compact.rs | 5 +- crates/goose/src/scheduler.rs | 4 +- crates/goose/src/session/session_manager.rs | 124 +++++++++++++----- crates/goose/tests/test_support.rs | 3 +- 14 files changed, 120 insertions(+), 70 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4b2e5e0144fe..f2d5a68033b0 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -75,7 +75,7 @@ async fn get_session_id(identifier: Identifier) -> Result { sessions .into_iter() - .find(|s| s.id == name || s.description.contains(&name)) + .find(|s| s.name == name) .map(|s| s.id) .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name)) } else if let Some(path) = identifier.path { diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index c2784530c6e6..7afa03b93bd7 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -222,7 +222,7 @@ pub async fn handle_schedule_sessions(id: String, limit: Option) -> Resul " - Session ID: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", session_name, // Display the session_name as Session ID metadata.working_dir.display(), - metadata.description, + metadata.name, metadata.schedule_id.as_deref().unwrap_or("N/A") ); } diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index 21d412ae629a..008e097a55d8 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -13,7 +13,7 @@ const TRUNCATED_DESC_LENGTH: usize = 60; pub async fn remove_sessions(sessions: Vec) -> Result<()> { println!("The following sessions will be removed:"); for session in &sessions { - println!("- {} {}", session.id, session.description); + println!("- {} {}", session.id, session.name); } let should_delete = confirm("Are you sure you want to delete these sessions?") @@ -45,10 +45,10 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result = sessions .iter() .map(|s| { - let desc = if s.description.is_empty() { - "(no description)" + let desc = if s.name.is_empty() { + "(no name)" } else { - &s.description + &s.name }; let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH); let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id); @@ -154,10 +154,7 @@ pub async fn handle_session_list( println!("Available sessions:"); for session in sessions { - let output = format!( - "{} - {} - {}", - session.id, session.description, session.updated_at - ); + let output = format!("{} - {} - {}", session.id, session.name, session.updated_at); println!("{}", output); } } @@ -188,7 +185,7 @@ pub async fn handle_session_export( let conversation = session .conversation .ok_or_else(|| anyhow::anyhow!("Session has no messages"))?; - export_session_to_markdown(conversation.messages().to_vec(), &session.description) + export_session_to_markdown(conversation.messages().to_vec(), &session.name) } _ => return Err(anyhow::anyhow!("Unsupported format: {}", format)), }; @@ -293,10 +290,10 @@ pub async fn prompt_interactive_session_selection() -> Result { let display_map: std::collections::HashMap = sessions .iter() .map(|s| { - let desc = if s.description.is_empty() { - "(no description)" + let desc = if s.name.is_empty() { + "(no name)" } else { - &s.description + &s.name }; let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH); diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index af0f066cb2dd..9157e337cdf1 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -226,7 +226,7 @@ pub async fn handle_web( async fn serve_index() -> Result { let session = SessionManager::create_session( std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - "Web session".to_string(), + Some("Web session".to_string()), ) .await .map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; @@ -291,7 +291,7 @@ async fn list_sessions() -> Json { session_info.push(serde_json::json!({ "name": session.id, "path": session.id, - "description": session.description, + "description": session.name, "message_count": session.message_count, "working_dir": session.working_dir })); diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index f5dde78bcecc..a12b2d2f6724 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -311,12 +311,9 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } else if let Some(session_id) = session_config.session_id { Some(session_id) } else { - let session = SessionManager::create_session( - std::env::current_dir().unwrap(), - "CLI Session".to_string(), - ) - .await - .unwrap(); + let session = SessionManager::create_session(std::env::current_dir().unwrap(), None) + .await + .unwrap(); Some(session.id) }; diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index bad6d27a2e99..a6790322994f 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -99,10 +99,10 @@ async fn start_agent( Json(payload): Json, ) -> Result, StatusCode> { let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; - let description = format!("New session {}", counter); + let name = format!("New session {}", counter); let mut session = - SessionManager::create_session(PathBuf::from(&payload.working_dir), description) + SessionManager::create_session(PathBuf::from(&payload.working_dir), Some(name)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 5203e8be703b..d9a0b049300c 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -69,7 +69,7 @@ fn default_limit() -> u32 { #[serde(rename_all = "camelCase")] pub struct SessionDisplayInfo { id: String, // Derived from session_name (filename) - name: String, // From metadata.description + name: String, // From metadata.name created_at: String, // Derived from session_name, in ISO 8601 format working_dir: String, // from metadata.working_dir (as String) schedule_id: Option, @@ -323,7 +323,7 @@ async fn sessions_handler( for (session_name, session) in session_tuples { display_infos.push(SessionDisplayInfo { id: session_name.clone(), - name: session.description, + name: session.name, created_at: parse_session_name_to_iso(&session_name), working_dir: session.working_dir.to_string_lossy().into_owned(), schedule_id: session.schedule_id, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 3bea6a1251f0..d21ec3e59a01 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -135,7 +135,7 @@ async fn update_session_description( } SessionManager::update_session(&session_id) - .description(request.description) + .name(request.description) .apply() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index d5533aa80ecf..9958722257f1 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1059,9 +1059,7 @@ impl Agent { let provider = self.provider().await?; let session_id = session_config.id.clone(); tokio::spawn(async move { - if let Err(e) = - SessionManager::maybe_update_description(&session_id, provider).await - { + if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await { warn!("Failed to generate session description: {}", e); } }); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 83f4cfb054fe..f1cc4d79f139 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -102,7 +102,7 @@ fn get_agent_messages( let working_dir = task_config.parent_working_dir; let session = SessionManager::create_session( working_dir.clone(), - format!("Subagent task for: {}", parent_session_id), + Some(format!("Subagent task for: {}", parent_session_id)), ) .await .map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?; diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index aebd1096e25a..9a5a42800fb2 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -316,7 +316,8 @@ mod tests { crate::session::Session { id: "test_session".to_string(), working_dir: PathBuf::from(working_dir), - description: "Test session".to_string(), + name: "Test session".to_string(), + user_set_name: false, created_at: Default::default(), updated_at: Default::default(), schedule_id: Some("test_job".to_string()), @@ -729,7 +730,7 @@ mod tests { comprehensive_metadata.working_dir.to_str().unwrap(), "/test/working/dir" ); - assert_eq!(comprehensive_metadata.description, "Test session"); + assert_eq!(comprehensive_metadata.name, "Test session"); assert_eq!( comprehensive_metadata.schedule_id, Some("test_job".to_string()) diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 93b696c820b2..d3e0df191648 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1171,11 +1171,11 @@ async fn run_scheduled_job_internal( // Create session upfront for both cases let session = match SessionManager::create_session( current_dir.clone(), - if recipe.prompt.is_some() { + Some(if recipe.prompt.is_some() { format!("Scheduled job: {}", job.id) } else { "Empty job - no prompt".to_string() - }, + }), ) .await { diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 283c52f434f1..bea26db6f560 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -18,7 +18,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 2; +const CURRENT_SCHEMA_VERSION: i32 = 3; static SESSION_STORAGE: OnceCell> = OnceCell::const_new(); @@ -27,7 +27,10 @@ pub struct Session { pub id: String, #[schema(value_type = String)] pub working_dir: PathBuf, - pub description: String, + #[serde(alias = "description")] + pub name: String, + #[serde(default)] + pub user_set_name: bool, pub created_at: DateTime, pub updated_at: DateTime, pub extension_data: ExtensionData, @@ -46,7 +49,8 @@ pub struct Session { pub struct SessionUpdateBuilder { session_id: String, - description: Option, + name: Option, + user_set_name: Option, working_dir: Option, extension_data: Option, total_tokens: Option>, @@ -73,7 +77,8 @@ impl SessionUpdateBuilder { fn new(session_id: String) -> Self { Self { session_id, - description: None, + name: None, + user_set_name: None, working_dir: None, extension_data: None, total_tokens: None, @@ -88,8 +93,13 @@ impl SessionUpdateBuilder { } } - pub fn description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn user_set_name(mut self, user_set: bool) -> Self { + self.user_set_name = Some(user_set); self } @@ -166,10 +176,10 @@ impl SessionManager { .map(Arc::clone) } - pub async fn create_session(working_dir: PathBuf, description: String) -> Result { + pub async fn create_session(working_dir: PathBuf, name: Option) -> Result { Self::instance() .await? - .create_session(working_dir, description) + .create_session(working_dir, name) .await } @@ -219,8 +229,13 @@ impl SessionManager { Self::instance().await?.import_session(json).await } - pub async fn maybe_update_description(id: &str, provider: Arc) -> Result<()> { + pub async fn maybe_update_name(id: &str, provider: Arc) -> Result<()> { let session = Self::get_session(id, true).await?; + + if session.user_set_name { + return Ok(()); + } + let conversation = session .conversation .ok_or_else(|| anyhow::anyhow!("No messages found"))?; @@ -232,11 +247,8 @@ impl SessionManager { .count(); if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION { - let description = provider.generate_session_name(&conversation).await?; - Self::update_session(id) - .description(description) - .apply() - .await + let name = provider.generate_session_name(&conversation).await?; + Self::update_session(id).name(name).apply().await } else { Ok(()) } @@ -269,7 +281,8 @@ impl Default for Session { Self { id: String::new(), working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - description: String::new(), + name: String::new(), + user_set_name: false, created_at: Default::default(), updated_at: Default::default(), extension_data: ExtensionData::default(), @@ -306,10 +319,17 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { let user_recipe_values = user_recipe_values_json.and_then(|json| serde_json::from_str(&json).ok()); + let name = row + .try_get("name") + .or_else(|_| row.try_get("description"))?; + + let user_set_name = row.try_get("user_set_name").unwrap_or(false); + Ok(Session { id: row.try_get("id")?, working_dir: PathBuf::from(row.try_get::("working_dir")?), - description: row.try_get("description")?, + name, + user_set_name, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, extension_data: serde_json::from_str(&row.try_get::("extension_data")?) @@ -396,7 +416,8 @@ impl SessionStorage { r#" CREATE TABLE sessions ( id TEXT PRIMARY KEY, - description TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + user_set_name BOOLEAN DEFAULT FALSE, working_dir TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -503,15 +524,16 @@ impl SessionStorage { sqlx::query( r#" INSERT INTO sessions ( - id, description, working_dir, created_at, updated_at, extension_data, + id, name, user_set_name, working_dir, created_at, updated_at, extension_data, total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&session.id) - .bind(&session.description) + .bind(&session.name) + .bind(session.user_set_name) .bind(session.working_dir.to_string_lossy().as_ref()) .bind(session.created_at) .bind(session.updated_at) @@ -610,6 +632,23 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 3 => { + sqlx::query( + r#" + ALTER TABLE sessions RENAME COLUMN description TO name + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE + "#, + ) + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -618,11 +657,15 @@ impl SessionStorage { Ok(()) } - async fn create_session(&self, working_dir: PathBuf, description: String) -> Result { + async fn create_session(&self, working_dir: PathBuf, name: Option) -> Result { let today = chrono::Utc::now().format("%Y%m%d").to_string(); + let (session_name, user_set_name) = match name { + Some(n) => (n, true), + None => ("CLI Session".to_string(), false), + }; Ok(sqlx::query_as( r#" - INSERT INTO sessions (id, description, working_dir, extension_data) + INSERT INTO sessions (id, name, user_set_name, working_dir, extension_data) VALUES ( ? || '_' || CAST(COALESCE(( SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER)) @@ -631,6 +674,7 @@ impl SessionStorage { ), 0) + 1 AS TEXT), ?, ?, + ?, '{}' ) RETURNING * @@ -638,7 +682,8 @@ impl SessionStorage { ) .bind(&today) .bind(&today) - .bind(&description) + .bind(&session_name) + .bind(user_set_name) .bind(working_dir.to_string_lossy().as_ref()) .fetch_one(&self.pool) .await?) @@ -647,7 +692,7 @@ impl SessionStorage { async fn get_session(&self, id: &str, include_messages: bool) -> Result { let mut session = sqlx::query_as::<_, Session>( r#" - SELECT id, working_dir, description, created_at, updated_at, extension_data, + SELECT id, working_dir, name, user_set_name, created_at, updated_at, extension_data, total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json @@ -693,7 +738,8 @@ impl SessionStorage { }; } - add_update!(builder.description, "description"); + add_update!(builder.name, "name"); + add_update!(builder.user_set_name, "user_set_name"); add_update!(builder.working_dir, "working_dir"); add_update!(builder.extension_data, "extension_data"); add_update!(builder.total_tokens, "total_tokens"); @@ -720,8 +766,11 @@ impl SessionStorage { let mut q = sqlx::query(&query); - if let Some(desc) = builder.description { - q = q.bind(desc); + if let Some(name) = builder.name { + q = q.bind(name); + } + if let Some(user_set_name) = builder.user_set_name { + q = q.bind(user_set_name); } if let Some(wd) = builder.working_dir { q = q.bind(wd.to_string_lossy().to_string()); @@ -847,7 +896,7 @@ impl SessionStorage { async fn list_sessions(&self) -> Result> { sqlx::query_as::<_, Session>( r#" - SELECT s.id, s.working_dir, s.description, s.created_at, s.updated_at, s.extension_data, + SELECT s.id, s.working_dir, s.name, s.user_set_name, s.created_at, s.updated_at, s.extension_data, s.total_tokens, s.input_tokens, s.output_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.schedule_id, s.recipe_json, s.user_recipe_values_json, @@ -913,7 +962,14 @@ impl SessionStorage { let import: Session = serde_json::from_str(json)?; let session = self - .create_session(import.working_dir.clone(), import.description.clone()) + .create_session( + import.working_dir.clone(), + if import.user_set_name { + Some(import.name.clone()) + } else { + None + }, + ) .await?; self.apply_update( @@ -964,7 +1020,7 @@ mod tests { let description = format!("Test session {}", i); let session = session_storage - .create_session(working_dir.clone(), description) + .create_session(working_dir.clone(), Some(description)) .await .unwrap(); @@ -999,7 +1055,7 @@ mod tests { session_storage .apply_update( SessionUpdateBuilder::new(session.id.clone()) - .description(format!("Updated session {}", i)) + .name(format!("Updated session {}", i)) .total_tokens(Some(100 * i)), ) .await @@ -1032,7 +1088,7 @@ mod tests { for session in &sessions { assert_eq!(session.message_count, 2); - assert!(session.description.starts_with("Updated session")); + assert!(session.name.starts_with("Updated session")); } let insights = storage.get_insights().await.unwrap(); @@ -1056,7 +1112,7 @@ mod tests { let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); let original = storage - .create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string()) + .create_session(PathBuf::from("/tmp/test"), Some(DESCRIPTION.to_string())) .await .unwrap(); @@ -1103,7 +1159,7 @@ mod tests { let imported = storage.import_session(&exported).await.unwrap(); assert_ne!(imported.id, original.id); - assert_eq!(imported.description, DESCRIPTION); + assert_eq!(imported.name, DESCRIPTION); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); assert_eq!(imported.total_tokens, Some(TOTAL_TOKENS)); assert_eq!(imported.input_tokens, Some(INPUT_TOKENS)); diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index d156b8459f29..6bba89e9198b 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -381,7 +381,8 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> Session { id: "".to_string(), working_dir: PathBuf::from(working_dir), - description: "Test session".to_string(), + name: "Test session".to_string(), + user_set_name: false, created_at: Default::default(), schedule_id: Some("test_job".to_string()), recipe: None, From d0be934687e04a28638f010ef0a1905e57c1a04c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 8 Oct 2025 18:23:45 -0400 Subject: [PATCH 02/16] Make everything backwards compatible --- crates/goose/src/session/session_manager.rs | 2 +- ui/desktop/openapi.json | 11 +++++---- ui/desktop/src/api/types.gen.ts | 3 ++- .../sessions/SessionHistoryView.tsx | 5 ++-- .../src/components/sessions/SessionItem.tsx | 3 ++- .../components/sessions/SessionListView.tsx | 19 ++++++++------- .../components/sessions/SessionsInsights.tsx | 5 ++-- .../src/components/sessions/SessionsView.tsx | 3 ++- ui/desktop/src/hooks/useAgent.ts | 4 ++-- ui/desktop/src/sessions.ts | 3 ++- ui/desktop/src/utils/sessionCompat.ts | 23 +++++++++++++++++++ 11 files changed, 55 insertions(+), 26 deletions(-) create mode 100644 ui/desktop/src/utils/sessionCompat.ts diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index bea26db6f560..9dfb04bb1493 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -319,7 +319,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { let user_recipe_values = user_recipe_values_json.and_then(|json| serde_json::from_str(&json).ok()); - let name = row + let name: String = row .try_get("name") .or_else(|_| row.try_get("description"))?; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a3e3ae4f9d33..cbe52a085986 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3922,7 +3922,7 @@ "required": [ "id", "working_dir", - "description", + "name", "created_at", "updated_at", "extension_data", @@ -3956,9 +3956,6 @@ "type": "string", "format": "date-time" }, - "description": { - "type": "string" - }, "extension_data": { "$ref": "#/components/schemas/ExtensionData" }, @@ -3974,6 +3971,9 @@ "type": "integer", "minimum": 0 }, + "name": { + "type": "string" + }, "output_tokens": { "type": "integer", "format": "int32", @@ -4007,6 +4007,9 @@ }, "nullable": true }, + "user_set_name": { + "type": "boolean" + }, "working_dir": { "type": "string" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 0b8d9af476b6..d388cfdb41b9 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -680,11 +680,11 @@ export type Session = { accumulated_total_tokens?: number | null; conversation?: Conversation | null; created_at: string; - description: string; extension_data: ExtensionData; id: string; input_tokens?: number | null; message_count: number; + name: string; output_tokens?: number | null; recipe?: Recipe | null; schedule_id?: string | null; @@ -693,6 +693,7 @@ export type Session = { user_recipe_values?: { [key: string]: string; } | null; + user_set_name?: boolean; working_dir: string; }; diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 781e42d84137..891fc456ad64 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -18,6 +18,7 @@ import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { ScrollArea } from '../ui/scroll-area'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { createSharedSession } from '../../sharedSessions'; +import { getSessionName } from '../../utils/sessionCompat'; import { Dialog, DialogContent, @@ -187,7 +188,7 @@ const SessionHistoryView: React.FC = ({ config.baseUrl, session.working_dir, messages, - session.description || 'Shared Session', + getSessionName(session) || 'Shared Session', session.total_tokens || 0 ); @@ -272,7 +273,7 @@ const SessionHistoryView: React.FC = ({
diff --git a/ui/desktop/src/components/sessions/SessionItem.tsx b/ui/desktop/src/components/sessions/SessionItem.tsx index 05a524b9899d..a7d241c38797 100644 --- a/ui/desktop/src/components/sessions/SessionItem.tsx +++ b/ui/desktop/src/components/sessions/SessionItem.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Card } from '../ui/card'; import { formatDate } from '../../utils/date'; import { Session } from '../../api'; +import { getSessionName } from '../../utils/sessionCompat'; interface SessionItemProps { session: Session; @@ -12,7 +13,7 @@ const SessionItem: React.FC = ({ session, extraActions }) => { return (
-
{session.description || `Session ${session.id}`}
+
{getSessionName(session)}
{formatDate(session.updated_at)} • {session.message_count} messages
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 2f6d8a67eb91..a99db44ec3b1 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -21,6 +21,7 @@ import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; import { Skeleton } from '../ui/skeleton'; import { toast } from 'react-toastify'; import { ConfirmationModal } from '../ui/ConfirmationModal'; +import { getSessionName } from '../../utils/sessionCompat'; import { deleteSession, exportSession, @@ -45,7 +46,7 @@ const EditSessionModal = React.memo( useEffect(() => { if (session && isOpen) { - setDescription(session.description || session.id); + setDescription(getSessionName(session)); } else if (!isOpen) { // Reset state when modal closes setDescription(''); @@ -57,7 +58,7 @@ const EditSessionModal = React.memo( if (!session || disabled) return; const trimmedDescription = description.trim(); - if (trimmedDescription === session.description) { + if (trimmedDescription === getSessionName(session)) { onClose(); return; } @@ -80,7 +81,7 @@ const EditSessionModal = React.memo( const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; console.error('Failed to update session description:', errorMessage); toast.error(`Failed to update session description: ${errorMessage}`); - setDescription(session.description || session.id); + setDescription(getSessionName(session)); } finally { setIsUpdating(false); } @@ -333,7 +334,7 @@ const SessionListView: React.FC = React.memo( startTransition(() => { const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase(); const filtered = sessions.filter((session) => { - const description = session.description || session.id; + const description = getSessionName(session); const workingDir = session.working_dir; const sessionId = session.id; @@ -416,7 +417,7 @@ const SessionListView: React.FC = React.memo( setShowDeleteConfirmation(false); const sessionToDeleteId = sessionToDelete.id; - const sessionName = sessionToDelete.description || sessionToDelete.id; + const sessionName = getSessionName(sessionToDelete); setSessionToDelete(null); try { @@ -451,7 +452,7 @@ const SessionListView: React.FC = React.memo( const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${session.description || session.id}.json`; + a.download = `${getSessionName(session)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -557,9 +558,7 @@ const SessionListView: React.FC = React.memo(
-

- {session.description || session.id} -

+

{getSessionName(session)}

@@ -806,7 +805,7 @@ const SessionListView: React.FC = React.memo( (null); @@ -332,9 +333,7 @@ export function SessionInsights() { >
- - {session.description || session.id} - + {getSessionName(session)}
{formatDateOnly(session.updated_at)} diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index dc822e31b273..a6d6cd27202f 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -70,13 +70,14 @@ const SessionsView: React.FC = () => { selectedSession || { id: initialSessionId || '', conversation: [], - description: 'Loading...', + name: 'Loading...', working_dir: '', message_count: 0, total_tokens: 0, created_at: '', updated_at: '', extension_data: {}, + user_set_name: false, } } isLoading={isLoadingSession} diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index dc588c1fa151..ecec21a4e8ec 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -76,7 +76,7 @@ export function useAgent(): UseAgentReturn { const messages = agentSession.conversation || []; return { sessionId: agentSession.id, - title: agentSession.recipe?.title || agentSession.description, + title: agentSession.recipe?.title || agentSession.name, messageHistoryIndex: 0, messages: messages?.map((message: ApiMessage) => convertApiMessageToFrontendMessage(message) @@ -169,7 +169,7 @@ export function useAgent(): UseAgentReturn { let initChat: ChatType = { sessionId: agentSession.id, - title: agentSession.recipe?.title || agentSession.description, + title: agentSession.recipe?.title || agentSession.name, messageHistoryIndex: 0, messages: messages, recipe: recipe, diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 5ea2413547e3..0b4b60a981c3 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,7 +1,8 @@ import { Session } from './api'; +import { getSessionName } from './utils/sessionCompat'; export function resumeSession(session: Session) { - console.log('Launching session in new window:', session.description || session.id); + console.log('Launching session in new window:', getSessionName(session)); const workingDir = session.working_dir; if (!workingDir) { throw new Error('Cannot resume session: working directory is missing in session'); diff --git a/ui/desktop/src/utils/sessionCompat.ts b/ui/desktop/src/utils/sessionCompat.ts new file mode 100644 index 000000000000..8d247e695597 --- /dev/null +++ b/ui/desktop/src/utils/sessionCompat.ts @@ -0,0 +1,23 @@ +import type { Session } from '../api/types.gen'; + +/** + * Get the display name for a session, handling both old and new formats + * @param session - Session object that may have either 'name' or 'description' field + * @returns The session's display name + */ +export function getSessionName(session: Session | null | undefined): string { + if (!session) return ''; + // Check for 'name' first (new format), then fall back to 'description' (old format), then id + // @ts-expect-error - description might not exist in newer types but can exist in old session data + return session.name || session.description || session.id; +} + +/** + * Check if a session has a user-provided name + * @param session - Session object + * @returns true if the session has a user-set name + */ +export function hasUserSetName(session: Session | null | undefined): boolean { + if (!session) return false; + return session.user_set_name === true; +} From 0d7c19b403814d1a831ecb32ddb071a09fbd0f64 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 15 Oct 2025 16:49:05 -0400 Subject: [PATCH 03/16] Add migration tests --- crates/goose/src/session/session_manager.rs | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 9dfb04bb1493..d3d2b5d2065c 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1172,4 +1172,59 @@ mod tests { assert_eq!(conversation.messages()[0].role, Role::User); assert_eq!(conversation.messages()[1].role, Role::Assistant); } + + #[tokio::test] + async fn test_import_session_with_description_field() { + const OLD_FORMAT_JSON: &str = r#"{ + "id": "20240101_1", + "description": "Old format session", + "user_set_name": true, + "working_dir": "/tmp/test", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "extension_data": {}, + "message_count": 0 + }"#; + + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_import.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let imported = storage.import_session(OLD_FORMAT_JSON).await.unwrap(); + + assert_eq!(imported.name, "Old format session"); + assert_eq!(imported.user_set_name, true); + assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); + } + + #[tokio::test] + async fn test_user_set_name_flag() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_user_name.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let user_session = storage + .create_session(PathBuf::from("/tmp"), Some("My Custom Name".to_string())) + .await + .unwrap(); + + assert_eq!(user_session.name, "My Custom Name"); + assert_eq!(user_session.user_set_name, true); + + let auto_session = storage + .create_session(PathBuf::from("/tmp"), None) + .await + .unwrap(); + + assert_eq!(auto_session.name, "CLI Session"); + assert_eq!(auto_session.user_set_name, false); + + storage.apply_update( + SessionUpdateBuilder::new(user_session.id.clone()) + .total_tokens(Some(100)) + ).await.unwrap(); + + let updated = storage.get_session(&user_session.id, false).await.unwrap(); + assert_eq!(updated.user_set_name, true); + } } From c37fc07e970d60d42528b445a038b9f78319a704 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 15 Oct 2025 16:49:18 -0400 Subject: [PATCH 04/16] Add DB migration helper script --- scripts/goose-db-helper.sh | 838 +++++++++++++++++++++++++++++++++++++ 1 file changed, 838 insertions(+) create mode 100755 scripts/goose-db-helper.sh diff --git a/scripts/goose-db-helper.sh b/scripts/goose-db-helper.sh new file mode 100755 index 000000000000..d374e3d03e3a --- /dev/null +++ b/scripts/goose-db-helper.sh @@ -0,0 +1,838 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BACKUP_DIR="${HOME}/.local/share/goose/goose-db-backups" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +DRY_RUN=false +SKIP_CONFIRM=false + +MIGRATIONS_DIR="${HOME}/.local/share/goose/migrations" +RUST_SESSION_MANAGER="crates/goose/src/session/session_manager.rs" + +get_latest_version() { + if [[ ! -d "${MIGRATIONS_DIR}" ]]; then + echo "0" + return + fi + + local latest=$(find "${MIGRATIONS_DIR}" -mindepth 1 -maxdepth 1 -type d -name "[0-9]*" 2>/dev/null | \ + sed 's/.*\/\([0-9]*\).*/\1/' | \ + sed 's/^0*//' | \ + sort -n | \ + tail -1) + + echo "${latest:-0}" +} + +find_migration_dir() { + local version=$1 + + if [[ ! -d "${MIGRATIONS_DIR}" ]]; then + return + fi + + local version_num=$(echo "${version}" | sed 's/^0*//') + + for dir in "${MIGRATIONS_DIR}"/*; do + if [[ -d "${dir}" ]]; then + local dir_version=$(basename "${dir}" | sed 's/^\([0-9]*\).*/\1/' | sed 's/^0*//') + if [[ "${dir_version}" == "${version_num}" ]]; then + echo "${dir}" + return + fi + fi + done +} + +get_migration_info() { + local version=$1 + + if [[ "${version}" == "0" ]]; then + echo "Initial schema (no schema_version table)" + return + fi + + local migration_dir=$(find_migration_dir "${version}") + if [[ -z "${migration_dir}" ]]; then + echo "Unknown migration" + return + fi + + local metadata_file="${migration_dir}/metadata.txt" + if [[ -f "${metadata_file}" ]]; then + local description=$(grep "^DESCRIPTION=" "${metadata_file}" | cut -d= -f2-) + echo "${description}" + else + echo "Migration ${version}" + fi +} + +list_available_migrations() { + echo -e "${BLUE}=== Available Migrations ===${NC}" + echo "" + echo -e "${CYAN}Version 0:${NC} Initial schema (no schema_version table)" + echo "" + + if [[ ! -d "${MIGRATIONS_DIR}" ]]; then + echo -e "${YELLOW}No migration files found in ${MIGRATIONS_DIR}${NC}" + return + fi + + for migration_dir in $(find "${MIGRATIONS_DIR}" -mindepth 1 -maxdepth 1 -type d -name "[0-9]*" | sort -V); do + local version=$(basename "${migration_dir}" | sed 's/^\([0-9]*\).*/\1/') + local info=$(get_migration_info "${version}") + + echo -e "${CYAN}Version ${version}:${NC} ${info}" + echo "" + done +} + +get_goose_db_path() { + if [[ -n "${GOOSE_PATH_ROOT:-}" ]]; then + echo "${GOOSE_PATH_ROOT}/data/sessions/sessions.db" + else + local possible_paths=( + "${HOME}/.local/share/goose/sessions/sessions.db" + "${HOME}/Library/Application Support/Block/goose/data/sessions/sessions.db" + ) + + for path in "${possible_paths[@]}"; do + if [[ -f "${path}" ]]; then + echo "${path}" + return + fi + done + + echo "${possible_paths[0]}" + fi +} + +DB_PATH=$(get_goose_db_path) + +confirm_action() { + local action="$1" + + if [[ "${SKIP_CONFIRM}" == "true" ]]; then + return 0 + fi + + echo -e "${YELLOW}You are about to: ${action}${NC}" + read -p "Continue? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + return 0 + else + return 1 + fi +} + +check_db_exists() { + if [[ ! -f "${DB_PATH}" ]]; then + echo -e "${RED}ERROR: Database not found at ${DB_PATH}${NC}" >&2 + exit 1 + fi +} + +get_schema_version() { + check_db_exists + local version=$(sqlite3 "${DB_PATH}" "SELECT MAX(version) FROM schema_version;" 2>/dev/null || echo "0") + echo "${version}" +} + +check_column_exists() { + local table=$1 + local column=$2 + check_db_exists + sqlite3 "${DB_PATH}" "PRAGMA table_info(${table});" | grep -q "^[0-9]*|${column}|" +} + +get_table_schema() { + local table=$1 + check_db_exists + sqlite3 "${DB_PATH}" "PRAGMA table_info(${table});" 2>/dev/null || echo "" +} + +create_backup() { + check_db_exists + mkdir -p "${BACKUP_DIR}" + local timestamp=$(date +%Y%m%d_%H%M%S) + local backup_path="${BACKUP_DIR}/sessions_v$(get_schema_version)_${timestamp}.db" + cp "${DB_PATH}" "${backup_path}" + echo -e "${GREEN}✓ Backup created: ${backup_path}${NC}" + echo "${backup_path}" +} + +show_version_history() { + list_available_migrations +} + +show_status() { + echo -e "${BLUE}=== Goose Database Status ===${NC}" + echo "Database path: ${DB_PATH}" + echo "" + + if [[ ! -f "${DB_PATH}" ]]; then + echo -e "${YELLOW}Status: No database found${NC}" + echo "" + echo "This is normal if you haven't run Goose yet." + echo "Once you run Goose, a database will be created automatically." + return + fi + + local version=$(get_schema_version) + local version_info=$(get_migration_info "${version}") + local latest_version=$(get_latest_version) + + echo -e "Current schema version: ${CYAN}${version}${NC}" + echo -e "Version info: ${version_info}" + echo "" + + echo -e "${BLUE}Sessions table schema:${NC}" + get_table_schema "sessions" | while IFS='|' read -r cid name type notnull dflt_value pk; do + echo " - ${name} (${type})" + done + echo "" + + local session_count=$(sqlite3 "${DB_PATH}" "SELECT COUNT(*) FROM sessions;" 2>/dev/null || echo "0") + local message_count=$(sqlite3 "${DB_PATH}" "SELECT COUNT(*) FROM messages;" 2>/dev/null || echo "0") + echo -e "${BLUE}Database contents:${NC}" + echo " Sessions: ${session_count}" + echo " Messages: ${message_count}" + echo "" + + if [[ ${version} -eq ${latest_version} ]]; then + echo -e "${GREEN}✓ Database is at the latest schema version${NC}" + elif [[ ${version} -lt ${latest_version} ]]; then + echo -e "${YELLOW}⚠ Database can be upgraded to v${latest_version}${NC}" + echo " Run: $0 migrate-to ${latest_version}" + fi +} + +apply_migration() { + local target_version=$1 + + if [[ "${target_version}" == "0" ]]; then + echo -e "${RED}ERROR: Cannot migrate forward to version 0${NC}" >&2 + return 1 + fi + + local migration_dir=$(find_migration_dir "${target_version}") + if [[ -z "${migration_dir}" ]]; then + echo -e "${RED}ERROR: Migration files not found for version ${target_version}${NC}" >&2 + echo -e "${YELLOW}Expected to find directory: ${MIGRATIONS_DIR}/${target_version}_*${NC}" + return 1 + fi + + local up_sql="${migration_dir}/up.sql" + if [[ ! -f "${up_sql}" ]]; then + echo -e "${RED}ERROR: Migration file not found: ${up_sql}${NC}" >&2 + return 1 + fi + + if ! sqlite3 "${DB_PATH}" < "${up_sql}"; then + echo -e "${RED}ERROR: Migration to v${target_version} failed${NC}" >&2 + echo -e "${YELLOW}Check the SQL file: ${up_sql}${NC}" + return 1 + fi +} + +rollback_migration() { + local from_version=$1 + + if [[ "${from_version}" == "0" ]]; then + echo -e "${RED}ERROR: Cannot rollback from version 0${NC}" >&2 + return 1 + fi + + local migration_dir=$(find_migration_dir "${from_version}") + if [[ -z "${migration_dir}" ]]; then + echo -e "${RED}ERROR: Migration files not found for version ${from_version}${NC}" >&2 + echo -e "${YELLOW}Expected to find directory: ${MIGRATIONS_DIR}/${from_version}_*${NC}" + return 1 + fi + + local down_sql="${migration_dir}/down.sql" + if [[ ! -f "${down_sql}" ]]; then + echo -e "${RED}ERROR: Rollback file not found: ${down_sql}${NC}" >&2 + return 1 + fi + + if ! sqlite3 "${DB_PATH}" < "${down_sql}"; then + echo -e "${RED}ERROR: Rollback from v${from_version} failed${NC}" >&2 + echo -e "${YELLOW}Check the SQL file: ${down_sql}${NC}" + return 1 + fi +} + +migrate_to_version() { + local target_version=$1 + local latest_version=$(get_latest_version) + + if [[ -z "${target_version}" ]]; then + echo -e "${RED}ERROR: Please specify a target version${NC}" >&2 + echo "Usage: $0 migrate-to " + echo "" + echo "Available versions: 0 to ${latest_version}" + return 1 + fi + + if [[ ! "${target_version}" =~ ^[0-9]+$ ]] || [[ ${target_version} -lt 0 ]] || [[ ${target_version} -gt ${latest_version} ]]; then + echo -e "${RED}ERROR: Invalid version: ${target_version}${NC}" >&2 + echo "Valid versions are: 0 to ${latest_version}" + return 1 + fi + + check_db_exists + local current_version=$(get_schema_version) + + if [[ ${current_version} -eq ${target_version} ]]; then + echo -e "${YELLOW}Already at version ${target_version}${NC}" + return 0 + fi + + echo -e "${BLUE}=== Migrating database from v${current_version} to v${target_version} ===${NC}" + echo "" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo -e "${CYAN}[DRY RUN] Would perform the following actions:${NC}" + echo "" + echo "1. Create backup at: ${BACKUP_DIR}/sessions_v${current_version}_.db" + echo "" + + if [[ ${target_version} -gt ${current_version} ]]; then + echo "2. Apply forward migrations:" + for version in $(seq $((current_version + 1)) ${target_version}); do + local migration_info=$(get_migration_info "${version}") + local migration_dir=$(find_migration_dir "${version}") + echo " - Migrate to v${version}: ${migration_info}" + echo " SQL file: ${migration_dir}/up.sql" + done + else + echo "2. Apply rollback migrations:" + for version in $(seq ${current_version} -1 $((target_version + 1))); do + local migration_info=$(get_migration_info "${version}") + local migration_dir=$(find_migration_dir "${version}") + echo " - Rollback from v${version}: ${migration_info}" + echo " SQL file: ${migration_dir}/down.sql" + done + fi + + echo "" + echo "3. Update schema_version table to ${target_version}" + echo "" + echo -e "${CYAN}[DRY RUN] No changes were made${NC}" + return 0 + fi + + if ! confirm_action "migrate database from v${current_version} to v${target_version}"; then + echo -e "${YELLOW}Migration cancelled${NC}" + return 2 + fi + + local backup_path=$(create_backup) + echo "" + + if [[ ${target_version} -gt ${current_version} ]]; then + for version in $(seq $((current_version + 1)) ${target_version}); do + local migration_info=$(get_migration_info "${version}") + echo -e "Applying migration to v${version}..." + apply_migration ${version} + echo -e "${GREEN}✓ Migrated to v${version}: ${migration_info}${NC}" + done + else + for version in $(seq ${current_version} -1 $((target_version + 1))); do + local migration_info=$(get_migration_info "${version}") + echo -e "Rolling back from v${version}..." + rollback_migration ${version} + echo -e "${GREEN}✓ Rolled back from v${version}${NC}" + done + fi + + echo "" + echo -e "${GREEN}✓ Migration complete!${NC}" + echo -e "Database is now at version ${target_version}" + echo "" + echo "Backup saved at: ${backup_path}" +} + +list_backups() { + if [[ ! -d "${BACKUP_DIR}" ]] || [[ -z "$(ls -A "${BACKUP_DIR}" 2>/dev/null)" ]]; then + echo -e "${YELLOW}No backups found${NC}" + return + fi + + echo -e "${BLUE}=== Available Backups ===${NC}" + echo "" + ls -lh "${BACKUP_DIR}" | tail -n +2 | while read -r line; do + local filename=$(echo "${line}" | awk '{print $NF}') + local size=$(echo "${line}" | awk '{print $5}') + local date=$(echo "${line}" | awk '{print $6, $7, $8}') + + if [[ "${filename}" =~ _v([0-9]+)_ ]]; then + local version="${BASH_REMATCH[1]}" + echo -e "${filename}" + echo -e " Size: ${size}, Date: ${date}, Schema: v${version}" + echo "" + else + echo -e "${filename}" + echo -e " Size: ${size}, Date: ${date}" + echo "" + fi + done +} + +restore_backup() { + local backup_file=$1 + + if [[ -z "${backup_file}" ]]; then + echo -e "${RED}ERROR: Please specify a backup file to restore${NC}" >&2 + echo "Usage: $0 restore " + echo "" + list_backups + exit 1 + fi + + if [[ ! -f "${backup_file}" ]]; then + echo -e "${RED}ERROR: Backup file not found: ${backup_file}${NC}" >&2 + exit 1 + fi + + check_db_exists + + if [[ "${DRY_RUN}" == "true" ]]; then + echo -e "${CYAN}[DRY RUN] Would perform the following actions:${NC}" + echo "" + echo "1. Create backup of current database at: ${BACKUP_DIR}/sessions_v_.db" + echo "2. Restore backup from: ${backup_file}" + echo "3. Replace current database at: ${DB_PATH}" + echo "" + echo -e "${CYAN}[DRY RUN] No changes were made${NC}" + return 0 + fi + + if ! confirm_action "restore backup from ${backup_file} (this will replace your current database)"; then + echo -e "${YELLOW}Restore cancelled${NC}" + return 2 + fi + + local current_backup=$(create_backup) + echo "" + + cp "${backup_file}" "${DB_PATH}" + echo -e "${GREEN}✓ Restored backup from: ${backup_file}${NC}" + echo "Current database backed up to: ${current_backup}" +} + +validate_sql_syntax() { + local sql=$1 + local file_desc=$2 + + if [[ -z "$sql" ]]; then + echo -e "${YELLOW}⚠ WARNING: Empty SQL in $file_desc${NC}" >&2 + return 1 + fi + + if ! echo "$sql" | grep -q ";"; then + echo -e "${YELLOW}⚠ WARNING: No semicolons found in $file_desc${NC}" >&2 + return 1 + fi + + local lines=$(echo "$sql" | grep -v "^--" | grep -v "^BEGIN" | grep -v "^COMMIT" | grep -v "^$") + while IFS= read -r line; do + if [[ -n "$line" ]]; then + if ! echo "$line" | grep -q ";$"; then + local next_line=$(echo "$lines" | grep -A1 "^$line$" | tail -1) + if [[ -n "$next_line" && ! "$next_line" =~ ^(BEGIN|COMMIT|INSERT|DELETE|$) ]]; then + echo -e "${YELLOW}⚠ WARNING: Possible missing semicolon in $file_desc:${NC}" >&2 + echo " $line" >&2 + return 1 + fi + fi + fi + done <<< "$lines" + + return 0 +} + +extract_migration_sql() { + local version=$1 + local rust_file=$2 + + awk -v ver="$version" ' + BEGIN { in_migration=0; sql=""; query_count=0; current_query="" } + /async fn apply_migration/ { found_func=1 } + found_func && $0 ~ ver " =>" { in_migration=1; next } + in_migration && /}$/ && !/=>/ { exit } + in_migration && /sqlx::query/ { + getline + if ($0 ~ /r#"/) { + if (current_query != "") { + if (sql != "") sql = sql ";\n" + sql = sql current_query + current_query = "" + } + getline + while ($0 !~ /"#/) { + if (current_query != "") current_query = current_query "\n" + current_query = current_query $0 + getline + } + query_count++ + } + } + END { + if (current_query != "") { + if (sql != "") sql = sql ";\n" + sql = sql current_query + } + if (sql != "") print sql ";" + } + ' "$rust_file" +} + +generate_rollback_sql() { + local version=$1 + local up_sql=$2 + + echo "BEGIN TRANSACTION;" + echo "" + + local statements=() + while IFS= read -r line; do + if [[ -n "$line" && "$line" != ";" ]]; then + statements+=("$line") + fi + done < <(echo "$up_sql" | sed 's/;$//' | awk 'BEGIN{RS=";"} {gsub(/^[ \t\n]+|[ \t\n]+$/, ""); if (length($0) > 0) print $0}') + + local rollback_stmts=() + local has_unsupported=false + + for stmt in "${statements[@]}"; do + if echo "$stmt" | grep -q "CREATE TABLE.*schema_version"; then + rollback_stmts+=("DROP TABLE IF EXISTS schema_version;") + elif echo "$stmt" | grep -q "RENAME COLUMN"; then + local table=$(echo "$stmt" | sed -n 's/.*ALTER TABLE \([^ ]*\).*/\1/p') + local old_col=$(echo "$stmt" | sed -n 's/.*RENAME COLUMN \([^ ]*\) TO.*/\1/p') + local new_col=$(echo "$stmt" | sed -n 's/.*TO \([^ ;]*\).*/\1/p') + rollback_stmts+=("ALTER TABLE $table RENAME COLUMN $new_col TO $old_col;") + elif echo "$stmt" | grep -q "ADD COLUMN"; then + local table=$(echo "$stmt" | sed -n 's/.*ALTER TABLE \([^ ]*\).*/\1/p') + local column=$(echo "$stmt" | sed -n 's/.*ADD COLUMN \([^ ]*\).*/\1/p') + rollback_stmts+=("ALTER TABLE $table DROP COLUMN $column;") + else + rollback_stmts+=("-- TODO: Unable to auto-generate rollback for: $stmt") + has_unsupported=true + fi + done + + for ((i=${#rollback_stmts[@]}-1; i>=0; i--)); do + echo "${rollback_stmts[$i]}" + done + + echo "" + echo "DELETE FROM schema_version WHERE version = $version;" + echo "" + echo "COMMIT;" + + if [[ "$has_unsupported" == "true" ]]; then + return 1 + fi +} + +generate_metadata() { + local version=$1 + local sql=$2 + local author=${USER:-system} + local date=$(date +%Y-%m-%d) + + local description="Migration $version" + if echo "$sql" | grep -q "CREATE TABLE.*schema_version"; then + description="Added schema_version tracking" + elif echo "$sql" | grep -q "ALTER TABLE.*ADD COLUMN"; then + local column=$(echo "$sql" | sed -n 's/.*ADD COLUMN \([^ ]*\).*/\1/p') + description="Added $column column" + elif echo "$sql" | grep -q "RENAME COLUMN"; then + local old_col=$(echo "$sql" | sed -n 's/.*RENAME COLUMN \([^ ]*\) TO.*/\1/p') + local new_col=$(echo "$sql" | sed -n 's/.*TO \([^ ]*\).*/\1/p') + description="Renamed $old_col to $new_col" + fi + + cat <&2 + echo "Make sure you're running this from the goose repository root." + exit 1 + fi + + echo -e "${BLUE}=== Generating Migrations from Rust Source ===${NC}" + echo "" + echo "Reading migrations from: ${RUST_SESSION_MANAGER}" + echo "Output directory: ${MIGRATIONS_DIR}" + echo "" + + mkdir -p "${MIGRATIONS_DIR}" + + local max_version=$(grep -E '^\s+[0-9]+ =>' "${RUST_SESSION_MANAGER}" | \ + sed 's/[^0-9]//g' | \ + sort -n | \ + tail -1) + + if [[ -z "$max_version" ]]; then + max_version=2 + fi + + local generated_count=0 + local skipped_count=0 + + for version in $(seq 1 $max_version); do + local padded_version=$(printf "%03d" $version) + local sql=$(extract_migration_sql "$version" "${RUST_SESSION_MANAGER}") + + if [[ -z "$sql" ]]; then + echo -e "${YELLOW}⚠ No SQL found for version $version, skipping...${NC}" + skipped_count=$((skipped_count + 1)) + continue + fi + + if ! validate_sql_syntax "$sql" "migration v$version"; then + echo -e "${YELLOW}⚠ Validation warning for version $version, but continuing...${NC}" + fi + + local migration_name + if echo "$sql" | grep -q "CREATE TABLE.*schema_version"; then + migration_name="add_schema_version" + elif echo "$sql" | grep -q "ALTER TABLE.*ADD COLUMN"; then + local column=$(echo "$sql" | sed -n 's/.*ADD COLUMN \([^ ]*\).*/\1/p' | head -1) + migration_name="add_${column}" + elif echo "$sql" | grep -q "RENAME COLUMN"; then + local old_col=$(echo "$sql" | sed -n 's/.*RENAME COLUMN \([^ ]*\) TO.*/\1/p') + local new_col=$(echo "$sql" | sed -n 's/.*TO \([^ ]*\).*/\1/p') + migration_name="rename_${old_col}_to_${new_col}" + else + migration_name="migration_${version}" + fi + + local migration_dir="${MIGRATIONS_DIR}/${padded_version}_${migration_name}" + mkdir -p "$migration_dir" + + echo "BEGIN TRANSACTION;" > "${migration_dir}/up.sql" + echo "" >> "${migration_dir}/up.sql" + echo "$sql" >> "${migration_dir}/up.sql" + echo "" >> "${migration_dir}/up.sql" + echo "INSERT INTO schema_version (version) VALUES ($version);" >> "${migration_dir}/up.sql" + echo "" >> "${migration_dir}/up.sql" + echo "COMMIT;" >> "${migration_dir}/up.sql" + + generate_rollback_sql "$version" "$sql" > "${migration_dir}/down.sql" + + generate_metadata "$version" "$sql" > "${migration_dir}/metadata.txt" + + echo -e "${GREEN}✓ Generated migration $padded_version: ${migration_dir##*/}${NC}" + generated_count=$((generated_count + 1)) + done + + echo "" + echo -e "${GREEN}✓ Generation complete!${NC}" + echo "Generated: $generated_count migrations" + if [[ $skipped_count -gt 0 ]]; then + echo "Skipped: $skipped_count migrations" + fi + echo "" + echo -e "${YELLOW}Note:${NC} Please review generated rollback SQL (down.sql) files." + echo "Some migrations may require manual rollback implementation." +} + +show_help() { + local latest_version=$(get_latest_version) + + echo -e "${BLUE}Goose Database Migration Helper${NC}" + echo "" + echo "This script is a developer utility for manually managing database schema" + echo "versions when switching between branches with different schema requirements." + echo "Migrations are stored in ${MIGRATIONS_DIR}." + echo "" + echo -e "${CYAN}Usage:${NC} $0 [flags] [arguments] [flags]" + echo "" + echo -e "${CYAN}Global Flags (can be placed before or after the command):${NC}" + echo -e " ${GREEN}--dry-run${NC}" + echo " Preview changes without modifying the database" + echo " Works with: migrate-to, restore" + echo "" + echo -e " ${GREEN}--yes, -y${NC}" + echo " Skip confirmation prompts (useful for automation)" + echo " Works with: migrate-to, restore" + echo "" + echo -e "${CYAN}Commands:${NC}" + echo -e " ${GREEN}status${NC}" + echo " Show current database schema version, table structure, and statistics" + echo "" + echo -e " ${GREEN}migrate-to ${NC}" + echo " Migrate database to a specific schema version (0-${latest_version})" + echo " Automatically handles forward migrations and rollbacks" + echo "" + echo -e " ${GREEN}history${NC}" + echo " Show all available migrations and their descriptions" + echo "" + echo -e " ${GREEN}generate-migrations${NC}" + echo " Auto-generate migration files from Rust source code (session_manager.rs)" + echo " Creates up.sql, down.sql, and metadata.txt for each migration" + echo "" + echo -e " ${GREEN}backup${NC}" + echo " Create a manual backup of the current database" + echo "" + echo -e " ${GREEN}list-backups${NC}" + echo " Show all available backups with their versions and sizes" + echo "" + echo -e " ${GREEN}restore ${NC}" + echo " Restore database from a backup file" + echo "" + echo -e " ${GREEN}help${NC}" + echo " Show this help message" + echo "" + echo -e "${CYAN}Examples:${NC}" + echo " # Check current status" + echo " $0 status" + echo "" + echo " # View all available migrations" + echo " $0 history" + echo "" + echo " # Preview migration without making changes (dry-run before)" + echo " $0 --dry-run migrate-to 3" + echo "" + echo " # Flags can also be placed after the command and arguments" + echo " $0 migrate-to 3 --dry-run" + echo "" + echo " # Migrate to version 2" + echo " $0 migrate-to 2" + echo "" + echo " # Rollback to version 1 without confirmation prompt" + echo " $0 migrate-to 1 --yes" + echo "" + echo " # Create a backup" + echo " $0 backup" + echo "" + echo -e "${CYAN}Adding New Migrations:${NC}" + echo " After adding a migration to session_manager.rs, run:" + echo "" + echo " $0 generate-migrations" + echo "" + echo " This will automatically extract migrations from the Rust source" + echo " and create the necessary SQL files in ${MIGRATIONS_DIR}." + echo "" + echo -e " ${YELLOW}Note:${NC} Review generated down.sql files, as some rollbacks" + echo -e " may require manual implementation." + echo "" + echo -e "${CYAN}Configuration:${NC}" + echo " Database: ${DB_PATH}" + echo " Backups: ${BACKUP_DIR}" + echo " Migrations: ${MIGRATIONS_DIR}" + echo " Latest: v${latest_version}" + echo "" + echo -e "${YELLOW}Note:${NC} All migrations automatically create backups before making changes." +} + +main() { + local non_flag_args=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + --yes|-y) + SKIP_CONFIRM=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + -*) + echo -e "${RED}ERROR: Unknown flag: $1${NC}" >&2 + echo "" + show_help + exit 1 + ;; + *) + non_flag_args+=("$1") + shift + ;; + esac + done + + local command=${non_flag_args[0]:-help} + + case "${command}" in + status) + show_status + ;; + migrate-to) + migrate_to_version "${non_flag_args[1]}" + ;; + history) + show_version_history + ;; + generate-migrations) + generate_migrations + ;; + backup) + create_backup + ;; + list-backups) + list_backups + ;; + restore) + restore_backup "${non_flag_args[1]}" + ;; + migrate) + local latest_version=$(get_latest_version) + echo -e "${YELLOW}Note: 'migrate' is deprecated. Use 'migrate-to ${latest_version}' instead.${NC}" + echo "" + migrate_to_version ${latest_version} + ;; + rollback) + echo -e "${YELLOW}Note: 'rollback' is deprecated. Use 'migrate-to ' instead.${NC}" + echo -e "${YELLOW}Use '$0 history' to see available versions.${NC}" + echo "" + show_version_history + ;; + compatible-with) + echo -e "${RED}ERROR: 'compatible-with' command has been removed.${NC}" >&2 + echo "" + echo "The script now uses a generic migration system." + echo "To migrate your database, use: $0 migrate-to " + echo "" + echo "Available migrations:" + show_version_history + exit 1 + ;; + help) + show_help + ;; + *) + echo -e "${RED}ERROR: Unknown command: ${command}${NC}" >&2 + echo "" + show_help + exit 1 + ;; + esac +} + +main "$@" From 0de346089f3e1a841e34d50b2210e022bf78f894 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 15 Oct 2025 19:06:48 -0400 Subject: [PATCH 05/16] Bug fixes when merging another DB migration from main branch --- crates/goose/src/session/session_manager.rs | 2 +- scripts/goose-db-helper.sh | 50 ++++++++++++++++++--- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 16c31d425813..164a2a6910bd 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -18,7 +18,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 3; +const CURRENT_SCHEMA_VERSION: i32 = 4; static SESSION_STORAGE: OnceCell> = OnceCell::const_new(); diff --git a/scripts/goose-db-helper.sh b/scripts/goose-db-helper.sh index d374e3d03e3a..96d3cf824092 100755 --- a/scripts/goose-db-helper.sh +++ b/scripts/goose-db-helper.sh @@ -13,6 +13,7 @@ NC='\033[0m' DRY_RUN=false SKIP_CONFIRM=false +CLEAN_GENERATE=false MIGRATIONS_DIR="${HOME}/.local/share/goose/migrations" RUST_SESSION_MANAGER="crates/goose/src/session/session_manager.rs" @@ -506,11 +507,7 @@ generate_rollback_sql() { echo "" local statements=() - while IFS= read -r line; do - if [[ -n "$line" && "$line" != ";" ]]; then - statements+=("$line") - fi - done < <(echo "$up_sql" | sed 's/;$//' | awk 'BEGIN{RS=";"} {gsub(/^[ \t\n]+|[ \t\n]+$/, ""); if (length($0) > 0) print $0}') + mapfile -d $'\0' -t statements < <(echo "$up_sql" | awk 'BEGIN{RS=";"} {gsub(/^[ \t\n]+|[ \t\n]+$/, ""); if (length($0) > 0) {print $0; printf "%c", 0}}') local rollback_stmts=() local has_unsupported=false @@ -586,6 +583,21 @@ generate_migrations() { echo "Output directory: ${MIGRATIONS_DIR}" echo "" + if [[ "${CLEAN_GENERATE}" == "true" ]]; then + if [[ -d "${MIGRATIONS_DIR}" ]]; then + local migration_count=$(find "${MIGRATIONS_DIR}" -mindepth 1 -maxdepth 1 -type d -name "[0-9]*" 2>/dev/null | wc -l) + if [[ ${migration_count} -gt 0 ]]; then + echo -e "${YELLOW}⚠ Clean mode: This will remove all ${migration_count} existing migration(s)${NC}" + if ! confirm_action "remove all existing migrations and regenerate from source"; then + echo -e "${YELLOW}Generation cancelled${NC}" + return 2 + fi + echo "Removing existing migrations..." + rm -rf "${MIGRATIONS_DIR}" + fi + fi + fi + mkdir -p "${MIGRATIONS_DIR}" local max_version=$(grep -E '^\s+[0-9]+ =>' "${RUST_SESSION_MANAGER}" | \ @@ -676,7 +688,12 @@ show_help() { echo "" echo -e " ${GREEN}--yes, -y${NC}" echo " Skip confirmation prompts (useful for automation)" - echo " Works with: migrate-to, restore" + echo " Works with: migrate-to, restore, generate-migrations --clean" + echo "" + echo -e " ${GREEN}--clean${NC}" + echo " Remove all existing migrations before regenerating" + echo " Works with: generate-migrations" + echo " Useful when switching between branches with different migrations" echo "" echo -e "${CYAN}Commands:${NC}" echo -e " ${GREEN}status${NC}" @@ -727,6 +744,12 @@ show_help() { echo " # Create a backup" echo " $0 backup" echo "" + echo " # Clean regenerate migrations (useful when switching branches)" + echo " $0 generate-migrations --clean" + echo "" + echo " # Clean regenerate without confirmation" + echo " $0 generate-migrations --clean --yes" + echo "" echo -e "${CYAN}Adding New Migrations:${NC}" echo " After adding a migration to session_manager.rs, run:" echo "" @@ -738,6 +761,17 @@ show_help() { echo -e " ${YELLOW}Note:${NC} Review generated down.sql files, as some rollbacks" echo -e " may require manual implementation." echo "" + echo -e "${CYAN}Switching Branches:${NC}" + echo " When switching between branches with different migrations:" + echo "" + echo " # Clean and regenerate to match current branch" + echo " git checkout main" + echo " $0 generate-migrations --clean" + echo "" + echo " # Or manually remove specific migrations" + echo " rm -rf ~/.local/share/goose/migrations/004_*" + echo " $0 generate-migrations" + echo "" echo -e "${CYAN}Configuration:${NC}" echo " Database: ${DB_PATH}" echo " Backups: ${BACKUP_DIR}" @@ -760,6 +794,10 @@ main() { SKIP_CONFIRM=true shift ;; + --clean) + CLEAN_GENERATE=true + shift + ;; --help|-h) show_help exit 0 From b53f98393d02befb3f0c8c49c28581e781bcaf81 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 15 Oct 2025 19:52:14 -0400 Subject: [PATCH 06/16] botched merge conflict resolution --- crates/goose/src/context_mgmt/auto_compact.rs | 767 ------------------ 1 file changed, 767 deletions(-) delete mode 100644 crates/goose/src/context_mgmt/auto_compact.rs diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs deleted file mode 100644 index d668685f011e..000000000000 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ /dev/null @@ -1,767 +0,0 @@ -use crate::conversation::message::Message; -use crate::conversation::Conversation; -use crate::{ - agents::Agent, config::Config, context_mgmt::get_messages_token_counts_async, - token_counter::create_async_token_counter, -}; -use anyhow::Result; -use tracing::{debug, info}; - -/// Result of auto-compaction check -#[derive(Debug)] -pub struct AutoCompactResult { - /// Whether compaction was performed - pub compacted: bool, - /// The messages after potential compaction - pub messages: Conversation, - /// Provider usage from summarization (if compaction occurred) - /// This contains the actual token counts after compaction - pub summarization_usage: Option, -} - -/// Result of checking if compaction is needed -#[derive(Debug)] -pub struct CompactionCheckResult { - /// Whether compaction is needed - pub needs_compaction: bool, - /// Current token count - pub current_tokens: usize, - /// Context limit being used - pub context_limit: usize, - /// Current usage ratio (0.0 to 1.0) - pub usage_ratio: f64, - /// Remaining tokens before compaction threshold - pub remaining_tokens: usize, - /// Percentage until compaction threshold (0.0 to 100.0) - pub percentage_until_compaction: f64, -} - -/// Check if messages need compaction without performing the compaction -/// -/// This function analyzes the current token usage and returns detailed information -/// about whether compaction is needed and how close we are to the threshold. -/// It prioritizes actual token counts from session metadata when available, -/// falling back to estimated counts if needed. -/// -/// # Arguments -/// * `agent` - The agent to use for context management -/// * `messages` - The current message history -/// * `threshold_override` - Optional threshold override (defaults to GOOSE_AUTO_COMPACT_THRESHOLD config) -/// * `session_metadata` - Optional session metadata containing actual token counts -/// -/// # Returns -/// * `CompactionCheckResult` containing detailed information about compaction needs -pub async fn check_compaction_needed( - agent: &Agent, - messages: &[Message], - threshold_override: Option, - session_metadata: Option<&crate::session::Session>, -) -> Result { - // Get threshold from config or use override - let config = Config::global(); - let threshold = threshold_override.unwrap_or_else(|| { - config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(0.8) // Default to 80% - }); - - let provider = agent.provider().await?; - let context_limit = provider.get_model_config().context_limit(); - - let (current_tokens, token_source) = match session_metadata.and_then(|m| m.total_tokens) { - Some(tokens) => (tokens as usize, "session metadata"), - None => { - let token_counter = create_async_token_counter() - .await - .map_err(|e| anyhow::anyhow!("Failed to create token counter: {}", e))?; - let token_counts = get_messages_token_counts_async(&token_counter, messages); - (token_counts.iter().sum(), "estimated") - } - }; - - // Calculate usage ratio - let usage_ratio = current_tokens as f64 / context_limit as f64; - - // Calculate threshold token count and remaining tokens - let threshold_tokens = (context_limit as f64 * threshold) as usize; - let remaining_tokens = threshold_tokens.saturating_sub(current_tokens); - - // Calculate percentage until compaction (how much more we can use before hitting threshold) - let percentage_until_compaction = if usage_ratio < threshold { - (threshold - usage_ratio) * 100.0 - } else { - 0.0 - }; - - // Check if compaction is needed (disabled if threshold is invalid) - let needs_compaction = if threshold <= 0.0 || threshold >= 1.0 { - false - } else { - usage_ratio > threshold - }; - - debug!( - "Compaction check: {} / {} tokens ({:.1}%), threshold: {:.1}%, needs compaction: {}, source: {}", - current_tokens, - context_limit, - usage_ratio * 100.0, - threshold * 100.0, - needs_compaction, - token_source - ); - - Ok(CompactionCheckResult { - needs_compaction, - current_tokens, - context_limit, - usage_ratio, - remaining_tokens, - percentage_until_compaction, - }) -} - -/// Perform compaction on messages without checking thresholds -/// -/// This function directly performs compaction on the provided messages. -/// If the most recent message is a user message, it will be preserved by removing it -/// before compaction and adding it back afterwards. -/// -/// # Arguments -/// * `agent` - The agent to use for context management -/// * `messages` - The current message history -/// -/// # Returns -/// * `AutoCompactResult` containing the compacted messages and metadata -pub async fn perform_compaction(agent: &Agent, messages: &[Message]) -> Result { - info!("Performing message compaction"); - - // Check if the most recent message is a user message - let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last() - { - if matches!(last_message.role, rmcp::model::Role::User) { - // Remove the last user message before compaction - (&messages[..messages.len() - 1], Some(last_message.clone())) - } else { - (messages, None) - } - } else { - (messages, None) - }; - - // Perform the compaction on messages excluding the preserved user message - let (mut compacted_messages, _, summarization_usage) = - agent.summarize_context(messages_to_compact).await?; - - // Add back the preserved user message if it exists - if let Some(user_message) = preserved_user_message { - compacted_messages.push(user_message); - } - - Ok(AutoCompactResult { - compacted: true, - messages: compacted_messages, - summarization_usage, - }) -} - -/// Check if messages need compaction and compact them if necessary -/// -/// This is a convenience wrapper function that combines checking and compaction. -/// Uses perform_compaction internally to handle the actual compaction process. -/// -/// # Arguments -/// * `agent` - The agent to use for context management -/// * `messages` - The current message history -/// * `threshold_override` - Optional threshold override (defaults to GOOSE_AUTO_COMPACT_THRESHOLD config) -/// * `session_metadata` - Optional session metadata containing actual token counts -/// -/// # Returns -/// * `AutoCompactResult` containing the potentially compacted messages and metadata -pub async fn check_and_compact_messages( - agent: &Agent, - messages: &[Message], - threshold_override: Option, - session_metadata: Option<&crate::session::Session>, -) -> Result { - // First check if compaction is needed - let check_result = - check_compaction_needed(agent, messages, threshold_override, session_metadata).await?; - - // If no compaction is needed, return early - if !check_result.needs_compaction { - debug!( - "No compaction needed (usage: {:.1}% <= {:.1}% threshold)", - check_result.usage_ratio * 100.0, - check_result.percentage_until_compaction - ); - return Ok(AutoCompactResult { - compacted: false, - messages: Conversation::new_unvalidated(messages.to_vec()), - summarization_usage: None, - }); - } - - info!( - "Auto-compacting messages (usage: {:.1}%)", - check_result.usage_ratio * 100.0 - ); - - perform_compaction(agent, messages).await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::conversation::message::{Message, MessageContent}; - use crate::session::extension_data; - use crate::{ - agents::Agent, - model::ModelConfig, - providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}, - providers::errors::ProviderError, - }; - use chrono::Utc; - use rmcp::model::{AnnotateAble, RawTextContent, Role, Tool}; - use std::sync::Arc; - - #[derive(Clone)] - struct MockProvider { - model_config: ModelConfig, - } - - #[async_trait::async_trait] - impl Provider for MockProvider { - fn metadata() -> ProviderMetadata { - ProviderMetadata::empty() - } - - fn get_model_config(&self) -> ModelConfig { - self.model_config.clone() - } - - async fn complete_with_model( - &self, - _model_config: &ModelConfig, - _system: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - // Return a short summary message - Ok(( - Message::new( - Role::Assistant, - Utc::now().timestamp(), - vec![MessageContent::Text( - RawTextContent { - text: "Summary of conversation".to_string(), - meta: None, - } - .no_annotation(), - )], - ), - ProviderUsage::new("mock".to_string(), Usage::default()), - )) - } - } - - fn create_test_message(text: &str) -> Message { - Message::new( - Role::User, - Utc::now().timestamp(), - vec![MessageContent::text(text.to_string())], - ) - } - - fn create_test_session_metadata( - message_count: usize, - working_dir: &str, - ) -> crate::session::Session { - use crate::conversation::Conversation; - use std::path::PathBuf; - - let mut conversation = Conversation::default(); - for i in 0..message_count { - conversation.push(create_test_message(format!("message {}", i).as_str())); - } - - crate::session::Session { - id: "test_session".to_string(), - working_dir: PathBuf::from(working_dir), - name: "Test session".to_string(), - user_set_name: false, - created_at: Default::default(), - updated_at: Default::default(), - schedule_id: Some("test_job".to_string()), - recipe: None, - total_tokens: Some(100), - input_tokens: Some(50), - output_tokens: Some(50), - accumulated_total_tokens: Some(100), - accumulated_input_tokens: Some(50), - accumulated_output_tokens: Some(50), - extension_data: extension_data::ExtensionData::new(), - conversation: Some(conversation), - message_count, - user_recipe_values: None, - } - } - - #[tokio::test] - async fn test_check_compaction_needed() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(Some(100_000)), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create small messages that won't trigger compaction - let messages = vec![create_test_message("Hello"), create_test_message("World")]; - - let result = check_compaction_needed(&agent, &messages, Some(0.3), None) - .await - .unwrap(); - - assert!(!result.needs_compaction); - assert!(result.current_tokens > 0); - assert!(result.context_limit > 0); - assert!(result.usage_ratio < 0.3); - assert!(result.remaining_tokens > 0); - assert!(result.percentage_until_compaction > 0.0); - } - - #[tokio::test] - async fn test_check_compaction_needed_disabled() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(Some(100_000)), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - let messages = vec![create_test_message("Hello")]; - - // Test with threshold 0 (disabled) - let result = check_compaction_needed(&agent, &messages, Some(0.0), None) - .await - .unwrap(); - - assert!(!result.needs_compaction); - - // Test with threshold 1.0 (disabled) - let result = check_compaction_needed(&agent, &messages, Some(1.0), None) - .await - .unwrap(); - - assert!(!result.needs_compaction); - } - - #[tokio::test] - async fn test_auto_compact_disabled() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(Some(10_000)), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - let messages = vec![create_test_message("Hello"), create_test_message("World")]; - - // Test with threshold 0 (disabled) - let result = check_and_compact_messages(&agent, &messages, Some(0.0), None) - .await - .unwrap(); - - assert!(!result.compacted); - assert_eq!(result.messages.len(), messages.len()); - assert!(result.summarization_usage.is_none()); - - // Test with threshold 1.0 (disabled) - let result = check_and_compact_messages(&agent, &messages, Some(1.0), None) - .await - .unwrap(); - - assert!(!result.compacted); - } - - #[tokio::test] - async fn test_auto_compact_below_threshold() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(Some(100_000)), // Increased to ensure overhead doesn't dominate - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create small messages that won't trigger compaction - let messages = vec![create_test_message("Hello"), create_test_message("World")]; - - let result = check_and_compact_messages(&agent, &messages, Some(0.3), None) - .await - .unwrap(); - - assert!(!result.compacted); - assert_eq!(result.messages.len(), messages.len()); - } - - #[tokio::test] - async fn test_auto_compact_above_threshold() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(30_000.into()), // Smaller context limit to make threshold easier to hit - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create messages that will exceed 30% of the context limit - // With 30k context limit, 30% is 9k tokens - let mut messages = Vec::new(); - - // Create much longer messages with more content to reach the threshold - for i in 0..300 { - messages.push(create_test_message(&format!( - "This is message number {} with significantly more content to increase token count substantially. \ - We need to ensure that our total token usage exceeds 30% of the available context \ - limit after accounting for system prompt and tools overhead. This message contains \ - multiple sentences to increase the token count substantially. Adding even more text here \ - to make sure we have enough tokens. Lorem ipsum dolor sit amet, consectetur adipiscing elit, \ - sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, \ - quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit \ - anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium \ - doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi \ - architecto beatae vitae dicta sunt explicabo.", - i - ))); - } - - let result = check_and_compact_messages(&agent, &messages, Some(0.3), None) - .await - .unwrap(); - - if !result.compacted { - eprintln!("Test failed - compaction not triggered"); - } - - assert!(result.compacted); - assert!(result.summarization_usage.is_some()); - - // Verify that summarization usage contains token counts - if let Some(usage) = &result.summarization_usage { - assert!(usage.usage.total_tokens.is_some()); - let after = usage.usage.total_tokens.unwrap_or(0) as usize; - assert!( - after > 0, - "Token count after compaction should be greater than 0" - ); - } - - // After visibility implementation, we keep all messages plus summary - // Original messages become user_visible only, summary becomes agent_visible only - assert!(result.messages.len() > messages.len()); - } - - #[tokio::test] - async fn test_auto_compact_respects_config() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(Some(30_000)), // Smaller context limit to make threshold easier to hit - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create enough messages to trigger compaction with low threshold - let mut messages = Vec::new(); - // With 30k context limit, after overhead we have ~27k usable tokens - // 10% of 27k = 2.7k tokens, so we need messages that exceed that - for i in 0..200 { - messages.push(create_test_message(&format!( - "Message {} with enough content to ensure we exceed 10% of the context limit. \ - Adding more content to increase token count substantially. This message contains \ - multiple sentences to increase the token count. We need to ensure that our total \ - token usage exceeds 10% of the available context limit after accounting for \ - system prompt and tools overhead.", - i - ))); - } - - // Set config value - let config = Config::global(); - config - .set_param("GOOSE_AUTO_COMPACT_THRESHOLD", serde_json::Value::from(0.1)) - .unwrap(); - - // Should use config value when no override provided - let result = check_and_compact_messages(&agent, &messages, None, None) - .await - .unwrap(); - - // Debug info if not compacted - if !result.compacted { - eprintln!("Test failed - compaction not triggered"); - } - - // With such a low threshold (10%), it should compact - assert!(result.compacted); - - // Clean up config - config - .set_param("GOOSE_AUTO_COMPACT_THRESHOLD", serde_json::Value::from(0.3)) - .unwrap(); - } - - #[tokio::test] - async fn test_auto_compact_uses_session_metadata() { - use crate::session::Session; - - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(10_000.into()), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create some test messages - let messages = vec![ - create_test_message("First message"), - create_test_message("Second message"), - ]; - - // Create session with specific token counts - #[allow(clippy::field_reassign_with_default)] - let mut session = Session::default(); - { - session.total_tokens = Some(8000); // High token count to trigger compaction - session.accumulated_total_tokens = Some(15000); // Even higher accumulated count - session.input_tokens = Some(5000); - session.output_tokens = Some(3000); - } - - // Test with session - should use total_tokens for compaction (not accumulated) - let result_with_metadata = check_compaction_needed( - &agent, - &messages, - Some(0.3), // 30% threshold - Some(&session), - ) - .await - .unwrap(); - - // With 8000 tokens and context limit around 10000, should trigger compaction - assert!(result_with_metadata.needs_compaction); - assert_eq!(result_with_metadata.current_tokens, 8000); - - // Test without session metadata - should use estimated tokens - let result_without_metadata = check_compaction_needed( - &agent, - &messages, - Some(0.3), // 30% threshold - None, - ) - .await - .unwrap(); - - // Without metadata, should use much lower estimated token count - assert!(!result_without_metadata.needs_compaction); - assert!(result_without_metadata.current_tokens < 8000); - - // Test with session that has only accumulated tokens (no total_tokens) - let mut session_metadata_no_total = Session::default(); - #[allow(clippy::field_reassign_with_default)] - { - session_metadata_no_total.accumulated_total_tokens = Some(7500); - } - - let result_with_no_total = check_compaction_needed( - &agent, - &messages, - Some(0.3), // 30% threshold - Some(&session_metadata_no_total), - ) - .await - .unwrap(); - - // Should fall back to estimation since total_tokens is None - assert!(!result_with_no_total.needs_compaction); - assert!(result_with_no_total.current_tokens < 7500); - - // Test with metadata that has no token counts - should fall back to estimation - let empty_metadata = Session::default(); - - let result_with_empty_metadata = check_compaction_needed( - &agent, - &messages, - Some(0.3), // 30% threshold - Some(&empty_metadata), - ) - .await - .unwrap(); - - // Should fall back to estimation - assert!(!result_with_empty_metadata.needs_compaction); - assert!(result_with_empty_metadata.current_tokens < 7500); - } - - #[tokio::test] - async fn test_auto_compact_end_to_end_with_metadata() { - use crate::session::Session; - - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(10_000.into()), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - // Create some test messages - let messages = vec![ - create_test_message("First message"), - create_test_message("Second message"), - create_test_message("Third message"), - create_test_message("Fourth message"), - create_test_message("Fifth message"), - ]; - - // Create session metadata with high token count to trigger compaction - let mut session = Session::default(); - #[allow(clippy::field_reassign_with_default)] - { - session.total_tokens = Some(9000); // High enough to trigger compaction - } - - // Test full compaction flow with session metadata - let result = check_and_compact_messages( - &agent, - &messages, - Some(0.3), // 30% threshold - Some(&session), - ) - .await - .unwrap(); - - // Should have triggered compaction - assert!(result.compacted); - assert!(result.summarization_usage.is_some()); - - // Verify the compacted messages are returned - assert!(!result.messages.is_empty()); - - // After visibility implementation, we keep all messages plus summary - // Original messages become user_visible only, summary becomes agent_visible only - assert!(result.messages.len() > messages.len()); - } - - #[tokio::test] - async fn test_auto_compact_with_comprehensive_session_metadata() { - let mock_provider = Arc::new(MockProvider { - model_config: ModelConfig::new("test-model") - .unwrap() - .with_context_limit(8_000.into()), - }); - - let agent = Agent::new(); - let _ = agent.update_provider(mock_provider).await; - - let messages = vec![ - create_test_message("Test message 1"), - create_test_message("Test message 2"), - create_test_message("Test message 3"), - ]; - - // Use the helper function to create comprehensive non-null session metadata - let comprehensive_metadata = create_test_session_metadata(3, "/test/working/dir"); - - // Verify the helper created non-null metadata - assert_eq!( - comprehensive_metadata - .clone() - .conversation - .unwrap_or_default() - .len(), - 3 - ); - assert_eq!( - comprehensive_metadata.working_dir.to_str().unwrap(), - "/test/working/dir" - ); - assert_eq!(comprehensive_metadata.name, "Test session"); - assert_eq!( - comprehensive_metadata.schedule_id, - Some("test_job".to_string()) - ); - assert_eq!(comprehensive_metadata.total_tokens, Some(100)); - assert_eq!(comprehensive_metadata.input_tokens, Some(50)); - assert_eq!(comprehensive_metadata.output_tokens, Some(50)); - assert_eq!(comprehensive_metadata.accumulated_total_tokens, Some(100)); - assert_eq!(comprehensive_metadata.accumulated_input_tokens, Some(50)); - assert_eq!(comprehensive_metadata.accumulated_output_tokens, Some(50)); - - // Test compaction with the comprehensive metadata (low token count, shouldn't compact) - let result_low_tokens = check_compaction_needed( - &agent, - &messages, - Some(0.7), // 70% threshold - Some(&comprehensive_metadata), - ) - .await - .unwrap(); - - assert!(!result_low_tokens.needs_compaction); - assert_eq!(result_low_tokens.current_tokens, 100); // Should use total_tokens from metadata - - // Create a modified version with high token count to trigger compaction - let mut high_token_metadata = create_test_session_metadata(5, "/test/working/dir"); - high_token_metadata.total_tokens = Some(6_000); // High enough to trigger compaction - high_token_metadata.input_tokens = Some(4_000); - high_token_metadata.output_tokens = Some(2_000); - high_token_metadata.accumulated_total_tokens = Some(12_000); - - let result_high_tokens = check_compaction_needed( - &agent, - &messages, - Some(0.7), // 70% threshold - Some(&high_token_metadata), - ) - .await - .unwrap(); - - assert!(result_high_tokens.needs_compaction); - assert_eq!(result_high_tokens.current_tokens, 6_000); // Should use total_tokens, not accumulated - - // Test that metadata fields are preserved correctly in edge cases - let mut edge_case_metadata = create_test_session_metadata(10, "/edge/case/dir"); - edge_case_metadata.total_tokens = None; // No total tokens - edge_case_metadata.accumulated_total_tokens = Some(7_000); // Has accumulated - - let result_edge_case = check_compaction_needed( - &agent, - &messages, - Some(0.5), // 50% threshold - Some(&edge_case_metadata), - ) - .await - .unwrap(); - - // Should fall back to estimation since total_tokens is None - assert!(result_edge_case.current_tokens < 7_000); - // With estimation, likely won't trigger compaction - assert!(!result_edge_case.needs_compaction); - } -} From 2b8bc8f3a76ccc7f175e7c244f9204eca0e00891 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 15 Oct 2025 20:02:21 -0400 Subject: [PATCH 07/16] linter findings --- crates/goose/src/session/session_manager.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 164a2a6910bd..41ea2eebb17a 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1214,7 +1214,7 @@ mod tests { let imported = storage.import_session(OLD_FORMAT_JSON).await.unwrap(); assert_eq!(imported.name, "Old format session"); - assert_eq!(imported.user_set_name, true); + assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } @@ -1230,7 +1230,7 @@ mod tests { .unwrap(); assert_eq!(user_session.name, "My Custom Name"); - assert_eq!(user_session.user_set_name, true); + assert!(user_session.user_set_name); let auto_session = storage .create_session(PathBuf::from("/tmp"), None) @@ -1238,7 +1238,7 @@ mod tests { .unwrap(); assert_eq!(auto_session.name, "CLI Session"); - assert_eq!(auto_session.user_set_name, false); + assert!(!auto_session.user_set_name); storage .apply_update( @@ -1248,6 +1248,6 @@ mod tests { .unwrap(); let updated = storage.get_session(&user_session.id, false).await.unwrap(); - assert_eq!(updated.user_set_name, true); + assert!(updated.user_set_name); } } From ae04e4e54ee9e729940674bb92b1e81d6c8f76c9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 13:59:13 -0400 Subject: [PATCH 08/16] missed this one when merging latest changes from main --- crates/goose/src/agents/chat_recall_extension.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/agents/chat_recall_extension.rs b/crates/goose/src/agents/chat_recall_extension.rs index b4b50534b0b4..8e3c08edbd67 100644 --- a/crates/goose/src/agents/chat_recall_extension.rs +++ b/crates/goose/src/agents/chat_recall_extension.rs @@ -114,7 +114,7 @@ impl ChatRecallClient { let mut output = format!( "Session: {} (ID: {})\nWorking Dir: {}\nTotal Messages: {}\n\n", - loaded_session.description, + loaded_session.name, sid, loaded_session.working_dir.display(), total From 4b7aa71d0f34837c0b876f6b72ae929379e25be3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 14:31:14 -0400 Subject: [PATCH 09/16] comment cleanup --- crates/goose-server/src/routes/schedule.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index e0c47cfdfcde..518252dc679e 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -68,10 +68,10 @@ fn default_limit() -> u32 { #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct SessionDisplayInfo { - id: String, // Derived from session_name (filename) - name: String, // From metadata.name - created_at: String, // Derived from session_name, in ISO 8601 format - working_dir: String, // from metadata.working_dir (as String) + id: String, + name: String, + created_at: String, + working_dir: String, schedule_id: Option, message_count: usize, total_tokens: Option, From 967d6c6df6b6efa043de79a36c0488573fb781de Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 14:40:16 -0400 Subject: [PATCH 10/16] We should still accept matching session ID -> name for backwards compatibility --- crates/goose-cli/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 637961219294..1035b4f01422 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -75,7 +75,7 @@ async fn get_session_id(identifier: Identifier) -> Result { sessions .into_iter() - .find(|s| s.name == name) + .find(|s| s.name == name || s.id == name) .map(|s| s.id) .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name)) } else if let Some(path) = identifier.path { From 484baeeececa511c9a59d4a3adb871df1856f2d6 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 14:59:13 -0400 Subject: [PATCH 11/16] Finish standardizing session naming: API, UI, and CLI search consistency --- crates/goose-server/src/openapi.rs | 4 +- crates/goose-server/src/routes/session.rs | 29 ++++----- ui/desktop/openapi.json | 64 +++++++++---------- ui/desktop/src/App.test.tsx | 2 +- ui/desktop/src/App.tsx | 2 +- ui/desktop/src/api/sdk.gen.ts | 22 +++---- ui/desktop/src/api/types.gen.ts | 46 ++++++------- ui/desktop/src/components/BaseChat2.tsx | 5 +- .../components/GooseSidebar/AppSidebar.tsx | 8 +-- .../sessions/SessionHistoryView.tsx | 5 +- .../src/components/sessions/SessionItem.tsx | 3 +- .../components/sessions/SessionListView.tsx | 23 ++++--- .../components/sessions/SessionsInsights.tsx | 3 +- ui/desktop/src/contexts/ChatContext.tsx | 2 +- ui/desktop/src/hooks/useAgent.ts | 4 +- ui/desktop/src/hooks/useChatEngine.test.ts | 2 +- ui/desktop/src/types/chat.ts | 2 +- ui/desktop/src/utils/sessionCompat.ts | 23 ------- 18 files changed, 109 insertions(+), 140 deletions(-) delete mode 100644 ui/desktop/src/utils/sessionCompat.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 73551c81271b..c56a701a2f90 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -355,7 +355,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::list_sessions, super::routes::session::get_session, super::routes::session::get_session_insights, - super::routes::session::update_session_description, + super::routes::session::update_session_name, super::routes::session::delete_session, super::routes::session::export_session, super::routes::session::import_session, @@ -398,7 +398,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::context::ContextManageResponse, super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, - super::routes::session::UpdateSessionDescriptionRequest, + super::routes::session::UpdateSessionNameRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, Message, MessageContent, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index d21ec3e59a01..97d711e7114c 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -22,9 +22,9 @@ pub struct SessionListResponse { #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct UpdateSessionDescriptionRequest { - /// Updated description (name) for the session (max 200 characters) - description: String, +pub struct UpdateSessionNameRequest { + /// Updated name for the session (max 200 characters) + name: String, } #[derive(Deserialize, ToSchema)] @@ -40,7 +40,7 @@ pub struct ImportSessionRequest { json: String, } -const MAX_DESCRIPTION_LENGTH: usize = 200; +const MAX_NAME_LENGTH: usize = 200; #[utoipa::path( get, @@ -109,14 +109,14 @@ async fn get_session_insights() -> Result, StatusCode> { #[utoipa::path( put, - path = "/sessions/{session_id}/description", - request_body = UpdateSessionDescriptionRequest, + path = "/sessions/{session_id}/name", + request_body = UpdateSessionNameRequest, params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( - (status = 200, description = "Session description updated successfully"), - (status = 400, description = "Bad request - Description too long (max 200 characters)"), + (status = 200, description = "Session name updated successfully"), + (status = 400, description = "Bad request - Name too long (max 200 characters)"), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") @@ -126,16 +126,16 @@ async fn get_session_insights() -> Result, StatusCode> { ), tag = "Session Management" )] -async fn update_session_description( +async fn update_session_name( Path(session_id): Path, - Json(request): Json, + Json(request): Json, ) -> Result { - if request.description.len() > MAX_DESCRIPTION_LENGTH { + if request.name.len() > MAX_NAME_LENGTH { return Err(StatusCode::BAD_REQUEST); } SessionManager::update_session(&session_id) - .name(request.description) + .name(request.name) .apply() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -264,10 +264,7 @@ pub fn routes(state: Arc) -> Router { .route("/sessions/{session_id}/export", get(export_session)) .route("/sessions/import", post(import_session)) .route("/sessions/insights", get(get_session_insights)) - .route( - "/sessions/{session_id}/description", - put(update_session_description), - ) + .route("/sessions/{session_id}/name", put(update_session_name)) .route( "/sessions/{session_id}/user_recipe_values", put(update_session_user_recipe_values), diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e07112214b9c..0f8d85040e3a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1886,12 +1886,12 @@ ] } }, - "/sessions/{session_id}/description": { - "put": { + "/sessions/{session_id}/export": { + "get": { "tags": [ "Session Management" ], - "operationId": "update_session_description", + "operationId": "export_session", "parameters": [ { "name": "session_id", @@ -1903,22 +1903,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSessionDescriptionRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Session description updated successfully" - }, - "400": { - "description": "Bad request - Description too long (max 200 characters)" + "description": "Session exported successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } }, "401": { "description": "Unauthorized - Invalid or missing API key" @@ -1937,12 +1931,12 @@ ] } }, - "/sessions/{session_id}/export": { - "get": { + "/sessions/{session_id}/name": { + "put": { "tags": [ "Session Management" ], - "operationId": "export_session", + "operationId": "update_session_name", "parameters": [ { "name": "session_id", @@ -1954,17 +1948,23 @@ } } ], - "responses": { - "200": { - "description": "Session exported successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionNameRequest" } } }, + "required": true + }, + "responses": { + "200": { + "description": "Session name updated successfully" + }, + "400": { + "description": "Bad request - Name too long (max 200 characters)" + }, "401": { "description": "Unauthorized - Invalid or missing API key" }, @@ -4688,15 +4688,15 @@ } } }, - "UpdateSessionDescriptionRequest": { + "UpdateSessionNameRequest": { "type": "object", "required": [ - "description" + "name" ], "properties": { - "description": { + "name": { "type": "string", - "description": "Updated description (name) for the session (max 200 characters)" + "description": "Updated name for the session (max 200 characters)" } } }, diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index 6889f4155883..1955821280d4 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -120,7 +120,7 @@ vi.mock('./contexts/ChatContext', () => ({ useChatContext: () => ({ chat: { id: 'test-id', - title: 'Test Chat', + name: 'Test Chat', messages: [], messageHistoryIndex: 0, recipe: null, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 403a21af8bb1..394e8df99b7c 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -302,7 +302,7 @@ export function AppInner() { const [chat, setChat] = useState({ sessionId: '', - title: 'Pair Chat', + name: 'Pair Chat', messages: [], messageHistoryIndex: 0, recipe: null, diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c87f1dbc69b7..26f699243e69 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetCustomProviderData, GetCustomProviderResponses, GetCustomProviderErrors, UpdateCustomProviderData, UpdateCustomProviderResponses, UpdateCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetCustomProviderData, GetCustomProviderResponses, GetCustomProviderErrors, UpdateCustomProviderData, UpdateCustomProviderResponses, UpdateCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionNameData, UpdateSessionNameResponses, UpdateSessionNameErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -504,9 +504,16 @@ export const getSession = (options: Option }); }; -export const updateSessionDescription = (options: Options) => { - return (options.client ?? _heyApiClient).put({ - url: '/sessions/{session_id}/description', +export const exportSession = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/sessions/{session_id}/export', + ...options + }); +}; + +export const updateSessionName = (options: Options) => { + return (options.client ?? _heyApiClient).put({ + url: '/sessions/{session_id}/name', ...options, headers: { 'Content-Type': 'application/json', @@ -515,13 +522,6 @@ export const updateSessionDescription = (o }); }; -export const exportSession = (options: Options) => { - return (options.client ?? _heyApiClient).get({ - url: '/sessions/{session_id}/export', - ...options - }); -}; - export const updateSessionUserRecipeValues = (options: Options) => { return (options.client ?? _heyApiClient).put({ url: '/sessions/{session_id}/user_recipe_values', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f17d6e79b842..6405bcef4fae 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -887,11 +887,11 @@ export type UpdateScheduleRequest = { cron: string; }; -export type UpdateSessionDescriptionRequest = { +export type UpdateSessionNameRequest = { /** - * Updated description (name) for the session (max 200 characters) + * Updated name for the session (max 200 characters) */ - description: string; + name: string; }; export type UpdateSessionUserRecipeValuesRequest = { @@ -2392,8 +2392,8 @@ export type GetSessionResponses = { export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; -export type UpdateSessionDescriptionData = { - body: UpdateSessionDescriptionRequest; +export type ExportSessionData = { + body?: never; path: { /** * Unique identifier for the session @@ -2401,14 +2401,10 @@ export type UpdateSessionDescriptionData = { session_id: string; }; query?: never; - url: '/sessions/{session_id}/description'; + url: '/sessions/{session_id}/export'; }; -export type UpdateSessionDescriptionErrors = { - /** - * Bad request - Description too long (max 200 characters) - */ - 400: unknown; +export type ExportSessionErrors = { /** * Unauthorized - Invalid or missing API key */ @@ -2423,15 +2419,17 @@ export type UpdateSessionDescriptionErrors = { 500: unknown; }; -export type UpdateSessionDescriptionResponses = { +export type ExportSessionResponses = { /** - * Session description updated successfully + * Session exported successfully */ - 200: unknown; + 200: string; }; -export type ExportSessionData = { - body?: never; +export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; + +export type UpdateSessionNameData = { + body: UpdateSessionNameRequest; path: { /** * Unique identifier for the session @@ -2439,10 +2437,14 @@ export type ExportSessionData = { session_id: string; }; query?: never; - url: '/sessions/{session_id}/export'; + url: '/sessions/{session_id}/name'; }; -export type ExportSessionErrors = { +export type UpdateSessionNameErrors = { + /** + * Bad request - Name too long (max 200 characters) + */ + 400: unknown; /** * Unauthorized - Invalid or missing API key */ @@ -2457,15 +2459,13 @@ export type ExportSessionErrors = { 500: unknown; }; -export type ExportSessionResponses = { +export type UpdateSessionNameResponses = { /** - * Session exported successfully + * Session name updated successfully */ - 200: string; + 200: unknown; }; -export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; - export type UpdateSessionUserRecipeValuesData = { body: UpdateSessionUserRecipeValuesRequest; path: { diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 20efce636cbe..68ca077671ce 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -18,7 +18,6 @@ import { useSidebar } from './ui/sidebar'; import { cn } from '../utils'; import { useChatStream } from '../hooks/useChatStream'; import { loadSession } from '../utils/sessionCache'; -import { getSessionName } from '../utils/sessionCompat'; interface BaseChatProps { chat: ChatType; @@ -95,7 +94,7 @@ function BaseChatContent({ // todo: set to null instead and handle that in other places const emptyChat: ChatType = { sessionId: resumeSessionId, - title: 'Loading...', + name: 'Loading...', messageHistoryIndex: 0, messages: [], recipe: null, @@ -108,7 +107,7 @@ function BaseChatContent({ const conversation = session.conversation || []; const loadedChat: ChatType = { sessionId: session.id, - title: getSessionName(session) || 'Untitled Chat', + name: session.name || 'Untitled Chat', messageHistoryIndex: 0, messages: conversation, recipe: null, diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 89efec1c5d9c..67d59bd18e8a 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -116,16 +116,16 @@ const AppSidebar: React.FC = ({ currentPath }) => { if ( currentPath === '/pair' && - chatContext?.chat?.title && - chatContext.chat.title !== DEFAULT_CHAT_TITLE + chatContext?.chat?.name && + chatContext.chat.name !== DEFAULT_CHAT_TITLE ) { - titleBits.push(chatContext.chat.title); + titleBits.push(chatContext.chat.name); } else if (currentPath !== '/' && currentItem) { titleBits.push(currentItem.label); } document.title = titleBits.join(' - '); - }, [currentPath, chatContext?.chat?.title]); + }, [currentPath, chatContext?.chat?.name]); const isActivePath = (path: string) => { return currentPath === path; diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 10072731235f..e6ffbd135af7 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -18,7 +18,6 @@ import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { ScrollArea } from '../ui/scroll-area'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { createSharedSession } from '../../sharedSessions'; -import { getSessionName } from '../../utils/sessionCompat'; import { Dialog, DialogContent, @@ -186,7 +185,7 @@ const SessionHistoryView: React.FC = ({ config.baseUrl, session.working_dir, messages, - getSessionName(session) || 'Shared Session', + session.name || 'Shared Session', session.total_tokens || 0 ); @@ -271,7 +270,7 @@ const SessionHistoryView: React.FC = ({
diff --git a/ui/desktop/src/components/sessions/SessionItem.tsx b/ui/desktop/src/components/sessions/SessionItem.tsx index a7d241c38797..e8a010c91b28 100644 --- a/ui/desktop/src/components/sessions/SessionItem.tsx +++ b/ui/desktop/src/components/sessions/SessionItem.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Card } from '../ui/card'; import { formatDate } from '../../utils/date'; import { Session } from '../../api'; -import { getSessionName } from '../../utils/sessionCompat'; interface SessionItemProps { session: Session; @@ -13,7 +12,7 @@ const SessionItem: React.FC = ({ session, extraActions }) => { return (
-
{getSessionName(session)}
+
{session.name}
{formatDate(session.updated_at)} • {session.message_count} messages
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 9f8a38afaba2..d137db74f7bc 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -21,14 +21,13 @@ import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; import { Skeleton } from '../ui/skeleton'; import { toast } from 'react-toastify'; import { ConfirmationModal } from '../ui/ConfirmationModal'; -import { getSessionName } from '../../utils/sessionCompat'; import { deleteSession, exportSession, importSession, listSessions, Session, - updateSessionDescription, + updateSessionName, } from '../../api'; interface EditSessionModalProps { @@ -46,7 +45,7 @@ const EditSessionModal = React.memo( useEffect(() => { if (session && isOpen) { - setDescription(getSessionName(session)); + setDescription(session.name); } else if (!isOpen) { // Reset state when modal closes setDescription(''); @@ -58,16 +57,16 @@ const EditSessionModal = React.memo( if (!session || disabled) return; const trimmedDescription = description.trim(); - if (trimmedDescription === getSessionName(session)) { + if (trimmedDescription === session.name) { onClose(); return; } setIsUpdating(true); try { - await updateSessionDescription({ + await updateSessionName({ path: { session_id: session.id }, - body: { description: trimmedDescription }, + body: { name: trimmedDescription }, throwOnError: true, }); await onSave(session.id, trimmedDescription); @@ -81,7 +80,7 @@ const EditSessionModal = React.memo( const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; console.error('Failed to update session description:', errorMessage); toast.error(`Failed to update session description: ${errorMessage}`); - setDescription(getSessionName(session)); + setDescription(session.name); } finally { setIsUpdating(false); } @@ -334,7 +333,7 @@ const SessionListView: React.FC = React.memo( startTransition(() => { const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase(); const filtered = sessions.filter((session) => { - const description = getSessionName(session); + const description = session.name; const workingDir = session.working_dir; const sessionId = session.id; @@ -417,7 +416,7 @@ const SessionListView: React.FC = React.memo( setShowDeleteConfirmation(false); const sessionToDeleteId = sessionToDelete.id; - const sessionName = getSessionName(sessionToDelete); + const sessionName = sessionToDelete.name; setSessionToDelete(null); try { @@ -452,7 +451,7 @@ const SessionListView: React.FC = React.memo( const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${getSessionName(session)}.json`; + a.download = `${session.name}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -558,7 +557,7 @@ const SessionListView: React.FC = React.memo(
-

{getSessionName(session)}

+

{session.name}

@@ -805,7 +804,7 @@ const SessionListView: React.FC = React.memo( (null); @@ -335,7 +334,7 @@ export function SessionInsights() { >
- {getSessionName(session)} + {session.name}
{formatDateOnly(session.updated_at)} diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 7e4cf90faffe..a23dc66ecd60 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -55,7 +55,7 @@ export const ChatProvider: React.FC = ({ const resetChat = () => { setChat({ sessionId: '', - title: DEFAULT_CHAT_TITLE, + name: DEFAULT_CHAT_TITLE, messages: [], messageHistoryIndex: 0, recipe: null, diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 970663ae05cb..df55e17e7abe 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -81,7 +81,7 @@ export function useAgent(): UseAgentReturn { const messages = agentSession.conversation || []; return { sessionId: agentSession.id, - title: agentSession.recipe?.title || agentSession.name, + name: agentSession.recipe?.title || agentSession.name, messageHistoryIndex: 0, messages, recipe: agentSession.recipe, @@ -182,7 +182,7 @@ export function useAgent(): UseAgentReturn { const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation; let initChat: ChatType = { sessionId: agentSession.id, - title: agentSession.recipe?.title || agentSession.name, + name: agentSession.recipe?.title || agentSession.name, messageHistoryIndex: 0, messages: messages, recipe: recipe, diff --git a/ui/desktop/src/hooks/useChatEngine.test.ts b/ui/desktop/src/hooks/useChatEngine.test.ts index f1241a1ff2b2..d62befeb9dc1 100644 --- a/ui/desktop/src/hooks/useChatEngine.test.ts +++ b/ui/desktop/src/hooks/useChatEngine.test.ts @@ -131,7 +131,7 @@ describe('useChatEngine', () => { const mockChat: ChatType = { sessionId: 'test-chat', messages: initialMessages, - title: 'Test Chat', + name: 'Test Chat', messageHistoryIndex: 0, }; diff --git a/ui/desktop/src/types/chat.ts b/ui/desktop/src/types/chat.ts index 70130ebcf935..5a9f994eacbc 100644 --- a/ui/desktop/src/types/chat.ts +++ b/ui/desktop/src/types/chat.ts @@ -3,7 +3,7 @@ import { Message } from '../api'; export interface ChatType { sessionId: string; - title: string; + name: string; messageHistoryIndex: number; messages: Message[]; recipe?: Recipe | null; // Add recipe configuration to chat state diff --git a/ui/desktop/src/utils/sessionCompat.ts b/ui/desktop/src/utils/sessionCompat.ts deleted file mode 100644 index 8d247e695597..000000000000 --- a/ui/desktop/src/utils/sessionCompat.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Session } from '../api/types.gen'; - -/** - * Get the display name for a session, handling both old and new formats - * @param session - Session object that may have either 'name' or 'description' field - * @returns The session's display name - */ -export function getSessionName(session: Session | null | undefined): string { - if (!session) return ''; - // Check for 'name' first (new format), then fall back to 'description' (old format), then id - // @ts-expect-error - description might not exist in newer types but can exist in old session data - return session.name || session.description || session.id; -} - -/** - * Check if a session has a user-provided name - * @param session - Session object - * @returns true if the session has a user-set name - */ -export function hasUserSetName(session: Session | null | undefined): boolean { - if (!session) return false; - return session.user_set_name === true; -} From 7bdafc80b3de2fb735cfad3c60bdbd7b7527bfcb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 16:13:35 -0400 Subject: [PATCH 12/16] Get rid of unnecessary public user_set_name function --- crates/goose/src/session/session_manager.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index b1724558a5fd..37f2d1af4abe 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -93,13 +93,17 @@ impl SessionUpdateBuilder { } } + /// User-provided name (prevents LLM from overwriting it) pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); + self.user_set_name = Some(true); self } - pub fn user_set_name(mut self, user_set: bool) -> Self { - self.user_set_name = Some(user_set); + /// System-generated name (allows LLM to update it later) + pub fn system_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self.user_set_name = Some(false); self } @@ -176,6 +180,7 @@ impl SessionManager { .map(Arc::clone) } + /// `None` allows LLM to automatically generate the name, `Some` preserves the user's choice. pub async fn create_session(working_dir: PathBuf, name: Option) -> Result { Self::instance() .await? @@ -248,7 +253,7 @@ impl SessionManager { if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION { let name = provider.generate_session_name(&conversation).await?; - Self::update_session(id).name(name).apply().await + Self::update_session(id).system_name(name).apply().await } else { Ok(()) } From f5fa1396bbdfe6aa1726f5bcb1ff295bd5470a93 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 16 Oct 2025 16:28:48 -0400 Subject: [PATCH 13/16] clarify serde alias --- crates/goose/src/session/session_manager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 37f2d1af4abe..64a3bbe2175e 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -27,6 +27,7 @@ pub struct Session { pub id: String, #[schema(value_type = String)] pub working_dir: PathBuf, + // Allow importing session exports from before 'description' was renamed to 'name' #[serde(alias = "description")] pub name: String, #[serde(default)] From 44c9a156982993add8f0bec648d21bfbc9f777a2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 17 Oct 2025 14:13:24 -0400 Subject: [PATCH 14/16] Name should be required when starting a session --- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/builder.rs | 9 +- crates/goose-server/src/routes/agent.rs | 2 +- crates/goose-server/src/routes/session.rs | 8 +- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 4 +- crates/goose/src/session/session_manager.rs | 118 ++++++------------ .../sessions/SessionHistoryView.tsx | 2 +- 8 files changed, 59 insertions(+), 88 deletions(-) diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index e06bc53395c2..636b2590ebbb 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -225,7 +225,7 @@ pub async fn handle_web( async fn serve_index() -> Result { let session = SessionManager::create_session( std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - Some("Web session".to_string()), + "Web session".to_string(), ) .await .map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 24995ec4e81d..1a5fda66e216 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -311,9 +311,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } else if let Some(session_id) = session_config.session_id { Some(session_id) } else { - let session = SessionManager::create_session(std::env::current_dir().unwrap(), None) - .await - .unwrap(); + let session = SessionManager::create_session( + std::env::current_dir().unwrap(), + "CLI session".to_string(), + ) + .await + .unwrap(); Some(session.id) }; diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 68118f9b8ba3..7f9e7a9c5b3b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -141,7 +141,7 @@ async fn start_agent( let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; let name = format!("New session {}", counter); - let mut session = SessionManager::create_session(PathBuf::from(&working_dir), Some(name)) + let mut session = SessionManager::create_session(PathBuf::from(&working_dir), name) .await .map_err(|err| { error!("Failed to create session: {}", err); diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 97d711e7114c..d32a9e88fef5 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -130,12 +130,16 @@ async fn update_session_name( Path(session_id): Path, Json(request): Json, ) -> Result { - if request.name.len() > MAX_NAME_LENGTH { + let name = request.name.trim(); + if name.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + if name.len() > MAX_NAME_LENGTH { return Err(StatusCode::BAD_REQUEST); } SessionManager::update_session(&session_id) - .name(request.name) + .user_provided_name(name.to_string()) .apply() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index f0016393b906..ad14f282f1b7 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -103,7 +103,7 @@ fn get_agent_messages( let working_dir = task_config.parent_working_dir; let session = SessionManager::create_session( working_dir.clone(), - Some(format!("Subagent task for: {}", parent_session_id)), + format!("Subagent task for: {}", parent_session_id), ) .await .map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index ac4c4b96c6ad..a339731498fe 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1174,11 +1174,11 @@ async fn run_scheduled_job_internal( // Create session upfront for both cases let session = match SessionManager::create_session( current_dir.clone(), - Some(if recipe.prompt.is_some() { + if recipe.prompt.is_some() { format!("Scheduled job: {}", job.id) } else { "Empty job - no prompt".to_string() - }), + }, ) .await { diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 64a3bbe2175e..f2b9dd50df79 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -94,17 +94,21 @@ impl SessionUpdateBuilder { } } - /// User-provided name (prevents LLM from overwriting it) - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self.user_set_name = Some(true); + pub fn user_provided_name(mut self, name: impl Into) -> Self { + let name = name.into().trim().to_string(); + if !name.is_empty() { + self.name = Some(name); + self.user_set_name = Some(true); + } self } - /// System-generated name (allows LLM to update it later) - pub fn system_name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self.user_set_name = Some(false); + pub fn system_generated_name(mut self, name: impl Into) -> Self { + let name = name.into().trim().to_string(); + if !name.is_empty() { + self.name = Some(name); + self.user_set_name = Some(false); + } self } @@ -181,8 +185,7 @@ impl SessionManager { .map(Arc::clone) } - /// `None` allows LLM to automatically generate the name, `Some` preserves the user's choice. - pub async fn create_session(working_dir: PathBuf, name: Option) -> Result { + pub async fn create_session(working_dir: PathBuf, name: String) -> Result { Self::instance() .await? .create_session(working_dir, name) @@ -254,7 +257,10 @@ impl SessionManager { if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION { let name = provider.generate_session_name(&conversation).await?; - Self::update_session(id).system_name(name).apply().await + Self::update_session(id) + .system_generated_name(name) + .apply() + .await } else { Ok(()) } @@ -673,12 +679,8 @@ impl SessionStorage { Ok(()) } - async fn create_session(&self, working_dir: PathBuf, name: Option) -> Result { + async fn create_session(&self, working_dir: PathBuf, name: String) -> Result { let today = chrono::Utc::now().format("%Y%m%d").to_string(); - let (session_name, user_set_name) = match name { - Some(n) => (n, true), - None => ("CLI Session".to_string(), false), - }; let session_id = sqlx::query_as( r#" INSERT INTO sessions (id, name, user_set_name, working_dir, extension_data) @@ -689,7 +691,7 @@ impl SessionStorage { WHERE id LIKE ? || '_%' ), 0) + 1 AS TEXT), ?, - ?, + FALSE, ?, '{}' ) @@ -698,8 +700,7 @@ impl SessionStorage { ) .bind(&today) .bind(&today) - .bind(&session_name) - .bind(user_set_name) + .bind(&name) .bind(working_dir.to_string_lossy().as_ref()) .fetch_one(&self.pool) .await?; @@ -995,30 +996,26 @@ impl SessionStorage { let import: Session = serde_json::from_str(json)?; let session = self - .create_session( - import.working_dir.clone(), - if import.user_set_name { - Some(import.name.clone()) - } else { - None - }, - ) + .create_session(import.working_dir.clone(), import.name.clone()) .await?; - self.apply_update( - SessionUpdateBuilder::new(session.id.clone()) - .extension_data(import.extension_data) - .total_tokens(import.total_tokens) - .input_tokens(import.input_tokens) - .output_tokens(import.output_tokens) - .accumulated_total_tokens(import.accumulated_total_tokens) - .accumulated_input_tokens(import.accumulated_input_tokens) - .accumulated_output_tokens(import.accumulated_output_tokens) - .schedule_id(import.schedule_id) - .recipe(import.recipe) - .user_recipe_values(import.user_recipe_values), - ) - .await?; + let mut builder = SessionUpdateBuilder::new(session.id.clone()) + .extension_data(import.extension_data) + .total_tokens(import.total_tokens) + .input_tokens(import.input_tokens) + .output_tokens(import.output_tokens) + .accumulated_total_tokens(import.accumulated_total_tokens) + .accumulated_input_tokens(import.accumulated_input_tokens) + .accumulated_output_tokens(import.accumulated_output_tokens) + .schedule_id(import.schedule_id) + .recipe(import.recipe) + .user_recipe_values(import.user_recipe_values); + + if import.user_set_name { + builder = builder.user_provided_name(import.name.clone()); + } + + self.apply_update(builder).await?; if let Some(conversation) = import.conversation { self.replace_conversation(&session.id, &conversation) @@ -1053,7 +1050,7 @@ mod tests { let description = format!("Test session {}", i); let session = session_storage - .create_session(working_dir.clone(), Some(description)) + .create_session(working_dir.clone(), description) .await .unwrap(); @@ -1088,7 +1085,7 @@ mod tests { session_storage .apply_update( SessionUpdateBuilder::new(session.id.clone()) - .name(format!("Updated session {}", i)) + .user_provided_name(format!("Updated session {}", i)) .total_tokens(Some(100 * i)), ) .await @@ -1145,7 +1142,7 @@ mod tests { let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); let original = storage - .create_session(PathBuf::from("/tmp/test"), Some(DESCRIPTION.to_string())) + .create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string()) .await .unwrap(); @@ -1229,37 +1226,4 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } - - #[tokio::test] - async fn test_user_set_name_flag() { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join("test_user_name.db"); - let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); - - let user_session = storage - .create_session(PathBuf::from("/tmp"), Some("My Custom Name".to_string())) - .await - .unwrap(); - - assert_eq!(user_session.name, "My Custom Name"); - assert!(user_session.user_set_name); - - let auto_session = storage - .create_session(PathBuf::from("/tmp"), None) - .await - .unwrap(); - - assert_eq!(auto_session.name, "CLI Session"); - assert!(!auto_session.user_set_name); - - storage - .apply_update( - SessionUpdateBuilder::new(user_session.id.clone()).total_tokens(Some(100)), - ) - .await - .unwrap(); - - let updated = storage.get_session(&user_session.id, false).await.unwrap(); - assert!(updated.user_set_name); - } } diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index e6ffbd135af7..ce3cd0844ba1 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -270,7 +270,7 @@ const SessionHistoryView: React.FC = ({
From 43d94168beeb2d8edbc8e9670b6c6c5bd10e4e66 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 17 Oct 2025 15:59:03 -0400 Subject: [PATCH 15/16] Unrelated redundant condition check that Claude Code found --- crates/goose/src/session/session_manager.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index f2b9dd50df79..bec7c1607228 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -782,9 +782,7 @@ impl SessionStorage { return Ok(()); } - if !updates.is_empty() { - query.push_str(", "); - } + query.push_str(", "); query.push_str("updated_at = datetime('now') WHERE id = ?"); let mut q = sqlx::query(&query); From 2389e98f16643c84b8093d15b59effe14734f68e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 17 Oct 2025 16:31:14 -0400 Subject: [PATCH 16/16] silly LLM change --- crates/goose-cli/src/session/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 0942e9173198..c078990f8523 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -313,7 +313,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } else { let session = SessionManager::create_session( std::env::current_dir().unwrap(), - "CLI session".to_string(), + "CLI Session".to_string(), ) .await .unwrap();