Skip to main content

eval_magic/adapters/claude_code/
stream_json.rs

1//! Claude Code `-p --output-format stream-json` transcript parsing.
2//!
3//! Parses the newline-delimited JSON event stream that `claude -p
4//! --output-format stream-json --verbose` writes (captured per task as
5//! `outputs/claude-events.jsonl`). The `assistant`/`user` events wrap a full
6//! Anthropic Messages object under `message`, so tool-call extraction is shared
7//! with the [`transcript`](super::transcript) record
8//! types. The differences are all in the envelope: there are no per-line
9//! timestamps, and a terminal `result` event carries the authoritative final
10//! text, wall-clock duration, and token usage. `system`, `rate_limit_event`, and
11//! any other non-message events are ignored (they don't deserialize into an
12//! assistant/user record, so the shared extractor skips them).
13
14use std::io;
15use std::path::Path;
16
17use serde::Deserialize;
18use serde_json::Value;
19
20use crate::core::ToolInvocation;
21
22use crate::adapters::TranscriptSummary;
23use crate::adapters::transcript::read_jsonl;
24
25use super::transcript::{TranscriptRecord, UsageRecord, extract_invocations, last_assistant_text};
26
27/// The terminal `{"type":"result", …}` event of a `-p` stream-json run.
28#[derive(Debug, serde::Deserialize)]
29struct ResultEvent {
30    #[serde(default)]
31    result: Option<String>,
32    #[serde(default)]
33    duration_ms: Option<i64>,
34    #[serde(default)]
35    is_error: Option<bool>,
36    #[serde(default)]
37    usage: Option<UsageRecord>,
38}
39
40/// Parse the event stream into ordered tool invocations. Reuses the shared
41/// extractor: non-message events deserialize into records the extractor skips.
42pub fn parse_claude_stream_json(path: &Path) -> io::Result<Vec<ToolInvocation>> {
43    Ok(extract_invocations(&read_jsonl::<TranscriptRecord>(path)?))
44}
45
46/// Parse the event stream into a full [`TranscriptSummary`]. Final text,
47/// duration, and token totals come from the terminal `result` event; on a
48/// missing or errored `result` the final text falls back to the last assistant
49/// message's text, and duration/tokens fall back to `None`.
50pub fn parse_claude_stream_json_full(path: &Path) -> io::Result<TranscriptSummary> {
51    let values = read_jsonl::<Value>(path)?;
52    let mut records: Vec<TranscriptRecord> = Vec::new();
53    let mut result_event: Option<ResultEvent> = None;
54    for value in &values {
55        // Skip lines that don't shape into records rather than failing the parse.
56        let Ok(record) = TranscriptRecord::deserialize(value) else {
57            continue;
58        };
59        if record.record_type.as_deref() == Some("result") {
60            result_event = ResultEvent::deserialize(value).ok();
61        }
62        records.push(record);
63    }
64
65    let total_tokens = result_event
66        .as_ref()
67        .and_then(|e| e.usage.as_ref())
68        .map(|u| {
69            u.input_tokens.unwrap_or(0)
70                + u.output_tokens.unwrap_or(0)
71                + u.cache_creation_input_tokens.unwrap_or(0)
72                + u.cache_read_input_tokens.unwrap_or(0)
73        });
74    let duration_ms = result_event.as_ref().and_then(|e| e.duration_ms);
75    let final_text = match &result_event {
76        Some(ev) if ev.is_error != Some(true) => {
77            ev.result.clone().or_else(|| last_assistant_text(&records))
78        }
79        _ => last_assistant_text(&records),
80    };
81
82    Ok(TranscriptSummary {
83        tool_invocations: extract_invocations(&records),
84        total_tokens,
85        duration_ms,
86        final_text,
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use serde_json::{Value, json};
93    use std::fs;
94    use std::path::Path;
95    use tempfile::TempDir;
96
97    fn write_jsonl(path: &Path, lines: &[Value]) {
98        let body = lines
99            .iter()
100            .map(|l| l.to_string())
101            .collect::<Vec<_>>()
102            .join("\n");
103        fs::write(path, format!("{body}\n")).unwrap();
104    }
105
106    fn usage() -> Value {
107        json!({
108            "input_tokens": 4932,
109            "output_tokens": 139,
110            "cache_creation_input_tokens": 8287,
111            "cache_read_input_tokens": 33490,
112        })
113    }
114
115    #[test]
116    fn extracts_tool_use_and_result_skipping_non_message_events() {
117        let dir = TempDir::new().unwrap();
118        let path = dir.path().join("events.jsonl");
119        write_jsonl(
120            &path,
121            &[
122                json!({"type": "system", "subtype": "init", "cwd": "/env", "model": "claude-opus-4-8", "tools": ["Bash"]}),
123                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "usage": usage(), "content": [
124                    {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
125                ]}}),
126                json!({"type": "rate_limit_event", "rate_limit_info": {}}),
127                json!({"type": "user", "message": {"role": "user", "content": [
128                    {"type": "tool_result", "tool_use_id": "toolu_1", "content": "a.txt"}
129                ]}}),
130                json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": usage()}),
131            ],
132        );
133
134        let result = super::parse_claude_stream_json(&path).unwrap();
135        assert_eq!(result.len(), 1);
136        assert_eq!(result[0].name, "Bash");
137        assert_eq!(result[0].ordinal, 0);
138        assert_eq!(result[0].args, Some(json!({"command": "ls"})));
139        assert_eq!(result[0].result, Some(Value::String("a.txt".into())));
140    }
141
142    #[test]
143    fn result_event_supplies_final_text_duration_and_tokens() {
144        let dir = TempDir::new().unwrap();
145        let path = dir.path().join("events.jsonl");
146        write_jsonl(
147            &path,
148            &[
149                json!({"type": "system", "subtype": "init"}),
150                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "working"}]}}),
151                json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": usage()}),
152            ],
153        );
154
155        let summary = super::parse_claude_stream_json_full(&path).unwrap();
156        assert_eq!(summary.final_text, Some("Done".into()));
157        assert_eq!(summary.duration_ms, Some(5637));
158        // 4932 + 139 + 8287 + 33490
159        assert_eq!(summary.total_tokens, Some(46848));
160    }
161
162    #[test]
163    fn skill_tool_use_is_preserved_for_meta_check() {
164        let dir = TempDir::new().unwrap();
165        let path = dir.path().join("events.jsonl");
166        write_jsonl(
167            &path,
168            &[
169                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
170                    {"type": "tool_use", "id": "toolu_s", "name": "Skill", "input": {"command": "slow-powers-eval-mr-review"}}
171                ]}}),
172                json!({"type": "result", "subtype": "success", "is_error": false, "result": "ok", "duration_ms": 10, "usage": usage()}),
173            ],
174        );
175        let result = super::parse_claude_stream_json(&path).unwrap();
176        assert_eq!(result.len(), 1);
177        assert_eq!(result[0].name, "Skill");
178        assert_eq!(
179            result[0].args,
180            Some(json!({"command": "slow-powers-eval-mr-review"}))
181        );
182    }
183
184    #[test]
185    fn final_text_falls_back_to_last_assistant_text_when_result_is_error() {
186        let dir = TempDir::new().unwrap();
187        let path = dir.path().join("events.jsonl");
188        write_jsonl(
189            &path,
190            &[
191                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "partial work"}]}}),
192                json!({"type": "result", "subtype": "error_during_execution", "is_error": true, "result": "Execution error", "duration_ms": 12, "usage": usage()}),
193            ],
194        );
195        let summary = super::parse_claude_stream_json_full(&path).unwrap();
196        assert_eq!(summary.final_text, Some("partial work".into()));
197    }
198
199    #[test]
200    fn skips_malformed_jsonl_lines() {
201        let dir = TempDir::new().unwrap();
202        let path = dir.path().join("events.jsonl");
203        let good = json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
204            {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "/tmp/x"}}
205        ]}});
206        let result_line = json!({"type": "result", "is_error": false, "result": "Done", "duration_ms": 1, "usage": usage()});
207        let body = format!("{good}\nnot valid json\n{result_line}\n");
208        fs::write(&path, body).unwrap();
209
210        let result = super::parse_claude_stream_json(&path).unwrap();
211        assert_eq!(result.len(), 1);
212        assert_eq!(result[0].name, "Read");
213    }
214
215    #[test]
216    fn null_duration_and_tokens_when_no_result_event() {
217        let dir = TempDir::new().unwrap();
218        let path = dir.path().join("events.jsonl");
219        write_jsonl(
220            &path,
221            &[
222                json!({"type": "system", "subtype": "init"}),
223                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "incomplete"}]}}),
224            ],
225        );
226        let summary = super::parse_claude_stream_json_full(&path).unwrap();
227        assert_eq!(summary.duration_ms, None);
228        assert_eq!(summary.total_tokens, None);
229        assert_eq!(summary.final_text, Some("incomplete".into()));
230    }
231}