feat(session): add opt-in ACP last message snippets (#9798)
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7979,6 +7979,10 @@
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"last_message_snippet": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"message_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user