diff --git a/src-tauri/src/parser/discover.rs b/src-tauri/src/parser/discover.rs index c7a06b1..15b56d7 100644 --- a/src-tauri/src/parser/discover.rs +++ b/src-tauri/src/parser/discover.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::Path; use std::time::SystemTime; -use super::entry::RawEntry; +use super::entry::{extract_session_id, RawEntry}; use super::spawn::parse_spawn_agent_output; /// Lightweight session info for the picker list. @@ -141,7 +141,7 @@ fn scan_session_file(path: &Path) -> Option { ai_title, ) = match entry.entry_type.as_str() { "session_meta" => { - let id = str_field(payload, "id"); + let id = extract_session_id(payload); let start_time = str_field(payload, "timestamp"); let cwd = opt_str(payload, "cwd"); let originator = opt_str(payload, "originator"); @@ -466,6 +466,50 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn discover_sessions_reads_id_from_session_id_field() { + // v0.129.0+ PR #20437: session_id field in session_meta payload + let tmp = tempdir().unwrap(); + let day_dir = tmp.path().join("2026/05/07"); + std::fs::create_dir_all(&day_dir).unwrap(); + let path = day_dir.join("rollout-2026-05-07T00-00-00-newsessid.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{"session_id":"new-sess-id","timestamp":"2026-05-07T00:00:00Z","cwd":"/tmp"}}"#, + r#"{"timestamp":"2026-05-07T00:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-07T00:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1746576002.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + let sessions = discover_sessions(tmp.path()).unwrap(); + let session = sessions.iter().find(|s| s.id == "new-sess-id").unwrap(); + assert_eq!(session.id, "new-sess-id"); + } + + #[test] + fn discover_sessions_reads_id_from_thread_session_id() { + // v0.129.0+ PR #21336: sessionId moved onto Thread object + let tmp = tempdir().unwrap(); + let day_dir = tmp.path().join("2026/05/07"); + std::fs::create_dir_all(&day_dir).unwrap(); + let path = day_dir.join("rollout-2026-05-07T00-01-00-threadsessid.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-07T00:01:00Z","type":"session_meta","payload":{"thread":{"sessionId":"thread-sess-id"},"timestamp":"2026-05-07T00:01:00Z","cwd":"/tmp"}}"#, + r#"{"timestamp":"2026-05-07T00:01:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-07T00:01:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1746576062.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + let sessions = discover_sessions(tmp.path()).unwrap(); + let session = sessions.iter().find(|s| s.id == "thread-sess-id").unwrap(); + assert_eq!(session.id, "thread-sess-id"); + } + #[test] fn date_group_from_path_test() { let path = PathBuf::from("/home/user/.codex/sessions/2026/04/25/rollout-abc.jsonl"); diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index 77e8dfe..d381216 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -74,6 +74,34 @@ pub fn event_msg_type(payload: &Value) -> Option<&str> { payload.get("type").and_then(|t| t.as_str()) } +/// Extract the session ID from a session_meta payload. +/// +/// Tries paths in version order for forward compatibility: +/// 1. `id` — all pre-v0.129.0 sessions +/// 2. `session_id` — v0.129.0+ (PR #20437) +/// 3. `thread.sessionId` — v0.129.0+ v2 API path (PR #21336) +pub fn extract_session_id(payload: &Value) -> String { + payload + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + payload + .get("session_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + .or_else(|| { + payload + .get("thread") + .and_then(|t| t.get("sessionId")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + .map(|s| s.to_string()) + .unwrap_or_default() +} + /// Parse an ISO timestamp string to Unix seconds (u64). pub fn parse_timestamp_secs(ts: &str) -> Option { use chrono::DateTime; @@ -85,6 +113,45 @@ pub fn parse_timestamp_secs(ts: &str) -> Option { mod tests { use super::*; + #[test] + fn extract_session_id_reads_id_field() { + let v: Value = + serde_json::from_str(r#"{"id":"abc-123","timestamp":"2026-05-07T00:00:00Z"}"#).unwrap(); + assert_eq!(extract_session_id(&v), "abc-123"); + } + + #[test] + fn extract_session_id_falls_back_to_session_id_field() { + // v0.129.0+ PR #20437: session_id field added alongside or instead of id + let v: Value = + serde_json::from_str(r#"{"session_id":"sess-456","timestamp":"2026-05-07T00:00:00Z"}"#) + .unwrap(); + assert_eq!(extract_session_id(&v), "sess-456"); + } + + #[test] + fn extract_session_id_falls_back_to_thread_session_id() { + // v0.129.0+ PR #21336: sessionId moved onto Thread object in v2 API + let v: Value = serde_json::from_str( + r#"{"thread":{"sessionId":"thread-789"},"timestamp":"2026-05-07T00:00:00Z"}"#, + ) + .unwrap(); + assert_eq!(extract_session_id(&v), "thread-789"); + } + + #[test] + fn extract_session_id_prefers_id_over_session_id() { + let v: Value = + serde_json::from_str(r#"{"id":"primary","session_id":"secondary"}"#).unwrap(); + assert_eq!(extract_session_id(&v), "primary"); + } + + #[test] + fn extract_session_id_returns_empty_when_absent() { + let v: Value = serde_json::from_str(r#"{"timestamp":"2026-05-07T00:00:00Z"}"#).unwrap(); + assert_eq!(extract_session_id(&v), ""); + } + #[test] fn parse_new_session_meta() { let line = r#"{"timestamp":"2026-04-25T10:00:00Z","type":"session_meta","payload":{"id":"abc","cwd":"/tmp"}}"#; diff --git a/src-tauri/src/parser/session.rs b/src-tauri/src/parser/session.rs index 6e167e8..122701c 100644 --- a/src-tauri/src/parser/session.rs +++ b/src-tauri/src/parser/session.rs @@ -6,7 +6,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::SystemTime; -use super::entry::RawEntry; +use super::entry::{extract_session_id, RawEntry}; use super::toolcall::ToolKind; use super::turn::{build_turns, CodexTurn, TokenInfo, TurnStatus}; @@ -224,11 +224,14 @@ fn session_file_id(path: &Path) -> Option { let line = line.ok()?; let entry = RawEntry::parse(&line)?; match entry.entry_type.as_str() { - "session_meta" => entry - .payload - .get("id") - .and_then(|id| id.as_str()) - .map(|id| id.to_string()), + "session_meta" => { + let id = extract_session_id(&entry.payload); + if id.is_empty() { + None + } else { + Some(id) + } + } "session_meta_root" => entry .raw .get("id") @@ -240,7 +243,7 @@ fn session_file_id(path: &Path) -> Option { } fn parse_session_meta_new(session: &mut CodexSession, payload: &Value, _raw: &Value) { - session.id = str_field(payload, "id"); + session.id = extract_session_id(payload); session.timestamp = str_field(payload, "timestamp"); session.cwd = opt_str(payload, "cwd"); session.originator = opt_str(payload, "originator"); @@ -317,6 +320,48 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn parse_session_reads_id_from_session_id_field() { + // v0.129.0+ PR #20437: session_id field in session_meta payload + let tmp = tempdir().unwrap(); + let path = tmp + .path() + .join("rollout-2026-05-07T00-00-00-newsessid.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{"session_id":"new-sess-id","timestamp":"2026-05-07T00:00:00Z","cwd":"/tmp"}}"#, + r#"{"timestamp":"2026-05-07T00:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-07T00:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1746576002.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + let session = parse_session(&path).unwrap(); + assert_eq!(session.id, "new-sess-id"); + } + + #[test] + fn parse_session_reads_id_from_thread_session_id() { + // v0.129.0+ PR #21336: sessionId moved onto Thread object + let tmp = tempdir().unwrap(); + let path = tmp + .path() + .join("rollout-2026-05-07T00-01-00-threadsessid.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-07T00:01:00Z","type":"session_meta","payload":{"thread":{"sessionId":"thread-sess-id"},"timestamp":"2026-05-07T00:01:00Z","cwd":"/tmp"}}"#, + r#"{"timestamp":"2026-05-07T00:01:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-07T00:01:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1746576062.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + let session = parse_session(&path).unwrap(); + assert_eq!(session.id, "thread-sess-id"); + } + #[test] fn default_sessions_dir_exists() { let dir = default_sessions_dir();