diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 5b0d89a88a..0ae6f9a75f 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -685,9 +685,9 @@ async fn search_sessions( .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)); - let search_results = state + let sessions = state .session_manager() - .search_chat_history( + .search_chat_sessions( query, Some(limit), after_date, @@ -698,23 +698,5 @@ async fn search_sessions( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Get full Session objects for matching session IDs - let session_ids: Vec = search_results - .results - .into_iter() - .map(|r| r.session_id) - .collect(); - - let all_sessions = state - .session_manager() - .list_sessions() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let matching_sessions: Vec = all_sessions - .into_iter() - .filter(|s| session_ids.contains(&s.id)) - .collect(); - - Ok(Json(matching_sessions)) + Ok(Json(sessions)) } diff --git a/crates/goose/src/session/chat_history_search.rs b/crates/goose/src/session/chat_history_search.rs index c06abb8464..22a89807e7 100644 --- a/crates/goose/src/session/chat_history_search.rs +++ b/crates/goose/src/session/chat_history_search.rs @@ -302,3 +302,137 @@ impl<'a> ChatHistorySearch<'a> { } } } + +pub(crate) struct ChatSessionSearch<'a> { + pool: &'a Pool, + query: &'a str, + limit: usize, + after_date: Option>, + before_date: Option>, + exclude_session_id: Option, + session_types: Vec, +} + +impl<'a> ChatSessionSearch<'a> { + pub fn new( + pool: &'a Pool, + query: &'a str, + limit: Option, + after_date: Option>, + before_date: Option>, + exclude_session_id: Option, + session_types: Vec, + ) -> Self { + Self { + pool, + query, + limit: limit.unwrap_or(10), + after_date, + before_date, + exclude_session_id, + session_types, + } + } + + pub async fn execute(self) -> Result> { + let keywords = self.parse_keywords(); + if keywords.is_empty() { + return Ok(Vec::new()); + } + + let sql = self.build_sql(&keywords); + let mut query_builder = sqlx::query_scalar::<_, String>(&sql); + + for keyword in &keywords { + query_builder = query_builder.bind(keyword); + } + + if let Some(after) = self.after_date { + query_builder = query_builder.bind(after); + } + if let Some(before) = self.before_date { + query_builder = query_builder.bind(before); + } + + if let Some(exclude_id) = &self.exclude_session_id { + query_builder = query_builder.bind(exclude_id); + } + + for t in &self.session_types { + query_builder = query_builder.bind(t.to_string()); + } + + query_builder = query_builder.bind(self.limit as i64); + + Ok(query_builder.fetch_all(self.pool).await?) + } + + fn parse_keywords(&self) -> Vec { + self.query + .split_whitespace() + .map(|word| format!("%{}%", word.to_lowercase())) + .collect() + } + + fn build_sql(&self, keywords: &[String]) -> String { + let mut sql = String::from( + r#" + SELECT s.id + FROM sessions s + WHERE EXISTS ( + SELECT 1 + FROM messages m + WHERE m.session_id = s.id + AND EXISTS ( + SELECT 1 FROM json_each(m.content_json) + WHERE json_extract(value, '$.type') = 'text' + AND ( + "#, + ); + + for (i, _) in keywords.iter().enumerate() { + if i > 0 { + sql.push_str(" OR "); + } + sql.push_str("LOWER(json_extract(value, '$.text')) LIKE ?"); + } + + sql.push_str( + r#" + ) + ) + "#, + ); + + if self.after_date.is_some() { + sql.push_str(" AND m.timestamp >= ?"); + } + if self.before_date.is_some() { + sql.push_str(" AND m.timestamp <= ?"); + } + + sql.push_str( + r#" + ) + "#, + ); + + if self.exclude_session_id.is_some() { + sql.push_str(" AND s.id != ?"); + } + + if !self.session_types.is_empty() { + let placeholders: String = self + .session_types + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + sql.push_str(&format!(" AND s.session_type IN ({})", placeholders)); + } + + sql.push_str(" ORDER BY s.updated_at DESC, s.id DESC LIMIT ?"); + + sql + } +} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 6b04183d3e..20caf461d6 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -472,6 +472,27 @@ impl SessionManager { .await } + pub async fn search_chat_sessions( + &self, + query: &str, + limit: Option, + after_date: Option>, + before_date: Option>, + exclude_session_id: Option, + session_types: Vec, + ) -> Result> { + self.storage + .search_chat_sessions( + query, + limit, + after_date, + before_date, + exclude_session_id, + session_types, + ) + .await + } + pub async fn update_message_metadata(id: &str, message_id: &str, f: F) -> Result<()> where F: FnOnce( @@ -1845,6 +1866,41 @@ impl SessionStorage { .await } + async fn search_chat_sessions( + &self, + query: &str, + limit: Option, + after_date: Option>, + before_date: Option>, + exclude_session_id: Option, + session_types: Vec, + ) -> Result> { + use crate::session::chat_history_search::ChatSessionSearch; + + let pool = self.pool().await?; + let session_ids = ChatSessionSearch::new( + pool, + query, + limit, + after_date, + before_date, + exclude_session_id, + session_types, + ) + .execute() + .await?; + + let mut sessions = Vec::with_capacity(session_ids.len()); + for session_id in session_ids { + match self.get_session(&session_id, false).await { + Ok(session) => sessions.push(session), + Err(err) if err.to_string() == "Session not found" => continue, + Err(err) => return Err(err), + } + } + Ok(sessions) + } + async fn update_message_metadata( &self, session_id: &str, @@ -2015,6 +2071,219 @@ mod tests { } } + async fn add_message_at(sm: &SessionManager, session_id: &str, text: &str, timestamp: &str) { + sm.add_message(session_id, &Message::user().with_text(text)) + .await + .unwrap(); + + let pool = sm.storage().pool().await.unwrap(); + let timestamp = chrono::DateTime::parse_from_rfc3339(timestamp).unwrap(); + let timestamp_string = timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + + sqlx::query( + "UPDATE messages SET timestamp = ?, created_timestamp = ? WHERE id = (SELECT MAX(id) FROM messages WHERE session_id = ?)", + ) + .bind(×tamp_string) + .bind(timestamp.timestamp()) + .bind(session_id) + .execute(pool) + .await + .unwrap(); + } + + async fn create_search_session( + sm: &SessionManager, + name: &str, + session_type: SessionType, + updated_at: &str, + messages: &[(&str, &str)], + ) -> String { + let session = sm + .create_session( + PathBuf::from("/tmp/search-test"), + name.to_string(), + session_type, + GooseMode::default(), + ) + .await + .unwrap(); + + for (text, timestamp) in messages { + add_message_at(sm, &session.id, text, timestamp).await; + } + set_sessions_updated_at(sm, std::slice::from_ref(&session.id), updated_at).await; + + session.id + } + + #[tokio::test] + async fn test_search_chat_history_preserves_message_limited_behavior() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let _older_target = create_search_session( + &sm, + "Older target", + SessionType::User, + "2026-05-01T00:00:00Z", + &[( + "does Acme have an email address for John Doe", + "2026-05-01T00:00:00Z", + )], + ) + .await; + + let newer_noise = create_search_session( + &sm, + "Newer noise", + SessionType::User, + "2026-05-22T00:00:00Z", + &[ + ("Acme person name looking for Acme", "2026-05-22T00:00:00Z"), + ( + "another Acme person name looking for Acme", + "2026-05-22T00:01:00Z", + ), + ], + ) + .await; + + let results = sm + .search_chat_history("Acme", Some(2), None, None, None, vec![SessionType::User]) + .await + .unwrap(); + + assert_eq!(results.results.len(), 1); + assert_eq!(results.results[0].session_id, newer_noise); + assert_eq!(results.results[0].messages.len(), 2); + } + + #[tokio::test] + async fn test_search_chat_sessions_limits_distinct_sessions() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let older_target = create_search_session( + &sm, + "Older target", + SessionType::User, + "2026-05-01T00:00:00Z", + &[( + "does Acme have an email address for John Doe", + "2026-05-01T00:00:00Z", + )], + ) + .await; + + let newer_noise = create_search_session( + &sm, + "Newer noise", + SessionType::User, + "2026-05-22T00:00:00Z", + &[ + ("Acme person name looking for Acme", "2026-05-22T00:00:00Z"), + ( + "another Acme person name looking for Acme", + "2026-05-22T00:01:00Z", + ), + ], + ) + .await; + + let results = sm + .search_chat_sessions("Acme", Some(2), None, None, None, vec![SessionType::User]) + .await + .unwrap(); + let ids = results + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, vec![newer_noise, older_target]); + } + + #[tokio::test] + async fn test_search_chat_sessions_applies_all_filters() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let excluded = create_search_session( + &sm, + "Excluded user", + SessionType::User, + "2026-05-20T00:00:00Z", + &[("Acme John excluded session", "2026-05-15T00:00:00Z")], + ) + .await; + + let scheduled_target = create_search_session( + &sm, + "Scheduled target", + SessionType::Scheduled, + "2026-05-19T00:00:00Z", + &[( + "John appears in scheduled Acme work", + "2026-05-16T00:00:00Z", + )], + ) + .await; + + let user_target = create_search_session( + &sm, + "User target", + SessionType::User, + "2026-05-18T00:00:00Z", + &[( + "Acme has an email address question for John Doe", + "2026-05-14T00:00:00Z", + )], + ) + .await; + + let _before_window = create_search_session( + &sm, + "Before window", + SessionType::User, + "2026-05-17T00:00:00Z", + &[("Acme John before date window", "2026-05-09T00:00:00Z")], + ) + .await; + + let _wrong_type = create_search_session( + &sm, + "ACP target", + SessionType::Acp, + "2026-05-16T00:00:00Z", + &[("Acme John wrong session type", "2026-05-15T00:00:00Z")], + ) + .await; + + let after = chrono::DateTime::parse_from_rfc3339("2026-05-10T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let before = chrono::DateTime::parse_from_rfc3339("2026-05-17T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + + let results = sm + .search_chat_sessions( + "Acme John", + Some(10), + Some(after), + Some(before), + Some(excluded), + vec![SessionType::User, SessionType::Scheduled], + ) + .await + .unwrap(); + let ids = results + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, vec![scheduled_target, user_target]); + } + async fn expected_session_list_ids(sm: &SessionManager, session_ids: &[String]) -> Vec { let mut sessions = Vec::new(); for session_id in session_ids {