Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface CodexTurn {
forked_from_thread_id: string | null;
/** Codex v0.135.0 (PR #24368): compaction metadata at turn start. Null for pre-v0.135.0 sessions. */
compaction_meta: CompactionMeta | null;
/** Active memories injected at turn start (Codex v0.135.0+, PR #24591). Empty for older sessions. */
memories?: string[];
}

export interface CodexSession {
Expand Down
44 changes: 44 additions & 0 deletions src-tauri/src/parser/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,4 +650,48 @@ mod tests {
let meta = RawEntry::parse(lines[0]).unwrap();
assert_eq!(meta.payload["cli_version"], "0.133.0");
}

// 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");
}
}
59 changes: 59 additions & 0 deletions src-tauri/src/parser/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,4 +945,63 @@ mod tests {
assert!(session.turns[0].reasoning_effort.is_none());
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());
}
}
76 changes: 76 additions & 0 deletions src-tauri/src/parser/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ pub struct CodexTurn {
/// Codex v0.135.0 (PR #24368): compaction metadata present at turn start.
/// Null when the turn header carries no compaction info (pre-v0.135.0 sessions).
pub compaction_meta: Option<CompactionMeta>,
/// Active memories injected into context at turn start (Codex v0.135.0+, PR #24591).
/// Empty for sessions from older Codex versions.
pub memories: Vec<String>,
}

impl CodexTurn {
Expand Down Expand Up @@ -128,6 +131,7 @@ impl CodexTurn {
trace_id: None,
forked_from_thread_id: None,
compaction_meta: None,
memories: Vec::new(),
}
}
}
Expand Down Expand Up @@ -889,6 +893,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<String> = 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) {
Expand All @@ -901,6 +916,9 @@ fn handle_turn_context(
if effort.is_some() {
turn.reasoning_effort = effort;
}
if !memories.is_empty() {
turn.memories = memories;
}
}
}
}
Expand Down Expand Up @@ -2402,4 +2420,62 @@ mod tests {
assert_eq!(meta.tokens_after, Some(30000));
assert!(meta.summary.is_none());
}

// 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"]
);
}
}
Loading