Expose last message timestamp on sessions (#9961)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Douwe Osinga
2026-06-24 12:28:01 -04:00
committed by GitHub
parent efafdf72a9
commit 2acae3b243
11 changed files with 267 additions and 91 deletions
+9 -4
View File
@@ -70,7 +70,8 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result<Vec<Sessio
&s.name
};
let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id);
let display_text =
format!("{} - {} ({})", session_activity_at(s), truncated_desc, s.id);
(display_text, s.clone())
})
.collect();
@@ -150,6 +151,10 @@ fn write_line_or_broken_pipe_ok<W: Write>(out: &mut W, line: &str) -> Result<boo
}
}
fn session_activity_at(session: &Session) -> chrono::DateTime<chrono::Utc> {
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)? {
+46 -51
View File
@@ -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<String, serde_json::Value> {
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<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
last_message_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
archived_at: Option<chrono::DateTime<chrono::Utc>>,
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<String, serde_json::Value> {
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(
+6 -5
View File
@@ -13,9 +13,10 @@ const ACP_SESSION_LIST_TYPES: [SessionType; 3] =
#[derive(Debug, Serialize, Deserialize)]
struct SessionListCursorToken {
updated_at: chrono::DateTime<chrono::Utc>,
// 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<chrono::Utc>,
// 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<String, agent_client_protocol::Error> {
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)?,
};
+184 -28
View File
@@ -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<HashMap<String, String>>,
pub conversation: Option<Conversation>,
pub message_count: usize,
#[serde(default)]
pub last_message_at: Option<DateTime<Utc>>,
pub provider_name: Option<String>,
pub model_config: Option<ModelConfig>,
#[serde(default)]
@@ -276,7 +279,7 @@ pub struct SessionManager {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionListCursor {
pub(crate) updated_at: DateTime<Utc>,
pub(crate) sort_at: DateTime<Utc>,
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<DateTime<Utc>> {
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<Utc> {
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::<Option<i64>, _>("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::<String, _>("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<i64>) = 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::<Vec<_>>().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(&timestamp_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();
@@ -71,7 +71,9 @@ pub async fn run_list_sessions<C: Connection>() {
// 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");
+5
View File
@@ -8124,6 +8124,11 @@
"id": {
"type": "string"
},
"last_message_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"last_message_snippet": {
"type": "string",
"nullable": true
+4
View File
@@ -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,
+1
View File
@@ -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;
@@ -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<SessionListViewProps> = React.memo(
<div className="flex-1 mt-2">
<div className="flex items-center text-text-secondary text-xs">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.updatedAt) / 1000)}</span>
<span>
{formatMessageTimestamp(Date.parse(sessionActivityAt(session)) / 1000)}
</span>
</div>
<div className="flex items-center text-text-secondary text-xs">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
@@ -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,
+5 -1
View File
@@ -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);