mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Expose last message timestamp on sessions (#9961)
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
@@ -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)? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)?,
|
||||
};
|
||||
|
||||
@@ -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(×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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8124,6 +8124,11 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_message_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"last_message_snippet": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user