From 8e48aafb700378e6b3503d5110cfa5784c448e89 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Sun, 31 May 2026 13:42:15 +1200 Subject: [PATCH] feat(parser): add trace_id, forked_from_thread_id, compaction_meta fields (Codex v0.134.0/v0.135.0) Implements compat for two Codex CLI releases: - v0.134.0 PR #23980: trace_id added to TurnStartedEvent. Exposed as CodexTurn.trace_id (Option / string | null) for OTel correlation. - v0.135.0 PR #24160: forked_from_thread_id added to turn metadata. Exposed as CodexTurn.forked_from_thread_id so forked sessions are visible as branches rather than independent roots. - v0.135.0 PR #24368: compaction metadata added to turn headers. Adds CompactionMeta struct (tokens_before, tokens_after, summary) and CodexTurn.compaction_meta for accurate context-window accounting. All three fields are optional (null for older sessions). Adds 7 new regression tests covering presence, absence, and combined field cases. Updates shared/types.ts and four frontend test fixtures. Fixes #86 --- shared/types.ts | 16 +++ src-tauri/src/parser/turn.rs | 178 +++++++++++++++++++++++++++ src/components/ToolCallItem.test.tsx | 3 + src/components/TurnDetail.test.tsx | 3 + src/components/TurnList.test.tsx | 3 + src/components/WorkerPanel.test.tsx | 3 + 6 files changed, 206 insertions(+) diff --git a/shared/types.ts b/shared/types.ts index 9efae78..dce6a52 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -21,6 +21,16 @@ export interface AgentMessage { is_reasoning: boolean; } +/** Codex v0.135.0 (PR #24368): compaction metadata from turn headers. */ +export interface CompactionMeta { + /** Context-window tokens present before compaction. */ + tokens_before: number | null; + /** Context-window tokens remaining after compaction. */ + tokens_after: number | null; + /** Optional human-readable summary of what was compacted. */ + summary: string | null; +} + export interface CollabSpawn { call_id: string; new_thread_id: string; @@ -84,6 +94,12 @@ export interface CodexTurn { has_compaction: boolean; thread_name: string | null; collab_spawns: CollabSpawn[]; + /** Codex v0.134.0 (PR #23980): OTel trace ID from TurnStartedEvent. Null for pre-v0.134.0 sessions. */ + trace_id: string | null; + /** Codex v0.135.0 (PR #24160): thread ID this turn was forked from. Null for non-forked turns. */ + 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; } export interface CodexSession { diff --git a/src-tauri/src/parser/turn.rs b/src-tauri/src/parser/turn.rs index 18747e7..c885935 100644 --- a/src-tauri/src/parser/turn.rs +++ b/src-tauri/src/parser/turn.rs @@ -35,6 +35,19 @@ pub struct TokenInfo { pub model_context_window: u64, } +/// Compaction metadata embedded in turn headers (Codex v0.135.0, PR #24368). +/// Captures the state of context compaction at the start of a turn so that +/// context-window accounting in traces remains accurate even after compaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompactionMeta { + /// Context-window tokens present before compaction. + pub tokens_before: Option, + /// Context-window tokens remaining after compaction. + pub tokens_after: Option, + /// Optional human-readable summary of what was compacted. + pub summary: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollabSpawn { pub call_id: String, @@ -79,6 +92,15 @@ pub struct CodexTurn { pub has_compaction: bool, pub thread_name: Option, pub collab_spawns: Vec, + /// Codex v0.134.0 (PR #23980): OpenTelemetry trace ID from TurnStartedEvent. + /// Null for sessions captured before v0.134.0. + pub trace_id: Option, + /// Codex v0.135.0 (PR #24160): thread ID this turn was forked from, if any. + /// Null for turns that are not forks of another thread. + pub forked_from_thread_id: Option, + /// 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, } impl CodexTurn { @@ -103,6 +125,9 @@ impl CodexTurn { has_compaction: false, thread_name: None, collab_spawns: Vec::new(), + trace_id: None, + forked_from_thread_id: None, + compaction_meta: None, } } } @@ -202,6 +227,28 @@ fn handle_event_msg( .or_else(|| entry.timestamp.as_deref().and_then(parse_timestamp_secs)); let mut turn = CodexTurn::new(turn_id.clone()); turn.started_at = started_at; + // Codex v0.134.0 (PR #23980): trace_id for OTel correlation. + turn.trace_id = payload + .get("trace_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + // Codex v0.135.0 (PR #24160): forked_from_thread_id for session-tree reconstruction. + turn.forked_from_thread_id = payload + .get("forked_from_thread_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + // Codex v0.135.0 (PR #24368): compaction metadata for context-window accounting. + turn.compaction_meta = payload.get("compaction").map(|c| CompactionMeta { + tokens_before: c.get("tokens_before").and_then(|v| v.as_u64()), + tokens_after: c.get("tokens_after").and_then(|v| v.as_u64()), + summary: c + .get("summary") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + }); turns.insert(turn_id.clone(), turn); *current_turn_id = Some(turn_id.clone()); tool_builders @@ -2072,4 +2119,135 @@ mod tests { assert_eq!(turns[0].status, TurnStatus::Complete); assert_eq!(turns[1].status, TurnStatus::Complete); } + + // Codex v0.134.0 (PR #23980): trace_id added to TurnStartedEvent for OTel correlation. + + #[test] + fn v0134_trace_id_in_task_started_is_captured() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-26T10:00:00Z","type":"session_meta","payload":{"id":"v0134-sess","timestamp":"2026-05-26T10:00:00Z","cli_version":"0.134.0"}}"#, + r#"{"timestamp":"2026-05-26T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","trace_id":"abc-trace-xyz-123"}}"#, + r#"{"timestamp":"2026-05-26T10:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748254802.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].trace_id.as_deref(), Some("abc-trace-xyz-123")); + } + + #[test] + fn v0134_absent_trace_id_is_none_for_older_sessions() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-25T10:00:00Z","type":"session_meta","payload":{"id":"pre-v0134","timestamp":"2026-05-25T10:00:00Z","cli_version":"0.133.0"}}"#, + r#"{"timestamp":"2026-05-25T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-25T10:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748168402.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert!( + turns[0].trace_id.is_none(), + "pre-v0.134.0 sessions must have no trace_id" + ); + } + + // Codex v0.135.0 (PR #24160): forked_from_thread_id added to turn metadata. + + #[test] + fn v0135_forked_from_thread_id_in_task_started_is_captured() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-fork","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","forked_from_thread_id":"parent-thread-abc"}}"#, + r#"{"timestamp":"2026-05-28T10:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426402.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].forked_from_thread_id.as_deref(), + Some("parent-thread-abc") + ); + } + + #[test] + fn v0135_absent_forked_from_thread_id_is_none_for_non_forked_turns() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T10:00:00Z","type":"session_meta","payload":{"id":"v0135-nofork","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":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748426402.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert!( + turns[0].forked_from_thread_id.is_none(), + "non-forked turn must have no forked_from_thread_id" + ); + } + + // Codex v0.135.0 (PR #24368): compaction metadata added to turn headers. + + #[test] + fn v0135_compaction_meta_in_task_started_is_captured() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T11:00:00Z","type":"session_meta","payload":{"id":"v0135-cmeta","timestamp":"2026-05-28T11:00:00Z","cli_version":"0.135.0"}}"#, + r#"{"timestamp":"2026-05-28T11:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","compaction":{"tokens_before":120000,"tokens_after":45000,"summary":"Summarised earlier turns"}}}"#, + r#"{"timestamp":"2026-05-28T11:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748430002.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + let meta = turns[0] + .compaction_meta + .as_ref() + .expect("compaction_meta must be present"); + assert_eq!(meta.tokens_before, Some(120000)); + assert_eq!(meta.tokens_after, Some(45000)); + assert_eq!(meta.summary.as_deref(), Some("Summarised earlier turns")); + } + + #[test] + fn v0135_absent_compaction_meta_is_none_for_uncompacted_turns() { + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T11:00:00Z","type":"session_meta","payload":{"id":"v0135-nocomp","timestamp":"2026-05-28T11:00:00Z","cli_version":"0.135.0"}}"#, + r#"{"timestamp":"2026-05-28T11:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"timestamp":"2026-05-28T11:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748430002.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert!( + turns[0].compaction_meta.is_none(), + "turns without compaction header must have no compaction_meta" + ); + } + + #[test] + fn v0135_all_three_new_fields_in_same_task_started() { + // All three v0.134.0/v0.135.0 fields may appear together in a single task_started. + let entries = entries(&[ + r#"{"timestamp":"2026-05-28T12:00:00Z","type":"session_meta","payload":{"id":"v0135-all","timestamp":"2026-05-28T12:00:00Z","cli_version":"0.135.0"}}"#, + r#"{"timestamp":"2026-05-28T12:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","trace_id":"otel-trace-001","forked_from_thread_id":"parent-thread-xyz","compaction":{"tokens_before":80000,"tokens_after":30000}}}"#, + r#"{"timestamp":"2026-05-28T12:00:02Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","completed_at":1748433602.0}}"#, + ]); + + let turns = build_turns(&entries); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].trace_id.as_deref(), Some("otel-trace-001")); + assert_eq!( + turns[0].forked_from_thread_id.as_deref(), + Some("parent-thread-xyz") + ); + let meta = turns[0].compaction_meta.as_ref().expect("compaction_meta"); + assert_eq!(meta.tokens_before, Some(80000)); + assert_eq!(meta.tokens_after, Some(30000)); + assert!(meta.summary.is_none()); + } } diff --git a/src/components/ToolCallItem.test.tsx b/src/components/ToolCallItem.test.tsx index ed279df..404b60a 100644 --- a/src/components/ToolCallItem.test.tsx +++ b/src/components/ToolCallItem.test.tsx @@ -65,6 +65,9 @@ function makeWorkerSession(toolCalls: CodexToolCall[]): CodexSession { has_compaction: false, thread_name: "Worker thread", collab_spawns: [], + trace_id: null, + forked_from_thread_id: null, + compaction_meta: null, }, ], is_ongoing: false, diff --git a/src/components/TurnDetail.test.tsx b/src/components/TurnDetail.test.tsx index 9f32f2e..e388ecd 100644 --- a/src/components/TurnDetail.test.tsx +++ b/src/components/TurnDetail.test.tsx @@ -39,6 +39,9 @@ function makeTurn(overrides: Partial = {}): CodexTurn { has_compaction: false, thread_name: null, collab_spawns: [], + trace_id: null, + forked_from_thread_id: null, + compaction_meta: null, ...overrides, }; } diff --git a/src/components/TurnList.test.tsx b/src/components/TurnList.test.tsx index 0df1597..548d94b 100644 --- a/src/components/TurnList.test.tsx +++ b/src/components/TurnList.test.tsx @@ -62,6 +62,9 @@ function makeTurn(overrides: Partial = {}): CodexTurn { has_compaction: false, thread_name: null, collab_spawns: [], + trace_id: null, + forked_from_thread_id: null, + compaction_meta: null, ...overrides, }; } diff --git a/src/components/WorkerPanel.test.tsx b/src/components/WorkerPanel.test.tsx index 38584c1..831826a 100644 --- a/src/components/WorkerPanel.test.tsx +++ b/src/components/WorkerPanel.test.tsx @@ -68,6 +68,9 @@ function makeSession(toolCalls: CodexToolCall[]): CodexSession { has_compaction: false, thread_name: "Same parent title", collab_spawns: [], + trace_id: null, + forked_from_thread_id: null, + compaction_meta: null, }, ], is_ongoing: false,