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::core::ToolInvocation;
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::HashMap;
14
15#[derive(Debug, Deserialize)]
16pub(crate) struct UsageRecord {
17    pub(crate) input_tokens: Option<i64>,
18    pub(crate) output_tokens: Option<i64>,
19    pub(crate) cache_creation_input_tokens: Option<i64>,
20    pub(crate) cache_read_input_tokens: Option<i64>,
21}
22
23#[derive(Debug, Deserialize)]
24struct Message {
25    /// String or array of content blocks; inspected as raw JSON.
26    content: Option<Value>,
27}
28
29#[derive(Debug, Deserialize)]
30pub(crate) struct TranscriptRecord {
31    #[serde(rename = "type")]
32    pub(crate) record_type: Option<String>,
33    message: Option<Message>,
34}
35
36/// Content blocks of a message: the array as-is, or empty for string/absent
37/// content.
38fn content_blocks(message: &Option<Message>) -> &[Value] {
39    match message.as_ref().and_then(|m| m.content.as_ref()) {
40        Some(Value::Array(arr)) => arr,
41        _ => &[],
42    }
43}
44
45/// Coerce a JSON value to a plain string the way JS `String(x)` would for the
46/// common cases (a `tool_result` array element's `text` field).
47fn value_to_plain_string(v: &Value) -> String {
48    match v {
49        Value::String(s) => s.clone(),
50        Value::Null => "null".into(),
51        Value::Bool(b) => b.to_string(),
52        Value::Number(n) => n.to_string(),
53        _ => serde_json::to_string(v).unwrap_or_default(),
54    }
55}
56
57/// Stringify a `tool_result` block's content: strings pass through; arrays join
58/// their elements with newlines (string as-is, object's `text` field coerced,
59/// else JSON); other values are JSON-encoded.
60fn stringify_result(content: Option<&Value>) -> String {
61    match content {
62        Some(Value::String(s)) => s.clone(),
63        Some(Value::Array(arr)) => arr
64            .iter()
65            .map(|c| match c {
66                Value::String(s) => s.clone(),
67                Value::Object(_) => match c.get("text") {
68                    Some(t) => value_to_plain_string(t),
69                    None => serde_json::to_string(c).unwrap_or_default(),
70                },
71                _ => serde_json::to_string(c).unwrap_or_default(),
72            })
73            .collect::<Vec<_>>()
74            .join("\n"),
75        Some(other) => serde_json::to_string(other).unwrap_or_default(),
76        None => String::new(),
77    }
78}
79
80pub(crate) fn extract_invocations(records: &[TranscriptRecord]) -> Vec<ToolInvocation> {
81    let mut invocations: Vec<ToolInvocation> = Vec::new();
82    let mut index_by_id: HashMap<String, usize> = HashMap::new();
83
84    for record in records {
85        let rtype = record.record_type.as_deref();
86        let blocks = content_blocks(&record.message);
87
88        if rtype == Some("assistant") {
89            for block in blocks {
90                if block.get("type").and_then(Value::as_str) != Some("tool_use") {
91                    continue;
92                }
93                let ordinal = invocations.len();
94                if let Some(id) = block.get("id").and_then(Value::as_str) {
95                    index_by_id.insert(id.to_string(), ordinal);
96                }
97                invocations.push(ToolInvocation {
98                    name: block
99                        .get("name")
100                        .and_then(Value::as_str)
101                        .unwrap_or("")
102                        .to_string(),
103                    args: block.get("input").cloned(),
104                    result: None,
105                    ordinal: ordinal as u32,
106                });
107            }
108        } else if rtype == Some("user") {
109            for block in blocks {
110                if block.get("type").and_then(Value::as_str) != Some("tool_result") {
111                    continue;
112                }
113                let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
114                    continue;
115                };
116                let Some(&idx) = index_by_id.get(id) else {
117                    continue;
118                };
119                invocations[idx].result =
120                    Some(Value::String(stringify_result(block.get("content"))));
121            }
122        }
123    }
124
125    invocations
126}
127
128/// The concatenated text blocks of the last assistant message carrying any text.
129/// Shared with the `-p` stream-json parser, which uses it as the final-message
130/// fallback when the terminal `result` event is absent or errored.
131pub(crate) fn last_assistant_text(records: &[TranscriptRecord]) -> Option<String> {
132    let mut final_text: Option<String> = None;
133    for record in records {
134        if record.record_type.as_deref() != Some("assistant") {
135            continue;
136        }
137        let texts: Vec<&str> = content_blocks(&record.message)
138            .iter()
139            .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
140            .filter_map(|b| b.get("text").and_then(Value::as_str))
141            .collect();
142        if !texts.is_empty() {
143            final_text = Some(texts.join("\n"));
144        }
145    }
146    final_text
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::adapters::transcript::read_jsonl;
153    use serde_json::{Value, json};
154    use std::fs;
155    use std::path::Path;
156    use tempfile::TempDir;
157
158    fn write_jsonl(path: &Path, lines: &[Value]) {
159        let body = lines
160            .iter()
161            .map(|l| l.to_string())
162            .collect::<Vec<_>>()
163            .join("\n");
164        fs::write(path, format!("{body}\n")).unwrap();
165    }
166
167    /// Read the records and run the shared tool-call extractor — the path the
168    /// stream-json parser also takes.
169    fn invocations(path: &Path) -> Vec<ToolInvocation> {
170        extract_invocations(&read_jsonl::<TranscriptRecord>(path).unwrap())
171    }
172
173    #[test]
174    fn extracts_tool_use_blocks_with_ordinal_and_args() {
175        let dir = TempDir::new().unwrap();
176        let path = dir.path().join("simple.jsonl");
177        write_jsonl(
178            &path,
179            &[
180                json!({"type": "user", "message": {"role": "user", "content": "Run the tests"}}),
181                json!({"type": "assistant", "message": {"role": "assistant", "content": [
182                    {"type": "text", "text": "Running tests now."},
183                    {"type": "tool_use", "id": "toolu_001", "name": "Bash", "input": {"command": "bun test"}}
184                ]}}),
185                json!({"type": "user", "message": {"role": "user", "content": [
186                    {"type": "tool_result", "tool_use_id": "toolu_001", "content": "2 pass\n0 fail"}
187                ]}}),
188                json!({"type": "assistant", "message": {"role": "assistant", "content": [
189                    {"type": "tool_use", "id": "toolu_002", "name": "Read", "input": {"file_path": "/tmp/x.txt"}}
190                ]}}),
191            ],
192        );
193
194        let result = invocations(&path);
195        assert_eq!(result.len(), 2);
196        assert_eq!(result[0].name, "Bash");
197        assert_eq!(result[0].ordinal, 0);
198        assert_eq!(result[0].args, Some(json!({"command": "bun test"})));
199        assert_eq!(
200            result[0].result,
201            Some(Value::String("2 pass\n0 fail".into()))
202        );
203        assert_eq!(result[1].name, "Read");
204        assert_eq!(result[1].ordinal, 1);
205        assert_eq!(result[1].args, Some(json!({"file_path": "/tmp/x.txt"})));
206        assert_eq!(result[1].result, None);
207    }
208
209    #[test]
210    fn returns_empty_when_no_tool_use_blocks() {
211        let dir = TempDir::new().unwrap();
212        let path = dir.path().join("no-tools.jsonl");
213        write_jsonl(
214            &path,
215            &[
216                json!({"type": "user", "message": {"role": "user", "content": "hi"}}),
217                json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}}),
218            ],
219        );
220        assert_eq!(invocations(&path), vec![]);
221    }
222
223    #[test]
224    fn skips_malformed_jsonl_lines() {
225        let dir = TempDir::new().unwrap();
226        let path = dir.path().join("malformed.jsonl");
227        let good_a = json!({"type": "assistant", "message": {"role": "assistant", "content": [
228            {"type": "tool_use", "id": "toolu_a", "name": "Bash", "input": {"command": "ls"}}
229        ]}});
230        let good_b = json!({"type": "assistant", "message": {"role": "assistant", "content": [
231            {"type": "tool_use", "id": "toolu_b", "name": "Read", "input": {"file_path": "/tmp"}}
232        ]}});
233        let body = format!("{good_a}\nnot valid json\n{good_b}\n");
234        fs::write(&path, body).unwrap();
235
236        let result = invocations(&path);
237        assert_eq!(result.len(), 2);
238        assert_eq!(
239            result.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
240            vec!["Bash", "Read"]
241        );
242    }
243
244    #[test]
245    fn handles_tool_result_with_array_content() {
246        let dir = TempDir::new().unwrap();
247        let path = dir.path().join("array-result.jsonl");
248        write_jsonl(
249            &path,
250            &[
251                json!({"type": "assistant", "message": {"role": "assistant", "content": [
252                    {"type": "tool_use", "id": "toolu_x", "name": "Bash", "input": {"command": "echo hi"}}
253                ]}}),
254                json!({"type": "user", "message": {"role": "user", "content": [
255                    {"type": "tool_result", "tool_use_id": "toolu_x", "content": [{"type": "text", "text": "hi"}]}
256                ]}}),
257            ],
258        );
259        let result = invocations(&path);
260        assert_eq!(result.len(), 1);
261        assert_eq!(result[0].result, Some(Value::String("hi".into())));
262    }
263
264    #[test]
265    fn last_assistant_text_concatenates_text_of_last_assistant_message() {
266        let dir = TempDir::new().unwrap();
267        let path = dir.path().join("final-text.jsonl");
268        write_jsonl(
269            &path,
270            &[
271                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "intermediate"}]}}),
272                json!({"type": "assistant", "message": {"id": "msg_2", "role": "assistant", "content": [
273                    {"type": "text", "text": "All tests pass."},
274                    {"type": "tool_use", "id": "toolu_z", "name": "Bash", "input": {"command": "true"}},
275                    {"type": "text", "text": "Wrapping up."}
276                ]}}),
277                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_z", "content": "ok"}]}}),
278            ],
279        );
280        assert_eq!(
281            last_assistant_text(&read_jsonl::<TranscriptRecord>(&path).unwrap()),
282            Some("All tests pass.\nWrapping up.".into())
283        );
284    }
285
286    #[test]
287    fn last_assistant_text_is_null_when_no_assistant_text() {
288        let dir = TempDir::new().unwrap();
289        let path = dir.path().join("no-text.jsonl");
290        write_jsonl(
291            &path,
292            &[json!({"type": "user", "message": {"role": "user", "content": "hi"}})],
293        );
294        assert_eq!(
295            last_assistant_text(&read_jsonl::<TranscriptRecord>(&path).unwrap()),
296            None
297        );
298    }
299}