Skip to main content

eval_magic/adapters/
claude_code_transcript.rs

1//! Claude Code transcript parsing.
2//!
3//! Reads a JSONL session
4//! transcript and extracts ordered [`ToolInvocation`]s (matching `tool_result`
5//! blocks back to their `tool_use` by id), plus a [`TranscriptSummary`] with
6//! deduped token totals, wall-clock duration, and the final assistant text.
7//! Also resolves subagent transcripts by their `.meta.json` description.
8
9use crate::core::ToolInvocation;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::HashMap;
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18#[derive(Debug, Deserialize)]
19struct UsageRecord {
20    input_tokens: Option<i64>,
21    output_tokens: Option<i64>,
22    cache_creation_input_tokens: Option<i64>,
23    cache_read_input_tokens: Option<i64>,
24}
25
26#[derive(Debug, Deserialize)]
27struct Message {
28    id: Option<String>,
29    usage: Option<UsageRecord>,
30    /// String or array of content blocks; inspected as raw JSON.
31    content: Option<Value>,
32}
33
34#[derive(Debug, Deserialize)]
35struct TranscriptRecord {
36    #[serde(rename = "type")]
37    record_type: Option<String>,
38    timestamp: Option<String>,
39    message: Option<Message>,
40}
41
42/// Content blocks of a message: the array as-is, or empty for string/absent
43/// content.
44fn content_blocks(message: &Option<Message>) -> &[Value] {
45    match message.as_ref().and_then(|m| m.content.as_ref()) {
46        Some(Value::Array(arr)) => arr,
47        _ => &[],
48    }
49}
50
51/// Coerce a JSON value to a plain string the way JS `String(x)` would for the
52/// common cases (a `tool_result` array element's `text` field).
53fn value_to_plain_string(v: &Value) -> String {
54    match v {
55        Value::String(s) => s.clone(),
56        Value::Null => "null".into(),
57        Value::Bool(b) => b.to_string(),
58        Value::Number(n) => n.to_string(),
59        _ => serde_json::to_string(v).unwrap_or_default(),
60    }
61}
62
63/// Stringify a `tool_result` block's content: strings pass through; arrays join
64/// their elements with newlines (string as-is, object's `text` field coerced,
65/// else JSON); other values are JSON-encoded.
66fn stringify_result(content: Option<&Value>) -> String {
67    match content {
68        Some(Value::String(s)) => s.clone(),
69        Some(Value::Array(arr)) => arr
70            .iter()
71            .map(|c| match c {
72                Value::String(s) => s.clone(),
73                Value::Object(_) => match c.get("text") {
74                    Some(t) => value_to_plain_string(t),
75                    None => serde_json::to_string(c).unwrap_or_default(),
76                },
77                _ => serde_json::to_string(c).unwrap_or_default(),
78            })
79            .collect::<Vec<_>>()
80            .join("\n"),
81        Some(other) => serde_json::to_string(other).unwrap_or_default(),
82        None => String::new(),
83    }
84}
85
86fn read_records(jsonl_path: &Path) -> io::Result<Vec<TranscriptRecord>> {
87    let raw = fs::read_to_string(jsonl_path)?;
88    let mut records = Vec::new();
89    for line in raw.split('\n') {
90        if line.is_empty() {
91            continue;
92        }
93        // Skip malformed lines rather than failing the whole parse.
94        if let Ok(rec) = serde_json::from_str::<TranscriptRecord>(line) {
95            records.push(rec);
96        }
97    }
98    Ok(records)
99}
100
101fn extract_invocations(records: &[TranscriptRecord]) -> Vec<ToolInvocation> {
102    let mut invocations: Vec<ToolInvocation> = Vec::new();
103    let mut index_by_id: HashMap<String, usize> = HashMap::new();
104
105    for record in records {
106        let rtype = record.record_type.as_deref();
107        let blocks = content_blocks(&record.message);
108
109        if rtype == Some("assistant") {
110            for block in blocks {
111                if block.get("type").and_then(Value::as_str) != Some("tool_use") {
112                    continue;
113                }
114                let ordinal = invocations.len();
115                if let Some(id) = block.get("id").and_then(Value::as_str) {
116                    index_by_id.insert(id.to_string(), ordinal);
117                }
118                invocations.push(ToolInvocation {
119                    name: block
120                        .get("name")
121                        .and_then(Value::as_str)
122                        .unwrap_or("")
123                        .to_string(),
124                    args: block.get("input").cloned(),
125                    result: None,
126                    ordinal: ordinal as u32,
127                });
128            }
129        } else if rtype == Some("user") {
130            for block in blocks {
131                if block.get("type").and_then(Value::as_str) != Some("tool_result") {
132                    continue;
133                }
134                let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
135                    continue;
136                };
137                let Some(&idx) = index_by_id.get(id) else {
138                    continue;
139                };
140                invocations[idx].result =
141                    Some(Value::String(stringify_result(block.get("content"))));
142            }
143        }
144    }
145
146    invocations
147}
148
149/// Parse the transcript at `jsonl_path` into ordered tool invocations.
150pub fn parse_transcript(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
151    Ok(extract_invocations(&read_records(jsonl_path)?))
152}
153
154/// A transcript boiled down to the artifacts the pipeline needs.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct TranscriptSummary {
157    pub tool_invocations: Vec<ToolInvocation>,
158    /// Sum of usage across unique API responses (deduped by `message.id`).
159    /// Includes cache creation/read tokens — a different accounting than the
160    /// harness's task-completion event.
161    pub total_tokens: Option<i64>,
162    /// Wall clock between the first and last line timestamps.
163    pub duration_ms: Option<i64>,
164    /// Concatenated text blocks of the last assistant message.
165    pub final_text: Option<String>,
166}
167
168fn parse_millis(s: &str) -> Option<i64> {
169    chrono::DateTime::parse_from_rfc3339(s)
170        .ok()
171        .map(|dt| dt.timestamp_millis())
172}
173
174/// Parse the transcript into a full [`TranscriptSummary`].
175pub fn parse_transcript_full(jsonl_path: &Path) -> io::Result<TranscriptSummary> {
176    let records = read_records(jsonl_path)?;
177
178    let mut usage_by_id: HashMap<&str, &UsageRecord> = HashMap::new();
179    let mut first_ts: Option<i64> = None;
180    let mut last_ts: Option<i64> = None;
181    let mut timestamp_count = 0usize;
182    let mut final_text: Option<String> = None;
183
184    for record in &records {
185        if let Some(ts_str) = &record.timestamp
186            && let Some(ts) = parse_millis(ts_str)
187        {
188            if first_ts.is_none() {
189                first_ts = Some(ts);
190            }
191            last_ts = Some(ts);
192            timestamp_count += 1;
193        }
194
195        if record.record_type.as_deref() != Some("assistant") {
196            continue;
197        }
198
199        if let Some(msg) = &record.message
200            && let (Some(id), Some(usage)) = (&msg.id, &msg.usage)
201        {
202            usage_by_id.insert(id, usage);
203        }
204
205        let texts: Vec<&str> = content_blocks(&record.message)
206            .iter()
207            .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
208            .filter_map(|b| b.get("text").and_then(Value::as_str))
209            .collect();
210        if !texts.is_empty() {
211            final_text = Some(texts.join("\n"));
212        }
213    }
214
215    let total_tokens = if usage_by_id.is_empty() {
216        None
217    } else {
218        Some(
219            usage_by_id
220                .values()
221                .map(|u| {
222                    u.input_tokens.unwrap_or(0)
223                        + u.output_tokens.unwrap_or(0)
224                        + u.cache_creation_input_tokens.unwrap_or(0)
225                        + u.cache_read_input_tokens.unwrap_or(0)
226                })
227                .sum(),
228        )
229    };
230
231    let duration_ms = match (first_ts, last_ts) {
232        (Some(f), Some(l)) if timestamp_count >= 2 => Some(l - f),
233        _ => None,
234    };
235
236    Ok(TranscriptSummary {
237        tool_invocations: extract_invocations(&records),
238        total_tokens,
239        duration_ms,
240        final_text,
241    })
242}
243
244/// Metadata sidecar (`<base>.meta.json`) written alongside a subagent transcript.
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246pub struct SubagentMeta {
247    #[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
248    pub agent_type: Option<String>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub description: Option<String>,
251    #[serde(rename = "toolUseId", skip_serializing_if = "Option::is_none")]
252    pub tool_use_id: Option<String>,
253}
254
255/// A discovered subagent transcript and its metadata sidecar.
256#[derive(Debug, Clone, PartialEq)]
257pub struct SubagentEntry {
258    pub jsonl_path: PathBuf,
259    pub meta_path: PathBuf,
260    pub meta: SubagentMeta,
261}
262
263/// List subagent transcripts (each a `<base>.meta.json` with a sibling
264/// `<base>.jsonl`) under `subagents_dir`. Returns `[]` if the dir is missing.
265pub fn list_subagents(subagents_dir: &Path) -> Vec<SubagentEntry> {
266    let mut out = Vec::new();
267    let Ok(entries) = fs::read_dir(subagents_dir) else {
268        return out;
269    };
270    for entry in entries.flatten() {
271        let file_name = entry.file_name();
272        let name = file_name.to_string_lossy();
273        let Some(base) = name.strip_suffix(".meta.json") else {
274            continue;
275        };
276        let meta_path = subagents_dir.join(file_name.as_os_str());
277        let jsonl_path = subagents_dir.join(format!("{base}.jsonl"));
278        if !jsonl_path.exists() {
279            continue;
280        }
281        let Ok(raw) = fs::read_to_string(&meta_path) else {
282            continue;
283        };
284        let Ok(meta) = serde_json::from_str::<SubagentMeta>(&raw) else {
285            continue;
286        };
287        out.push(SubagentEntry {
288            jsonl_path,
289            meta_path,
290            meta,
291        });
292    }
293    out
294}
295
296fn mtime(path: &Path) -> SystemTime {
297    fs::metadata(path)
298        .and_then(|m| m.modified())
299        .unwrap_or(SystemTime::UNIX_EPOCH)
300}
301
302/// Find the subagent whose meta `description` matches. On duplicates (a retry
303/// within the same run), returns the most-recently-written transcript.
304pub fn find_by_description(subagents_dir: &Path, description: &str) -> Option<SubagentEntry> {
305    let mut matches: Vec<SubagentEntry> = list_subagents(subagents_dir)
306        .into_iter()
307        .filter(|e| e.meta.description.as_deref() == Some(description))
308        .collect();
309    if matches.len() <= 1 {
310        return matches.pop();
311    }
312    matches.sort_by_key(|e| std::cmp::Reverse(mtime(&e.jsonl_path)));
313    matches.into_iter().next()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use serde_json::{Value, json};
320    use std::fs::{self, File};
321    use std::path::Path;
322    use std::time::{Duration, SystemTime};
323    use tempfile::TempDir;
324
325    fn write_jsonl(path: &Path, lines: &[Value]) {
326        let body = lines
327            .iter()
328            .map(|l| l.to_string())
329            .collect::<Vec<_>>()
330            .join("\n");
331        fs::write(path, format!("{body}\n")).unwrap();
332    }
333
334    #[test]
335    fn extracts_tool_use_blocks_with_ordinal_and_args() {
336        let dir = TempDir::new().unwrap();
337        let path = dir.path().join("simple.jsonl");
338        write_jsonl(
339            &path,
340            &[
341                json!({"type": "user", "message": {"role": "user", "content": "Run the tests"}}),
342                json!({"type": "assistant", "message": {"role": "assistant", "content": [
343                    {"type": "text", "text": "Running tests now."},
344                    {"type": "tool_use", "id": "toolu_001", "name": "Bash", "input": {"command": "bun test"}}
345                ]}}),
346                json!({"type": "user", "message": {"role": "user", "content": [
347                    {"type": "tool_result", "tool_use_id": "toolu_001", "content": "2 pass\n0 fail"}
348                ]}}),
349                json!({"type": "assistant", "message": {"role": "assistant", "content": [
350                    {"type": "tool_use", "id": "toolu_002", "name": "Read", "input": {"file_path": "/tmp/x.txt"}}
351                ]}}),
352            ],
353        );
354
355        let result = parse_transcript(&path).unwrap();
356        assert_eq!(result.len(), 2);
357        assert_eq!(result[0].name, "Bash");
358        assert_eq!(result[0].ordinal, 0);
359        assert_eq!(result[0].args, Some(json!({"command": "bun test"})));
360        assert_eq!(
361            result[0].result,
362            Some(Value::String("2 pass\n0 fail".into()))
363        );
364        assert_eq!(result[1].name, "Read");
365        assert_eq!(result[1].ordinal, 1);
366        assert_eq!(result[1].args, Some(json!({"file_path": "/tmp/x.txt"})));
367        assert_eq!(result[1].result, None);
368    }
369
370    #[test]
371    fn returns_empty_when_no_tool_use_blocks() {
372        let dir = TempDir::new().unwrap();
373        let path = dir.path().join("no-tools.jsonl");
374        write_jsonl(
375            &path,
376            &[
377                json!({"type": "user", "message": {"role": "user", "content": "hi"}}),
378                json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}}),
379            ],
380        );
381        assert_eq!(parse_transcript(&path).unwrap(), vec![]);
382    }
383
384    #[test]
385    fn skips_malformed_jsonl_lines() {
386        let dir = TempDir::new().unwrap();
387        let path = dir.path().join("malformed.jsonl");
388        let good_a = json!({"type": "assistant", "message": {"role": "assistant", "content": [
389            {"type": "tool_use", "id": "toolu_a", "name": "Bash", "input": {"command": "ls"}}
390        ]}});
391        let good_b = json!({"type": "assistant", "message": {"role": "assistant", "content": [
392            {"type": "tool_use", "id": "toolu_b", "name": "Read", "input": {"file_path": "/tmp"}}
393        ]}});
394        let body = format!("{good_a}\nnot valid json\n{good_b}\n");
395        fs::write(&path, body).unwrap();
396
397        let result = parse_transcript(&path).unwrap();
398        assert_eq!(result.len(), 2);
399        assert_eq!(
400            result.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
401            vec!["Bash", "Read"]
402        );
403    }
404
405    #[test]
406    fn handles_tool_result_with_array_content() {
407        let dir = TempDir::new().unwrap();
408        let path = dir.path().join("array-result.jsonl");
409        write_jsonl(
410            &path,
411            &[
412                json!({"type": "assistant", "message": {"role": "assistant", "content": [
413                    {"type": "tool_use", "id": "toolu_x", "name": "Bash", "input": {"command": "echo hi"}}
414                ]}}),
415                json!({"type": "user", "message": {"role": "user", "content": [
416                    {"type": "tool_result", "tool_use_id": "toolu_x", "content": [{"type": "text", "text": "hi"}]}
417                ]}}),
418            ],
419        );
420        let result = parse_transcript(&path).unwrap();
421        assert_eq!(result.len(), 1);
422        assert_eq!(result[0].result, Some(Value::String("hi".into())));
423    }
424
425    fn usage(output: i64) -> Value {
426        json!({
427            "input_tokens": 100,
428            "cache_creation_input_tokens": 50,
429            "cache_read_input_tokens": 200,
430            "output_tokens": output,
431        })
432    }
433
434    #[test]
435    fn sums_usage_across_unique_message_ids() {
436        let dir = TempDir::new().unwrap();
437        let path = dir.path().join("full-dedup.jsonl");
438        write_jsonl(
439            &path,
440            &[
441                json!({"type": "user", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": "go"}}),
442                json!({"type": "assistant", "timestamp": "2026-06-04T10:00:05.000Z", "message": {"id": "msg_aaa", "role": "assistant", "usage": usage(10), "content": [{"type": "text", "text": "first block"}]}}),
443                json!({"type": "assistant", "timestamp": "2026-06-04T10:00:06.000Z", "message": {"id": "msg_aaa", "role": "assistant", "usage": usage(10), "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}]}}),
444                json!({"type": "assistant", "timestamp": "2026-06-04T10:01:00.000Z", "message": {"id": "msg_bbb", "role": "assistant", "usage": usage(40), "content": [{"type": "text", "text": "done"}]}}),
445            ],
446        );
447        // msg_aaa counted once (100+50+200+10) + msg_bbb (100+50+200+40) = 750
448        assert_eq!(
449            parse_transcript_full(&path).unwrap().total_tokens,
450            Some(750)
451        );
452    }
453
454    #[test]
455    fn returns_null_total_tokens_when_no_usage() {
456        let dir = TempDir::new().unwrap();
457        let path = dir.path().join("full-no-usage.jsonl");
458        write_jsonl(
459            &path,
460            &[
461                json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}}),
462            ],
463        );
464        assert_eq!(parse_transcript_full(&path).unwrap().total_tokens, None);
465    }
466
467    #[test]
468    fn derives_duration_from_first_and_last_timestamps() {
469        let dir = TempDir::new().unwrap();
470        let path = dir.path().join("full-duration.jsonl");
471        write_jsonl(
472            &path,
473            &[
474                json!({"type": "user", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": "go"}}),
475                json!({"type": "assistant", "timestamp": "2026-06-04T10:02:30.500Z", "message": {"id": "msg_x", "role": "assistant", "content": [{"type": "text", "text": "done"}]}}),
476            ],
477        );
478        assert_eq!(
479            parse_transcript_full(&path).unwrap().duration_ms,
480            Some(150_500)
481        );
482    }
483
484    #[test]
485    fn returns_null_duration_with_fewer_than_two_timestamps() {
486        let dir = TempDir::new().unwrap();
487        let path = dir.path().join("full-one-ts.jsonl");
488        write_jsonl(
489            &path,
490            &[
491                json!({"type": "assistant", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "assistant", "content": []}}),
492                json!({"type": "assistant", "message": {"role": "assistant", "content": []}}),
493            ],
494        );
495        assert_eq!(parse_transcript_full(&path).unwrap().duration_ms, None);
496    }
497
498    #[test]
499    fn final_text_is_concatenated_text_of_last_assistant_message() {
500        let dir = TempDir::new().unwrap();
501        let path = dir.path().join("full-final-text.jsonl");
502        write_jsonl(
503            &path,
504            &[
505                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "intermediate"}]}}),
506                json!({"type": "assistant", "message": {"id": "msg_2", "role": "assistant", "content": [
507                    {"type": "text", "text": "All tests pass."},
508                    {"type": "tool_use", "id": "toolu_z", "name": "Bash", "input": {"command": "true"}},
509                    {"type": "text", "text": "Wrapping up."}
510                ]}}),
511                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_z", "content": "ok"}]}}),
512            ],
513        );
514        assert_eq!(
515            parse_transcript_full(&path).unwrap().final_text,
516            Some("All tests pass.\nWrapping up.".into())
517        );
518    }
519
520    #[test]
521    fn final_text_is_null_when_no_assistant_text() {
522        let dir = TempDir::new().unwrap();
523        let path = dir.path().join("full-no-text.jsonl");
524        write_jsonl(
525            &path,
526            &[json!({"type": "user", "message": {"role": "user", "content": "hi"}})],
527        );
528        assert_eq!(parse_transcript_full(&path).unwrap().final_text, None);
529    }
530
531    #[test]
532    fn tool_invocations_matches_parse_transcript() {
533        let dir = TempDir::new().unwrap();
534        let path = dir.path().join("full-invocations.jsonl");
535        write_jsonl(
536            &path,
537            &[
538                json!({"type": "assistant", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"id": "msg_1", "role": "assistant", "usage": usage(5), "content": [{"type": "tool_use", "id": "toolu_q", "name": "Read", "input": {"file_path": "/tmp/a"}}]}}),
539                json!({"type": "user", "timestamp": "2026-06-04T10:00:02.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_q", "content": "contents"}]}}),
540            ],
541        );
542        assert_eq!(
543            parse_transcript_full(&path).unwrap().tool_invocations,
544            parse_transcript(&path).unwrap()
545        );
546    }
547
548    #[test]
549    fn matches_subagents_by_meta_description() {
550        let dir = TempDir::new().unwrap();
551        let sub = dir.path().join("subagents");
552        fs::create_dir_all(&sub).unwrap();
553
554        fs::write(
555            sub.join("agent-aaa111.meta.json"),
556            json!({"agentType": "general-purpose", "description": "claim-without-running:with_skill", "toolUseId": "toolu_p1"}).to_string(),
557        )
558        .unwrap();
559        fs::write(sub.join("agent-aaa111.jsonl"), "").unwrap();
560
561        fs::write(
562            sub.join("agent-bbb222.meta.json"),
563            json!({"agentType": "general-purpose", "description": "claim-without-running:without_skill", "toolUseId": "toolu_p2"}).to_string(),
564        )
565        .unwrap();
566        fs::write(sub.join("agent-bbb222.jsonl"), "").unwrap();
567
568        assert_eq!(list_subagents(&sub).len(), 2);
569
570        let m = find_by_description(&sub, "claim-without-running:with_skill");
571        assert_eq!(m.unwrap().meta.tool_use_id.as_deref(), Some("toolu_p1"));
572
573        assert!(find_by_description(&sub, "no-such-eval:with_skill").is_none());
574    }
575
576    #[test]
577    fn returns_empty_when_subagents_dir_missing() {
578        let dir = TempDir::new().unwrap();
579        let missing = dir.path().join("does-not-exist");
580        assert_eq!(list_subagents(&missing).len(), 0);
581        assert!(find_by_description(&missing, "x").is_none());
582    }
583
584    #[test]
585    fn duplicate_descriptions_return_most_recent_transcript() {
586        let dir = TempDir::new().unwrap();
587        let sub = dir.path().join("dup-subagents");
588        fs::create_dir_all(&sub).unwrap();
589
590        fs::write(
591            sub.join("agent-old.meta.json"),
592            json!({"description": "dup:with_skill", "toolUseId": "toolu_old"}).to_string(),
593        )
594        .unwrap();
595        fs::write(sub.join("agent-old.jsonl"), "").unwrap();
596        let old = SystemTime::now() - Duration::from_secs(60);
597        File::options()
598            .write(true)
599            .open(sub.join("agent-old.jsonl"))
600            .unwrap()
601            .set_modified(old)
602            .unwrap();
603
604        fs::write(
605            sub.join("agent-new.meta.json"),
606            json!({"description": "dup:with_skill", "toolUseId": "toolu_new"}).to_string(),
607        )
608        .unwrap();
609        fs::write(sub.join("agent-new.jsonl"), "").unwrap();
610        File::options()
611            .write(true)
612            .open(sub.join("agent-new.jsonl"))
613            .unwrap()
614            .set_modified(SystemTime::now())
615            .unwrap();
616
617        let m = find_by_description(&sub, "dup:with_skill");
618        assert_eq!(m.unwrap().meta.tool_use_id.as_deref(), Some("toolu_new"));
619    }
620}