Skip to main content

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