Skip to main content

khive_pack_session/mirror/
parse.rs

1//! JSONL line parsers for Claude Code and Codex CLI session transcripts.
2//!
3//! Every function here is deterministic and side-effect-free so the unit tests
4//! can run without any runtime or DB setup.
5
6use chrono::DateTime;
7use khive_runtime::secret_gate;
8use serde_json::Value;
9
10/// A single parsed event, source-agnostic.
11#[derive(Debug, Clone, PartialEq)]
12pub struct ParsedEvent {
13    /// Event UUID — the primary key for idempotency.
14    ///
15    /// For Claude Code events this is the top-level `uuid` field.
16    /// For Codex events (which carry no per-message uuid) this is synthesised
17    /// as `"{session_id}:{abs_byte_offset}"`.
18    pub uuid: String,
19    /// Session UUID.
20    pub session_id: String,
21    /// Parent event UUID if present.
22    pub parent_uuid: Option<String>,
23    /// Whether this event is on a sidechain.
24    pub is_sidechain: bool,
25    /// `message.role` (CC) or `payload.role` (Codex) when present.
26    pub role: Option<String>,
27    /// Top-level `type` field.
28    pub msg_type: String,
29    /// Extracted display text, secrets masked; `None` for non-message events.
30    pub text: Option<String>,
31    /// Full original line with secrets masked.
32    pub raw: String,
33    /// `timestamp` as microseconds since the Unix epoch; 0 if absent or unparseable.
34    pub created_at_micros: i64,
35    /// `cwd` if present.
36    pub cwd: Option<String>,
37    /// `gitBranch` (CC) or `payload.git.branch` (Codex) if present.
38    pub git_branch: Option<String>,
39    /// `slug` if present (CC-only; Codex files carry no slug concept).
40    pub slug: Option<String>,
41}
42
43/// Parse one Claude Code JSONL line.
44///
45/// Returns `None` for:
46/// - blank or whitespace-only lines
47/// - lines that are not valid JSON objects
48/// - lines that lack a top-level `uuid` field
49/// - lines that lack a top-level `sessionId` field
50///
51/// The returned `raw` and `text` fields have secrets masked.
52pub fn parse_cc_line(line: &str) -> Option<ParsedEvent> {
53    let trimmed = line.trim();
54    if trimmed.is_empty() {
55        return None;
56    }
57
58    let obj: Value = serde_json::from_str(trimmed).ok()?;
59    let map = obj.as_object()?;
60
61    // Both uuid and sessionId are required for idempotency and routing.
62    let uuid = map.get("uuid")?.as_str()?.to_string();
63    if uuid.is_empty() {
64        return None;
65    }
66    let session_id = map.get("sessionId")?.as_str()?.to_string();
67    if session_id.is_empty() {
68        return None;
69    }
70
71    let parent_uuid = map
72        .get("parentUuid")
73        .and_then(|v| v.as_str())
74        .filter(|s| !s.is_empty())
75        .map(str::to_string);
76
77    let is_sidechain = map
78        .get("isSidechain")
79        .and_then(|v| v.as_bool())
80        .unwrap_or(false);
81
82    let msg_type = map
83        .get("type")
84        .and_then(|v| v.as_str())
85        .unwrap_or("unknown")
86        .to_string();
87
88    let cwd = map.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
89
90    let git_branch = map
91        .get("gitBranch")
92        .and_then(|v| v.as_str())
93        .map(str::to_string);
94
95    let slug = map.get("slug").and_then(|v| v.as_str()).map(str::to_string);
96
97    let created_at_micros = map
98        .get("timestamp")
99        .and_then(|v| v.as_str())
100        .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
101        .map(|dt| dt.timestamp_micros())
102        .unwrap_or(0);
103
104    // Extract role and text from message when present.
105    let (role, text) = match map.get("message").and_then(|m| m.as_object()) {
106        None => (None, None),
107        Some(msg) => {
108            let role = msg.get("role").and_then(|v| v.as_str()).map(str::to_string);
109            let text = extract_text(msg.get("content"));
110            (role, text)
111        }
112    };
113
114    // Apply masking to the raw line and the extracted text, reusing the
115    // canonical write-time secret detector (khive-runtime) — never a second,
116    // weaker masker.
117    let raw = secret_gate::mask_secrets(trimmed).into_owned();
118    let text = text.map(|t| secret_gate::mask_secrets(&t).into_owned());
119
120    Some(ParsedEvent {
121        uuid,
122        session_id,
123        parent_uuid,
124        is_sidechain,
125        role,
126        msg_type,
127        text,
128        raw,
129        created_at_micros,
130        cwd,
131        git_branch,
132        slug,
133    })
134}
135
136/// Parse one Codex CLI JSONL line.
137///
138/// `session_id` must be derived from the filename before calling this function
139/// (e.g. from `rollout-<timestamp>-<uuid>.jsonl`).  `abs_byte_offset` is the
140/// file byte offset of the **start** of this line; it is embedded in the
141/// synthesised event UUID so that `INSERT OR IGNORE` on `session_messages.id`
142/// is idempotent across re-tails of an append-only file.
143///
144/// Returns `None` for:
145/// - blank or whitespace-only lines
146/// - lines that are not valid JSON objects
147/// - lines whose top-level `type` is `"event_msg"` — these are duplicate
148///   event-stream representations of messages and must not be double-stored
149/// - any other line type that carries no useful message content
150///
151/// The returned `raw` and `text` fields have secrets masked.
152pub fn parse_codex_line(line: &str, session_id: &str, abs_byte_offset: u64) -> Option<ParsedEvent> {
153    let trimmed = line.trim();
154    if trimmed.is_empty() {
155        return None;
156    }
157
158    let obj: Value = serde_json::from_str(trimmed).ok()?;
159    let map = obj.as_object()?;
160
161    let line_type = map.get("type")?.as_str()?;
162
163    // event_msg lines are duplicate event-stream representations — skip them.
164    if line_type == "event_msg" {
165        return None;
166    }
167
168    let created_at_micros = map
169        .get("timestamp")
170        .and_then(|v| v.as_str())
171        .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
172        .map(|dt| dt.timestamp_micros())
173        .unwrap_or(0);
174
175    match line_type {
176        "session_meta" => {
177            // session_meta carries cwd and git metadata; the session UUID in
178            // payload.id should match the filename-derived session_id, but we
179            // do NOT use it as the event id — use the synthesised offset key so
180            // the message row is unique and idempotent.
181            let payload = map.get("payload").and_then(|v| v.as_object());
182
183            let cwd = payload
184                .and_then(|p| p.get("cwd"))
185                .and_then(|v| v.as_str())
186                .map(str::to_string);
187
188            let git_branch = payload
189                .and_then(|p| p.get("git"))
190                .and_then(|g| g.as_object())
191                .and_then(|g| g.get("branch"))
192                .and_then(|v| v.as_str())
193                .map(str::to_string);
194
195            let uuid = format!("{session_id}:{abs_byte_offset}");
196            let raw = secret_gate::mask_secrets(trimmed).into_owned();
197
198            Some(ParsedEvent {
199                uuid,
200                session_id: session_id.to_string(),
201                parent_uuid: None,
202                is_sidechain: false,
203                role: None,
204                msg_type: "session_meta".to_string(),
205                text: None,
206                raw,
207                created_at_micros,
208                cwd,
209                git_branch,
210                slug: None,
211            })
212        }
213        "response_item" => {
214            let payload = map.get("payload").and_then(|v| v.as_object())?;
215
216            // Only ingest message items; skip tool_call, completion, etc.
217            if payload.get("type").and_then(|v| v.as_str()) != Some("message") {
218                return None;
219            }
220
221            let role = payload
222                .get("role")
223                .and_then(|v| v.as_str())
224                .map(str::to_string);
225
226            let text = extract_text(payload.get("content"));
227            let text = text.map(|t| {
228                let masked = secret_gate::mask_secrets(&t).into_owned();
229                truncate(&masked, 500)
230            });
231
232            let uuid = format!("{session_id}:{abs_byte_offset}");
233            let raw = secret_gate::mask_secrets(trimmed).into_owned();
234
235            Some(ParsedEvent {
236                uuid,
237                session_id: session_id.to_string(),
238                parent_uuid: None,
239                is_sidechain: false,
240                role,
241                msg_type: "response_item".to_string(),
242                text,
243                raw,
244                created_at_micros,
245                cwd: None,
246                git_branch: None,
247                slug: None,
248            })
249        }
250        // Unknown line types are silently skipped.
251        _ => None,
252    }
253}
254
255/// Extract a display-friendly text string from a message `content` value.
256///
257/// Handles both the string form and the structured-block array form.
258fn extract_text(content: Option<&Value>) -> Option<String> {
259    match content? {
260        Value::String(s) => Some(s.clone()),
261        Value::Array(blocks) => {
262            let parts: Vec<String> = blocks.iter().filter_map(extract_block).collect();
263            if parts.is_empty() {
264                None
265            } else {
266                Some(parts.join("\n"))
267            }
268        }
269        _ => None,
270    }
271}
272
273/// Extract a display string from a single content block.
274///
275/// Handled block types:
276/// - `"text"` — Claude Code plain text block.
277/// - `"input_text"` / `"output_text"` — Codex user and assistant text blocks.
278/// - `"tool_use"` — tool invocation (name + input JSON, truncated to 500 chars).
279/// - `"tool_result"` — tool output (content string, truncated to 500 chars).
280fn extract_block(block: &Value) -> Option<String> {
281    let map = block.as_object()?;
282    match map.get("type")?.as_str()? {
283        // Claude Code text block and Codex user/assistant text blocks all carry
284        // their display text in a "text" field — same extraction logic.
285        "text" | "input_text" | "output_text" => {
286            map.get("text").and_then(|v| v.as_str()).map(str::to_string)
287        }
288        "tool_use" => {
289            let name = map
290                .get("name")
291                .and_then(|v| v.as_str())
292                .unwrap_or("unknown");
293            let input = map.get("input").cloned().unwrap_or(Value::Null);
294            let input_str = truncate(&serde_json::to_string(&input).unwrap_or_default(), 500);
295            Some(format!("[tool_use: {name}] {input_str}"))
296        }
297        "tool_result" => {
298            let content_val = map.get("content").cloned().unwrap_or(Value::Null);
299            let content_str = match &content_val {
300                Value::String(s) => s.clone(),
301                other => serde_json::to_string(other).unwrap_or_default(),
302            };
303            Some(format!("[tool_result] {}", truncate(&content_str, 500)))
304        }
305        _ => None,
306    }
307}
308
309/// Truncate a string to at most `max_chars` characters, appending `…` if truncated.
310fn truncate(s: &str, max_chars: usize) -> String {
311    if s.chars().count() <= max_chars {
312        s.to_string()
313    } else {
314        let mut out: String = s.chars().take(max_chars).collect();
315        out.push('…');
316        out
317    }
318}
319
320// ── unit tests ────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    // Helper: build a minimal CC event JSON string.
327    fn make_line(uuid: &str, session_id: &str, type_: &str, extra: &str) -> String {
328        format!(
329            r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"{type_}","timestamp":"2026-06-29T10:00:00Z"{extra}}}"#,
330        )
331    }
332
333    #[test]
334    fn test_blank_line_returns_none() {
335        assert!(parse_cc_line("").is_none());
336        assert!(parse_cc_line("   ").is_none());
337    }
338
339    #[test]
340    fn test_no_uuid_returns_none() {
341        // pr-link style: no uuid
342        let line = r#"{"type":"pr-link","sessionId":"sess-1","url":"https://github.com/foo"}"#;
343        assert!(parse_cc_line(line).is_none());
344    }
345
346    #[test]
347    fn test_no_session_id_returns_none() {
348        let line = r#"{"uuid":"aaaa-bbbb","type":"user"}"#;
349        assert!(parse_cc_line(line).is_none());
350    }
351
352    #[test]
353    fn test_user_text_line() {
354        let line = make_line(
355            "aaaa-bbbb",
356            "sess-1111",
357            "user",
358            r#","message":{"role":"user","content":"Hello world"},"cwd":"/proj","gitBranch":"main","slug":"my-proj""#,
359        );
360        let ev = parse_cc_line(&line).expect("should parse");
361        assert_eq!(ev.uuid, "aaaa-bbbb");
362        assert_eq!(ev.session_id, "sess-1111");
363        assert_eq!(ev.role.as_deref(), Some("user"));
364        assert_eq!(ev.msg_type, "user");
365        assert_eq!(ev.text.as_deref(), Some("Hello world"));
366        assert_eq!(ev.cwd.as_deref(), Some("/proj"));
367        assert_eq!(ev.git_branch.as_deref(), Some("main"));
368        assert_eq!(ev.slug.as_deref(), Some("my-proj"));
369        assert!(ev.created_at_micros > 0);
370        assert!(!ev.is_sidechain);
371    }
372
373    #[test]
374    fn test_assistant_with_text_and_tool_use_blocks() {
375        let line = r#"{"uuid":"cccc-dddd","sessionId":"sess-1111","type":"assistant","timestamp":"2026-06-29T10:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll run a search."},{"type":"tool_use","name":"bash","input":{"command":"ls"}}]}}"#
376            .to_string();
377        let ev = parse_cc_line(&line).expect("should parse");
378        assert_eq!(ev.role.as_deref(), Some("assistant"));
379        let text = ev.text.expect("text should be present");
380        assert!(text.contains("I'll run a search."), "text: {text}");
381        assert!(text.contains("[tool_use: bash]"), "text: {text}");
382        assert!(text.contains("command"), "text: {text}");
383    }
384
385    #[test]
386    fn test_tool_result_block() {
387        let line = r#"{"uuid":"eeee-ffff","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:02:00Z","message":{"role":"user","content":[{"type":"tool_result","content":"file1.rs\nfile2.rs"}]}}"#
388            .to_string();
389        let ev = parse_cc_line(&line).expect("should parse");
390        let text = ev.text.expect("text should be present");
391        assert!(text.contains("[tool_result]"), "text: {text}");
392        assert!(text.contains("file1.rs"), "text: {text}");
393    }
394
395    #[test]
396    fn test_attachment_line_no_message() {
397        // uuid present, sessionId present, but no message -> role/text None
398        let line = r#"{"uuid":"gggg-hhhh","sessionId":"sess-1111","type":"attachment","timestamp":"2026-06-29T10:02:00Z","filename":"file.txt"}"#
399            .to_string();
400        let ev = parse_cc_line(&line).expect("should parse");
401        assert_eq!(ev.msg_type, "attachment");
402        assert!(ev.role.is_none());
403        assert!(ev.text.is_none());
404    }
405
406    #[test]
407    fn test_secret_masking_in_text_and_raw() {
408        let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
409        let line = format!(
410            r#"{{"uuid":"iiii-jjjj","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:03:00Z","message":{{"role":"user","content":"my key is {secret}"}}}}"#
411        );
412        let ev = parse_cc_line(&line).expect("should parse");
413
414        let text = ev.text.expect("text should be present");
415        assert!(
416            !text.contains(secret),
417            "secret must not appear in text: {text}"
418        );
419        assert!(
420            text.contains("***MASKED***"),
421            "MASKED marker must appear in text: {text}"
422        );
423
424        assert!(
425            !ev.raw.contains(secret),
426            "secret must not appear in raw: {}",
427            ev.raw
428        );
429        assert!(
430            ev.raw.contains("***MASKED***"),
431            "MASKED marker must appear in raw: {}",
432            ev.raw
433        );
434    }
435
436    #[test]
437    fn test_github_pat_masked() {
438        let secret = "github_pat_ABCDE12345fghij67890KLMNO";
439        let line = format!(
440            r#"{{"uuid":"kkkk-llll","sessionId":"sess-2","type":"user","timestamp":"2026-06-29T10:04:00Z","message":{{"role":"user","content":"token={secret}"}}}}"#
441        );
442        let ev = parse_cc_line(&line).unwrap();
443        assert!(!ev.raw.contains(secret));
444        assert!(ev.raw.contains("***MASKED***"));
445    }
446
447    #[test]
448    fn test_timestamp_to_micros() {
449        let line = make_line(
450            "ts-test",
451            "sess-ts",
452            "system",
453            r#","timestamp":"2026-06-29T17:56:01.123Z""#,
454        );
455        let ev = parse_cc_line(&line).unwrap();
456        // 2026-06-29T17:56:01.123Z in micros should be a large positive number
457        assert!(ev.created_at_micros > 0, "created_at_micros should be > 0");
458    }
459
460    #[test]
461    fn test_sidechain_flag() {
462        let line = make_line("side-uuid", "sess-side", "user", r#","isSidechain":true"#);
463        let ev = parse_cc_line(&line).unwrap();
464        assert!(ev.is_sidechain);
465    }
466
467    // ── parse_codex_line tests ─────────────────────────────────────────────────
468
469    const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
470
471    /// Build a Codex user message line using the real `input_text` block shape.
472    fn codex_user_msg(text: &str) -> String {
473        format!(
474            r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#
475        )
476    }
477
478    /// Build a Codex assistant message line using the real `output_text` block shape.
479    fn codex_asst_msg(text: &str) -> String {
480        format!(
481            r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"{text}"}}]}}}}"#
482        )
483    }
484
485    /// Build a Codex session_meta line.
486    fn codex_meta(cwd: &str, branch: &str) -> String {
487        format!(
488            r#"{{"type":"session_meta","timestamp":"2026-06-30T09:00:00Z","payload":{{"id":"{CDX_SID}","cwd":"{cwd}","git":{{"branch":"{branch}","commit_hash":"abc123","repository_url":"https://github.com/example/repo"}}}}}}"#
489        )
490    }
491
492    /// Build a Codex response_item with a tool_use block (no text block).
493    fn codex_tool_use_msg() -> String {
494        r#"{"type":"response_item","timestamp":"2026-06-30T09:01:00Z","payload":{"type":"message","role":"assistant","content":[{"type":"tool_use","name":"bash","input":{"command":"cargo test"}}]}}"#.to_string()
495    }
496
497    /// Build a Codex event_msg line (duplicate; must be skipped).
498    fn codex_event_msg() -> String {
499        r#"{"type":"event_msg","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"user_message","content":"duplicate"}}"#.to_string()
500    }
501
502    #[test]
503    fn test_codex_blank_returns_none() {
504        assert!(parse_codex_line("", CDX_SID, 0).is_none());
505        assert!(parse_codex_line("   ", CDX_SID, 0).is_none());
506    }
507
508    #[test]
509    fn test_codex_event_msg_skipped() {
510        let line = codex_event_msg();
511        assert!(
512            parse_codex_line(&line, CDX_SID, 42).is_none(),
513            "event_msg must be skipped"
514        );
515    }
516
517    #[test]
518    fn test_codex_unknown_type_skipped() {
519        let line = r#"{"type":"some_other_event","timestamp":"2026-06-30T09:00:00Z","payload":{}}"#;
520        assert!(parse_codex_line(line, CDX_SID, 0).is_none());
521    }
522
523    #[test]
524    fn test_codex_session_meta_produces_event() {
525        let line = codex_meta("/workspace/proj", "main");
526        let ev = parse_codex_line(&line, CDX_SID, 0).expect("session_meta should parse");
527        assert_eq!(ev.session_id, CDX_SID);
528        assert_eq!(ev.msg_type, "session_meta");
529        assert_eq!(ev.cwd.as_deref(), Some("/workspace/proj"));
530        assert_eq!(ev.git_branch.as_deref(), Some("main"));
531        assert!(ev.role.is_none());
532        assert!(ev.text.is_none());
533        // Synthetic uuid: "{session_id}:{offset}".
534        assert_eq!(ev.uuid, format!("{CDX_SID}:0"));
535        assert!(ev.created_at_micros > 0);
536    }
537
538    #[test]
539    fn test_codex_user_message_input_text_block() {
540        // Regression for Finding 1: real Codex user messages use `input_text` blocks.
541        let line = codex_user_msg("Hello Codex");
542        let ev = parse_codex_line(&line, CDX_SID, 128).expect("user message should parse");
543        assert_eq!(ev.session_id, CDX_SID);
544        assert_eq!(ev.msg_type, "response_item");
545        assert_eq!(ev.role.as_deref(), Some("user"));
546        // text must NOT be None — this was the NULL bug.
547        let text = ev.text.expect("text must be non-NULL for input_text block");
548        assert_eq!(text, "Hello Codex");
549        assert_eq!(ev.uuid, format!("{CDX_SID}:128"));
550    }
551
552    #[test]
553    fn test_codex_assistant_message_output_text_block() {
554        // Regression for Finding 1: real Codex assistant messages use `output_text` blocks.
555        let line = codex_asst_msg("Hello from assistant");
556        let ev = parse_codex_line(&line, CDX_SID, 256).expect("assistant message should parse");
557        assert_eq!(ev.role.as_deref(), Some("assistant"));
558        // text must NOT be None.
559        let text = ev
560            .text
561            .expect("text must be non-NULL for output_text block");
562        assert_eq!(text, "Hello from assistant");
563        assert_eq!(ev.uuid, format!("{CDX_SID}:256"));
564    }
565
566    #[test]
567    fn test_codex_tool_use_block_extracted() {
568        let line = codex_tool_use_msg();
569        let ev = parse_codex_line(&line, CDX_SID, 512).expect("tool_use message should parse");
570        assert_eq!(ev.role.as_deref(), Some("assistant"));
571        let text = ev.text.expect("text must be present");
572        assert!(text.contains("[tool_use: bash]"), "text: {text}");
573        assert!(text.contains("cargo test"), "text: {text}");
574    }
575
576    #[test]
577    fn test_codex_text_truncated_at_500_chars() {
578        // input_text block with 600-char body — must be truncated.
579        let long_text = "x".repeat(600);
580        let line = codex_user_msg(&long_text);
581        let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
582        let text = ev.text.expect("text must be present");
583        // char count must be ≤ 501 (500 + the '…' ellipsis char).
584        assert!(
585            text.chars().count() <= 501,
586            "text must be truncated: len={}",
587            text.chars().count()
588        );
589        assert!(text.ends_with('…'), "truncated text must end with ellipsis");
590    }
591
592    #[test]
593    fn test_codex_secret_masked_in_text_and_raw() {
594        // input_text block carrying a secret — masking must apply to both text and raw.
595        let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
596        let line = codex_user_msg(secret);
597        let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
598        let text = ev.text.expect("text present");
599        assert!(!text.contains(secret), "secret must not appear in text");
600        assert!(
601            text.contains("***MASKED***"),
602            "MASKED marker must appear in text"
603        );
604        assert!(!ev.raw.contains(secret), "secret must not appear in raw");
605        assert!(
606            ev.raw.contains("***MASKED***"),
607            "MASKED marker must appear in raw"
608        );
609    }
610
611    #[test]
612    fn test_codex_synthetic_uuid_stable_across_calls() {
613        // The same line at the same offset must produce the same uuid regardless
614        // of how many times it is called (deterministic, no random component).
615        let line = codex_user_msg("consistency");
616        let ev1 = parse_codex_line(&line, CDX_SID, 999).unwrap();
617        let ev2 = parse_codex_line(&line, CDX_SID, 999).unwrap();
618        assert_eq!(ev1.uuid, ev2.uuid);
619        assert_eq!(ev1.uuid, format!("{CDX_SID}:999"));
620    }
621
622    #[test]
623    fn test_codex_response_item_non_message_payload_skipped() {
624        // A response_item where payload.type != "message" must be skipped.
625        let line = r#"{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"tool_call","name":"some_tool"}}"#;
626        assert!(parse_codex_line(line, CDX_SID, 0).is_none());
627    }
628
629    #[test]
630    fn test_cc_text_block_still_works() {
631        // Regression guard: adding input_text/output_text must not break the
632        // existing CC "text" block handling.
633        let line = r#"{"uuid":"cc-t1","sessionId":"cc-sess","type":"assistant","timestamp":"2026-06-30T09:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"CC still works"}]}}"#;
634        let ev = parse_cc_line(line).expect("CC text block must parse");
635        assert_eq!(ev.text.as_deref(), Some("CC still works"));
636    }
637}