eval_magic/adapters/claude_code/
transcript.rs1use crate::adapters::transcript::TranscriptEvent;
11use crate::core::ToolInvocation;
12use serde::Deserialize;
13use serde_json::Value;
14use std::collections::HashMap;
15
16#[derive(Debug, Deserialize)]
17pub(crate) struct UsageRecord {
18 pub(crate) input_tokens: Option<i64>,
19 pub(crate) output_tokens: Option<i64>,
20 pub(crate) cache_creation_input_tokens: Option<i64>,
21 pub(crate) cache_read_input_tokens: Option<i64>,
22}
23
24#[derive(Debug, Deserialize)]
25struct Message {
26 content: Option<Value>,
28}
29
30#[derive(Debug, Deserialize)]
31pub(crate) struct TranscriptRecord {
32 #[serde(rename = "type")]
33 pub(crate) record_type: Option<String>,
34 message: Option<Message>,
35}
36
37fn content_blocks(message: &Option<Message>) -> &[Value] {
40 match message.as_ref().and_then(|m| m.content.as_ref()) {
41 Some(Value::Array(arr)) => arr,
42 _ => &[],
43 }
44}
45
46fn value_to_plain_string(v: &Value) -> String {
49 match v {
50 Value::String(s) => s.clone(),
51 Value::Null => "null".into(),
52 Value::Bool(b) => b.to_string(),
53 Value::Number(n) => n.to_string(),
54 _ => serde_json::to_string(v).unwrap_or_default(),
55 }
56}
57
58fn stringify_result(content: Option<&Value>) -> String {
62 match content {
63 Some(Value::String(s)) => s.clone(),
64 Some(Value::Array(arr)) => arr
65 .iter()
66 .map(|c| match c {
67 Value::String(s) => s.clone(),
68 Value::Object(_) => match c.get("text") {
69 Some(t) => value_to_plain_string(t),
70 None => serde_json::to_string(c).unwrap_or_default(),
71 },
72 _ => serde_json::to_string(c).unwrap_or_default(),
73 })
74 .collect::<Vec<_>>()
75 .join("\n"),
76 Some(other) => serde_json::to_string(other).unwrap_or_default(),
77 None => String::new(),
78 }
79}
80
81pub(crate) fn extract_invocations(records: &[TranscriptRecord]) -> Vec<ToolInvocation> {
82 let mut invocations: Vec<ToolInvocation> = Vec::new();
83 let mut index_by_id: HashMap<String, usize> = HashMap::new();
84
85 for record in records {
86 let rtype = record.record_type.as_deref();
87 let blocks = content_blocks(&record.message);
88
89 if rtype == Some("assistant") {
90 for block in blocks {
91 if block.get("type").and_then(Value::as_str) != Some("tool_use") {
92 continue;
93 }
94 let ordinal = invocations.len();
95 if let Some(id) = block.get("id").and_then(Value::as_str) {
96 index_by_id.insert(id.to_string(), ordinal);
97 }
98 invocations.push(ToolInvocation {
99 name: block
100 .get("name")
101 .and_then(Value::as_str)
102 .unwrap_or("")
103 .to_string(),
104 args: block.get("input").cloned(),
105 result: None,
106 ordinal: ordinal as u32,
107 });
108 }
109 } else if rtype == Some("user") {
110 for block in blocks {
111 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
112 continue;
113 }
114 let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
115 continue;
116 };
117 let Some(&idx) = index_by_id.get(id) else {
118 continue;
119 };
120 invocations[idx].result =
121 Some(Value::String(stringify_result(block.get("content"))));
122 }
123 }
124 }
125
126 invocations
127}
128
129pub(crate) fn extract_events(records: &[TranscriptRecord]) -> Vec<TranscriptEvent> {
130 let invocations = extract_invocations(records);
131 let mut tool_index = 0usize;
132 let mut events = Vec::new();
133 for record in records {
134 if record.record_type.as_deref() != Some("assistant") {
135 continue;
136 }
137 for block in content_blocks(&record.message) {
138 let ordinal = events.len() as u32;
139 match block.get("type").and_then(Value::as_str) {
140 Some("text") => {
141 if let Some(text) = block.get("text").and_then(Value::as_str) {
142 events.push(TranscriptEvent::AssistantMessage {
143 ordinal,
144 text: text.to_string(),
145 });
146 }
147 }
148 Some("tool_use") => {
149 if let Some(invocation) = invocations.get(tool_index) {
150 events.push(TranscriptEvent::ToolInvocation {
151 ordinal,
152 name: invocation.name.clone(),
153 args: invocation.args.clone(),
154 result: invocation.result.clone(),
155 });
156 }
157 tool_index += 1;
158 }
159 _ => {}
160 }
161 }
162 }
163 events
164}
165
166pub(crate) fn last_assistant_text(records: &[TranscriptRecord]) -> Option<String> {
170 let mut final_text: Option<String> = None;
171 for record in records {
172 if record.record_type.as_deref() != Some("assistant") {
173 continue;
174 }
175 let texts: Vec<&str> = content_blocks(&record.message)
176 .iter()
177 .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
178 .filter_map(|b| b.get("text").and_then(Value::as_str))
179 .collect();
180 if !texts.is_empty() {
181 final_text = Some(texts.join("\n"));
182 }
183 }
184 final_text
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::adapters::transcript::read_jsonl;
191 use serde_json::{Value, json};
192 use std::fs;
193 use std::path::Path;
194 use tempfile::TempDir;
195
196 fn write_jsonl(path: &Path, lines: &[Value]) {
197 let body = lines
198 .iter()
199 .map(|l| l.to_string())
200 .collect::<Vec<_>>()
201 .join("\n");
202 fs::write(path, format!("{body}\n")).unwrap();
203 }
204
205 fn invocations(path: &Path) -> Vec<ToolInvocation> {
208 extract_invocations(&read_jsonl::<TranscriptRecord>(path).unwrap())
209 }
210
211 #[test]
212 fn extracts_tool_use_blocks_with_ordinal_and_args() {
213 let dir = TempDir::new().unwrap();
214 let path = dir.path().join("simple.jsonl");
215 write_jsonl(
216 &path,
217 &[
218 json!({"type": "user", "message": {"role": "user", "content": "Run the tests"}}),
219 json!({"type": "assistant", "message": {"role": "assistant", "content": [
220 {"type": "text", "text": "Running tests now."},
221 {"type": "tool_use", "id": "toolu_001", "name": "Bash", "input": {"command": "bun test"}}
222 ]}}),
223 json!({"type": "user", "message": {"role": "user", "content": [
224 {"type": "tool_result", "tool_use_id": "toolu_001", "content": "2 pass\n0 fail"}
225 ]}}),
226 json!({"type": "assistant", "message": {"role": "assistant", "content": [
227 {"type": "tool_use", "id": "toolu_002", "name": "Read", "input": {"file_path": "/tmp/x.txt"}}
228 ]}}),
229 ],
230 );
231
232 let result = invocations(&path);
233 assert_eq!(result.len(), 2);
234 assert_eq!(result[0].name, "Bash");
235 assert_eq!(result[0].ordinal, 0);
236 assert_eq!(result[0].args, Some(json!({"command": "bun test"})));
237 assert_eq!(
238 result[0].result,
239 Some(Value::String("2 pass\n0 fail".into()))
240 );
241 assert_eq!(result[1].name, "Read");
242 assert_eq!(result[1].ordinal, 1);
243 assert_eq!(result[1].args, Some(json!({"file_path": "/tmp/x.txt"})));
244 assert_eq!(result[1].result, None);
245 }
246
247 #[test]
248 fn returns_empty_when_no_tool_use_blocks() {
249 let dir = TempDir::new().unwrap();
250 let path = dir.path().join("no-tools.jsonl");
251 write_jsonl(
252 &path,
253 &[
254 json!({"type": "user", "message": {"role": "user", "content": "hi"}}),
255 json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}}),
256 ],
257 );
258 assert_eq!(invocations(&path), vec![]);
259 }
260
261 #[test]
262 fn skips_malformed_jsonl_lines() {
263 let dir = TempDir::new().unwrap();
264 let path = dir.path().join("malformed.jsonl");
265 let good_a = json!({"type": "assistant", "message": {"role": "assistant", "content": [
266 {"type": "tool_use", "id": "toolu_a", "name": "Bash", "input": {"command": "ls"}}
267 ]}});
268 let good_b = json!({"type": "assistant", "message": {"role": "assistant", "content": [
269 {"type": "tool_use", "id": "toolu_b", "name": "Read", "input": {"file_path": "/tmp"}}
270 ]}});
271 let body = format!("{good_a}\nnot valid json\n{good_b}\n");
272 fs::write(&path, body).unwrap();
273
274 let result = invocations(&path);
275 assert_eq!(result.len(), 2);
276 assert_eq!(
277 result.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
278 vec!["Bash", "Read"]
279 );
280 }
281
282 #[test]
283 fn handles_tool_result_with_array_content() {
284 let dir = TempDir::new().unwrap();
285 let path = dir.path().join("array-result.jsonl");
286 write_jsonl(
287 &path,
288 &[
289 json!({"type": "assistant", "message": {"role": "assistant", "content": [
290 {"type": "tool_use", "id": "toolu_x", "name": "Bash", "input": {"command": "echo hi"}}
291 ]}}),
292 json!({"type": "user", "message": {"role": "user", "content": [
293 {"type": "tool_result", "tool_use_id": "toolu_x", "content": [{"type": "text", "text": "hi"}]}
294 ]}}),
295 ],
296 );
297 let result = invocations(&path);
298 assert_eq!(result.len(), 1);
299 assert_eq!(result[0].result, Some(Value::String("hi".into())));
300 }
301
302 #[test]
303 fn last_assistant_text_concatenates_text_of_last_assistant_message() {
304 let dir = TempDir::new().unwrap();
305 let path = dir.path().join("final-text.jsonl");
306 write_jsonl(
307 &path,
308 &[
309 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "intermediate"}]}}),
310 json!({"type": "assistant", "message": {"id": "msg_2", "role": "assistant", "content": [
311 {"type": "text", "text": "All tests pass."},
312 {"type": "tool_use", "id": "toolu_z", "name": "Bash", "input": {"command": "true"}},
313 {"type": "text", "text": "Wrapping up."}
314 ]}}),
315 json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_z", "content": "ok"}]}}),
316 ],
317 );
318 assert_eq!(
319 last_assistant_text(&read_jsonl::<TranscriptRecord>(&path).unwrap()),
320 Some("All tests pass.\nWrapping up.".into())
321 );
322 }
323
324 #[test]
325 fn last_assistant_text_is_null_when_no_assistant_text() {
326 let dir = TempDir::new().unwrap();
327 let path = dir.path().join("no-text.jsonl");
328 write_jsonl(
329 &path,
330 &[json!({"type": "user", "message": {"role": "user", "content": "hi"}})],
331 );
332 assert_eq!(
333 last_assistant_text(&read_jsonl::<TranscriptRecord>(&path).unwrap()),
334 None
335 );
336 }
337}