From b335443d431d67c3889dfe91f4ea9b8357a7d434 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 20:13:19 +1000 Subject: [PATCH 1/6] extract list sessions --- crates/goose/src/acp/server.rs | 159 +----------------- crates/goose/src/acp/server/list_sessions.rs | 165 +++++++++++++++++++ 2 files changed, 167 insertions(+), 157 deletions(-) create mode 100644 crates/goose/src/acp/server/list_sessions.rs diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 7cefd1e000c2..5e3301c3be4f 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -34,7 +34,7 @@ use crate::providers::inventory::{ ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, RefreshPlan, RefreshSkipReason, }; -use crate::session::session_manager::{SessionListCursor, SessionType}; +use crate::session::session_manager::SessionType; use crate::session::{ EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, }; @@ -63,7 +63,6 @@ use agent_client_protocol::{ Responder, }; use anyhow::Result; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use fs_err as fs; use futures::future::BoxFuture; use futures::stream::{self, StreamExt}; @@ -72,7 +71,6 @@ use rmcp::model::{ AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, }; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; @@ -89,6 +87,7 @@ mod dictation; mod dispatch; mod extensions; mod fork_session; +mod list_sessions; mod load_session; mod manage_sessions; mod new_session; @@ -109,10 +108,6 @@ pub type AcpProviderFactory = Arc< + Sync, >; -const SESSION_LIST_PAGE_SIZE: usize = 50; -const ACP_SESSION_LIST_TYPES: [SessionType; 3] = - [SessionType::User, SessionType::Scheduled, SessionType::Acp]; - /// Convenience conversions from any `Display` error into an `agent_client_protocol::Error`. /// /// Replaces the repetitive `.internal_err()` @@ -226,107 +221,6 @@ pub(super) fn sid_short(id: &str) -> String { id.chars().take(8).collect() } -#[derive(Debug, Serialize, Deserialize)] -struct SessionListCursorToken { - updated_at: chrono::DateTime, - // Goose stores updated_at with second precision in common write paths, so the - // cursor needs the full (updated_at, id) sort key to avoid skipping tied rows. - session_id: String, - filter_hash: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SessionListCursorFilters { - cwd: Option, - session_types: Vec, - non_empty: bool, -} - -fn invalid_session_list_cursor(message: &'static str) -> agent_client_protocol::Error { - agent_client_protocol::Error::invalid_params().data(message) -} - -// bind cursors to the effective filters so they cannot be reused for a different list. -fn session_list_filter_hash( - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result { - let mut session_type_names = session_types - .iter() - .map(ToString::to_string) - .collect::>(); - session_type_names.sort(); - let filters = SessionListCursorFilters { - cwd: cwd.map(|path| path.to_string_lossy().to_string()), - session_types: session_type_names, - non_empty: true, - }; - let bytes = - serde_json::to_vec(&filters).internal_err_ctx("Failed to encode session list filters")?; - Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))) -} - -fn decode_session_list_cursor( - cursor: Option<&str>, - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result, agent_client_protocol::Error> { - let Some(cursor) = cursor else { - return Ok(None); - }; - - let bytes = URL_SAFE_NO_PAD - .decode(cursor) - .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; - let token: SessionListCursorToken = serde_json::from_slice(&bytes) - .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; - - if token.session_id.is_empty() || token.filter_hash.is_empty() { - return Err(invalid_session_list_cursor("malformed session list cursor")); - } - - let expected_filter_hash = session_list_filter_hash(cwd, session_types)?; - if token.filter_hash != expected_filter_hash { - return Err(invalid_session_list_cursor( - "session list cursor does not match filters", - )); - } - - Ok(Some(SessionListCursor { - updated_at: token.updated_at, - session_id: token.session_id, - })) -} - -fn encode_session_list_cursor( - cursor: &SessionListCursor, - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result { - let token = SessionListCursorToken { - updated_at: cursor.updated_at, - session_id: cursor.session_id.clone(), - filter_hash: session_list_filter_hash(cwd, session_types)?, - }; - let bytes = - serde_json::to_vec(&token).internal_err_ctx("Failed to encode session list cursor")?; - Ok(URL_SAFE_NO_PAD.encode(bytes)) -} - -fn display_title(s: &Session) -> Option { - if !s.user_set_name { - if let Some(recipe) = &s.recipe { - return Some(recipe.title.clone()); - } - } - if s.name.is_empty() { - None - } else { - Some(s.name.clone()) - } -} - pub(super) fn session_meta(session: &Session) -> serde_json::Map { let mut meta = serde_json::Map::new(); meta.insert( @@ -2830,55 +2724,6 @@ impl GooseAcpAgent { Ok(()) } - async fn on_list_sessions( - &self, - req: ListSessionsRequest, - ) -> Result { - if let Some(cwd) = req.cwd.as_deref() { - if !cwd.is_absolute() { - return Err(agent_client_protocol::Error::invalid_params() - .data("cwd must be an absolute path")); - } - } - - let cwd = req.cwd.as_deref(); - let cursor = - decode_session_list_cursor(req.cursor.as_deref(), cwd, &ACP_SESSION_LIST_TYPES)?; - - // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. - let page = self - .session_manager - .list_nonempty_sessions_by_types_paged( - &ACP_SESSION_LIST_TYPES, - cwd, - cursor.as_ref(), - SESSION_LIST_PAGE_SIZE, - ) - .await - .internal_err()?; - let session_infos: Vec = page - .sessions - .into_iter() - .map(|s| { - let meta = session_meta(&s); - let title = display_title(&s); - let mut info = SessionInfo::new(SessionId::new(s.id), s.working_dir) - .updated_at(s.updated_at.to_rfc3339()) - .meta(meta); - if let Some(t) = title { - info = info.title(t); - } - info - }) - .collect(); - let next_cursor = page - .next_cursor - .as_ref() - .map(|cursor| encode_session_list_cursor(cursor, cwd, &ACP_SESSION_LIST_TYPES)) - .transpose()?; - Ok(ListSessionsResponse::new(session_infos).next_cursor(next_cursor)) - } - async fn on_fork_session( &self, cx: &ConnectionTo, diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs new file mode 100644 index 000000000000..44e53b8196d4 --- /dev/null +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -0,0 +1,165 @@ +use super::{session_meta, GooseAcpAgent, ResultExt}; +use crate::session::session_manager::{SessionListCursor, SessionType}; +use crate::session::Session; +use agent_client_protocol::schema::{ + ListSessionsRequest, ListSessionsResponse, SessionId, SessionInfo, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const SESSION_LIST_PAGE_SIZE: usize = 50; +const ACP_SESSION_LIST_TYPES: [SessionType; 3] = + [SessionType::User, SessionType::Scheduled, SessionType::Acp]; + +#[derive(Debug, Serialize, Deserialize)] +struct SessionListCursorToken { + updated_at: chrono::DateTime, + // Goose stores updated_at with second precision in common write paths, so the + // cursor needs the full (updated_at, id) sort key to avoid skipping tied rows. + session_id: String, + filter_hash: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionListCursorFilters { + cwd: Option, + session_types: Vec, + non_empty: bool, +} + +fn invalid_session_list_cursor(message: &'static str) -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(message) +} + +// bind cursors to the effective filters so they cannot be reused for a different list. +fn session_list_filter_hash( + cwd: Option<&std::path::Path>, + session_types: &[SessionType], +) -> Result { + let mut session_type_names = session_types + .iter() + .map(ToString::to_string) + .collect::>(); + session_type_names.sort(); + let filters = SessionListCursorFilters { + cwd: cwd.map(|path| path.to_string_lossy().to_string()), + session_types: session_type_names, + non_empty: true, + }; + let bytes = + serde_json::to_vec(&filters).internal_err_ctx("Failed to encode session list filters")?; + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))) +} + +fn decode_session_list_cursor( + cursor: Option<&str>, + cwd: Option<&std::path::Path>, + session_types: &[SessionType], +) -> Result, agent_client_protocol::Error> { + let Some(cursor) = cursor else { + return Ok(None); + }; + + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; + let token: SessionListCursorToken = serde_json::from_slice(&bytes) + .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; + + if token.session_id.is_empty() || token.filter_hash.is_empty() { + return Err(invalid_session_list_cursor("malformed session list cursor")); + } + + let expected_filter_hash = session_list_filter_hash(cwd, session_types)?; + if token.filter_hash != expected_filter_hash { + return Err(invalid_session_list_cursor( + "session list cursor does not match filters", + )); + } + + Ok(Some(SessionListCursor { + updated_at: token.updated_at, + session_id: token.session_id, + })) +} + +fn encode_session_list_cursor( + cursor: &SessionListCursor, + cwd: Option<&std::path::Path>, + session_types: &[SessionType], +) -> Result { + let token = SessionListCursorToken { + updated_at: cursor.updated_at, + session_id: cursor.session_id.clone(), + filter_hash: session_list_filter_hash(cwd, session_types)?, + }; + let bytes = + serde_json::to_vec(&token).internal_err_ctx("Failed to encode session list cursor")?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn display_title(s: &Session) -> Option { + if !s.user_set_name { + if let Some(recipe) = &s.recipe { + return Some(recipe.title.clone()); + } + } + if s.name.is_empty() { + None + } else { + Some(s.name.clone()) + } +} + +impl GooseAcpAgent { + pub(super) async fn on_list_sessions( + &self, + req: ListSessionsRequest, + ) -> Result { + if let Some(cwd) = req.cwd.as_deref() { + if !cwd.is_absolute() { + return Err(agent_client_protocol::Error::invalid_params() + .data("cwd must be an absolute path")); + } + } + + let cwd = req.cwd.as_deref(); + let cursor = + decode_session_list_cursor(req.cursor.as_deref(), cwd, &ACP_SESSION_LIST_TYPES)?; + + // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. + let page = self + .session_manager + .list_nonempty_sessions_by_types_paged( + &ACP_SESSION_LIST_TYPES, + cwd, + cursor.as_ref(), + SESSION_LIST_PAGE_SIZE, + ) + .await + .internal_err()?; + let session_infos: Vec = page + .sessions + .into_iter() + .map(|s| { + let meta = session_meta(&s); + let title = display_title(&s); + let mut info = SessionInfo::new(SessionId::new(s.id), s.working_dir) + .updated_at(s.updated_at.to_rfc3339()) + .meta(meta); + if let Some(t) = title { + info = info.title(t); + } + info + }) + .collect(); + let next_cursor = page + .next_cursor + .as_ref() + .map(|cursor| encode_session_list_cursor(cursor, cwd, &ACP_SESSION_LIST_TYPES)) + .transpose()?; + Ok(ListSessionsResponse::new(session_infos).next_cursor(next_cursor)) + } +} From 996300d4f05e9c49d566d30802c92ebdfdd489c8 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 20:20:15 +1000 Subject: [PATCH 2/6] create struct with filter and query --- crates/goose/src/acp/server/list_sessions.rs | 19 +++-- crates/goose/src/session/session_manager.rs | 88 +++++++++++--------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index 44e53b8196d4..c5d916ae6dfb 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -1,5 +1,7 @@ use super::{session_meta, GooseAcpAgent, ResultExt}; -use crate::session::session_manager::{SessionListCursor, SessionType}; +use crate::session::session_manager::{ + SessionListCursor, SessionListFilters, SessionListPageQuery, SessionType, +}; use crate::session::Session; use agent_client_protocol::schema::{ ListSessionsRequest, ListSessionsResponse, SessionId, SessionInfo, @@ -132,12 +134,15 @@ impl GooseAcpAgent { // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. let page = self .session_manager - .list_nonempty_sessions_by_types_paged( - &ACP_SESSION_LIST_TYPES, - cwd, - cursor.as_ref(), - SESSION_LIST_PAGE_SIZE, - ) + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&ACP_SESSION_LIST_TYPES), + working_dir: cwd, + require_messages: true, + }, + cursor: cursor.as_ref(), + page_size: SESSION_LIST_PAGE_SIZE, + }) .await .internal_err()?; let session_infos: Vec = page diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 946d12629c4b..e94cfd874e75 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -286,13 +286,25 @@ pub(crate) struct SessionListPage { pub(crate) next_cursor: Option, } +#[derive(Debug, Default, Clone)] +pub(crate) struct SessionListFilters<'a> { + pub(crate) types: Option<&'a [SessionType]>, + pub(crate) working_dir: Option<&'a Path>, + pub(crate) require_messages: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionListPageQuery<'a> { + pub(crate) filters: SessionListFilters<'a>, + pub(crate) cursor: Option<&'a SessionListCursor>, + pub(crate) page_size: usize, +} + #[derive(Debug, Default)] struct SessionListQuery<'a> { - types: Option<&'a [SessionType]>, - working_dir: Option<&'a Path>, + filters: SessionListFilters<'a>, cursor: Option<&'a SessionListCursor>, limit: Option, - require_messages: bool, } #[derive(Debug, Clone)] @@ -361,16 +373,11 @@ impl SessionManager { self.storage.list_sessions_by_types(Some(types)).await } - pub(crate) async fn list_nonempty_sessions_by_types_paged( + pub(crate) async fn list_sessions_paged( &self, - types: &[SessionType], - working_dir: Option<&Path>, - cursor: Option<&SessionListCursor>, - page_size: usize, + query: SessionListPageQuery<'_>, ) -> Result { - self.storage - .list_nonempty_sessions_by_types_paged(types, working_dir, cursor, page_size) - .await + self.storage.list_sessions_paged(query).await } pub async fn list_all_sessions(&self) -> Result> { @@ -1532,20 +1539,21 @@ impl SessionStorage { Self::replace_conversation_inner(pool, session_id, conversation).await } - async fn list_sessions_matching(&self, options: SessionListQuery<'_>) -> Result> { - if matches!(options.types, Some(types) if types.is_empty()) { + async fn list_sessions_matching(&self, query: SessionListQuery<'_>) -> Result> { + let filters = &query.filters; + if matches!(filters.types, Some(types) if types.is_empty()) { return Ok(Vec::new()); } let mut where_clauses = Vec::new(); - if let Some(types) = options.types { + if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); where_clauses.push(format!("s.session_type IN ({})", placeholders)); } - if options.working_dir.is_some() { + if filters.working_dir.is_some() { where_clauses.push("s.working_dir = ?".to_string()); } - if options.cursor.is_some() { + if query.cursor.is_some() { where_clauses.push( "(datetime(s.updated_at) < datetime(?) \ OR (datetime(s.updated_at) = datetime(?) AND s.id < ?))" @@ -1558,17 +1566,17 @@ impl SessionStorage { } else { format!("WHERE {}", where_clauses.join(" AND ")) }; - let message_join = if options.require_messages { + let message_join = if filters.require_messages { "JOIN messages m ON s.id = m.session_id" } else { "LEFT JOIN messages m ON s.id = m.session_id" }; - let order_by = if options.cursor.is_some() || options.limit.is_some() { + let order_by = if query.cursor.is_some() || query.limit.is_some() { "ORDER BY datetime(s.updated_at) DESC, s.id DESC" } else { "ORDER BY s.updated_at DESC" }; - let limit_clause = if options.limit.is_some() { + let limit_clause = if query.limit.is_some() { "LIMIT ?" } else { "" @@ -1595,22 +1603,22 @@ impl SessionStorage { ); let mut q = sqlx::query_as::<_, Session>(&query); - if let Some(types) = options.types { + if let Some(types) = filters.types { for session_type in types { q = q.bind(session_type.to_string()); } } - if let Some(working_dir) = options.working_dir { + if let Some(working_dir) = filters.working_dir { q = q.bind(working_dir.to_string_lossy().to_string()); } - if let Some(cursor) = options.cursor { + if let Some(cursor) = query.cursor { let updated_at = cursor.updated_at.to_rfc3339(); // Normalize mixed SQLite CURRENT_TIMESTAMP and RFC3339 stored values. q = q.bind(updated_at.clone()); q = q.bind(updated_at); q = q.bind(&cursor.session_id); } - if let Some(limit) = options.limit { + if let Some(limit) = query.limit { q = q.bind(limit as i64); } @@ -1620,33 +1628,29 @@ impl SessionStorage { async fn list_sessions_by_types(&self, types: Option<&[SessionType]>) -> Result> { self.list_sessions_matching(SessionListQuery { - types, + filters: SessionListFilters { + types, + ..Default::default() + }, ..Default::default() }) .await } - async fn list_nonempty_sessions_by_types_paged( - &self, - types: &[SessionType], - working_dir: Option<&Path>, - cursor: Option<&SessionListCursor>, - page_size: usize, - ) -> Result { - if types.is_empty() || page_size == 0 { + async fn list_sessions_paged(&self, query: SessionListPageQuery<'_>) -> Result { + if matches!(query.filters.types, Some(types) if types.is_empty()) || query.page_size == 0 { return Ok(SessionListPage { sessions: Vec::new(), next_cursor: None, }); } + let page_size = query.page_size; let mut sessions = self .list_sessions_matching(SessionListQuery { - types: Some(types), - working_dir, - cursor, + filters: query.filters, + cursor: query.cursor, limit: Some(page_size + 1), - require_messages: true, }) .await?; let has_next_page = sessions.len() > page_size; @@ -2306,13 +2310,17 @@ mod tests { expected_ids: &[String], expected_next_cursor: bool, ) -> Option { + let types = [SessionType::User]; let page = sm - .list_nonempty_sessions_by_types_paged( - &[SessionType::User], - working_dir.map(Path::new), + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + working_dir: working_dir.map(Path::new), + require_messages: true, + }, cursor, page_size, - ) + }) .await .unwrap(); let ids = page From b737d87f66e5bef24d81b9dadc3da02de42f1037 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 20:44:51 +1000 Subject: [PATCH 3/6] implmenet query search in session --- crates/goose/src/acp/server/list_sessions.rs | 1 + crates/goose/src/session/session_manager.rs | 222 ++++++++++++++++++- 2 files changed, 221 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index c5d916ae6dfb..b83410d6627f 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -139,6 +139,7 @@ impl GooseAcpAgent { types: Some(&ACP_SESSION_LIST_TYPES), working_dir: cwd, require_messages: true, + ..Default::default() }, cursor: cursor.as_ref(), page_size: SESSION_LIST_PAGE_SIZE, diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index e94cfd874e75..8fafd3157bd1 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -290,6 +290,7 @@ pub(crate) struct SessionListPage { pub(crate) struct SessionListFilters<'a> { pub(crate) types: Option<&'a [SessionType]>, pub(crate) working_dir: Option<&'a Path>, + pub(crate) keyword: Option<&'a str>, pub(crate) require_messages: bool, } @@ -307,6 +308,37 @@ struct SessionListQuery<'a> { limit: Option, } +fn keyword_like_patterns(query: Option<&str>) -> Vec { + query + .unwrap_or_default() + .split_whitespace() + .map(|word| format!("%{}%", word.to_lowercase())) + .collect() +} + +fn message_keyword_clause(keyword_count: usize) -> String { + let keyword_clauses = (0..keyword_count) + .map(|_| "LOWER(json_extract(value, '$.text')) LIKE ?") + .collect::>() + .join(" OR "); + + format!( + r#" + EXISTS ( + SELECT 1 + FROM messages mq + WHERE mq.session_id = s.id + AND EXISTS ( + SELECT 1 + FROM json_each(mq.content_json) + WHERE json_extract(value, '$.type') = 'text' + AND ({keyword_clauses}) + ) + ) + "# + ) +} + #[derive(Debug, Clone)] pub struct SessionNameUpdate { pub session_id: String, @@ -1545,6 +1577,7 @@ impl SessionStorage { return Ok(Vec::new()); } + let keyword_patterns = keyword_like_patterns(filters.keyword); let mut where_clauses = Vec::new(); if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); @@ -1553,6 +1586,9 @@ impl SessionStorage { if filters.working_dir.is_some() { where_clauses.push("s.working_dir = ?".to_string()); } + if !keyword_patterns.is_empty() { + where_clauses.push(message_keyword_clause(keyword_patterns.len())); + } if query.cursor.is_some() { where_clauses.push( "(datetime(s.updated_at) < datetime(?) \ @@ -1582,7 +1618,7 @@ impl SessionStorage { "" }; - let query = format!( + let sql = format!( r#" SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, s.total_tokens, s.input_tokens, s.output_tokens, @@ -1602,7 +1638,7 @@ impl SessionStorage { message_join, where_clause, order_by, limit_clause ); - let mut q = sqlx::query_as::<_, Session>(&query); + let mut q = sqlx::query_as::<_, Session>(&sql); if let Some(types) = filters.types { for session_type in types { q = q.bind(session_type.to_string()); @@ -1611,6 +1647,9 @@ impl SessionStorage { if let Some(working_dir) = filters.working_dir { q = q.bind(working_dir.to_string_lossy().to_string()); } + for pattern in keyword_patterns { + q = q.bind(pattern); + } if let Some(cursor) = query.cursor { let updated_at = cursor.updated_at.to_rfc3339(); // Normalize mixed SQLite CURRENT_TIMESTAMP and RFC3339 stored values. @@ -2057,6 +2096,18 @@ mod tests { session.id } + async fn create_session_for_list_with_message( + sm: &SessionManager, + working_dir: &str, + message: &str, + ) -> String { + let session_id = create_session_for_list(sm, working_dir, false).await; + sm.add_message(&session_id, &Message::user().with_text(message)) + .await + .unwrap(); + session_id + } + async fn set_sessions_updated_at( sm: &SessionManager, session_ids: &[String], @@ -2317,6 +2368,7 @@ mod tests { types: Some(&types), working_dir: working_dir.map(Path::new), require_messages: true, + ..Default::default() }, cursor, page_size, @@ -2489,6 +2541,172 @@ mod tests { .await; } + #[tokio::test] + async fn test_session_list_paged_filters_by_keyword() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let target = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "Discuss Postgres migrations", + ) + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "Plan the mobile release") + .await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("postgres"), + require_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, vec![target]); + assert!(page.next_cursor.is_none()); + } + + #[tokio::test] + async fn test_session_list_paged_keyword_uses_or_terms() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let postgres = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "Postgres migration plan", + ) + .await; + let sqlite = + create_session_for_list_with_message(&sm, "/tmp/session-list", "SQLite backup notes") + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "Mobile release notes") + .await; + let expected_ids = expected_session_list_ids(&sm, &[postgres, sqlite]).await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("postgres sqlite"), + require_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, expected_ids); + assert!(page.next_cursor.is_none()); + } + + #[tokio::test] + async fn test_session_list_paged_empty_keyword_matches_plain_list() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let expected_ids = vec![ + create_session_for_list_with_message(&sm, "/tmp/session-list", "first message").await, + create_session_for_list_with_message(&sm, "/tmp/session-list", "second message").await, + ]; + let expected_ids = expected_session_list_ids(&sm, &expected_ids).await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some(" "), + require_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, expected_ids); + } + + #[tokio::test] + async fn test_session_list_paged_keyword_combines_with_cwd_and_pagination() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let expected_ids = vec![ + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Postgres plan one") + .await, + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Postgres plan two") + .await, + ]; + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Mobile release").await; + create_session_for_list_with_message(&sm, "/tmp/session-list/b", "Postgres plan other") + .await; + let expected_ids = expected_session_list_ids(&sm, &expected_ids).await; + + let types = [SessionType::User]; + let filters = SessionListFilters { + types: Some(&types), + working_dir: Some(Path::new("/tmp/session-list/a")), + keyword: Some("postgres"), + require_messages: true, + }; + let cursor = sm + .list_sessions_paged(SessionListPageQuery { + filters: filters.clone(), + cursor: None, + page_size: 1, + }) + .await + .unwrap(); + let ids = cursor + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(ids, expected_ids[0..1]); + assert!(cursor.next_cursor.is_some()); + + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters, + cursor: cursor.next_cursor.as_ref(), + page_size: 1, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(ids, expected_ids[1..2]); + assert!(page.next_cursor.is_none()); + } + #[tokio::test] async fn test_concurrent_session_creation() { let temp_dir = TempDir::new().unwrap(); From 0621eb0d23f8f8e22e18127ca7cf6bbf2de1655f Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 21:25:23 +1000 Subject: [PATCH 4/6] wire keywords and session type in list session request meta --- crates/goose/src/acp/server.rs | 26 +++-- crates/goose/src/acp/server/list_sessions.rs | 66 ++++++++++-- crates/goose/src/acp/server/new_session.rs | 6 +- crates/goose/src/session/session_manager.rs | 25 +++-- crates/goose/tests/acp_server_test.rs | 102 +++++++++++++++++++ 5 files changed, 191 insertions(+), 34 deletions(-) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 5e3301c3be4f..cf7566c94eec 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -34,7 +34,6 @@ use crate::providers::inventory::{ ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, RefreshPlan, RefreshSkipReason, }; -use crate::session::session_manager::SessionType; use crate::session::{ EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, }; @@ -50,7 +49,7 @@ use agent_client_protocol::schema::{ McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, - SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfo, SessionInfoUpdate, + SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfoUpdate, SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, @@ -70,7 +69,7 @@ use futures::FutureExt; use rmcp::model::{ AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; @@ -267,10 +266,22 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map, key: &str) -> Option { - meta.and_then(|m| m.get(key)) - .and_then(|v| v.as_str()) - .map(ToString::to_string) +fn meta_string( + meta: Option<&Meta>, + key: &str, +) -> Result, agent_client_protocol::Error> { + let Some(value) = meta.and_then(|m| m.get(key)) else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let Some(value) = value.as_str() else { + return Err( + agent_client_protocol::Error::invalid_params().data(format!("{key} must be a string")) + ); + }; + Ok(Some(value.to_string())) } fn spawn_session_name_update_notifier( @@ -2802,6 +2813,7 @@ pub async fn run(builtins: Vec) -> Result<()> { mod tests { use super::*; use crate::conversation::message::{ToolRequest, ToolResponse}; + use crate::session::session_manager::SessionType; use agent_client_protocol::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, PermissionOptionId, ResourceLink, SelectedPermissionOutcome, diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index b83410d6627f..307f2b5976cc 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -1,10 +1,10 @@ -use super::{session_meta, GooseAcpAgent, ResultExt}; +use super::{meta_string, session_meta, GooseAcpAgent, ResultExt}; use crate::session::session_manager::{ SessionListCursor, SessionListFilters, SessionListPageQuery, SessionType, }; use crate::session::Session; use agent_client_protocol::schema::{ - ListSessionsRequest, ListSessionsResponse, SessionId, SessionInfo, + ListSessionsRequest, ListSessionsResponse, Meta, SessionId, SessionInfo, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; @@ -28,17 +28,49 @@ struct SessionListCursorToken { struct SessionListCursorFilters { cwd: Option, session_types: Vec, - non_empty: bool, + keyword: Option, + only_sessions_with_messages: bool, } fn invalid_session_list_cursor(message: &'static str) -> agent_client_protocol::Error { agent_client_protocol::Error::invalid_params().data(message) } +fn session_keyword_from_meta( + meta: Option<&Meta>, +) -> Result, agent_client_protocol::Error> { + Ok(meta_string(meta, "query")? + .map(|keyword| keyword.trim().to_string()) + .filter(|keyword| !keyword.is_empty())) +} + +fn session_types_from_meta( + meta: Option<&Meta>, +) -> Result, agent_client_protocol::Error> { + let Some(value) = meta.and_then(|meta| meta.get("types")) else { + return Ok(ACP_SESSION_LIST_TYPES.to_vec()); + }; + if value.is_null() { + return Ok(ACP_SESSION_LIST_TYPES.to_vec()); + } + + let session_types = + serde_json::from_value::>(value.clone()).map_err(|_| { + agent_client_protocol::Error::invalid_params() + .data("types must be an array of session type strings") + })?; + if session_types.is_empty() { + Ok(ACP_SESSION_LIST_TYPES.to_vec()) + } else { + Ok(session_types) + } +} + // bind cursors to the effective filters so they cannot be reused for a different list. fn session_list_filter_hash( cwd: Option<&std::path::Path>, session_types: &[SessionType], + keyword: Option<&str>, ) -> Result { let mut session_type_names = session_types .iter() @@ -48,7 +80,8 @@ fn session_list_filter_hash( let filters = SessionListCursorFilters { cwd: cwd.map(|path| path.to_string_lossy().to_string()), session_types: session_type_names, - non_empty: true, + keyword: keyword.map(ToString::to_string), + only_sessions_with_messages: true, }; let bytes = serde_json::to_vec(&filters).internal_err_ctx("Failed to encode session list filters")?; @@ -59,6 +92,7 @@ fn decode_session_list_cursor( cursor: Option<&str>, cwd: Option<&std::path::Path>, session_types: &[SessionType], + keyword: Option<&str>, ) -> Result, agent_client_protocol::Error> { let Some(cursor) = cursor else { return Ok(None); @@ -74,7 +108,7 @@ fn decode_session_list_cursor( return Err(invalid_session_list_cursor("malformed session list cursor")); } - let expected_filter_hash = session_list_filter_hash(cwd, session_types)?; + let expected_filter_hash = session_list_filter_hash(cwd, session_types, keyword)?; if token.filter_hash != expected_filter_hash { return Err(invalid_session_list_cursor( "session list cursor does not match filters", @@ -91,11 +125,12 @@ fn encode_session_list_cursor( cursor: &SessionListCursor, cwd: Option<&std::path::Path>, session_types: &[SessionType], + keyword: Option<&str>, ) -> Result { let token = SessionListCursorToken { updated_at: cursor.updated_at, session_id: cursor.session_id.clone(), - filter_hash: session_list_filter_hash(cwd, session_types)?, + filter_hash: session_list_filter_hash(cwd, session_types, keyword)?, }; let bytes = serde_json::to_vec(&token).internal_err_ctx("Failed to encode session list cursor")?; @@ -128,17 +163,24 @@ impl GooseAcpAgent { } let cwd = req.cwd.as_deref(); - let cursor = - decode_session_list_cursor(req.cursor.as_deref(), cwd, &ACP_SESSION_LIST_TYPES)?; + let keyword = session_keyword_from_meta(req.meta.as_ref())?; + let session_types = session_types_from_meta(req.meta.as_ref())?; + let cursor = decode_session_list_cursor( + req.cursor.as_deref(), + cwd, + &session_types, + keyword.as_deref(), + )?; // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. let page = self .session_manager .list_sessions_paged(SessionListPageQuery { filters: SessionListFilters { - types: Some(&ACP_SESSION_LIST_TYPES), + types: Some(&session_types), working_dir: cwd, - require_messages: true, + keyword: keyword.as_deref(), + only_sessions_with_messages: true, ..Default::default() }, cursor: cursor.as_ref(), @@ -164,7 +206,9 @@ impl GooseAcpAgent { let next_cursor = page .next_cursor .as_ref() - .map(|cursor| encode_session_list_cursor(cursor, cwd, &ACP_SESSION_LIST_TYPES)) + .map(|cursor| { + encode_session_list_cursor(cursor, cwd, &session_types, keyword.as_deref()) + }) .transpose()?; Ok(ListSessionsResponse::new(session_infos).next_cursor(next_cursor)) } diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs index c1a6d5c5354e..a17d568de30c 100644 --- a/crates/goose/src/acp/server/new_session.rs +++ b/crates/goose/src/acp/server/new_session.rs @@ -18,14 +18,14 @@ impl GooseAcpAgent { debug!(?args, "new session request"); let t_start = std::time::Instant::now(); validate_absolute_cwd(&args.cwd)?; - let project_id = meta_string(args.meta.as_ref(), "projectId"); - let session_type = match meta_string(args.meta.as_ref(), "client") { + let project_id = meta_string(args.meta.as_ref(), "projectId")?; + let session_type = match meta_string(args.meta.as_ref(), "client")? { Some(_) => SessionType::User, None => SessionType::Acp, }; let config = Config::global(); let (resolved_provider, resolved_model_config) = - match meta_string(args.meta.as_ref(), "provider") { + match meta_string(args.meta.as_ref(), "provider")? { Some(provider) => { let model_config = super::resolve_provider_default_model_config(&provider).await?; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 8fafd3157bd1..9f491d41d547 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -291,7 +291,7 @@ pub(crate) struct SessionListFilters<'a> { pub(crate) types: Option<&'a [SessionType]>, pub(crate) working_dir: Option<&'a Path>, pub(crate) keyword: Option<&'a str>, - pub(crate) require_messages: bool, + pub(crate) only_sessions_with_messages: bool, } #[derive(Debug, Clone)] @@ -1602,7 +1602,7 @@ impl SessionStorage { } else { format!("WHERE {}", where_clauses.join(" AND ")) }; - let message_join = if filters.require_messages { + let message_join = if filters.only_sessions_with_messages { "JOIN messages m ON s.id = m.session_id" } else { "LEFT JOIN messages m ON s.id = m.session_id" @@ -1612,11 +1612,7 @@ impl SessionStorage { } else { "ORDER BY s.updated_at DESC" }; - let limit_clause = if query.limit.is_some() { - "LIMIT ?" - } else { - "" - }; + let limit_clause = if query.limit.is_some() { "LIMIT ?" } else { "" }; let sql = format!( r#" @@ -1676,7 +1672,10 @@ impl SessionStorage { .await } - async fn list_sessions_paged(&self, query: SessionListPageQuery<'_>) -> Result { + async fn list_sessions_paged( + &self, + query: SessionListPageQuery<'_>, + ) -> Result { if matches!(query.filters.types, Some(types) if types.is_empty()) || query.page_size == 0 { return Ok(SessionListPage { sessions: Vec::new(), @@ -2367,7 +2366,7 @@ mod tests { filters: SessionListFilters { types: Some(&types), working_dir: working_dir.map(Path::new), - require_messages: true, + only_sessions_with_messages: true, ..Default::default() }, cursor, @@ -2560,7 +2559,7 @@ mod tests { filters: SessionListFilters { types: Some(&types), keyword: Some("postgres"), - require_messages: true, + only_sessions_with_messages: true, ..Default::default() }, cursor: None, @@ -2601,7 +2600,7 @@ mod tests { filters: SessionListFilters { types: Some(&types), keyword: Some("postgres sqlite"), - require_messages: true, + only_sessions_with_messages: true, ..Default::default() }, cursor: None, @@ -2635,7 +2634,7 @@ mod tests { filters: SessionListFilters { types: Some(&types), keyword: Some(" "), - require_messages: true, + only_sessions_with_messages: true, ..Default::default() }, cursor: None, @@ -2672,7 +2671,7 @@ mod tests { types: Some(&types), working_dir: Some(Path::new("/tmp/session-list/a")), keyword: Some("postgres"), - require_messages: true, + only_sessions_with_messages: true, }; let cursor = sm .list_sessions_paged(SessionListPageQuery { diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 22cbfa32d76d..794958d98303 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -46,6 +46,29 @@ async fn seed_list_sessions(data_root: &Path, working_dir: &Path, count: usize) } } +async fn seed_list_session_with_message( + data_root: &Path, + working_dir: &Path, + name: &str, + session_type: SessionType, + message: &str, +) { + let session_manager = SessionManager::new(data_root.to_path_buf()); + let session = session_manager + .create_session( + working_dir.to_path_buf(), + name.to_string(), + session_type, + GooseMode::default(), + ) + .await + .unwrap(); + session_manager + .add_message(&session.id, &Message::user().with_text(message)) + .await + .unwrap(); +} + async fn new_connection(data_root: &Path) -> AcpServerConnection { let openai = OpenAiFixture::new( vec![], @@ -122,6 +145,85 @@ fn test_list_sessions_pagination() { }); } +#[test] +fn test_list_sessions_query_filters_results() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list"); + seed_list_session_with_message( + data_root.path(), + cwd, + "Postgres session", + SessionType::Acp, + "Discuss Postgres migrations", + ) + .await; + seed_list_session_with_message( + data_root.path(), + cwd, + "Mobile session", + SessionType::Acp, + "Plan the mobile release", + ) + .await; + let conn = new_connection(data_root.path()).await; + + let mut meta = serde_json::Map::new(); + meta.insert( + "query".to_string(), + serde_json::Value::String("postgres".to_string()), + ); + let response = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!( + response.sessions[0].title.as_deref(), + Some("Postgres session") + ); + assert!(response.next_cursor.is_none()); + }); +} + +#[test] +fn test_list_sessions_types_override_filters_results() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list"); + seed_list_session_with_message( + data_root.path(), + cwd, + "ACP session", + SessionType::Acp, + "ACP message", + ) + .await; + seed_list_session_with_message( + data_root.path(), + cwd, + "User session", + SessionType::User, + "User message", + ) + .await; + let conn = new_connection(data_root.path()).await; + + let mut meta = serde_json::Map::new(); + meta.insert( + "types".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String("user".to_string())]), + ); + let response = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(response.sessions[0].title.as_deref(), Some("User session")); + assert!(response.next_cursor.is_none()); + }); +} + #[test] fn test_list_sessions_invalid_params() { run_test(async { From 1acafbc1bb664bf64a4baef363d9b3c9c92ee90a Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 21:50:49 +1000 Subject: [PATCH 5/6] address comments --- crates/goose/src/acp/server/list_sessions.rs | 8 +++++++- crates/goose/tests/acp_server_test.rs | 21 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index 307f2b5976cc..d71a0f12b60d 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -62,6 +62,13 @@ fn session_types_from_meta( if session_types.is_empty() { Ok(ACP_SESSION_LIST_TYPES.to_vec()) } else { + if session_types + .iter() + .any(|session_type| !ACP_SESSION_LIST_TYPES.contains(session_type)) + { + return Err(agent_client_protocol::Error::invalid_params() + .data("types may only include user, scheduled, or acp")); + } Ok(session_types) } } @@ -181,7 +188,6 @@ impl GooseAcpAgent { working_dir: cwd, keyword: keyword.as_deref(), only_sessions_with_messages: true, - ..Default::default() }, cursor: cursor.as_ref(), page_size: SESSION_LIST_PAGE_SIZE, diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 794958d98303..4fb7bbd5a5a1 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -224,6 +224,27 @@ fn test_list_sessions_types_override_filters_results() { }); } +#[test] +fn test_list_sessions_types_rejects_internal_session_types() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let conn = new_connection(data_root.path()).await; + + for session_type in ["hidden", "sub_agent"] { + let mut meta = serde_json::Map::new(); + meta.insert( + "types".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String(session_type.to_string())]), + ); + + let error = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap_err(); + assert_invalid_params(error); + } + }); +} + #[test] fn test_list_sessions_invalid_params() { run_test(async { From d42836e4fb7987d2727d73ba3abbe2daddcd36ec Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 9 Jun 2026 23:04:04 +1000 Subject: [PATCH 6/6] used instr function instead of like condition with escape --- crates/goose/src/session/session_manager.rs | 73 ++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 9f491d41d547..e5253909eba3 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -308,17 +308,17 @@ struct SessionListQuery<'a> { limit: Option, } -fn keyword_like_patterns(query: Option<&str>) -> Vec { +fn keyword_terms(query: Option<&str>) -> Vec { query .unwrap_or_default() .split_whitespace() - .map(|word| format!("%{}%", word.to_lowercase())) + .map(|word| word.to_lowercase()) .collect() } fn message_keyword_clause(keyword_count: usize) -> String { let keyword_clauses = (0..keyword_count) - .map(|_| "LOWER(json_extract(value, '$.text')) LIKE ?") + .map(|_| "instr(LOWER(json_extract(value, '$.text')), ?) > 0") .collect::>() .join(" OR "); @@ -1577,7 +1577,7 @@ impl SessionStorage { return Ok(Vec::new()); } - let keyword_patterns = keyword_like_patterns(filters.keyword); + let keywords = keyword_terms(filters.keyword); let mut where_clauses = Vec::new(); if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); @@ -1586,8 +1586,8 @@ impl SessionStorage { if filters.working_dir.is_some() { where_clauses.push("s.working_dir = ?".to_string()); } - if !keyword_patterns.is_empty() { - where_clauses.push(message_keyword_clause(keyword_patterns.len())); + if !keywords.is_empty() { + where_clauses.push(message_keyword_clause(keywords.len())); } if query.cursor.is_some() { where_clauses.push( @@ -1643,8 +1643,8 @@ impl SessionStorage { if let Some(working_dir) = filters.working_dir { q = q.bind(working_dir.to_string_lossy().to_string()); } - for pattern in keyword_patterns { - q = q.bind(pattern); + for term in keywords { + q = q.bind(term); } if let Some(cursor) = query.cursor { let updated_at = cursor.updated_at.to_rfc3339(); @@ -2651,6 +2651,63 @@ mod tests { assert_eq!(ids, expected_ids); } + #[tokio::test] + async fn test_session_list_paged_keyword_treats_like_wildcards_as_literals() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let percent_id = + create_session_for_list_with_message(&sm, "/tmp/session-list", "Deploy is 100% done") + .await; + let underscore_id = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "feature_flag is enabled", + ) + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "plain message").await; + + let types = [SessionType::User]; + let percent_page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("%"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let percent_ids = percent_page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(percent_ids, vec![percent_id]); + + let underscore_page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("_"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let underscore_ids = underscore_page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(underscore_ids, vec![underscore_id]); + } + #[tokio::test] async fn test_session_list_paged_keyword_combines_with_cwd_and_pagination() { let temp_dir = TempDir::new().unwrap();