Skip to main content

eval_magic/adapters/claude_code/
transcript.rs

1//! Claude Code transcript record types and tool-call extraction.
2//!
3//! Defines the JSONL record shapes and the shared extractors — ordered
4//! [`ToolInvocation`]s (matching `tool_result` blocks back to their `tool_use` by
5//! id) and the last assistant text — reused by the `claude -p` stream-json parser
6//! ([`stream_json`](super::stream_json)). The harness-neutral
7//! [`TranscriptSummary`](crate::adapters::TranscriptSummary) the pipeline
8//! consumes lives in `crate::adapters::transcript`.
9
10use 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    /// String or array of content blocks; inspected as raw JSON.
27    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
37/// Content blocks of a message: the array as-is, or empty for string/absent
38/// content.
39fn 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
46/// Coerce a JSON value to a plain string the way JS `String(x)` would for the
47/// common cases (a `tool_result` array element's `text` field).
48fn 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
58/// Stringify a `tool_result` block's content: strings pass through; arrays join
59/// their elements with newlines (string as-is, object's `text` field coerced,
60/// else JSON); other values are JSON-encoded.
61fn 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
166/// The concatenated text blocks of the last assistant message carrying any text.
167/// Shared with the `-p` stream-json parser, which uses it as the final-message
168/// fallback when the terminal `result` event is absent or errored.
169pub(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    /// Read the records and run the shared tool-call extractor — the path the
206    /// stream-json parser also takes.
207    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}