diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index 15d5908888..72c3610fe3 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -70,7 +70,8 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result(out: &mut W, line: &str) -> Result chrono::DateTime { + session.last_message_at.unwrap_or(session.updated_at) +} + pub async fn handle_session_list( format: String, ascending: bool, @@ -170,9 +175,9 @@ pub async fn handle_session_list( } if ascending { - sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); + sessions.sort_by_key(session_activity_at); } else { - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|b| std::cmp::Reverse(session_activity_at(b))); } if let Some(n) = limit { @@ -206,7 +211,7 @@ pub async fn handle_session_list( "{} - {} - {} - {}", session.id, session.name, - session.updated_at, + session_activity_at(&session), display_path_with_tilde(&session.working_dir) ); if !write_line_or_broken_pipe_ok(&mut out, &output)? { diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index d307d91152..cb948e59bd 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -11,6 +11,7 @@ use agent_client_protocol::schema::{ use agent_client_protocol::{Client, ConnectionTo}; use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; +use serde::Serialize; use strum::{EnumMessage, VariantNames}; use super::server::{build_usage_updates, DEFAULT_PROVIDER_ID, DEFAULT_PROVIDER_LABEL}; @@ -22,60 +23,54 @@ pub(super) fn session_provider_selection(session: &Session) -> &str { .unwrap_or(DEFAULT_PROVIDER_ID) } -pub(super) fn session_meta(session: &Session) -> serde_json::Map { - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(session.message_count.into()), - ); - meta.insert( - "createdAt".to_string(), - serde_json::Value::String(session.created_at.to_rfc3339()), - ); - if let Some(ref archived_at) = session.archived_at { - meta.insert( - "archivedAt".to_string(), - serde_json::Value::String(archived_at.to_rfc3339()), - ); - } - meta.insert( - "userSetName".to_string(), - serde_json::Value::Bool(session.user_set_name), - ); - meta.insert( - "sessionType".to_string(), - serde_json::Value::String(session.session_type.to_string()), - ); - meta.insert( - "hasRecipe".to_string(), - serde_json::Value::Bool(session.recipe.is_some()), - ); +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionMeta<'a> { + message_count: usize, + created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + last_message_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + archived_at: Option>, + user_set_name: bool, + session_type: String, + has_recipe: bool, + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + provider_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + model_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + last_message_snippet: Option<&'a str>, +} - if let Some(ref pid) = session.project_id { - meta.insert( - "projectId".to_string(), - serde_json::Value::String(pid.clone()), - ); +impl<'a> From<&'a Session> for SessionMeta<'a> { + fn from(session: &'a Session) -> Self { + Self { + message_count: session.message_count, + created_at: session.created_at, + last_message_at: session.last_message_at, + archived_at: session.archived_at, + user_set_name: session.user_set_name, + session_type: session.session_type.to_string(), + has_recipe: session.recipe.is_some(), + project_id: session.project_id.as_deref(), + provider_id: session.provider_name.as_deref(), + model_id: session + .model_config + .as_ref() + .map(|mc| mc.model_name.as_str()), + last_message_snippet: session.last_message_snippet.as_deref(), + } } - if let Some(ref provider) = session.provider_name { - meta.insert( - "providerId".to_string(), - serde_json::Value::String(provider.clone()), - ); +} + +pub(super) fn session_meta(session: &Session) -> serde_json::Map { + match serde_json::to_value(SessionMeta::from(session)) { + Ok(serde_json::Value::Object(meta)) => meta, + _ => serde_json::Map::new(), } - if let Some(ref mc) = session.model_config { - meta.insert( - "modelId".to_string(), - 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 } pub(super) fn session_response_meta( diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index 48e51f10e8..b56e73b8da 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -13,9 +13,10 @@ const ACP_SESSION_LIST_TYPES: [SessionType; 3] = #[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. + #[serde(alias = "updated_at")] + sort_at: chrono::DateTime, + // Goose stores timestamps with second precision in common write paths, so the + // cursor needs the full (sort_at, id) sort key to avoid skipping tied rows. session_id: String, filter_hash: String, } @@ -146,7 +147,7 @@ fn decode_session_list_cursor( } Ok(Some(SessionListCursor { - updated_at: token.updated_at, + sort_at: token.sort_at, session_id: token.session_id, })) } @@ -158,7 +159,7 @@ fn encode_session_list_cursor( keyword: Option<&str>, ) -> Result { let token = SessionListCursorToken { - updated_at: cursor.updated_at, + sort_at: cursor.sort_at, session_id: cursor.session_id.clone(), filter_hash: session_list_filter_hash(cwd, session_types, keyword)?, }; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7b9e3f43de..ed64b8d1eb 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -9,7 +9,7 @@ use crate::session::session_naming::{ generate_session_name, MSG_COUNT_FOR_SESSION_NAME_GENERATION, }; use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use goose_providers::conversation::token_usage::Usage; use goose_providers::model::ModelConfig; use rmcp::model::Role; @@ -26,6 +26,7 @@ use utoipa::ToSchema; pub const CURRENT_SCHEMA_VERSION: i32 = 14; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; +const MILLISECOND_TIMESTAMP_THRESHOLD: i64 = 10_000_000_000; #[derive( Debug, @@ -80,6 +81,8 @@ pub struct Session { pub user_recipe_values: Option>, pub conversation: Option, pub message_count: usize, + #[serde(default)] + pub last_message_at: Option>, pub provider_name: Option, pub model_config: Option, #[serde(default)] @@ -276,7 +279,7 @@ pub struct SessionManager { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SessionListCursor { - pub(crate) updated_at: DateTime, + pub(crate) sort_at: DateTime, pub(crate) session_id: String, } @@ -601,6 +604,25 @@ pub(crate) fn role_to_string(role: &Role) -> &'static str { } } +fn message_timestamp_to_datetime(timestamp: i64) -> Option> { + let timestamp = if timestamp > MILLISECOND_TIMESTAMP_THRESHOLD { + timestamp / 1000 + } else { + timestamp + }; + Utc.timestamp_opt(timestamp, 0).single() +} + +fn normalized_message_timestamp_sql(column: &str) -> String { + format!( + "CASE WHEN {column} > {MILLISECOND_TIMESTAMP_THRESHOLD} THEN {column} / 1000 ELSE {column} END" + ) +} + +fn session_sort_at(session: &Session) -> DateTime { + session.last_message_at.unwrap_or(session.updated_at) +} + impl Default for Session { fn default() -> Self { Self { @@ -620,6 +642,7 @@ impl Default for Session { user_recipe_values: None, conversation: None, message_count: 0, + last_message_at: None, provider_name: None, model_config: None, goose_mode: GooseMode::default(), @@ -667,6 +690,12 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { .unwrap_or_else(|_| "user".to_string()); let session_type = session_type_str.parse().unwrap_or_default(); + let last_message_at = row + .try_get::, _>("last_message_timestamp") + .ok() + .flatten() + .and_then(message_timestamp_to_datetime); + Ok(Session { id: row.try_get("id")?, working_dir: PathBuf::from(row.try_get::("working_dir")?), @@ -703,6 +732,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { user_recipe_values, conversation: None, message_count: row.try_get("message_count").unwrap_or(0) as usize, + last_message_at, provider_name: row.try_get("provider_name").ok().flatten(), model_config, goose_mode: row @@ -1374,14 +1404,24 @@ impl SessionStorage { if include_messages { let conv = self.get_conversation(&session.id).await?; session.message_count = conv.messages().len(); + session.last_message_at = conv + .messages() + .iter() + .filter_map(|message| message_timestamp_to_datetime(message.created)) + .max(); session.conversation = Some(conv); } else { - let count = - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages WHERE session_id = ?") - .bind(&session.id) - .fetch_one(pool) - .await? as usize; - session.message_count = count; + let sql = format!( + "SELECT COUNT(*), MAX({}) FROM messages WHERE session_id = ?", + normalized_message_timestamp_sql("created_timestamp") + ); + let (count, last_message_timestamp): (i64, Option) = sqlx::query_as(&sql) + .bind(&session.id) + .fetch_one(pool) + .await?; + session.message_count = count as usize; + session.last_message_at = + last_message_timestamp.and_then(message_timestamp_to_datetime); } Ok(session) @@ -1650,6 +1690,10 @@ impl SessionStorage { let keywords = keyword_terms(filters.keyword); let mut where_clauses = Vec::new(); + let mut having_clauses = Vec::new(); + let normalized_message_timestamp = normalized_message_timestamp_sql("m.created_timestamp"); + let sort_timestamp_sql = + format!("COALESCE(MAX({normalized_message_timestamp}), unixepoch(s.updated_at))"); if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); where_clauses.push(format!("s.session_type IN ({})", placeholders)); @@ -1661,11 +1705,9 @@ impl SessionStorage { where_clauses.push(message_keyword_clause(keywords.len())); } if query.cursor.is_some() { - where_clauses.push( - "(datetime(s.updated_at) < datetime(?) \ - OR (datetime(s.updated_at) = datetime(?) AND s.id < ?))" - .to_string(), - ); + having_clauses.push(format!( + "({sort_timestamp_sql} < ? OR ({sort_timestamp_sql} = ? AND s.id < ?))" + )); } let where_clause = if where_clauses.is_empty() { @@ -1673,16 +1715,17 @@ impl SessionStorage { } else { format!("WHERE {}", where_clauses.join(" AND ")) }; + let having_clause = if having_clauses.is_empty() { + String::new() + } else { + format!("HAVING {}", having_clauses.join(" AND ")) + }; 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" }; - 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 order_by = "ORDER BY sort_timestamp DESC, s.id DESC"; let limit_clause = if query.limit.is_some() { "LIMIT ?" } else { "" }; let sql = format!( @@ -1696,15 +1739,24 @@ impl SessionStorage { s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.provider_name, s.model_config_json, s.goose_mode, s.archived_at, s.project_id, - COUNT(m.id) as message_count + COUNT(m.id) as message_count, + MAX({}) as last_message_timestamp, + {} as sort_timestamp FROM sessions s {} {} GROUP BY s.id {} {} + {} "#, - message_join, where_clause, order_by, limit_clause + normalized_message_timestamp, + sort_timestamp_sql, + message_join, + where_clause, + having_clause, + order_by, + limit_clause ); let mut q = sqlx::query_as::<_, Session>(&sql); @@ -1720,10 +1772,9 @@ impl SessionStorage { q = q.bind(term); } 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); + let sort_at = cursor.sort_at.timestamp(); + q = q.bind(sort_at); + q = q.bind(sort_at); q = q.bind(&cursor.session_id); } if let Some(limit) = query.limit { @@ -1769,7 +1820,7 @@ impl SessionStorage { let next_cursor = if has_next_page { let anchor = &sessions[page_size - 1]; Some(SessionListCursor { - updated_at: anchor.updated_at, + sort_at: session_sort_at(anchor), session_id: anchor.id.clone(), }) } else { @@ -2269,6 +2320,31 @@ mod tests { .unwrap(); } + async fn add_message_at_millis( + 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_millis()) + .bind(session_id) + .execute(pool) + .await + .unwrap(); + } + async fn set_message_timestamp( sm: &SessionManager, session_id: &str, @@ -2297,6 +2373,40 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_last_message_at_is_derived_from_messages() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let session = sm + .create_session( + PathBuf::from("/tmp/test"), + "Session recency".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + let empty = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(empty.message_count, 0); + assert_eq!(empty.last_message_at, None); + + add_message_at_millis(&sm, &session.id, "older", "2026-01-01T00:00:00Z").await; + add_message_at(&sm, &session.id, "newer", "2026-01-02T03:04:05Z").await; + + let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z") + .unwrap() + .with_timezone(&Utc); + + let without_messages = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(without_messages.message_count, 2); + assert_eq!(without_messages.last_message_at, Some(expected)); + + let with_messages = sm.get_session(&session.id, true).await.unwrap(); + assert_eq!(with_messages.message_count, 2); + assert_eq!(with_messages.last_message_at, Some(expected)); + } + #[tokio::test] async fn test_truncate_conversation_from_message_keeps_same_second_previous_rows() { let temp_dir = TempDir::new().unwrap(); @@ -2561,8 +2671,8 @@ mod tests { sessions.push(sm.get_session(session_id, false).await.unwrap()); } sessions.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) + session_sort_at(b) + .cmp(&session_sort_at(a)) .then_with(|| b.id.cmp(&a.id)) }); sessions.into_iter().map(|session| session.id).collect() @@ -2711,7 +2821,53 @@ mod tests { } #[tokio::test] - async fn test_session_list_paged_uses_id_tiebreaker_for_duplicate_updated_at() { + async fn test_session_list_paged_sorts_by_last_message_at() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let stale_but_modified = create_session_for_list(&sm, "/tmp/session-list", false).await; + add_message_at( + &sm, + &stale_but_modified, + "older message", + "2026-01-01T00:00:00Z", + ) + .await; + set_sessions_updated_at( + &sm, + std::slice::from_ref(&stale_but_modified), + "2026-02-01T00:00:00Z", + ) + .await; + + let active_but_not_modified = + create_session_for_list(&sm, "/tmp/session-list", false).await; + add_message_at( + &sm, + &active_but_not_modified, + "newer message", + "2026-01-02T00:00:00Z", + ) + .await; + set_sessions_updated_at( + &sm, + std::slice::from_ref(&active_but_not_modified), + "2026-01-15T00:00:00Z", + ) + .await; + + assert_session_list_page( + &sm, + None, + None, + 2, + &[active_but_not_modified, stale_but_modified], + false, + ) + .await; + } + + #[tokio::test] + async fn test_session_list_paged_uses_id_tiebreaker_for_duplicate_activity_time() { let temp_dir = TempDir::new().unwrap(); let sm = SessionManager::new(temp_dir.path().to_path_buf()); let mut expected_ids = Vec::new(); diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index 0065a7bd10..5d1e8592c5 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -71,7 +71,9 @@ pub async fn run_list_sessions() { // createdAt is a dynamic timestamp — verify it exists then remove for comparison. if let Some(ref mut meta) = s.meta { assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); + assert!(meta.get("lastMessageAt").and_then(|v| v.as_str()).is_some()); meta.remove("createdAt"); + meta.remove("lastMessageAt"); // Provider/model metadata varies by test fixture; not relevant here. meta.remove("providerId"); meta.remove("modelId"); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 3665effac0..84c028bf06 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -8124,6 +8124,11 @@ "id": { "type": "string" }, + "last_message_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "last_message_snippet": { "type": "string", "nullable": true diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 001c99a9fc..549cea15ef 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -14,6 +14,7 @@ import type { Recipe } from '../recipe'; interface GooseSessionInfoMeta { messageCount?: number; createdAt?: string; + lastMessageAt?: string; archivedAt?: string; projectId?: string; providerId?: string; @@ -30,6 +31,7 @@ export interface SessionListItem { workingDir: string; updatedAt: string; messageCount: number; + lastMessageAt?: string; createdAt: string; archivedAt?: string; projectId?: string; @@ -94,6 +96,7 @@ export function sessionInfoToSession(s: SessionInfo, loadMeta: LoadSessionMeta = working_dir: loadMeta.workingDir ?? s.cwd, created_at: createdAt, updated_at: updatedAt, + last_message_at: meta.lastMessageAt, message_count: meta.messageCount ?? 0, extension_data: {}, archived_at: meta.archivedAt, @@ -116,6 +119,7 @@ function sessionInfoToListItem(s: SessionInfo): SessionListItem { workingDir: s.cwd, updatedAt: s.updatedAt ?? '', messageCount: meta.messageCount ?? 0, + lastMessageAt: meta.lastMessageAt, createdAt: meta.createdAt ?? s.updatedAt ?? '', archivedAt: meta.archivedAt, projectId: meta.projectId, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f8b414683a..31b39b4558 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1414,6 +1414,7 @@ export type Session = { extension_data: ExtensionData; goose_mode?: GooseMode; id: string; + last_message_at?: string | null; last_message_snippet?: string | null; message_count: number; model_config?: ModelConfig | null; diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index bf505c0f4a..51ea3d9d98 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -21,7 +21,7 @@ import { ScrollArea } from '../ui/scroll-area'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { SearchView } from '../conversation/SearchView'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; -import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; +import { groupSessionsByDate, sessionActivityAt, type DateGroup } from '../../utils/dateUtils'; import { errorMessage } from '../../utils/conversionUtils'; import { Skeleton } from '../ui/skeleton'; import { toast } from 'react-toastify'; @@ -734,7 +734,9 @@ const SessionListView: React.FC = React.memo(
- {formatMessageTimestamp(Date.parse(session.updatedAt) / 1000)} + + {formatMessageTimestamp(Date.parse(sessionActivityAt(session)) / 1000)} +
diff --git a/ui/desktop/src/hooks/useNavigationSessions.ts b/ui/desktop/src/hooks/useNavigationSessions.ts index 6977cefa88..b623356aa8 100644 --- a/ui/desktop/src/hooks/useNavigationSessions.ts +++ b/ui/desktop/src/hooks/useNavigationSessions.ts @@ -31,6 +31,7 @@ export function sessionToListItem(s: Session): SessionListItem { workingDir: s.working_dir, updatedAt: s.updated_at, messageCount: s.message_count, + lastMessageAt: s.last_message_at ?? undefined, createdAt: s.created_at, archivedAt: s.archived_at ?? undefined, projectId: s.project_id ?? undefined, diff --git a/ui/desktop/src/utils/dateUtils.ts b/ui/desktop/src/utils/dateUtils.ts index 542ea344a9..172fe482da 100644 --- a/ui/desktop/src/utils/dateUtils.ts +++ b/ui/desktop/src/utils/dateUtils.ts @@ -6,6 +6,10 @@ export interface DateGroup { date: Date; } +export function sessionActivityAt(session: SessionListItem): string { + return session.lastMessageAt ?? session.updatedAt; +} + export function groupSessionsByDate(sessions: SessionListItem[]): DateGroup[] { const today = new Date(); today.setHours(0, 0, 0, 0); @@ -16,7 +20,7 @@ export function groupSessionsByDate(sessions: SessionListItem[]): DateGroup[] { const groups: { [key: string]: DateGroup } = {}; sessions.forEach((session) => { - const sessionDate = new Date(session.updatedAt); + const sessionDate = new Date(sessionActivityAt(session)); const sessionDateStart = new Date(sessionDate); sessionDateStart.setHours(0, 0, 0, 0);