Skip to main content

hindsight/parser/
models.rs

1//! Data models for Claude Code JSONL transcript parsing
2//!
3//! Follows Rust best practices:
4//! - Borrowing over cloning (uses &str where possible)
5//! - Derives for common traits (Debug, Clone, Serialize, Deserialize)
6//! - Clear documentation for all types
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Typed node classification — replaces raw `String` comparisons with
12/// compile-time checked variants. Serializes to kebab-case for JSON compat.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum NodeType {
16    User,
17    Assistant,
18    Progress,
19    System,
20    FileHistorySnapshot,
21    QueueOperation,
22    LastPrompt,
23    PrLink,
24    /// Catch-all for future/unknown types — prevents deserialization failures.
25    #[serde(other)]
26    Unknown,
27}
28
29impl NodeType {
30    /// Returns the kebab-case string representation (matches JSON serialization).
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            NodeType::User => "user",
34            NodeType::Assistant => "assistant",
35            NodeType::Progress => "progress",
36            NodeType::System => "system",
37            NodeType::FileHistorySnapshot => "file-history-snapshot",
38            NodeType::QueueOperation => "queue-operation",
39            NodeType::LastPrompt => "last-prompt",
40            NodeType::PrLink => "pr-link",
41            NodeType::Unknown => "unknown",
42        }
43    }
44}
45
46impl std::fmt::Display for NodeType {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str(self.as_str())
49    }
50}
51
52/// Custom deserializer for timestamp that handles both string and number formats
53mod timestamp_format {
54    use serde::{Deserialize, Deserializer};
55
56    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        #[derive(Deserialize)]
61        #[serde(untagged)]
62        enum TimestampFormat {
63            Number(i64),
64            String(String),
65        }
66
67        match Option::<TimestampFormat>::deserialize(deserializer)? {
68            None => Ok(None),
69            Some(TimestampFormat::Number(n)) => Ok(Some(n)),
70            Some(TimestampFormat::String(s)) => {
71                // Parse ISO 8601 string to milliseconds
72                chrono::DateTime::parse_from_rfc3339(&s)
73                    .map(|dt| Some(dt.timestamp_millis()))
74                    .map_err(serde::de::Error::custom)
75            }
76        }
77    }
78}
79
80/// A typed content block matching the 4 real Anthropic content block types.
81///
82/// Uses internally-tagged serde representation to match the JSON `"type"` field.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "snake_case")]
85pub enum ContentBlock {
86    Text {
87        text: String,
88    },
89    Thinking {
90        thinking: String,
91        #[serde(skip_serializing_if = "Option::is_none")]
92        signature: Option<String>,
93    },
94    ToolUse {
95        id: String,
96        name: String,
97        input: serde_json::Value,
98    },
99    ToolResult {
100        tool_use_id: String,
101        #[serde(skip_serializing_if = "Option::is_none")]
102        content: Option<serde_json::Value>,
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        is_error: Option<bool>,
105    },
106    #[serde(untagged)]
107    Unknown(serde_json::Value),
108}
109
110/// Message content — handles both legacy string and modern block array.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum MessageContent {
114    Text(String),
115    Blocks(Vec<ContentBlock>),
116}
117
118/// A single execution node in the Claude Code session tree
119///
120/// Represents any event in the transcript: user messages, assistant responses,
121/// tool calls, thinking blocks, etc. Nodes are linked via uuid/parentUuid.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ExecutionNode {
124    /// Unique identifier for this node
125    pub uuid: Option<String>,
126
127    /// Parent node UUID (for building hierarchy).
128    /// The JSONL field is camelCase `parentUuid` — must match exactly.
129    #[serde(rename = "parentUuid")]
130    pub parent_uuid: Option<String>,
131
132    /// Timestamp in milliseconds (accepts both ISO 8601 string and number)
133    #[serde(default, deserialize_with = "timestamp_format::deserialize")]
134    pub timestamp: Option<i64>,
135
136    /// Node type (user, assistant, progress, system, file-history-snapshot, …)
137    #[serde(rename = "type")]
138    pub node_type: NodeType,
139
140    /// Whether this node belongs to a sidechain (subagent parallel execution).
141    #[serde(rename = "isSidechain", default, skip_serializing_if = "Option::is_none")]
142    pub is_sidechain: Option<bool>,
143
144    /// Session ID this node belongs to (mirrors the filename stem).
145    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
146    pub session_id: Option<String>,
147
148    /// Working directory at the time of this node.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub cwd: Option<String>,
151
152    /// Message content (for user/assistant messages)
153    pub message: Option<Message>,
154
155    /// Tool use details (legacy top-level format — real data uses ContentBlock::ToolUse)
156    pub tool_use: Option<ToolUse>,
157
158    /// Tool result (legacy top-level format — real data uses ContentBlock::ToolResult)
159    pub tool_result: Option<ToolResult>,
160
161    /// Tool use result (raw tool output - can be string or object)
162    #[serde(rename = "toolUseResult")]
163    pub tool_use_result: Option<serde_json::Value>,
164
165    /// Thinking content (legacy top-level format)
166    pub thinking: Option<String>,
167
168    /// Progress updates (legacy field — real data stores progress details in `extra["data"]`)
169    pub progress: Option<Progress>,
170
171    /// Token usage statistics
172    pub token_usage: Option<TokenUsage>,
173
174    /// Additional metadata (optional to save memory when not present)
175    #[serde(flatten)]
176    pub extra: Option<HashMap<String, serde_json::Value>>,
177}
178
179impl ExecutionNode {
180    /// Returns token usage for this node.
181    ///
182    /// Claude Code stores usage inside `message.usage`, not at the top level.
183    /// This helper checks both places so callers don't need to know the layout.
184    pub fn effective_token_usage(&self) -> Option<&TokenUsage> {
185        self.token_usage
186            .as_ref()
187            .or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
188    }
189
190    /// Returns true if this node represents a tool error.
191    ///
192    /// Checks all known error signals in one place:
193    /// 1. `tool_result.is_error` flag
194    /// 2. `<tool_use_error>` tag in `tool_result.content`
195    /// 3. `toolUseResult` (raw JSON) deserialized as ToolResult with is_error
196    /// 4. `ContentBlock::ToolResult` with is_error or `<tool_use_error>` content
197    pub fn has_error(&self) -> bool {
198        let tr = self.tool_result.as_ref();
199        let tool_result_error = tr.and_then(|r| r.is_error).unwrap_or(false);
200        let content_tag_error = tr
201            .and_then(|r| r.content.as_deref())
202            .map(|c| c.contains("<tool_use_error>"))
203            .unwrap_or(false);
204
205        let tool_use_result_error = self
206            .tool_use_result
207            .as_ref()
208            .and_then(|v| {
209                serde_json::from_value::<ToolResult>(v.clone())
210                    .ok()
211                    .and_then(|r| r.is_error)
212            })
213            .unwrap_or(false);
214
215        let block_error = self
216            .message
217            .as_ref()
218            .map(|m| {
219                m.content_blocks().iter().any(|b| match b {
220                    ContentBlock::ToolResult {
221                        content, is_error, ..
222                    } => {
223                        is_error.unwrap_or(false)
224                            || content
225                                .as_ref()
226                                .and_then(|v| v.as_str())
227                                .map(|s| s.contains("<tool_use_error>"))
228                                .unwrap_or(false)
229                    }
230                    _ => false,
231                })
232            })
233            .unwrap_or(false);
234
235        tool_result_error || content_tag_error || tool_use_result_error || block_error
236    }
237}
238
239/// Message content (user or assistant)
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Message {
242    /// Message ID — used for SSE stream deduplication
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub id: Option<String>,
245
246    /// Role (user, assistant, system)
247    pub role: Option<String>,
248
249    /// Model string e.g. "claude-sonnet-4-5-20250929"
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub model: Option<String>,
252
253    /// Content (string or typed block array)
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub content: Option<MessageContent>,
256
257    /// Token usage — populated on assistant messages from the API response
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub usage: Option<TokenUsage>,
260
261    /// Additional message metadata
262    #[serde(flatten)]
263    pub extra: HashMap<String, serde_json::Value>,
264}
265
266impl Message {
267    /// Returns typed content blocks (empty slice for legacy string content).
268    pub fn content_blocks(&self) -> &[ContentBlock] {
269        match &self.content {
270            Some(MessageContent::Blocks(b)) => b.as_slice(),
271            _ => &[],
272        }
273    }
274
275    /// Returns all plain text, handling both legacy strings and block arrays.
276    pub fn text_content(&self) -> String {
277        match &self.content {
278            Some(MessageContent::Text(s)) => s.clone(),
279            Some(MessageContent::Blocks(blocks)) => blocks
280                .iter()
281                .filter_map(|b| match b {
282                    ContentBlock::Text { text } => Some(text.as_str()),
283                    _ => None,
284                })
285                .collect::<Vec<_>>()
286                .join("\n\n"),
287            None => String::new(),
288        }
289    }
290
291    /// Model name with date suffix stripped.
292    /// "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5"
293    pub fn model_short(&self) -> Option<&str> {
294        self.model.as_deref().map(strip_model_date_suffix)
295    }
296}
297
298/// Strip an 8-digit date suffix from a model name (regex-free).
299fn strip_model_date_suffix(model: &str) -> &str {
300    if model.len() > 9 {
301        let bytes = model.as_bytes();
302        for i in (0..model.len().saturating_sub(8)).rev() {
303            if bytes[i] == b'-' {
304                let suffix = &model[i + 1..];
305                if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
306                    return &model[..i];
307                }
308            }
309        }
310    }
311    model
312}
313
314/// Tool use (tool call) details
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ToolUse {
317    /// Tool name (e.g., "Read", "Write", "Bash")
318    pub name: String,
319
320    /// Tool input parameters (JSON)
321    pub input: serde_json::Value,
322
323    /// Unique tool use ID
324    pub id: Option<String>,
325}
326
327/// File information from toolUseResult
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct FileInfo {
330    #[serde(rename = "filePath")]
331    pub file_path: Option<String>,
332
333    pub content: Option<String>,
334
335    #[serde(rename = "numLines")]
336    pub num_lines: Option<i64>,
337}
338
339/// Tool result (tool output) details
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ToolResult {
342    /// Tool use ID this result corresponds to
343    pub tool_use_id: Option<String>,
344
345    /// Result content (may have line numbers - prefer file.content)
346    pub content: Option<String>,
347
348    /// File information (clean content without line numbers)
349    pub file: Option<FileInfo>,
350
351    /// Whether tool succeeded
352    pub is_error: Option<bool>,
353
354    /// Error message if failed
355    pub error: Option<String>,
356
357    /// Duration in milliseconds
358    pub duration_ms: Option<i64>,
359}
360
361/// Progress update information
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct Progress {
364    /// Progress message
365    pub message: Option<String>,
366
367    /// Progress percentage (0-100)
368    pub percentage: Option<f64>,
369}
370
371/// Token usage statistics
372#[derive(Debug, Clone, Serialize, Deserialize, Default)]
373pub struct TokenUsage {
374    /// Input tokens
375    pub input_tokens: Option<i64>,
376
377    /// Output tokens
378    pub output_tokens: Option<i64>,
379
380    /// Cache creation tokens
381    pub cache_creation_input_tokens: Option<i64>,
382
383    /// Cache read tokens
384    pub cache_read_input_tokens: Option<i64>,
385}
386
387impl TokenUsage {
388    /// Total effective input tokens (base + cache creation + cache reads).
389    /// Matches LangSmith: all three are billed as input, just at different rates.
390    pub fn total_input(&self) -> i64 {
391        self.input_tokens.unwrap_or(0)
392            + self.cache_creation_input_tokens.unwrap_or(0)
393            + self.cache_read_input_tokens.unwrap_or(0)
394    }
395
396    pub fn total_output(&self) -> i64 {
397        self.output_tokens.unwrap_or(0)
398    }
399
400    pub fn total(&self) -> i64 {
401        self.total_input() + self.total_output()
402    }
403
404    /// Take the LAST value for each field (SSE cumulative — later = more complete).
405    pub fn merge_last(&mut self, other: &TokenUsage) {
406        if other.input_tokens.is_some() {
407            self.input_tokens = other.input_tokens;
408        }
409        if other.output_tokens.is_some() {
410            self.output_tokens = other.output_tokens;
411        }
412        if other.cache_creation_input_tokens.is_some() {
413            self.cache_creation_input_tokens = other.cache_creation_input_tokens;
414        }
415        if other.cache_read_input_tokens.is_some() {
416            self.cache_read_input_tokens = other.cache_read_input_tokens;
417        }
418    }
419}
420
421/// Tool use result from user nodes (file operations)
422#[allow(dead_code)]
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ToolUseResult {
425    /// Operation type (create, update, delete)
426    #[serde(rename = "type")]
427    pub operation_type: Option<String>,
428
429    /// File path affected
430    pub file_path: Option<String>,
431
432    /// File content
433    pub content: Option<String>,
434
435    /// Structured patch information
436    pub structured_patch: Option<serde_json::Value>,
437}
438
439/// Progress data (nested in progress nodes)
440#[allow(dead_code)]
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ProgressData {
443    /// Progress subtype (bash_progress, hook_progress, waiting_for_task)
444    #[serde(rename = "type")]
445    pub progress_type: Option<String>,
446
447    /// Elapsed time in seconds (for bash_progress)
448    pub elapsed_time_seconds: Option<f64>,
449
450    /// Full output (for bash_progress)
451    pub full_output: Option<String>,
452
453    /// Exit code (for bash_progress)
454    pub exit_code: Option<i32>,
455
456    /// Hook name (for hook_progress)
457    pub hook_name: Option<String>,
458
459    /// Status (for hook_progress)
460    pub status: Option<String>,
461
462    /// Task description (for waiting_for_task)
463    pub task_description: Option<String>,
464
465    /// Task ID (for waiting_for_task)
466    pub task_id: Option<String>,
467}
468
469/// Complete session parsed from JSONL transcript
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct Session {
472    /// Session identifier
473    pub session_id: String,
474
475    /// Full file path to the JSONL file
476    pub file_path: Option<String>,
477
478    /// All execution nodes (flat list)
479    pub nodes: Vec<ExecutionNode>,
480
481    /// Session start time
482    pub start_time: Option<i64>,
483
484    /// Session end time
485    pub end_time: Option<i64>,
486
487    /// Total tool calls
488    pub total_tools: usize,
489
490    /// Number of errors
491    pub error_count: usize,
492
493    /// Detected model (date suffix stripped)
494    pub model: Option<String>,
495}
496
497impl Session {
498    /// Create a new session from parsed nodes
499    pub fn new(session_id: String, file_path: Option<String>, nodes: Vec<ExecutionNode>) -> Self {
500        // Count tool calls from ContentBlock::ToolUse in assistant messages.
501        // The legacy top-level `tool_use` field is never populated by real Claude Code
502        // transcripts — all tool invocations live in message.content blocks.
503        let total_tools = nodes
504            .iter()
505            .flat_map(|n| {
506                n.message
507                    .as_ref()
508                    .map(|m| m.content_blocks())
509                    .unwrap_or(&[])
510            })
511            .filter(|b| matches!(b, ContentBlock::ToolUse { .. }))
512            .count();
513
514        let error_count = nodes.iter().filter(|n| n.has_error()).count();
515
516        let start_time = nodes.iter().filter_map(|n| n.timestamp).min();
517        let end_time = nodes.iter().filter_map(|n| n.timestamp).max();
518
519        // Model detection: find first assistant message with a model field, strip date suffix
520        let model: Option<String> = nodes
521            .iter()
522            .filter_map(|n| n.message.as_ref())
523            .filter_map(|m| m.model_short())
524            .next()
525            .map(str::to_string);
526
527        Session {
528            session_id,
529            file_path,
530            nodes,
531            start_time,
532            end_time,
533            total_tools,
534            error_count,
535            model,
536        }
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    // ── ContentBlock deserialization ──────────────────────────────────────────
545
546    #[test]
547    fn test_content_block_text_roundtrip() {
548        let json = r#"{"type":"text","text":"hello world"}"#;
549        let block: ContentBlock = serde_json::from_str(json).unwrap();
550        assert!(matches!(block, ContentBlock::Text { ref text } if text == "hello world"));
551        let back = serde_json::to_string(&block).unwrap();
552        assert!(back.contains("hello world"));
553    }
554
555    #[test]
556    fn test_content_block_thinking_roundtrip() {
557        let json = r#"{"type":"thinking","thinking":"deep thoughts"}"#;
558        let block: ContentBlock = serde_json::from_str(json).unwrap();
559        assert!(
560            matches!(block, ContentBlock::Thinking { thinking, .. } if thinking == "deep thoughts")
561        );
562    }
563
564    #[test]
565    fn test_content_block_tool_use_roundtrip() {
566        let json =
567            r#"{"type":"tool_use","id":"tu_123","name":"Read","input":{"file_path":"test.rs"}}"#;
568        let block: ContentBlock = serde_json::from_str(json).unwrap();
569        assert!(matches!(block, ContentBlock::ToolUse { name, .. } if name == "Read"));
570    }
571
572    #[test]
573    fn test_content_block_tool_result_roundtrip() {
574        let json = r#"{"type":"tool_result","tool_use_id":"tu_123","content":"result text","is_error":false}"#;
575        let block: ContentBlock = serde_json::from_str(json).unwrap();
576        assert!(
577            matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tu_123")
578        );
579    }
580
581    #[test]
582    fn test_content_block_unknown_falls_through_to_value() {
583        let json = r#"{"type":"future_type","data":"something"}"#;
584        let block: ContentBlock = serde_json::from_str(json).unwrap();
585        assert!(matches!(block, ContentBlock::Unknown(_)));
586    }
587
588    // ── MessageContent ────────────────────────────────────────────────────────
589
590    #[test]
591    fn test_message_content_legacy_string_deserializes() {
592        let json = r#""hello""#;
593        let mc: MessageContent = serde_json::from_str(json).unwrap();
594        assert!(matches!(mc, MessageContent::Text(_)));
595    }
596
597    #[test]
598    fn test_message_content_block_array_deserializes() {
599        let json = r#"[{"type":"text","text":"hi"}]"#;
600        let mc: MessageContent = serde_json::from_str(json).unwrap();
601        assert!(matches!(mc, MessageContent::Blocks(_)));
602    }
603
604    // ── Message helpers ───────────────────────────────────────────────────────
605
606    fn make_message_with_content(content: MessageContent) -> Message {
607        Message {
608            id: None,
609            role: Some("assistant".to_string()),
610            model: None,
611            content: Some(content),
612            usage: None,
613            extra: HashMap::new(),
614        }
615    }
616
617    #[test]
618    fn test_message_text_content_from_string() {
619        let msg = make_message_with_content(MessageContent::Text("hello".to_string()));
620        assert_eq!(msg.text_content(), "hello");
621    }
622
623    #[test]
624    fn test_message_text_content_from_blocks() {
625        let blocks = vec![
626            ContentBlock::Text {
627                text: "line one".to_string(),
628            },
629            ContentBlock::Thinking {
630                thinking: "hidden".to_string(),
631                signature: None,
632            },
633            ContentBlock::Text {
634                text: "line two".to_string(),
635            },
636        ];
637        let msg = make_message_with_content(MessageContent::Blocks(blocks));
638        let text = msg.text_content();
639        assert!(text.contains("line one"));
640        assert!(text.contains("line two"));
641        assert!(!text.contains("hidden"));
642    }
643
644    #[test]
645    fn test_message_content_blocks_empty_for_string() {
646        let msg = make_message_with_content(MessageContent::Text("x".to_string()));
647        assert!(msg.content_blocks().is_empty());
648    }
649
650    // ── strip_model_date_suffix ───────────────────────────────────────────────
651
652    #[test]
653    fn test_strip_date_suffix_removes_8_digit_suffix() {
654        assert_eq!(
655            strip_model_date_suffix("claude-sonnet-4-5-20250929"),
656            "claude-sonnet-4-5"
657        );
658        assert_eq!(
659            strip_model_date_suffix("claude-opus-4-6-20260101"),
660            "claude-opus-4-6"
661        );
662        assert_eq!(
663            strip_model_date_suffix("claude-haiku-4-5-20251001"),
664            "claude-haiku-4-5"
665        );
666    }
667
668    #[test]
669    fn test_strip_date_suffix_no_change_when_no_suffix() {
670        assert_eq!(
671            strip_model_date_suffix("claude-sonnet-4-5"),
672            "claude-sonnet-4-5"
673        );
674        assert_eq!(strip_model_date_suffix("claude"), "claude");
675        assert_eq!(strip_model_date_suffix(""), "");
676    }
677
678    // ── TokenUsage ────────────────────────────────────────────────────────────
679
680    #[test]
681    fn test_token_usage_total_includes_cache_tokens() {
682        let tu = TokenUsage {
683            input_tokens: Some(100),
684            output_tokens: Some(50),
685            cache_creation_input_tokens: Some(200),
686            cache_read_input_tokens: Some(300),
687        };
688        assert_eq!(tu.total_input(), 600);
689        assert_eq!(tu.total_output(), 50);
690        assert_eq!(tu.total(), 650);
691    }
692
693    #[test]
694    fn test_token_usage_merge_last_replaces_non_none_fields() {
695        let mut base = TokenUsage {
696            input_tokens: Some(10),
697            output_tokens: Some(20),
698            cache_creation_input_tokens: None,
699            cache_read_input_tokens: None,
700        };
701        let other = TokenUsage {
702            input_tokens: Some(100),
703            output_tokens: Some(200),
704            cache_creation_input_tokens: Some(50),
705            cache_read_input_tokens: None,
706        };
707        base.merge_last(&other);
708        assert_eq!(base.input_tokens, Some(100));
709        assert_eq!(base.output_tokens, Some(200));
710        assert_eq!(base.cache_creation_input_tokens, Some(50));
711        assert_eq!(base.cache_read_input_tokens, None);
712    }
713
714    #[test]
715    fn test_token_usage_merge_last_preserves_none_fields() {
716        let mut base = TokenUsage {
717            input_tokens: Some(10),
718            output_tokens: Some(20),
719            cache_creation_input_tokens: Some(5),
720            cache_read_input_tokens: Some(3),
721        };
722        let other = TokenUsage {
723            input_tokens: None,
724            output_tokens: None,
725            cache_creation_input_tokens: None,
726            cache_read_input_tokens: None,
727        };
728        base.merge_last(&other);
729        // All fields preserved from base since other has None
730        assert_eq!(base.input_tokens, Some(10));
731        assert_eq!(base.output_tokens, Some(20));
732        assert_eq!(base.cache_creation_input_tokens, Some(5));
733        assert_eq!(base.cache_read_input_tokens, Some(3));
734    }
735
736}