1use 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 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
42fn 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
51fn 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
63fn 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 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
149pub fn parse_transcript(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
151 Ok(extract_invocations(&read_records(jsonl_path)?))
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct TranscriptSummary {
157 pub tool_invocations: Vec<ToolInvocation>,
158 pub total_tokens: Option<i64>,
162 pub duration_ms: Option<i64>,
164 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
174pub 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#[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#[derive(Debug, Clone, PartialEq)]
257pub struct SubagentEntry {
258 pub jsonl_path: PathBuf,
259 pub meta_path: PathBuf,
260 pub meta: SubagentMeta,
261}
262
263pub 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
302pub 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 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}