Merge pull request #3108 from Yeachan-Heo/fix/issue-335-session-created-at-ms

Expose created_at_ms in session list JSON
This commit is contained in:
YeonGyu-Kim
2026-05-26 02:15:40 +09:00
committed by GitHub
3 changed files with 110 additions and 29 deletions

View File

@@ -413,6 +413,7 @@ impl Session {
.get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()?
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
.unwrap_or(now);
let updated_at_ms = object
.get("updated_at_ms")
@@ -500,7 +501,10 @@ impl Session {
"session_meta" => {
version = required_u32(object, "version")?;
session_id = Some(required_string(object, "session_id")?);
created_at_ms = Some(required_u64(object, "created_at_ms")?);
created_at_ms = object
.get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()?;
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
workspace_root = object
@@ -543,11 +547,15 @@ impl Session {
}
let now = current_time_millis();
let session_id = session_id.unwrap_or_else(generate_session_id);
let created_at_ms = created_at_ms
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
.unwrap_or(now);
Ok(Self {
version,
session_id: session_id.unwrap_or_else(generate_session_id),
created_at_ms: created_at_ms.unwrap_or(now),
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
session_id,
created_at_ms,
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms),
messages,
compaction,
fork,
@@ -1291,6 +1299,15 @@ fn current_time_millis() -> u64 {
}
}
pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option<u64> {
let timestamp_and_suffix = session_id.strip_prefix("session-")?;
let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?;
if suffix.is_empty() {
return None;
}
timestamp.parse::<u64>().ok()
}
fn generate_session_id() -> String {
let millis = current_time_millis();
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
@@ -1380,8 +1397,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
#[cfg(test)]
mod tests {
use super::{
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
ConversationMessage, MessageRole, Session, SessionFork,
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id,
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session,
SessionFork,
};
use crate::json::JsonValue;
use crate::usage::TokenUsage;
@@ -1502,6 +1520,44 @@ mod tests {
assert!(!restored.session_id.is_empty());
}
#[test]
fn created_at_parser_requires_full_session_id_shape() {
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123-0"),
Some(1_743_724_800_123)
);
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123"),
None
);
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123-"),
None
);
assert_eq!(
parse_created_at_ms_from_session_id("other-1743724800123-0"),
None
);
}
#[test]
fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() {
let path = temp_session_path("legacy-jsonl-created-at");
fs::write(
&path,
r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456}
"#,
)
.expect("legacy jsonl should write");
let restored = Session::load_from_path(&path).expect("legacy jsonl should load");
fs::remove_file(&path).expect("temp file should be removable");
assert_eq!(restored.session_id, "session-1743724800123-0");
assert_eq!(restored.created_at_ms, 1_743_724_800_123);
assert_eq!(restored.updated_at_ms, 1_743_724_800_456);
}
#[test]
fn appends_messages_to_persisted_jsonl_session() {
let path = temp_session_path("append");

View File

@@ -5,7 +5,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::session::{Session, SessionError};
use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError};
/// Per-worktree session store that namespaces on-disk session files by
/// workspace fingerprint so that parallel `opencode serve` instances never
@@ -345,6 +345,9 @@ impl SessionStore {
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
let fallback_created_at_ms =
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
let summary = match Session::load_from_path(&path) {
Ok(session) => {
if self.validate_loaded_session(&path, &session).is_err() {
@@ -353,6 +356,7 @@ impl SessionStore {
ManagedSessionSummary {
id: session.session_id,
path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis,
message_count: session.messages.len(),
@@ -367,12 +371,9 @@ impl SessionStore {
}
}
Err(_) => ManagedSessionSummary {
id: path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
id: fallback_id,
path,
created_at_ms: fallback_created_at_ms,
updated_at_ms: 0,
modified_epoch_millis,
message_count: 0,
@@ -409,10 +410,14 @@ impl SessionStore {
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
let fallback_created_at_ms =
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
let summary = match Session::load_from_path(&path) {
Ok(session) => ManagedSessionSummary {
id: session.session_id,
path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis,
message_count: session.messages.len(),
@@ -426,12 +431,9 @@ impl SessionStore {
.and_then(|fork| fork.branch_name.clone()),
},
Err(_) => ManagedSessionSummary {
id: path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
id: fallback_id,
path,
created_at_ms: fallback_created_at_ms,
updated_at_ms: 0,
modified_epoch_millis,
message_count: 0,
@@ -483,6 +485,7 @@ pub struct SessionHandle {
pub struct ManagedSessionSummary {
pub id: String,
pub path: PathBuf,
pub created_at_ms: u64,
pub updated_at_ms: u64,
pub modified_epoch_millis: u128,
pub message_count: usize,
@@ -810,6 +813,7 @@ mod tests {
ManagedSessionSummary {
id: "older-file-newer-session".to_string(),
path: PathBuf::from("/tmp/older"),
created_at_ms: 100,
updated_at_ms: 200,
modified_epoch_millis: 100,
message_count: 2,
@@ -819,6 +823,7 @@ mod tests {
ManagedSessionSummary {
id: "newer-file-older-session".to_string(),
path: PathBuf::from("/tmp/newer"),
created_at_ms: 50,
updated_at_ms: 100,
modified_epoch_millis: 200,
message_count: 1,

View File

@@ -3960,18 +3960,7 @@ fn run_resume_command(
let session_list_outcome = || -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let session_details: Vec<serde_json::Value> = sessions
.iter()
.map(|session| {
serde_json::json!({
"id": session.id,
"path": session.path.display().to_string(),
"message_count": session.message_count,
"updated_at_ms": session.updated_at_ms,
"lifecycle": session.lifecycle.json_value(),
})
})
.collect();
let session_details = session_details_json(&sessions);
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
@@ -4550,6 +4539,7 @@ struct SessionHandle {
struct ManagedSessionSummary {
id: String,
path: PathBuf,
created_at_ms: u64,
updated_at_ms: u64,
modified_epoch_millis: u128,
message_count: usize,
@@ -6367,6 +6357,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.map(|session| ManagedSessionSummary {
id: session.id,
path: session.path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
@@ -6386,6 +6377,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
Ok(ManagedSessionSummary {
id: session.id,
path: session.path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
@@ -6443,6 +6435,7 @@ fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec<serde_json::V
"id": session.id,
"path": session.path.display().to_string(),
"message_count": session.message_count,
"created_at_ms": session.created_at_ms,
"updated_at_ms": session.updated_at_ms,
"modified_epoch_millis": session.modified_epoch_millis,
"parent_session_id": session.parent_session_id,
@@ -14270,6 +14263,33 @@ UU conflicted.rs",
assert_eq!(missing["session_id"], "missing-session");
assert!(missing["candidate_path"].as_str().is_some());
let list_command = SlashCommand::parse("/session list")
.expect("parse should succeed")
.expect("command should exist");
let list = run_resume_command(&active.path, &active_session, &list_command)
.expect("list should run")
.json
.expect("list should return json");
assert_eq!(list["kind"], "sessions");
let details = list["session_details"]
.as_array()
.expect("session_details should be an array");
let saved_path = saved.path.display().to_string();
let saved_detail = details
.iter()
.find(|detail| detail["path"] == saved_path)
.expect("saved session detail should exist");
let created_at_ms = saved_detail["created_at_ms"]
.as_u64()
.expect("created_at_ms should be present");
let updated_at_ms = saved_detail["updated_at_ms"]
.as_u64()
.expect("updated_at_ms should be present");
assert!(
created_at_ms <= updated_at_ms,
"created_at_ms should not be after updated_at_ms"
);
let delete_command = SlashCommand::parse("/session delete session-saved --force")
.expect("parse should succeed")
.expect("command should exist");