eval_magic/adapters/claude_code/
stream_json.rs1use 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#[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
40pub fn parse_claude_stream_json(path: &Path) -> io::Result<Vec<ToolInvocation>> {
43 Ok(extract_invocations(&read_jsonl::<TranscriptRecord>(path)?))
44}
45
46pub 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 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 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}