Skip to main content

toolpath_pi/
types.rs

1//! Pi session schema (format version 3).
2//!
3//! Pi is a terminal coding agent that stores sessions as JSONL files. The first
4//! line is a [`SessionHeader`]; every subsequent line is an [`Entry`], forming
5//! a tree via `id` / `parentId`.
6//!
7//! All structs carry an `extra` catch-all to preserve unknown fields for
8//! forward compatibility.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Shared fields on every non-header entry.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct EntryBase {
16    pub id: String,
17    #[serde(rename = "parentId", default)]
18    pub parent_id: Option<String>,
19    pub timestamp: String,
20}
21
22/// Pi session file header (first line of a `.jsonl` file).
23///
24/// The header JSON looks like:
25/// ```json
26/// {"type":"session","version":3,"id":"...","timestamp":"...","cwd":"...","parentSession":"..."}
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct SessionHeader {
30    #[serde(default)]
31    pub version: u32,
32    pub id: String,
33    pub timestamp: String,
34    pub cwd: String,
35    #[serde(
36        rename = "parentSession",
37        default,
38        skip_serializing_if = "Option::is_none"
39    )]
40    pub parent_session: Option<String>,
41    #[serde(default, flatten)]
42    pub extra: HashMap<String, serde_json::Value>,
43}
44
45/// A single entry in a Pi session JSONL.
46///
47/// Tagged by the `type` discriminant. The `session` variant matches the file
48/// header; every other variant carries an [`EntryBase`] (id / parentId /
49/// timestamp) flattened into the payload.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum Entry {
53    Session(SessionHeader),
54    Message {
55        #[serde(flatten)]
56        base: EntryBase,
57        message: AgentMessage,
58        #[serde(default, flatten)]
59        extra: HashMap<String, serde_json::Value>,
60    },
61    ModelChange {
62        #[serde(flatten)]
63        base: EntryBase,
64        provider: String,
65        #[serde(rename = "modelId")]
66        model_id: String,
67        #[serde(default, flatten)]
68        extra: HashMap<String, serde_json::Value>,
69    },
70    ThinkingLevelChange {
71        #[serde(flatten)]
72        base: EntryBase,
73        #[serde(rename = "thinkingLevel")]
74        thinking_level: String,
75        #[serde(default, flatten)]
76        extra: HashMap<String, serde_json::Value>,
77    },
78    Compaction {
79        #[serde(flatten)]
80        base: EntryBase,
81        summary: String,
82        #[serde(rename = "firstKeptEntryId")]
83        first_kept_entry_id: String,
84        #[serde(rename = "tokensBefore")]
85        tokens_before: u64,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        details: Option<serde_json::Value>,
88        #[serde(rename = "fromHook", default, skip_serializing_if = "Option::is_none")]
89        from_hook: Option<bool>,
90        #[serde(default, flatten)]
91        extra: HashMap<String, serde_json::Value>,
92    },
93    BranchSummary {
94        #[serde(flatten)]
95        base: EntryBase,
96        #[serde(rename = "fromId")]
97        from_id: String,
98        summary: String,
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        details: Option<serde_json::Value>,
101        #[serde(rename = "fromHook", default, skip_serializing_if = "Option::is_none")]
102        from_hook: Option<bool>,
103        #[serde(default, flatten)]
104        extra: HashMap<String, serde_json::Value>,
105    },
106    Custom {
107        #[serde(flatten)]
108        base: EntryBase,
109        #[serde(rename = "customType")]
110        custom_type: String,
111        data: serde_json::Map<String, serde_json::Value>,
112        #[serde(default, flatten)]
113        extra: HashMap<String, serde_json::Value>,
114    },
115    CustomMessage {
116        #[serde(flatten)]
117        base: EntryBase,
118        #[serde(rename = "customType")]
119        custom_type: String,
120        content: MessageContent,
121        display: bool,
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        details: Option<serde_json::Value>,
124        #[serde(default, flatten)]
125        extra: HashMap<String, serde_json::Value>,
126    },
127    Label {
128        #[serde(flatten)]
129        base: EntryBase,
130        #[serde(default, flatten)]
131        extra: HashMap<String, serde_json::Value>,
132    },
133}
134
135/// Content field for user / custom-role messages: either a bare string or a
136/// list of content blocks.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum MessageContent {
140    Text(String),
141    Blocks(Vec<ContentBlock>),
142}
143
144/// A tool-use request inside an assistant content block.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ToolCall {
147    pub id: String,
148    pub name: String,
149    pub arguments: serde_json::Value,
150    #[serde(default, flatten)]
151    pub extra: HashMap<String, serde_json::Value>,
152}
153
154/// One element of a message's `content` array.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(tag = "type", rename_all = "camelCase")]
157pub enum ContentBlock {
158    Text {
159        text: String,
160        #[serde(default, flatten)]
161        extra: HashMap<String, serde_json::Value>,
162    },
163    Image {
164        data: String,
165        #[serde(rename = "mimeType")]
166        mime_type: String,
167        #[serde(default, flatten)]
168        extra: HashMap<String, serde_json::Value>,
169    },
170    Thinking {
171        thinking: String,
172        #[serde(default, flatten)]
173        extra: HashMap<String, serde_json::Value>,
174    },
175    ToolCall {
176        id: String,
177        name: String,
178        arguments: serde_json::Value,
179        #[serde(default, flatten)]
180        extra: HashMap<String, serde_json::Value>,
181    },
182}
183
184/// Restricted content block set for `toolResult` messages. Per the Pi schema,
185/// tool results may only carry text or image content.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(tag = "type", rename_all = "camelCase")]
188pub enum ToolResultContent {
189    Text {
190        text: String,
191        #[serde(default, flatten)]
192        extra: HashMap<String, serde_json::Value>,
193    },
194    Image {
195        data: String,
196        #[serde(rename = "mimeType")]
197        mime_type: String,
198        #[serde(default, flatten)]
199        extra: HashMap<String, serde_json::Value>,
200    },
201}
202
203/// Assistant stop reason. Unknown values round-trip through [`StopReason::Other`].
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(rename_all = "camelCase", untagged)]
206pub enum StopReason {
207    Known(KnownStopReason),
208    Other(String),
209}
210
211/// Enumerated stop reasons defined by Pi. Kept inside [`StopReason`] so that
212/// unknown strings (e.g. from future Pi versions) still deserialize.
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(rename_all = "camelCase")]
215pub enum KnownStopReason {
216    Stop,
217    Length,
218    ToolUse,
219    Error,
220    Aborted,
221}
222
223/// A message inside a `message` entry. Role-tagged union.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(tag = "role", rename_all = "camelCase")]
226pub enum AgentMessage {
227    User {
228        content: MessageContent,
229        timestamp: u64,
230        #[serde(default, flatten)]
231        extra: HashMap<String, serde_json::Value>,
232    },
233    Assistant {
234        content: Vec<ContentBlock>,
235        api: String,
236        provider: String,
237        model: String,
238        usage: Usage,
239        #[serde(rename = "stopReason")]
240        stop_reason: StopReason,
241        #[serde(
242            rename = "errorMessage",
243            default,
244            skip_serializing_if = "Option::is_none"
245        )]
246        error_message: Option<String>,
247        timestamp: u64,
248        #[serde(default, flatten)]
249        extra: HashMap<String, serde_json::Value>,
250    },
251    ToolResult {
252        #[serde(rename = "toolCallId")]
253        tool_call_id: String,
254        #[serde(rename = "toolName")]
255        tool_name: String,
256        content: Vec<ToolResultContent>,
257        #[serde(default, skip_serializing_if = "Option::is_none")]
258        details: Option<serde_json::Value>,
259        #[serde(rename = "isError")]
260        is_error: bool,
261        timestamp: u64,
262        #[serde(default, flatten)]
263        extra: HashMap<String, serde_json::Value>,
264    },
265    BashExecution {
266        command: String,
267        output: String,
268        #[serde(rename = "exitCode")]
269        exit_code: Option<i64>,
270        cancelled: bool,
271        truncated: bool,
272        #[serde(
273            rename = "fullOutputPath",
274            default,
275            skip_serializing_if = "Option::is_none"
276        )]
277        full_output_path: Option<String>,
278        #[serde(
279            rename = "excludeFromContext",
280            default,
281            skip_serializing_if = "Option::is_none"
282        )]
283        exclude_from_context: Option<bool>,
284        timestamp: u64,
285        #[serde(default, flatten)]
286        extra: HashMap<String, serde_json::Value>,
287    },
288    Custom {
289        #[serde(rename = "customType")]
290        custom_type: String,
291        content: MessageContent,
292        display: bool,
293        #[serde(default, skip_serializing_if = "Option::is_none")]
294        details: Option<serde_json::Value>,
295        timestamp: u64,
296        #[serde(default, flatten)]
297        extra: HashMap<String, serde_json::Value>,
298    },
299    BranchSummary {
300        #[serde(default, flatten)]
301        extra: HashMap<String, serde_json::Value>,
302    },
303    CompactionSummary {
304        #[serde(default, flatten)]
305        extra: HashMap<String, serde_json::Value>,
306    },
307}
308
309/// Per-turn token accounting.
310#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
311pub struct Usage {
312    #[serde(default)]
313    pub input: u64,
314    #[serde(default)]
315    pub output: u64,
316    #[serde(default, rename = "cacheRead")]
317    pub cache_read: u64,
318    #[serde(default, rename = "cacheWrite")]
319    pub cache_write: u64,
320    #[serde(default, rename = "totalTokens")]
321    pub total_tokens: u64,
322    #[serde(default)]
323    pub cost: CostBreakdown,
324}
325
326/// Dollar cost breakdown accompanying [`Usage`].
327#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
328pub struct CostBreakdown {
329    #[serde(default)]
330    pub input: f64,
331    #[serde(default)]
332    pub output: f64,
333    #[serde(default, rename = "cacheRead")]
334    pub cache_read: f64,
335    #[serde(default, rename = "cacheWrite")]
336    pub cache_write: f64,
337    #[serde(default)]
338    pub total: f64,
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use serde_json::json;
345
346    fn roundtrip<T: Serialize + serde::de::DeserializeOwned>(value: &T) -> T {
347        let s = serde_json::to_string(value).expect("serialize");
348        serde_json::from_str(&s).expect("deserialize")
349    }
350
351    #[test]
352    fn test_session_header_roundtrip() {
353        let header = SessionHeader {
354            version: 3,
355            id: "abc123".into(),
356            timestamp: "2026-04-16T00:00:00.000Z".into(),
357            cwd: "/tmp/project".into(),
358            parent_session: Some("/tmp/parent.jsonl".into()),
359            extra: HashMap::new(),
360        };
361        let back: SessionHeader = roundtrip(&header);
362        assert_eq!(back.version, 3);
363        assert_eq!(back.id, "abc123");
364        assert_eq!(back.cwd, "/tmp/project");
365        assert_eq!(back.parent_session.as_deref(), Some("/tmp/parent.jsonl"));
366    }
367
368    #[test]
369    fn test_session_header_without_parent() {
370        let header = SessionHeader {
371            version: 3,
372            id: "x".into(),
373            timestamp: "t".into(),
374            cwd: "/".into(),
375            parent_session: None,
376            extra: HashMap::new(),
377        };
378        let s = serde_json::to_string(&header).unwrap();
379        assert!(!s.contains("parentSession"));
380        let back: SessionHeader = serde_json::from_str(&s).unwrap();
381        assert!(back.parent_session.is_none());
382    }
383
384    #[test]
385    fn test_entry_message_user_string_content() {
386        let raw = json!({
387            "type": "message",
388            "id": "aa11bb22",
389            "parentId": null,
390            "timestamp": "2026-04-16T00:00:00.000Z",
391            "message": {
392                "role": "user",
393                "content": "hello",
394                "timestamp": 1_700_000_000_000u64
395            }
396        });
397        let entry: Entry = serde_json::from_value(raw.clone()).unwrap();
398        match &entry {
399            Entry::Message { base, message, .. } => {
400                assert_eq!(base.id, "aa11bb22");
401                match message {
402                    AgentMessage::User {
403                        content, timestamp, ..
404                    } => {
405                        assert_eq!(*timestamp, 1_700_000_000_000);
406                        match content {
407                            MessageContent::Text(s) => assert_eq!(s, "hello"),
408                            _ => panic!("wrong content variant"),
409                        }
410                    }
411                    _ => panic!("wrong role"),
412                }
413            }
414            _ => panic!("wrong entry type"),
415        }
416        let back: Entry = roundtrip(&entry);
417        assert!(matches!(back, Entry::Message { .. }));
418    }
419
420    #[test]
421    fn test_entry_message_user_blocks_content() {
422        let raw = json!({
423            "type": "message",
424            "id": "aa11bb23",
425            "parentId": "aa11bb22",
426            "timestamp": "2026-04-16T00:00:00.000Z",
427            "message": {
428                "role": "user",
429                "content": [{"type": "text", "text": "hi"}],
430                "timestamp": 1_700_000_000_000u64
431            }
432        });
433        let entry: Entry = serde_json::from_value(raw).unwrap();
434        match entry {
435            Entry::Message {
436                message: AgentMessage::User { content, .. },
437                ..
438            } => match content {
439                MessageContent::Blocks(blocks) => {
440                    assert_eq!(blocks.len(), 1);
441                    assert!(matches!(&blocks[0], ContentBlock::Text { text, .. } if text == "hi"));
442                }
443                _ => panic!("expected blocks"),
444            },
445            _ => panic!("wrong variant"),
446        }
447    }
448
449    #[test]
450    fn test_entry_message_assistant() {
451        let raw = json!({
452            "type": "message",
453            "id": "bb00",
454            "parentId": "aa00",
455            "timestamp": "t",
456            "message": {
457                "role": "assistant",
458                "content": [
459                    {"type": "text", "text": "ok"},
460                    {"type": "toolCall", "id": "tc1", "name": "read", "arguments": {"path": "/x"}}
461                ],
462                "api": "anthropic",
463                "provider": "anthropic",
464                "model": "claude-opus",
465                "usage": {
466                    "input": 10, "output": 20, "cacheRead": 1, "cacheWrite": 2,
467                    "totalTokens": 33,
468                    "cost": {"input": 0.001, "output": 0.002, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.003}
469                },
470                "stopReason": "toolUse",
471                "timestamp": 1_700_000_000_000u64
472            }
473        });
474        let entry: Entry = serde_json::from_value(raw).unwrap();
475        match &entry {
476            Entry::Message {
477                message:
478                    AgentMessage::Assistant {
479                        content,
480                        usage,
481                        stop_reason,
482                        ..
483                    },
484                ..
485            } => {
486                assert_eq!(content.len(), 2);
487                assert_eq!(usage.total_tokens, 33);
488                assert_eq!(*stop_reason, StopReason::Known(KnownStopReason::ToolUse));
489            }
490            _ => panic!("wrong variant"),
491        }
492        let _: Entry = roundtrip(&entry);
493    }
494
495    #[test]
496    fn test_entry_message_tool_result() {
497        let raw = json!({
498            "type": "message",
499            "id": "cc00",
500            "parentId": "bb00",
501            "timestamp": "t",
502            "message": {
503                "role": "toolResult",
504                "toolCallId": "tc1",
505                "toolName": "read",
506                "content": [{"type": "text", "text": "file contents"}],
507                "isError": false,
508                "timestamp": 1_700_000_000_000u64
509            }
510        });
511        let entry: Entry = serde_json::from_value(raw).unwrap();
512        match &entry {
513            Entry::Message {
514                message:
515                    AgentMessage::ToolResult {
516                        tool_call_id,
517                        tool_name,
518                        content,
519                        is_error,
520                        ..
521                    },
522                ..
523            } => {
524                assert_eq!(tool_call_id, "tc1");
525                assert_eq!(tool_name, "read");
526                assert_eq!(content.len(), 1);
527                assert!(!is_error);
528            }
529            _ => panic!("wrong variant"),
530        }
531        let _: Entry = roundtrip(&entry);
532    }
533
534    #[test]
535    fn test_entry_message_bash_execution() {
536        let raw = json!({
537            "type": "message",
538            "id": "dd00",
539            "parentId": null,
540            "timestamp": "t",
541            "message": {
542                "role": "bashExecution",
543                "command": "ls",
544                "output": "a\nb\n",
545                "exitCode": 0,
546                "cancelled": false,
547                "truncated": false,
548                "timestamp": 1_700_000_000_000u64
549            }
550        });
551        let entry: Entry = serde_json::from_value(raw).unwrap();
552        match &entry {
553            Entry::Message {
554                message:
555                    AgentMessage::BashExecution {
556                        command,
557                        exit_code,
558                        cancelled,
559                        ..
560                    },
561                ..
562            } => {
563                assert_eq!(command, "ls");
564                assert_eq!(*exit_code, Some(0));
565                assert!(!cancelled);
566            }
567            _ => panic!("wrong variant"),
568        }
569        let _: Entry = roundtrip(&entry);
570    }
571
572    #[test]
573    fn test_entry_message_custom() {
574        let raw = json!({
575            "type": "message",
576            "id": "ee00",
577            "parentId": null,
578            "timestamp": "t",
579            "message": {
580                "role": "custom",
581                "customType": "note",
582                "content": "a note",
583                "display": true,
584                "timestamp": 1_700_000_000_000u64
585            }
586        });
587        let entry: Entry = serde_json::from_value(raw).unwrap();
588        match &entry {
589            Entry::Message {
590                message:
591                    AgentMessage::Custom {
592                        custom_type,
593                        display,
594                        ..
595                    },
596                ..
597            } => {
598                assert_eq!(custom_type, "note");
599                assert!(*display);
600            }
601            _ => panic!("wrong variant"),
602        }
603        let _: Entry = roundtrip(&entry);
604    }
605
606    #[test]
607    fn test_entry_model_change() {
608        let raw = json!({
609            "type": "model_change",
610            "id": "ff00",
611            "parentId": null,
612            "timestamp": "t",
613            "provider": "anthropic",
614            "modelId": "claude-opus-4-7"
615        });
616        let entry: Entry = serde_json::from_value(raw).unwrap();
617        match &entry {
618            Entry::ModelChange {
619                provider, model_id, ..
620            } => {
621                assert_eq!(provider, "anthropic");
622                assert_eq!(model_id, "claude-opus-4-7");
623            }
624            _ => panic!("wrong variant"),
625        }
626        let _: Entry = roundtrip(&entry);
627    }
628
629    #[test]
630    fn test_entry_thinking_level_change() {
631        let raw = json!({
632            "type": "thinking_level_change",
633            "id": "aa00",
634            "parentId": null,
635            "timestamp": "t",
636            "thinkingLevel": "high"
637        });
638        let entry: Entry = serde_json::from_value(raw).unwrap();
639        match &entry {
640            Entry::ThinkingLevelChange { thinking_level, .. } => {
641                assert_eq!(thinking_level, "high");
642            }
643            _ => panic!("wrong variant"),
644        }
645        let _: Entry = roundtrip(&entry);
646    }
647
648    #[test]
649    fn test_entry_compaction() {
650        let raw = json!({
651            "type": "compaction",
652            "id": "c000",
653            "parentId": null,
654            "timestamp": "t",
655            "summary": "sum",
656            "firstKeptEntryId": "bb00",
657            "tokensBefore": 100000,
658            "fromHook": false
659        });
660        let entry: Entry = serde_json::from_value(raw).unwrap();
661        match &entry {
662            Entry::Compaction {
663                summary,
664                first_kept_entry_id,
665                tokens_before,
666                from_hook,
667                ..
668            } => {
669                assert_eq!(summary, "sum");
670                assert_eq!(first_kept_entry_id, "bb00");
671                assert_eq!(*tokens_before, 100000);
672                assert_eq!(*from_hook, Some(false));
673            }
674            _ => panic!("wrong variant"),
675        }
676        let _: Entry = roundtrip(&entry);
677    }
678
679    #[test]
680    fn test_entry_branch_summary() {
681        let raw = json!({
682            "type": "branch_summary",
683            "id": "bs00",
684            "parentId": null,
685            "timestamp": "t",
686            "fromId": "aa00",
687            "summary": "branched off"
688        });
689        let entry: Entry = serde_json::from_value(raw).unwrap();
690        match &entry {
691            Entry::BranchSummary {
692                from_id, summary, ..
693            } => {
694                assert_eq!(from_id, "aa00");
695                assert_eq!(summary, "branched off");
696            }
697            _ => panic!("wrong variant"),
698        }
699        let _: Entry = roundtrip(&entry);
700    }
701
702    #[test]
703    fn test_entry_custom() {
704        let raw = json!({
705            "type": "custom",
706            "id": "cu00",
707            "parentId": null,
708            "timestamp": "t",
709            "customType": "telemetry",
710            "data": {"k": "v"}
711        });
712        let entry: Entry = serde_json::from_value(raw).unwrap();
713        match &entry {
714            Entry::Custom {
715                custom_type, data, ..
716            } => {
717                assert_eq!(custom_type, "telemetry");
718                assert_eq!(data.get("k").and_then(|v| v.as_str()), Some("v"));
719            }
720            _ => panic!("wrong variant"),
721        }
722        let _: Entry = roundtrip(&entry);
723    }
724
725    #[test]
726    fn test_entry_custom_message() {
727        let raw = json!({
728            "type": "custom_message",
729            "id": "cm00",
730            "parentId": null,
731            "timestamp": "t",
732            "customType": "hint",
733            "content": "some hint",
734            "display": true
735        });
736        let entry: Entry = serde_json::from_value(raw).unwrap();
737        match &entry {
738            Entry::CustomMessage {
739                custom_type,
740                display,
741                content,
742                ..
743            } => {
744                assert_eq!(custom_type, "hint");
745                assert!(*display);
746                assert!(matches!(content, MessageContent::Text(s) if s == "some hint"));
747            }
748            _ => panic!("wrong variant"),
749        }
750        let _: Entry = roundtrip(&entry);
751    }
752
753    #[test]
754    fn test_content_block_text() {
755        let v: ContentBlock =
756            serde_json::from_value(json!({"type": "text", "text": "hi"})).unwrap();
757        assert!(matches!(&v, ContentBlock::Text { text, .. } if text == "hi"));
758        let s = serde_json::to_string(&v).unwrap();
759        assert!(s.contains("\"type\":\"text\""));
760    }
761
762    #[test]
763    fn test_content_block_image() {
764        let v: ContentBlock = serde_json::from_value(json!({
765            "type": "image",
766            "data": "ZGF0YQ==",
767            "mimeType": "image/png"
768        }))
769        .unwrap();
770        match &v {
771            ContentBlock::Image {
772                data, mime_type, ..
773            } => {
774                assert_eq!(data, "ZGF0YQ==");
775                assert_eq!(mime_type, "image/png");
776            }
777            _ => panic!("wrong variant"),
778        }
779        let s = serde_json::to_string(&v).unwrap();
780        assert!(s.contains("mimeType"));
781    }
782
783    #[test]
784    fn test_content_block_thinking() {
785        let v: ContentBlock =
786            serde_json::from_value(json!({"type": "thinking", "thinking": "hmm"})).unwrap();
787        assert!(matches!(&v, ContentBlock::Thinking { thinking, .. } if thinking == "hmm"));
788    }
789
790    #[test]
791    fn test_content_block_tool_call() {
792        let v: ContentBlock = serde_json::from_value(json!({
793            "type": "toolCall",
794            "id": "tc1",
795            "name": "read",
796            "arguments": {"path": "/x"}
797        }))
798        .unwrap();
799        match &v {
800            ContentBlock::ToolCall {
801                id,
802                name,
803                arguments,
804                ..
805            } => {
806                assert_eq!(id, "tc1");
807                assert_eq!(name, "read");
808                assert_eq!(arguments.get("path").and_then(|p| p.as_str()), Some("/x"));
809            }
810            _ => panic!("wrong variant"),
811        }
812        let s = serde_json::to_string(&v).unwrap();
813        assert!(s.contains("\"type\":\"toolCall\""));
814    }
815
816    #[test]
817    fn test_stop_reason_variants() {
818        let cases = [
819            ("\"stop\"", StopReason::Known(KnownStopReason::Stop)),
820            ("\"length\"", StopReason::Known(KnownStopReason::Length)),
821            ("\"toolUse\"", StopReason::Known(KnownStopReason::ToolUse)),
822            ("\"error\"", StopReason::Known(KnownStopReason::Error)),
823            ("\"aborted\"", StopReason::Known(KnownStopReason::Aborted)),
824        ];
825        for (s, expected) in cases {
826            let parsed: StopReason = serde_json::from_str(s).unwrap();
827            assert_eq!(parsed, expected);
828            let back = serde_json::to_string(&parsed).unwrap();
829            assert_eq!(back, s);
830        }
831        let other: StopReason = serde_json::from_str("\"someFutureReason\"").unwrap();
832        assert_eq!(other, StopReason::Other("someFutureReason".into()));
833        let back = serde_json::to_string(&other).unwrap();
834        assert_eq!(back, "\"someFutureReason\"");
835    }
836
837    #[test]
838    fn test_usage_roundtrip() {
839        let u = Usage {
840            input: 100,
841            output: 200,
842            cache_read: 5,
843            cache_write: 6,
844            total_tokens: 311,
845            cost: CostBreakdown {
846                input: 0.1,
847                output: 0.2,
848                cache_read: 0.01,
849                cache_write: 0.02,
850                total: 0.33,
851            },
852        };
853        let s = serde_json::to_string(&u).unwrap();
854        assert!(s.contains("cacheRead"));
855        assert!(s.contains("cacheWrite"));
856        assert!(s.contains("totalTokens"));
857        let back: Usage = serde_json::from_str(&s).unwrap();
858        assert_eq!(back, u);
859    }
860
861    #[test]
862    fn test_extra_fields_preserved() {
863        let raw = json!({
864            "type": "model_change",
865            "id": "ff00",
866            "parentId": null,
867            "timestamp": "t",
868            "provider": "anthropic",
869            "modelId": "claude-opus",
870            "futureField": {"nested": 42}
871        });
872        let entry: Entry = serde_json::from_value(raw).unwrap();
873        let back = serde_json::to_value(&entry).unwrap();
874        assert_eq!(
875            back.get("futureField")
876                .and_then(|v| v.get("nested"))
877                .and_then(|n| n.as_i64()),
878            Some(42)
879        );
880    }
881
882    #[test]
883    fn test_parse_real_fixture_line_by_line() {
884        let jsonl = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp/proj"}
885{"type":"message","id":"aa000001","parentId":null,"timestamp":"2026-04-16T00:00:01.000Z","message":{"role":"user","content":"hello","timestamp":1700000000000}}
886{"type":"message","id":"aa000002","parentId":"aa000001","timestamp":"2026-04-16T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"},{"type":"toolCall","id":"tc1","name":"read","arguments":{"path":"/x"}}],"api":"anthropic","provider":"anthropic","model":"claude-opus","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":15,"cost":{"input":0.001,"output":0.0005,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0015}},"stopReason":"toolUse","timestamp":1700000001000}}
887{"type":"message","id":"aa000003","parentId":"aa000002","timestamp":"2026-04-16T00:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","toolName":"read","content":[{"type":"text","text":"file body"}],"isError":false,"timestamp":1700000002000}}"#;
888
889        let mut entries: Vec<Entry> = Vec::new();
890        for line in jsonl.lines() {
891            let e: Entry = serde_json::from_str(line).unwrap_or_else(|e| {
892                panic!("failed to parse line `{}`: {}", line, e);
893            });
894            entries.push(e);
895        }
896        assert_eq!(entries.len(), 4);
897        assert!(matches!(entries[0], Entry::Session(_)));
898        assert!(matches!(
899            &entries[1],
900            Entry::Message {
901                message: AgentMessage::User { .. },
902                ..
903            }
904        ));
905        assert!(matches!(
906            &entries[2],
907            Entry::Message {
908                message: AgentMessage::Assistant { .. },
909                ..
910            }
911        ));
912        assert!(matches!(
913            &entries[3],
914            Entry::Message {
915                message: AgentMessage::ToolResult { .. },
916                ..
917            }
918        ));
919    }
920}