feat(session): add opt-in ACP last message snippets (#9798)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-06-15 16:28:27 +10:00
committed by GitHub
parent 3063de7584
commit b3aa4c2e13
9 changed files with 731 additions and 3 deletions
+6
View File
@@ -64,6 +64,12 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map<String, serde_j
serde_json::Value::String(mc.model_name.clone()),
);
}
if let Some(ref snippet) = session.last_message_snippet {
meta.insert(
"lastMessageSnippet".to_string(),
serde_json::Value::String(snippet.clone()),
);
}
meta
}
+1
View File
@@ -3713,6 +3713,7 @@ print(\"hello, world\")
goose_mode: GooseMode::default(),
archived_at: None,
project_id: None,
last_message_snippet: None,
}
}
@@ -70,6 +70,32 @@ fn session_types_from_meta(
}
}
fn include_last_message_snippet_from_meta(
meta: Option<&Meta>,
) -> Result<bool, agent_client_protocol::Error> {
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<SessionInfo> =
page.sessions.into_iter().map(build_session_info).collect();
let next_cursor = page
@@ -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<String>,
message_id: Option<String>,
}
pub(super) async fn hydrate_last_message_snippets(
pool: &Pool<Sqlite>,
sessions: &mut [Session],
) -> Result<()> {
if sessions.is_empty() {
return Ok(());
}
let session_ids = sessions
.iter()
.map(|session| session.id.clone())
.collect::<Vec<_>>();
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<Sqlite>,
session_ids: &[String],
) -> Result<Vec<RecentMessageRow>> {
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::<Vec<_>>()
.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<Option<Message>> {
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<String> {
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::<Vec<_>>()
.join("\n");
let normalized = text.split_whitespace().collect::<Vec<_>>().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<String, Option<String>> {
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<String> {
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);
}
}
+1
View File
@@ -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;
@@ -86,6 +86,8 @@ pub struct Session {
pub archived_at: Option<DateTime<Utc>>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub last_message_snippet: Option<String>,
}
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();
+114 -3
View File
@@ -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<String, serde_json::Value> {
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::<AcpServerConnection>().await });
@@ -131,6 +150,79 @@ fn test_list_sessions() {
run_test(async { run_list_sessions::<AcpServerConnection>().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);
});
}
+4
View File
@@ -7979,6 +7979,10 @@
"format": "int32",
"nullable": true
},
"last_message_snippet": {
"type": "string",
"nullable": true
},
"message_count": {
"type": "integer",
"minimum": 0
+1
View File
@@ -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;