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)]
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 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
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
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 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
149pub fn parse_transcript(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
151 Ok(extract_invocations(&read_records(jsonl_path)?))
152}
153
154pub(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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct TranscriptSummary {
178 pub tool_invocations: Vec<ToolInvocation>,
179 pub total_tokens: Option<i64>,
183 pub duration_ms: Option<i64>,
185 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
195pub 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#[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#[derive(Debug, Clone, PartialEq)]
270pub struct SubagentEntry {
271 pub jsonl_path: PathBuf,
272 pub meta_path: PathBuf,
273 pub meta: SubagentMeta,
274}
275
276pub 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
315pub 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 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}