From b0e0871e56e580922c7a010f788afb6da5e62a67 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Sun, 31 May 2026 13:44:06 +1200 Subject: [PATCH] feat(parser): parse memory context from turn_context for Codex v0.135.0 Codex v0.135.0 (PR #24591) moved memory runtime state from file-based storage to a dedicated SQLite database. Active memories are now injected into the context at turn start and written into the turn_context JSONL event under a `memories` array field. Add `memories: Vec` to `CodexTurn` (Rust) and `memories?: string[]` to the TypeScript `CodexTurn` interface. Update `handle_turn_context` to extract the field. Pre-v0.135.0 sessions with no memories field produce an empty Vec gracefully. Fixes #85 --- shared/types.ts | 2 + src-tauri/src/parser/entry.rs | 44 +++++++++++++++++++ src-tauri/src/parser/session.rs | 59 +++++++++++++++++++++++++ src-tauri/src/parser/turn.rs | 76 +++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/shared/types.ts b/shared/types.ts index 9efae78..336795e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -84,6 +84,8 @@ export interface CodexTurn { has_compaction: boolean; thread_name: string | null; collab_spawns: CollabSpawn[]; + /** Active memories injected at turn start (Codex v0.135.0+, PR #24591). Empty for older sessions. */ + memories?: string[]; } export interface CodexSession { diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index 5d29441..49368e0 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -429,4 +429,48 @@ mod tests { assert_eq!(meta.payload["cli_version"], "0.131.0"); assert_eq!(meta.payload["profile"], "default"); } + + // Codex v0.135.0 (PR #24591): memory state moved to a dedicated SQLite DB. + // Active memories are injected into context at turn start and written into + // turn_context payloads. The RawEntry parser must pass through the memories + // array so downstream consumers (handle_turn_context) can extract it. + + #[test] + fn v0135_turn_context_with_memories_parses_correctly() { + let line = r#"{"timestamp":"2026-05-28T10:00:00Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/project","memories":["User prefers terse output","Project uses TypeScript strict mode"]}}"#; + let e = RawEntry::parse(line).expect("turn_context with memories must parse"); + assert_eq!(e.entry_type, "turn_context"); + let mems = e.payload["memories"] + .as_array() + .expect("memories must be array"); + assert_eq!(mems.len(), 2); + assert_eq!(mems[0], "User prefers terse output"); + assert_eq!(mems[1], "Project uses TypeScript strict mode"); + } + + #[test] + fn v0135_all_standard_entry_types_parse_correctly() { + // Regression guard: all four standard JSONL entry types from a v0.135.0 session + // must parse correctly. The turn_context now includes a memories array. + let lines = [ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-session","timestamp":"2026-05-28T10:00:00Z","cwd":"/project","cli_version":"0.135.0","model_provider":"openai"}}"#, + r#"{"timestamp":"2026-05-28T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T10:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":"Hello"}}"#, + r#"{"timestamp":"2026-05-28T10:00:03Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/project","memories":["Active memory note"]}}"#, + r#"{"timestamp":"2026-05-28T10:00:04Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426404.0}}"#, + ]; + let expected_types = [ + "session_meta", + "event_msg", + "response_item", + "turn_context", + "event_msg", + ]; + for (line, expected) in lines.iter().zip(expected_types.iter()) { + let entry = RawEntry::parse(line).expect("parse failed"); + assert_eq!(entry.entry_type, *expected, "wrong type for: {line}"); + } + let meta = RawEntry::parse(lines[0]).unwrap(); + assert_eq!(meta.payload["cli_version"], "0.135.0"); + } } diff --git a/src-tauri/src/parser/session.rs b/src-tauri/src/parser/session.rs index 9b15c57..717b4a4 100644 --- a/src-tauri/src/parser/session.rs +++ b/src-tauri/src/parser/session.rs @@ -767,4 +767,63 @@ mod tests { assert_eq!(session.turns.len(), 1); assert!(!session.is_ongoing); } + + // Codex v0.135.0 (PR #24591): memory state moved from file-based storage to a dedicated + // SQLite DB. Active memories are injected into context at turn start and written into the + // turn_context JSONL event. parse_session must expose them on each CodexTurn. + + #[test] + fn v0135_session_with_memories_in_turn_context() { + let tmp = tempdir().unwrap(); + let path = tmp + .path() + .join("rollout-2026-05-28T10-00-00-v0135mem.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-mem-session","timestamp":"2026-05-28T10:00:00Z","cwd":"/project","cli_version":"0.135.0","model_provider":"openai"}}"#, + r#"{"timestamp":"2026-05-28T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T10:00:02Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/project","memories":["User prefers terse output","Project uses TypeScript strict mode"]}}"#, + r#"{"timestamp":"2026-05-28T10:00:03Z","type":"response_item","payload":{"type":"message","role":"assistant","content":"Hello"}}"#, + r#"{"timestamp":"2026-05-28T10:00:04Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426404.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + + let session = parse_session(&path).unwrap(); + assert_eq!(session.id, "v0135-mem-session"); + assert_eq!(session.cli_version.as_deref(), Some("0.135.0")); + assert_eq!(session.turns.len(), 1); + assert_eq!( + session.turns[0].memories, + vec![ + "User prefers terse output", + "Project uses TypeScript strict mode" + ] + ); + assert!(!session.is_ongoing); + } + + #[test] + fn v0135_session_without_memories_produces_empty_vec() { + // Pre-v0.135.0 sessions must parse normally with an empty memories Vec. + let tmp = tempdir().unwrap(); + let path = tmp.path().join("rollout-2026-05-28T10-01-00-nomem.jsonl"); + std::fs::write( + &path, + [ + r#"{"timestamp":"2026-05-28T10:01:00Z","type":"session_meta","payload":{"id":"v0134-no-memories","timestamp":"2026-05-28T10:01:00Z","cwd":"/project","cli_version":"0.134.0","model_provider":"openai"}}"#, + r#"{"timestamp":"2026-05-28T10:01:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T10:01:02Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/project","effort":"high"}}"#, + r#"{"timestamp":"2026-05-28T10:01:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426463.0}}"#, + ] + .join("\n"), + ) + .unwrap(); + + let session = parse_session(&path).unwrap(); + assert_eq!(session.id, "v0134-no-memories"); + assert!(session.turns[0].memories.is_empty()); + } } diff --git a/src-tauri/src/parser/turn.rs b/src-tauri/src/parser/turn.rs index 18747e7..cdba057 100644 --- a/src-tauri/src/parser/turn.rs +++ b/src-tauri/src/parser/turn.rs @@ -79,6 +79,9 @@ pub struct CodexTurn { pub has_compaction: bool, pub thread_name: Option, pub collab_spawns: Vec, + /// Active memories injected into context at turn start (Codex v0.135.0+, PR #24591). + /// Empty for sessions from older Codex versions. + pub memories: Vec, } impl CodexTurn { @@ -103,6 +106,7 @@ impl CodexTurn { has_compaction: false, thread_name: None, collab_spawns: Vec::new(), + memories: Vec::new(), } } } @@ -803,6 +807,17 @@ fn handle_turn_context( .get("effort") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + // Codex v0.135.0 (PR #24591): memories are now stored in a dedicated SQLite DB and + // injected into the context at turn start. The active set is written into turn_context. + let memories: Vec = payload + .get("memories") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); if let Some(ref tid) = current_turn_id { if let Some(turn) = turns.get_mut(tid) { @@ -815,6 +830,9 @@ fn handle_turn_context( if effort.is_some() { turn.reasoning_effort = effort; } + if !memories.is_empty() { + turn.memories = memories; + } } } } @@ -2072,4 +2090,62 @@ mod tests { assert_eq!(turns[0].status, TurnStatus::Complete); assert_eq!(turns[1].status, TurnStatus::Complete); } + + // Codex v0.135.0 (PR #24591): memory state moved from file-based storage to a dedicated + // SQLite DB. Active memories are now injected into context at turn start and written into + // the turn_context JSONL event. codex-trace must parse and expose them on CodexTurn. + + #[test] + fn turn_context_with_memories_parsed_correctly() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-mem","timestamp":"2026-05-28T10:00:00Z","cwd":"/project","cli_version":"0.135.0"}}"#, + r#"{"timestamp":"2026-05-28T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T10:00:02Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/project","memories":["User prefers terse output","Project uses TypeScript strict mode"]}}"#, + r#"{"timestamp":"2026-05-28T10:00:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426403.0}}"#, + ]); + + let turns = build_turns(&entries); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].memories.len(), 2); + assert_eq!(turns[0].memories[0], "User prefers terse output"); + assert_eq!(turns[0].memories[1], "Project uses TypeScript strict mode"); + assert_eq!(turns[0].model.as_deref(), Some("gpt-5")); + } + + #[test] + fn turn_context_without_memories_produces_empty_vec() { + // Pre-v0.135.0 sessions: turn_context has no memories field → empty Vec, not None/panic. + let entries = entries(&[ + r#"{"timestamp":"2026-05-20T10:00:00Z","type":"session_meta","payload":{"id":"v0134-nomem","timestamp":"2026-05-20T10:00:00Z","cli_version":"0.134.0"}}"#, + r#"{"timestamp":"2026-05-20T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-20T10:00:02Z","type":"turn_context","payload":{"model":"gpt-5","cwd":"/tmp","effort":"medium"}}"#, + r#"{"timestamp":"2026-05-20T10:00:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1747734003.0}}"#, + ]); + + let turns = build_turns(&entries); + assert_eq!(turns.len(), 1); + assert!(turns[0].memories.is_empty()); + } + + #[test] + fn memories_preserved_across_multiple_turns() { + // Each turn_context carries its own memories snapshot; last one wins per turn. + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-multiturn","timestamp":"2026-05-28T10:00:00Z","cli_version":"0.135.0"}}"#, + r#"{"timestamp":"2026-05-28T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T10:00:02Z","type":"turn_context","payload":{"model":"gpt-5","memories":["Initial memory"]}}"#, + r#"{"timestamp":"2026-05-28T10:00:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426403.0}}"#, + r#"{"timestamp":"2026-05-28T10:00:04Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + r#"{"timestamp":"2026-05-28T10:00:05Z","type":"turn_context","payload":{"model":"gpt-5","memories":["Initial memory","New memory added"]}}"#, + r#"{"timestamp":"2026-05-28T10:00:06Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-2","completed_at":1748426406.0}}"#, + ]); + + let turns = build_turns(&entries); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].memories, vec!["Initial memory"]); + assert_eq!( + turns[1].memories, + vec!["Initial memory", "New memory added"] + ); + } }