eval_magic/adapters/claude_code/
transcript.rs1use 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 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
36fn 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
45fn 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
57fn 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
128pub(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 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}