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)]
19pub(crate) struct UsageRecord {
20    pub(crate) input_tokens: Option<i64>,
21    pub(crate) output_tokens: Option<i64>,
22    pub(crate) cache_creation_input_tokens: Option<i64>,
23    pub(crate) 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)]
35pub(crate) struct TranscriptRecord {
36    #[serde(rename = "type")]
37    pub(crate) 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
86pub(crate) fn 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
101pub(crate) fn 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/// The concatenated text blocks of the last assistant message carrying any text.
155/// Shared with the `-p` stream-json parser, which uses it as the final-message
156/// fallback when the terminal `result` event is absent or errored.
157pub(crate) fn last_assistant_text(records: &[TranscriptRecord]) -> Option<String> {
158    let mut final_text: Option<String> = None;
159    for record in records {
160        if record.record_type.as_deref() != Some("assistant") {
161            continue;
162        }
163        let texts: Vec<&str> = content_blocks(&record.message)
164            .iter()
165            .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
166            .filter_map(|b| b.get("text").and_then(Value::as_str))
167            .collect();
168        if !texts.is_empty() {
169            final_text = Some(texts.join("\n"));
170        }
171    }
172    final_text
173}
174
175/// A transcript boiled down to the artifacts the pipeline needs.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct TranscriptSummary {
178    pub tool_invocations: Vec<ToolInvocation>,
179    /// Sum of usage across unique API responses (deduped by `message.id`).
180    /// Includes cache creation/read tokens — a different accounting than the
181    /// harness's task-completion event.
182    pub total_tokens: Option<i64>,
183    /// Wall clock between the first and last line timestamps.
184    pub duration_ms: Option<i64>,
185    /// Concatenated text blocks of the last assistant message.
186    pub final_text: Option<String>,
187}
188
189fn parse_millis(s: &str) -> Option<i64> {
190    chrono::DateTime::parse_from_rfc3339(s)
191        .ok()
192        .map(|dt| dt.timestamp_millis())
193}
194
195/// Parse the transcript into a full [`TranscriptSummary`].
196pub fn parse_transcript_full(jsonl_path: &Path) -> io::Result<TranscriptSummary> {
197    let records = read_records(jsonl_path)?;
198
199    let mut usage_by_id: HashMap<&str, &UsageRecord> = HashMap::new();
200    let mut first_ts: Option<i64> = None;
201    let mut last_ts: Option<i64> = None;
202    let mut timestamp_count = 0usize;
203
204    for record in &records {
205        if let Some(ts_str) = &record.timestamp
206            && let Some(ts) = parse_millis(ts_str)
207        {
208            if first_ts.is_none() {
209                first_ts = Some(ts);
210            }
211            last_ts = Some(ts);
212            timestamp_count += 1;
213        }
214
215        if record.record_type.as_deref() != Some("assistant") {
216            continue;
217        }
218
219        if let Some(msg) = &record.message
220            && let (Some(id), Some(usage)) = (&msg.id, &msg.usage)
221        {
222            usage_by_id.insert(id, usage);
223        }
224    }
225
226    let final_text = last_assistant_text(&records);
227
228    let total_tokens = if usage_by_id.is_empty() {
229        None
230    } else {
231        Some(
232            usage_by_id
233                .values()
234                .map(|u| {
235                    u.input_tokens.unwrap_or(0)
236                        + u.output_tokens.unwrap_or(0)
237                        + u.cache_creation_input_tokens.unwrap_or(0)
238                        + u.cache_read_input_tokens.unwrap_or(0)
239                })
240                .sum(),
241        )
242    };
243
244    let duration_ms = match (first_ts, last_ts) {
245        (Some(f), Some(l)) if timestamp_count >= 2 => Some(l - f),
246        _ => None,
247    };
248
249    Ok(TranscriptSummary {
250        tool_invocations: extract_invocations(&records),
251        total_tokens,
252        duration_ms,
253        final_text,
254    })
255}
256
257/// Metadata sidecar (`<base>.meta.json`) written alongside a subagent transcript.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct SubagentMeta {
260    #[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
261    pub agent_type: Option<String>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub description: Option<String>,
264    #[serde(rename = "toolUseId", skip_serializing_if = "Option::is_none")]
265    pub tool_use_id: Option<String>,
266}
267
268/// A discovered subagent transcript and its metadata sidecar.
269#[derive(Debug, Clone, PartialEq)]
270pub struct SubagentEntry {
271    pub jsonl_path: PathBuf,
272    pub meta_path: PathBuf,
273    pub meta: SubagentMeta,
274}
275
276/// List subagent transcripts (each a `<base>.meta.json` with a sibling
277/// `<base>.jsonl`) under `subagents_dir`. Returns `[]` if the dir is missing.
278pub fn list_subagents(subagents_dir: &Path) -> Vec<SubagentEntry> {
279    let mut out = Vec::new();
280    let Ok(entries) = fs::read_dir(subagents_dir) else {
281        return out;
282    };
283    for entry in entries.flatten() {
284        let file_name = entry.file_name();
285        let name = file_name.to_string_lossy();
286        let Some(base) = name.strip_suffix(".meta.json") else {
287            continue;
288        };
289        let meta_path = subagents_dir.join(file_name.as_os_str());
290        let jsonl_path = subagents_dir.join(format!("{base}.jsonl"));
291        if !jsonl_path.exists() {
292            continue;
293        }
294        let Ok(raw) = fs::read_to_string(&meta_path) else {
295            continue;
296        };
297        let Ok(meta) = serde_json::from_str::<SubagentMeta>(&raw) else {
298            continue;
299        };
300        out.push(SubagentEntry {
301            jsonl_path,
302            meta_path,
303            meta,
304        });
305    }
306    out
307}
308
309fn mtime(path: &Path) -> SystemTime {
310    fs::metadata(path)
311        .and_then(|m| m.modified())
312        .unwrap_or(SystemTime::UNIX_EPOCH)
313}
314
315/// Find the subagent whose meta `description` matches. On duplicates (a retry
316/// within the same run), returns the most-recently-written transcript.
317pub fn find_by_description(subagents_dir: &Path, description: &str) -> Option<SubagentEntry> {
318    let mut matches: Vec<SubagentEntry> = list_subagents(subagents_dir)
319        .into_iter()
320        .filter(|e| e.meta.description.as_deref() == Some(description))
321        .collect();
322    if matches.len() <= 1 {
323        return matches.pop();
324    }
325    matches.sort_by_key(|e| std::cmp::Reverse(mtime(&e.jsonl_path)));
326    matches.into_iter().next()
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::{Value, json};
333    use std::fs::{self, File};
334    use std::path::Path;
335    use std::time::{Duration, SystemTime};
336    use tempfile::TempDir;
337
338    fn write_jsonl(path: &Path, lines: &[Value]) {
339        let body = lines
340            .iter()
341            .map(|l| l.to_string())
342            .collect::<Vec<_>>()
343            .join("\n");
344        fs::write(path, format!("{body}\n")).unwrap();
345    }
346
347    #[test]
348    fn extracts_tool_use_blocks_with_ordinal_and_args() {
349        let dir = TempDir::new().unwrap();
350        let path = dir.path().join("simple.jsonl");
351        write_jsonl(
352            &path,
353            &[
354                json!({"type": "user", "message": {"role": "user", "content": "Run the tests"}}),
355                json!({"type": "assistant", "message": {"role": "assistant", "content": [
356                    {"type": "text", "text": "Running tests now."},
357                    {"type": "tool_use", "id": "toolu_001", "name": "Bash", "input": {"command": "bun test"}}
358                ]}}),
359                json!({"type": "user", "message": {"role": "user", "content": [
360                    {"type": "tool_result", "tool_use_id": "toolu_001", "content": "2 pass\n0 fail"}
361                ]}}),
362                json!({"type": "assistant", "message": {"role": "assistant", "content": [
363                    {"type": "tool_use", "id": "toolu_002", "name": "Read", "input": {"file_path": "/tmp/x.txt"}}
364                ]}}),
365            ],
366        );
367
368        let result = parse_transcript(&path).unwrap();
369        assert_eq!(result.len(), 2);
370        assert_eq!(result[0].name, "Bash");
371        assert_eq!(result[0].ordinal, 0);
372        assert_eq!(result[0].args, Some(json!({"command": "bun test"})));
373        assert_eq!(
374            result[0].result,
375            Some(Value::String("2 pass\n0 fail".into()))
376        );
377        assert_eq!(result[1].name, "Read");
378        assert_eq!(result[1].ordinal, 1);
379        assert_eq!(result[1].args, Some(json!({"file_path": "/tmp/x.txt"})));
380        assert_eq!(result[1].result, None);
381    }
382
383    #[test]
384    fn returns_empty_when_no_tool_use_blocks() {
385        let dir = TempDir::new().unwrap();
386        let path = dir.path().join("no-tools.jsonl");
387        write_jsonl(
388            &path,
389            &[
390                json!({"type": "user", "message": {"role": "user", "content": "hi"}}),
391                json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}}),
392            ],
393        );
394        assert_eq!(parse_transcript(&path).unwrap(), vec![]);
395    }
396
397    #[test]
398    fn skips_malformed_jsonl_lines() {
399        let dir = TempDir::new().unwrap();
400        let path = dir.path().join("malformed.jsonl");
401        let good_a = json!({"type": "assistant", "message": {"role": "assistant", "content": [
402            {"type": "tool_use", "id": "toolu_a", "name": "Bash", "input": {"command": "ls"}}
403        ]}});
404        let good_b = json!({"type": "assistant", "message": {"role": "assistant", "content": [
405            {"type": "tool_use", "id": "toolu_b", "name": "Read", "input": {"file_path": "/tmp"}}
406        ]}});
407        let body = format!("{good_a}\nnot valid json\n{good_b}\n");
408        fs::write(&path, body).unwrap();
409
410        let result = parse_transcript(&path).unwrap();
411        assert_eq!(result.len(), 2);
412        assert_eq!(
413            result.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
414            vec!["Bash", "Read"]
415        );
416    }
417
418    #[test]
419    fn handles_tool_result_with_array_content() {
420        let dir = TempDir::new().unwrap();
421        let path = dir.path().join("array-result.jsonl");
422        write_jsonl(
423            &path,
424            &[
425                json!({"type": "assistant", "message": {"role": "assistant", "content": [
426                    {"type": "tool_use", "id": "toolu_x", "name": "Bash", "input": {"command": "echo hi"}}
427                ]}}),
428                json!({"type": "user", "message": {"role": "user", "content": [
429                    {"type": "tool_result", "tool_use_id": "toolu_x", "content": [{"type": "text", "text": "hi"}]}
430                ]}}),
431            ],
432        );
433        let result = parse_transcript(&path).unwrap();
434        assert_eq!(result.len(), 1);
435        assert_eq!(result[0].result, Some(Value::String("hi".into())));
436    }
437
438    fn usage(output: i64) -> Value {
439        json!({
440            "input_tokens": 100,
441            "cache_creation_input_tokens": 50,
442            "cache_read_input_tokens": 200,
443            "output_tokens": output,
444        })
445    }
446
447    #[test]
448    fn sums_usage_across_unique_message_ids() {
449        let dir = TempDir::new().unwrap();
450        let path = dir.path().join("full-dedup.jsonl");
451        write_jsonl(
452            &path,
453            &[
454                json!({"type": "user", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": "go"}}),
455                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"}]}}),
456                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"}}]}}),
457                json!({"type": "assistant", "timestamp": "2026-06-04T10:01:00.000Z", "message": {"id": "msg_bbb", "role": "assistant", "usage": usage(40), "content": [{"type": "text", "text": "done"}]}}),
458            ],
459        );
460        // msg_aaa counted once (100+50+200+10) + msg_bbb (100+50+200+40) = 750
461        assert_eq!(
462            parse_transcript_full(&path).unwrap().total_tokens,
463            Some(750)
464        );
465    }
466
467    #[test]
468    fn returns_null_total_tokens_when_no_usage() {
469        let dir = TempDir::new().unwrap();
470        let path = dir.path().join("full-no-usage.jsonl");
471        write_jsonl(
472            &path,
473            &[
474                json!({"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}}),
475            ],
476        );
477        assert_eq!(parse_transcript_full(&path).unwrap().total_tokens, None);
478    }
479
480    #[test]
481    fn derives_duration_from_first_and_last_timestamps() {
482        let dir = TempDir::new().unwrap();
483        let path = dir.path().join("full-duration.jsonl");
484        write_jsonl(
485            &path,
486            &[
487                json!({"type": "user", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": "go"}}),
488                json!({"type": "assistant", "timestamp": "2026-06-04T10:02:30.500Z", "message": {"id": "msg_x", "role": "assistant", "content": [{"type": "text", "text": "done"}]}}),
489            ],
490        );
491        assert_eq!(
492            parse_transcript_full(&path).unwrap().duration_ms,
493            Some(150_500)
494        );
495    }
496
497    #[test]
498    fn returns_null_duration_with_fewer_than_two_timestamps() {
499        let dir = TempDir::new().unwrap();
500        let path = dir.path().join("full-one-ts.jsonl");
501        write_jsonl(
502            &path,
503            &[
504                json!({"type": "assistant", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "assistant", "content": []}}),
505                json!({"type": "assistant", "message": {"role": "assistant", "content": []}}),
506            ],
507        );
508        assert_eq!(parse_transcript_full(&path).unwrap().duration_ms, None);
509    }
510
511    #[test]
512    fn final_text_is_concatenated_text_of_last_assistant_message() {
513        let dir = TempDir::new().unwrap();
514        let path = dir.path().join("full-final-text.jsonl");
515        write_jsonl(
516            &path,
517            &[
518                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "intermediate"}]}}),
519                json!({"type": "assistant", "message": {"id": "msg_2", "role": "assistant", "content": [
520                    {"type": "text", "text": "All tests pass."},
521                    {"type": "tool_use", "id": "toolu_z", "name": "Bash", "input": {"command": "true"}},
522                    {"type": "text", "text": "Wrapping up."}
523                ]}}),
524                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_z", "content": "ok"}]}}),
525            ],
526        );
527        assert_eq!(
528            parse_transcript_full(&path).unwrap().final_text,
529            Some("All tests pass.\nWrapping up.".into())
530        );
531    }
532
533    #[test]
534    fn final_text_is_null_when_no_assistant_text() {
535        let dir = TempDir::new().unwrap();
536        let path = dir.path().join("full-no-text.jsonl");
537        write_jsonl(
538            &path,
539            &[json!({"type": "user", "message": {"role": "user", "content": "hi"}})],
540        );
541        assert_eq!(parse_transcript_full(&path).unwrap().final_text, None);
542    }
543
544    #[test]
545    fn tool_invocations_matches_parse_transcript() {
546        let dir = TempDir::new().unwrap();
547        let path = dir.path().join("full-invocations.jsonl");
548        write_jsonl(
549            &path,
550            &[
551                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"}}]}}),
552                json!({"type": "user", "timestamp": "2026-06-04T10:00:02.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_q", "content": "contents"}]}}),
553            ],
554        );
555        assert_eq!(
556            parse_transcript_full(&path).unwrap().tool_invocations,
557            parse_transcript(&path).unwrap()
558        );
559    }
560
561    #[test]
562    fn matches_subagents_by_meta_description() {
563        let dir = TempDir::new().unwrap();
564        let sub = dir.path().join("subagents");
565        fs::create_dir_all(&sub).unwrap();
566
567        fs::write(
568            sub.join("agent-aaa111.meta.json"),
569            json!({"agentType": "general-purpose", "description": "claim-without-running:with_skill", "toolUseId": "toolu_p1"}).to_string(),
570        )
571        .unwrap();
572        fs::write(sub.join("agent-aaa111.jsonl"), "").unwrap();
573
574        fs::write(
575            sub.join("agent-bbb222.meta.json"),
576            json!({"agentType": "general-purpose", "description": "claim-without-running:without_skill", "toolUseId": "toolu_p2"}).to_string(),
577        )
578        .unwrap();
579        fs::write(sub.join("agent-bbb222.jsonl"), "").unwrap();
580
581        assert_eq!(list_subagents(&sub).len(), 2);
582
583        let m = find_by_description(&sub, "claim-without-running:with_skill");
584        assert_eq!(m.unwrap().meta.tool_use_id.as_deref(), Some("toolu_p1"));
585
586        assert!(find_by_description(&sub, "no-such-eval:with_skill").is_none());
587    }
588
589    #[test]
590    fn returns_empty_when_subagents_dir_missing() {
591        let dir = TempDir::new().unwrap();
592        let missing = dir.path().join("does-not-exist");
593        assert_eq!(list_subagents(&missing).len(), 0);
594        assert!(find_by_description(&missing, "x").is_none());
595    }
596
597    #[test]
598    fn duplicate_descriptions_return_most_recent_transcript() {
599        let dir = TempDir::new().unwrap();
600        let sub = dir.path().join("dup-subagents");
601        fs::create_dir_all(&sub).unwrap();
602
603        fs::write(
604            sub.join("agent-old.meta.json"),
605            json!({"description": "dup:with_skill", "toolUseId": "toolu_old"}).to_string(),
606        )
607        .unwrap();
608        fs::write(sub.join("agent-old.jsonl"), "").unwrap();
609        let old = SystemTime::now() - Duration::from_secs(60);
610        File::options()
611            .write(true)
612            .open(sub.join("agent-old.jsonl"))
613            .unwrap()
614            .set_modified(old)
615            .unwrap();
616
617        fs::write(
618            sub.join("agent-new.meta.json"),
619            json!({"description": "dup:with_skill", "toolUseId": "toolu_new"}).to_string(),
620        )
621        .unwrap();
622        fs::write(sub.join("agent-new.jsonl"), "").unwrap();
623        File::options()
624            .write(true)
625            .open(sub.join("agent-new.jsonl"))
626            .unwrap()
627            .set_modified(SystemTime::now())
628            .unwrap();
629
630        let m = find_by_description(&sub, "dup:with_skill");
631        assert_eq!(m.unwrap().meta.tool_use_id.as_deref(), Some("toolu_new"));
632    }
633}