1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum MessageContent {
140 Text(String),
141 Blocks(Vec<ContentBlock>),
142}
143
144#[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#[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#[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#[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#[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#[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#[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#[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}