diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index 41789bb81..c16d7463e 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -64,6 +64,12 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map, +) -> Result { + let Some(value) = meta.and_then(|meta| meta.get("goose")) else { + return Ok(false); + }; + if value.is_null() { + return Ok(false); + } + + let Some(goose_meta) = value.as_object() else { + return Err(agent_client_protocol::Error::invalid_params().data("goose must be an object")); + }; + let Some(value) = goose_meta.get("includeLastMessageSnippet") else { + return Ok(false); + }; + if value.is_null() { + return Ok(false); + } + + value.as_bool().ok_or_else(|| { + agent_client_protocol::Error::invalid_params() + .data("goose.includeLastMessageSnippet must be a boolean") + }) +} + // 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>, @@ -156,6 +182,8 @@ impl GooseAcpAgent { let cwd = req.cwd.as_deref(); let keyword = session_keyword_from_meta(req.meta.as_ref())?; let session_types = session_types_from_meta(req.meta.as_ref())?; + let include_last_message_snippet = + include_last_message_snippet_from_meta(req.meta.as_ref())?; let cursor = decode_session_list_cursor( req.cursor.as_deref(), cwd, @@ -175,9 +203,11 @@ impl GooseAcpAgent { }, cursor: cursor.as_ref(), page_size: SESSION_LIST_PAGE_SIZE, + include_last_message_snippet, }) .await .internal_err()?; + let session_infos: Vec = page.sessions.into_iter().map(build_session_info).collect(); let next_cursor = page diff --git a/crates/goose/src/session/last_message_snippet.rs b/crates/goose/src/session/last_message_snippet.rs new file mode 100644 index 000000000..c645b5589 --- /dev/null +++ b/crates/goose/src/session/last_message_snippet.rs @@ -0,0 +1,556 @@ +use crate::conversation::message::Message; +use crate::session::session_manager::Session; +use anyhow::Result; +use rmcp::model::Role; +use sqlx::{Pool, Sqlite}; +use std::collections::HashMap; + +const LAST_MESSAGE_SNIPPET_MAX_CHARS: usize = 128; +const RECENT_MESSAGE_SNIPPET_SCAN_LIMIT: usize = 8; + +#[derive(Debug, sqlx::FromRow)] +struct RecentMessageRow { + row_id: i64, + session_id: String, + role: String, + content_json: String, + created_timestamp: i64, + metadata_json: Option, + message_id: Option, +} + +pub(super) async fn hydrate_last_message_snippets( + pool: &Pool, + sessions: &mut [Session], +) -> Result<()> { + if sessions.is_empty() { + return Ok(()); + } + + let session_ids = sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + let mut snippets = HashMap::with_capacity(session_ids.len()); + + let rows = recent_message_rows(pool, &session_ids).await?; + + for row in rows { + if snippets.contains_key(&row.session_id) { + continue; + } + + let session_id = row.session_id.clone(); + let Some(message) = message_from_recent_row(row)? else { + continue; + }; + if let Some(snippet) = message_snippet(&message, LAST_MESSAGE_SNIPPET_MAX_CHARS) { + snippets.insert(session_id, snippet); + } + } + + for session in sessions { + session.last_message_snippet = snippets.remove(&session.id); + } + + Ok(()) +} + +async fn recent_message_rows( + pool: &Pool, + session_ids: &[String], +) -> Result> { + if session_ids.is_empty() { + return Ok(Vec::new()); + } + + let branch = r#" + SELECT row_id, session_id, role, content_json, created_timestamp, metadata_json, message_id + FROM ( + SELECT id AS row_id, session_id, role, content_json, created_timestamp, metadata_json, message_id + FROM messages + WHERE session_id = ? + ORDER BY created_timestamp DESC, id DESC + LIMIT ? + ) + "#; + let sql = std::iter::repeat_n(branch, session_ids.len()) + .collect::>() + .join(" UNION ALL "); + + let mut query = sqlx::query_as::<_, RecentMessageRow>(&sql); + for session_id in session_ids { + query = query + .bind(session_id) + .bind(RECENT_MESSAGE_SNIPPET_SCAN_LIMIT as i64); + } + + let mut rows = query.fetch_all(pool).await?; + rows.sort_by(|left, right| { + left.session_id + .cmp(&right.session_id) + .then_with(|| right.created_timestamp.cmp(&left.created_timestamp)) + .then_with(|| right.row_id.cmp(&left.row_id)) + }); + Ok(rows) +} + +fn message_from_recent_row(row: RecentMessageRow) -> Result> { + let role = match row.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => return Ok(None), + }; + + let content = match serde_json::from_str(&row.content_json) { + Ok(content) => content, + Err(_) => return Ok(None), + }; + let metadata = row + .metadata_json + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + + let mut message = Message::new(role, row.created_timestamp, content); + message.metadata = metadata; + if let Some(id) = row.message_id { + message = message.with_id(id); + } + Ok(Some(message)) +} + +/// Build a bounded, single-line snippet from user-visible message text. +/// +/// Tool-request, tool-response, thinking, image-only, and assistant-audience +/// blocks collapse to an empty string and return `None`. Internal whitespace +/// and newlines are collapsed to single spaces, and the result includes at most +/// `max_chars` characters of content; if truncated, a trailing `…` is appended +/// so it can be rendered verbatim by clients. +fn message_snippet(message: &Message, max_chars: usize) -> Option { + if !message.metadata.user_visible { + return None; + } + + let text = message + .content + .iter() + .filter_map(|content| content.filter_for_audience(Role::User)) + .filter_map(|content| content.as_text().map(|text| text.to_string())) + .collect::>() + .join("\n"); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + return None; + } + + let mut chars = normalized.chars(); + let mut result: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + let end = result.trim_end().len(); + result.truncate(end); + result.push('…'); + } + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GooseMode; + use crate::conversation::message::{MessageContent, MessageMetadata}; + use crate::session::session_manager::{ + SessionListFilters, SessionListPageQuery, SessionManager, SessionType, + }; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + + async fn snippet_session(sm: &SessionManager) -> String { + sm.create_session( + PathBuf::from("/tmp/snippet"), + "Snippet session".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap() + .id + } + + async fn listed_snippets(sm: &SessionManager) -> HashMap> { + let types = [SessionType::User]; + sm.list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + working_dir: Some(Path::new("/tmp/snippet")), + ..Default::default() + }, + cursor: None, + page_size: 100, + include_last_message_snippet: true, + }) + .await + .unwrap() + .sessions + .into_iter() + .map(|session| (session.id, session.last_message_snippet)) + .collect() + } + + async fn hydrated_snippet_of(sm: &SessionManager, id: &str) -> Option { + let mut snippets = listed_snippets(sm).await; + snippets.remove(id).unwrap() + } + + fn message_at(mut message: Message, created: i64) -> Message { + message.created = created; + message + } + + fn assistant_audience_text(text: &str) -> MessageContent { + use rmcp::model::{AnnotateAble, RawTextContent}; + + MessageContent::Text( + RawTextContent { + text: text.to_string(), + meta: None, + } + .no_annotation() + .with_audience(vec![Role::Assistant]), + ) + } + + #[test] + fn test_message_snippet_collapses_whitespace_and_truncates() { + use rmcp::model::CallToolRequestParams; + + let collapsed = Message::user().with_text(" hello\n\nworld\t again "); + assert_eq!( + message_snippet(&collapsed, 20).as_deref(), + Some("hello world again") + ); + + let exact = Message::user().with_text("one two three four x"); + assert_eq!( + message_snippet(&exact, 20).as_deref(), + Some("one two three four x") + ); + + let long = Message::user().with_text("abcde fghij klmno p qrstuv"); + assert_eq!( + message_snippet(&long, 20).as_deref(), + Some("abcde fghij klmno p…") + ); + + let blob = Message::user().with_text("x".repeat(5000)); + let s = message_snippet(&blob, 20).unwrap(); + assert_eq!(s.chars().count(), 21); + assert!(s.ends_with('…')); + + let tool = + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))); + assert_eq!(message_snippet(&tool, 20), None); + + let thinking = Message::assistant().with_thinking("internal reasoning", "sig"); + assert_eq!(message_snippet(&thinking, 20), None); + + let agent_only = Message::assistant() + .with_text("hidden summary") + .with_metadata(MessageMetadata::agent_only()); + assert_eq!(message_snippet(&agent_only, 20), None); + } + + #[test] + fn test_message_snippet_ignores_assistant_audience_text_blocks() { + let mixed = Message::user() + .with_content(assistant_audience_text("assistant-only preprompt")) + .with_text("visible prompt"); + + assert_eq!( + message_snippet(&mixed, 128).as_deref(), + Some("visible prompt") + ); + + let only_assistant = + Message::user().with_content(assistant_audience_text("assistant-only details")); + + assert_eq!(message_snippet(&only_assistant, 128), None); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_text_append() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + + sm.add_message(&id, &Message::user().with_text("hello there world")) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("hello there world") + ); + + let message = Message::user() + .with_content(assistant_audience_text("assistant-only preprompt")) + .with_text("visible prompt"); + sm.add_message(&id, &message).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("visible prompt") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_ignores_tool_messages() { + use rmcp::model::{CallToolRequestParams, CallToolResult, Content}; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("real text message")) + .await + .unwrap(); + sm.add_message( + &id, + &Message::assistant() + .with_text("hidden context summary") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + sm.add_message( + &id, + &Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + ) + .await + .unwrap(); + sm.add_message( + &id, + &Message::user().with_tool_response( + "t1", + Ok(CallToolResult::success(vec![Content::text("done")])), + ), + ) + .await + .unwrap(); + sm.add_message(&id, &Message::assistant().with_thinking("pondering", "sig")) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("real text message") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_replaced_conversation() { + use crate::conversation::Conversation; + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("stale original message")) + .await + .unwrap(); + + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_text("first user prompt"), + Message::assistant().with_text("assistant reply here"), + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + Message::assistant() + .with_text("hidden compacted summary") + .with_metadata(MessageMetadata::agent_only()), + ]); + sm.replace_conversation(&id, &conversation).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("assistant reply here") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_truncated_conversation() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + let previous = message_at(Message::user().with_text("previous remaining text"), 1_000); + let tool = message_at( + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + 2_000, + ); + let latest = message_at( + Message::assistant().with_text("latest text to remove"), + 3_000, + ); + let hidden = message_at( + Message::assistant() + .with_text("hidden compacted summary") + .with_metadata(MessageMetadata::agent_only()), + 2_500, + ); + + sm.add_message(&id, &previous).await.unwrap(); + sm.add_message(&id, &tool).await.unwrap(); + sm.add_message(&id, &hidden).await.unwrap(); + sm.add_message(&id, &latest).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("latest text to remove") + ); + + sm.truncate_conversation(&id, 3_000).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("previous remaining text") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_null_after_truncate_without_text_messages() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + let tool = message_at( + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + 500, + ); + let text = message_at(Message::user().with_text("only text to remove"), 1_000); + + sm.add_message(&id, &tool).await.unwrap(); + sm.add_message(&id, &text).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("only text to remove") + ); + + sm.truncate_conversation(&id, 1_000).await.unwrap(); + + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + } + + #[tokio::test] + async fn test_live_last_message_snippets_read_from_recent_messages() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let visible_id = snippet_session(&sm).await; + + sm.add_message( + &visible_id, + &Message::user().with_text("**raw** _markdown_ subtitle"), + ) + .await + .unwrap(); + for index in 0..3 { + sm.add_message( + &visible_id, + &Message::assistant() + .with_text(format!("hidden summary {index}")) + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + } + let empty_id = snippet_session(&sm).await; + sm.add_message( + &empty_id, + &Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + ) + .await + .unwrap(); + sm.add_message( + &empty_id, + &Message::assistant() + .with_text("hidden only") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + let by_id = listed_snippets(&sm).await; + + assert_eq!( + by_id + .get(&visible_id) + .and_then(|snippet| snippet.as_deref()), + Some("**raw** _markdown_ subtitle") + ); + assert_eq!(by_id.get(&empty_id), Some(&None)); + } + + #[tokio::test] + async fn test_live_last_message_snippet_skips_unparseable_recent_rows() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message( + &id, + &message_at(Message::user().with_text("older visible text"), 1_000), + ) + .await + .unwrap(); + + let pool = sm.storage().pool().await.unwrap(); + sqlx::query( + r#" + INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp, metadata_json) + VALUES (?, ?, ?, ?, ?, ?) + "#, + ) + .bind("invalid-content") + .bind(&id) + .bind("assistant") + .bind("not valid json") + .bind(2_000_i64) + .bind("{}") + .execute(pool) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("older visible text") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippets_stays_bounded() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("older visible text")) + .await + .unwrap(); + for index in 0..RECENT_MESSAGE_SNIPPET_SCAN_LIMIT { + sm.add_message( + &id, + &Message::assistant() + .with_text(format!("hidden summary {index}")) + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + } + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + } +} diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 00ffa766a..58fdf8674 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -2,6 +2,7 @@ mod chat_history_search; mod diagnostics; pub mod extension_data; pub mod import_formats; +mod last_message_snippet; mod legacy; #[cfg(feature = "nostr")] pub mod nostr_share; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index c216ada06..a5910bd45 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -86,6 +86,8 @@ pub struct Session { pub archived_at: Option>, #[serde(default)] pub project_id: Option, + #[serde(default)] + pub last_message_snippet: Option, } impl Session { @@ -314,6 +316,7 @@ pub(crate) struct SessionListPageQuery<'a> { pub(crate) filters: SessionListFilters<'a>, pub(crate) cursor: Option<&'a SessionListCursor>, pub(crate) page_size: usize, + pub(crate) include_last_message_snippet: bool, } #[derive(Debug, Default)] @@ -596,6 +599,7 @@ impl Default for Session { goose_mode: GooseMode::default(), archived_at: None, project_id: None, + last_message_snippet: None, } } } @@ -668,6 +672,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { .unwrap_or_default(), archived_at: row.try_get("archived_at").ok(), project_id: row.try_get("project_id").ok().flatten(), + last_message_snippet: None, }) } } @@ -1678,6 +1683,7 @@ impl SessionStorage { } let page_size = query.page_size; + let include_last_message_snippet = query.include_last_message_snippet; let mut sessions = self .list_sessions_matching(SessionListQuery { filters: query.filters, @@ -1698,6 +1704,10 @@ impl SessionStorage { if has_next_page { sessions.truncate(page_size); } + if include_last_message_snippet { + let pool = self.pool().await?; + super::last_message_snippet::hydrate_last_message_snippets(pool, &mut sessions).await?; + } Ok(SessionListPage { sessions, @@ -2204,6 +2214,7 @@ mod tests { }, cursor, page_size, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2397,6 +2408,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2438,6 +2450,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2472,6 +2485,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2510,6 +2524,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2530,6 +2545,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2568,6 +2584,7 @@ mod tests { filters: filters.clone(), cursor: None, page_size: 1, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2584,6 +2601,7 @@ mod tests { filters, cursor: cursor.next_cursor.as_ref(), page_size: 1, + include_last_message_snippet: false, }) .await .unwrap(); diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 2407d2062..5f3b084cf 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -3,7 +3,7 @@ mod common_tests; use agent_client_protocol::schema::{ ListSessionsRequest, ListSessionsResponse, SessionConfigKind, SessionConfigOptionCategory, - SessionConfigOptionValue, SetSessionConfigOptionRequest, + SessionConfigOptionValue, SessionInfo, SetSessionConfigOptionRequest, }; use agent_client_protocol::ErrorCode; use common_tests::fixtures::server::AcpServerConnection; @@ -23,7 +23,7 @@ use common_tests::{ run_shell_terminal_false, run_shell_terminal_true, }; use goose::config::GooseMode; -use goose::conversation::message::Message; +use goose::conversation::message::{Message, MessageMetadata}; use goose::custom_requests::{GetSessionInfoRequest, GetSessionInfoResponse}; use goose::session::{SessionManager, SessionType}; use std::path::Path; @@ -116,6 +116,25 @@ fn assert_invalid_params(error: anyhow::Error) { assert_eq!(acp_error.code, ErrorCode::InvalidParams); } +fn include_last_message_snippet_meta( + value: serde_json::Value, +) -> serde_json::Map { + let mut goose = serde_json::Map::new(); + goose.insert("includeLastMessageSnippet".to_string(), value); + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + meta +} + +fn last_message_snippet(session: &SessionInfo) -> Option<&str> { + session + .meta + .as_ref() + .and_then(|meta| meta.get("lastMessageSnippet")) + .and_then(serde_json::Value::as_str) +} + #[test] fn test_config_mcp() { run_test(async { run_config_mcp::().await }); @@ -131,6 +150,79 @@ fn test_list_sessions() { run_test(async { run_list_sessions::().await }); } +#[test] +fn test_list_sessions_emits_computed_snippet() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list-snippet"); + let session_manager = SessionManager::new(data_root.path().to_path_buf()); + let session = session_manager + .create_session( + cwd.to_path_buf(), + "Live subtitle".to_string(), + SessionType::Acp, + GooseMode::default(), + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &Message::user().with_text("**raw** _markdown_ subtitle"), + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &Message::assistant() + .with_text("hidden newer text") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + + let conn = new_connection(data_root.path()).await; + let response = list_sessions_request( + &conn, + ListSessionsRequest::new() + .meta(include_last_message_snippet_meta(serde_json::Value::Null)), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(last_message_snippet(&response.sessions[0]), None); + + let response = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::Bool(false), + )), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(last_message_snippet(&response.sessions[0]), None); + + let response = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::Bool(true), + )), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!( + last_message_snippet(&response.sessions[0]), + Some("**raw** _markdown_ subtitle") + ); + }); +} + #[test] fn test_list_sessions_pagination() { run_test(async { @@ -142,15 +234,24 @@ fn test_list_sessions_pagination() { .await .unwrap(); assert_eq!(first.sessions.len(), 50); + assert!(first + .sessions + .iter() + .all(|session| last_message_snippet(session).is_none())); let second = list_sessions_request( &conn, - ListSessionsRequest::new().cursor(first.next_cursor.clone().unwrap()), + ListSessionsRequest::new() + .cursor(first.next_cursor.clone().unwrap()) + .meta(include_last_message_snippet_meta(serde_json::Value::Bool( + true, + ))), ) .await .unwrap(); assert_eq!(second.sessions.len(), 1); assert!(second.next_cursor.is_none()); + assert_eq!(last_message_snippet(&second.sessions[0]), Some("hello")); let second_id = &second.sessions[0].session_id; assert!(first @@ -296,6 +397,16 @@ fn test_list_sessions_invalid_params() { .await .unwrap_err(); assert_invalid_params(error); + + let error = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::String("true".to_string()), + )), + ) + .await + .unwrap_err(); + assert_invalid_params(error); }); } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index bf8bb14a9..a2576f118 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -7979,6 +7979,10 @@ "format": "int32", "nullable": true }, + "last_message_snippet": { + "type": "string", + "nullable": true + }, "message_count": { "type": "integer", "minimum": 0 diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index ca49c97ee..0568d9257 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1340,6 +1340,7 @@ export type Session = { goose_mode?: GooseMode; id: string; input_tokens?: number | null; + last_message_snippet?: string | null; message_count: number; model_config?: ModelConfig | null; name: string;