1use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::types::{
14 AgentSession, LlmResponse, SessionCandidate, SessionDirStat, SessionEvents, TokenUsage,
15 ToolEvent, ToolPath, UserPrompt,
16};
17use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_CURSOR, AGENT_GEMINI};
18
19pub fn discover_session_files() -> Vec<SessionCandidate> {
21 user_home_dir()
22 .as_deref()
23 .map(discover_session_files_in_home)
24 .unwrap_or_default()
25}
26
27pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
29 let roots = [
30 (AGENT_CLAUDE, home.join(".claude/projects")),
31 (AGENT_CODEX, home.join(".codex/sessions")),
32 (AGENT_GEMINI, home.join(".gemini/tmp")),
33 (AGENT_CURSOR, home.join(".cursor/projects")),
34 ];
35 let mut out = Vec::new();
36 for (agent, dir) in roots {
37 walk_agent_files(agent, &dir, &mut |path, meta| {
38 out.push(SessionCandidate {
39 agent,
40 path: path.to_path_buf(),
41 updated: candidate_updated(agent, path, meta),
42 });
43 });
44 }
45 dedupe_cursor_candidates(&mut out);
46 out
47}
48
49pub fn discover_session_files_in_dir(agent: &'static str, dir: &Path) -> Vec<SessionCandidate> {
50 let mut out = Vec::new();
51 walk_agent_files(agent, dir, &mut |path, meta| {
52 out.push(SessionCandidate {
53 agent,
54 path: path.to_path_buf(),
55 updated: candidate_updated(agent, path, meta),
56 });
57 });
58 dedupe_cursor_candidates(&mut out);
59 out
60}
61
62fn candidate_updated(agent: &str, path: &Path, meta: &fs::Metadata) -> SystemTime {
63 let updated = meta.modified().unwrap_or(UNIX_EPOCH);
64 if agent == AGENT_CURSOR {
65 cursor_candidate_updated(path, updated)
66 } else {
67 updated
68 }
69}
70
71fn cursor_candidate_updated(path: &Path, parent_updated: SystemTime) -> SystemTime {
72 let mut updated = parent_updated;
73 let Some(subagents) = path.parent().map(|dir| dir.join("subagents")) else {
74 return updated;
75 };
76 let Ok(entries) = fs::read_dir(subagents) else {
77 return updated;
78 };
79 for entry in entries.flatten() {
80 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("jsonl")
81 && let Ok(meta) = entry.metadata()
82 {
83 updated = updated.max(meta.modified().unwrap_or(UNIX_EPOCH));
84 }
85 }
86 updated
87}
88
89fn dedupe_cursor_candidates(out: &mut Vec<SessionCandidate>) {
90 let mut best: BTreeMap<String, (bool, SystemTime, usize)> = BTreeMap::new();
91 let mut drop = vec![false; out.len()];
92 for (idx, candidate) in out.iter().enumerate() {
93 if candidate.agent != AGENT_CURSOR {
94 continue;
95 }
96 let Some(stem) = candidate.path.file_stem().and_then(|stem| stem.to_str()) else {
97 continue;
98 };
99 let rank = (
100 !cursor_is_empty_window(&candidate.path),
101 candidate.updated,
102 idx,
103 );
104 match best.get_mut(stem) {
105 None => {
106 best.insert(stem.to_string(), rank);
107 }
108 Some(entry) => {
109 if (rank.0, rank.1) > (entry.0, entry.1) {
110 drop[entry.2] = true;
111 *entry = rank;
112 } else {
113 drop[idx] = true;
114 }
115 }
116 }
117 }
118 let mut drop = drop.into_iter();
119 out.retain(|_| !drop.next().unwrap_or_default());
120}
121
122fn cursor_is_empty_window(path: &Path) -> bool {
123 let mut previous = None;
124 for component in path.components() {
125 let name = component.as_os_str();
126 if name == "agent-transcripts" {
127 return previous.is_some_and(|project| project == "empty-window");
128 }
129 previous = Some(name);
130 }
131 false
132}
133
134pub fn count_session_dirs() -> Vec<SessionDirStat> {
136 user_home_dir()
137 .as_deref()
138 .map(count_session_dirs_in_home)
139 .unwrap_or_default()
140}
141
142pub fn count_session_dirs_in_home(home: &Path) -> Vec<SessionDirStat> {
144 [
145 (AGENT_CLAUDE, home.join(".claude/projects")),
146 (AGENT_CODEX, home.join(".codex/sessions")),
147 (AGENT_GEMINI, home.join(".gemini/tmp")),
148 (AGENT_CURSOR, home.join(".cursor/projects")),
149 ]
150 .into_iter()
151 .filter_map(|(agent, dir)| {
152 let (mut sessions, mut bytes) = (0usize, 0u64);
153 walk_agent_files(agent, &dir, &mut |_, meta| {
154 sessions += 1;
155 bytes += meta.len();
156 });
157 (sessions > 0).then_some(SessionDirStat {
158 agent,
159 dir,
160 sessions,
161 bytes,
162 })
163 })
164 .collect()
165}
166
167pub fn session_candidate_from_path(path: &Path) -> Option<SessionCandidate> {
168 let agent = agent_source_for_path(path).or_else(|| loose_agent_source_for_path(path))?;
169 let updated = fs::metadata(path)
170 .and_then(|metadata| metadata.modified())
171 .unwrap_or(UNIX_EPOCH);
172 Some(SessionCandidate {
173 agent,
174 path: path.to_path_buf(),
175 updated,
176 })
177}
178
179pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
181 let content = fs::read_to_string(&candidate.path).ok()?;
182 parse_session_content(
183 candidate.agent,
184 &candidate.path,
185 candidate.updated,
186 &content,
187 )
188}
189
190pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
192 parse_session_file(&session_candidate_from_path(path)?)
193}
194
195pub fn parse_session_content(
197 agent: &str,
198 path: &Path,
199 updated: SystemTime,
200 content: &str,
201) -> Option<AgentSession> {
202 parse_session_impl(agent, path, updated, content)
203}
204
205fn parse_session_impl(
206 agent: &str,
207 path: &Path,
208 updated: SystemTime,
209 content: &str,
210) -> Option<AgentSession> {
211 if agent == AGENT_GEMINI {
212 parse_gemini_json(path, updated, content)
213 } else if agent == AGENT_CURSOR {
214 let children = read_cursor_subagents(path);
216 parse_cursor_jsonl(path, updated, content, &children)
217 } else {
218 parse_jsonl(agent, path, updated, content)
219 }
220}
221
222pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
224 let trimmed = raw.trim().trim_end_matches(" (deleted)");
225 if trimmed.is_empty() {
226 return None;
227 }
228 let path = Path::new(trimmed);
229 if !path.is_absolute() || !is_agent_session_file(path) {
230 return None;
231 }
232 agent_source_for_path(path).map(|_| normalize_session_log_path(path))
233}
234
235pub fn normalize_session_log_path(path: &Path) -> PathBuf {
237 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
238}
239
240pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
242 let value = path.to_string_lossy();
243 if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
244 {
245 Some(AGENT_CLAUDE)
246 } else if value.contains("/.codex/")
247 && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
248 {
249 Some(AGENT_CODEX)
250 } else if value.contains("/.gemini/")
251 && path.extension().and_then(|ext| ext.to_str()) == Some("json")
252 {
253 Some(AGENT_GEMINI)
254 } else if value.contains("/.cursor/") && is_cursor_transcript(path) {
255 Some(AGENT_CURSOR)
256 } else {
257 None
258 }
259}
260
261fn is_cursor_transcript(path: &Path) -> bool {
262 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
263 && path.to_string_lossy().contains("/agent-transcripts/")
264}
265
266fn is_cursor_parent_transcript(path: &Path) -> bool {
267 is_cursor_transcript(path)
268 && path.file_stem().is_some_and(|stem| {
269 path.parent()
270 .and_then(|dir| dir.file_name())
271 .is_some_and(|dir| dir == stem)
272 })
273}
274
275fn loose_agent_source_for_path(path: &Path) -> Option<&'static str> {
276 let value = path.to_string_lossy();
277 if value.contains("/codex/") && value.contains("sessions") {
278 Some(AGENT_CODEX)
279 } else if value.contains("/claude/") && value.contains("projects") {
280 Some(AGENT_CLAUDE)
281 } else if value.contains("/cursor/") && value.contains("agent-transcripts") {
282 Some(AGENT_CURSOR)
283 } else {
284 None
285 }
286}
287
288pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
290 match agent {
291 AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
292 AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
293 AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
294 AGENT_CURSOR => {
295 Some(home.join(".cursor/projects/test/agent-transcripts/session/session.jsonl"))
296 }
297 _ => None,
298 }
299}
300
301pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
303 target.is_some_and(|target| {
304 Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
305 && !target.contains("/node_modules/")
306 })
307}
308
309pub fn codex_exec_prompt(command: &str) -> Option<String> {
311 let args = shell_words(command.split_once(" exec ")?.1.trim())?;
312 let mut index = 0usize;
313 while index < args.len() {
314 let arg = args[index].as_str();
315 if arg == "--" {
316 index += 1;
317 break;
318 }
319 if !arg.starts_with('-') {
320 break;
321 }
322 let consumed = codex_exec_option_arity(arg)?;
323 index += consumed;
324 }
325 (index < args.len())
326 .then(|| args[index..].join(" "))
327 .and_then(|prompt| clean_prompt_text(&prompt))
328}
329
330fn codex_exec_option_arity(arg: &str) -> Option<usize> {
331 if arg.contains('=') && arg.starts_with("--") {
332 return Some(1);
333 }
334
335 match arg {
336 "--json"
337 | "--skip-git-repo-check"
338 | "--ephemeral"
339 | "--ignore-user-config"
340 | "--full-auto"
341 | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
342 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
343 | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
344 | "--output-format" | "--color" => Some(2),
345 _ => None,
346 }
347}
348
349fn shell_words(input: &str) -> Option<Vec<String>> {
350 let mut words = Vec::new();
351 let mut current = String::new();
352 let mut quote = None::<char>;
353 let mut chars = input.chars().peekable();
354
355 while let Some(ch) = chars.next() {
356 match (quote, ch) {
357 (None, c) if c.is_whitespace() => {
358 if !current.is_empty() {
359 words.push(std::mem::take(&mut current));
360 }
361 }
362 (None, '\'' | '"') => quote = Some(ch),
363 (Some(q), c) if c == q => quote = None,
364 (_, '\\') => {
365 if let Some(next) = chars.next() {
366 current.push(next);
367 }
368 }
369 _ => current.push(ch),
370 }
371 }
372 if quote.is_some() {
373 return None;
374 }
375 if !current.is_empty() {
376 words.push(current);
377 }
378 Some(words)
379}
380
381#[derive(Default)]
386struct SemanticTaskStack {
387 root: Option<String>,
388 active_plan: Option<String>,
389}
390
391impl SemanticTaskStack {
392 fn observe_user(&mut self, text: &str) {
393 let label = semantic_task_label(text);
394 if self.root.is_some() && is_continuation_prompt(&label) {
395 return;
396 }
397 self.root = Some(label);
398 self.active_plan = None;
399 }
400
401 fn observe_plan(&mut self, input: &Value) {
402 let active = input
403 .get("plan")
404 .and_then(Value::as_array)
405 .into_iter()
406 .flatten()
407 .filter_map(|item| {
408 (item.get("status").and_then(Value::as_str) == Some("in_progress"))
409 .then(|| item.get("step").and_then(Value::as_str))
410 .flatten()
411 .map(semantic_task_label)
412 })
413 .collect::<Vec<_>>();
414 self.active_plan = match active.as_slice() {
415 [] => None,
416 [only] => Some(only.clone()),
417 many => self
418 .active_plan
419 .as_ref()
420 .filter(|current| many.contains(current))
421 .cloned()
422 .or_else(|| many.first().cloned()),
423 };
424 }
425
426 fn path(&self) -> Vec<String> {
427 self.root
428 .iter()
429 .chain(self.active_plan.iter())
430 .cloned()
431 .collect()
432 }
433
434 fn path_for_tool(&self, name: &str, input: &Value) -> Vec<String> {
435 let mut path = self.path();
436 if name == "spawn_agent"
437 && let Some(label) = input
438 .get("task_name")
439 .or_else(|| input.get("message"))
440 .and_then(Value::as_str)
441 {
442 path.push(semantic_task_label(label));
443 }
444 path
445 }
446}
447
448pub fn semantic_task_label(text: &str) -> String {
449 let mut selected = text.trim();
450 if let Some(start) = selected.rfind("## My request for Codex:") {
451 selected = &selected[start + "## My request for Codex:".len()..];
452 } else if let Some(start) = selected.find("<objective>")
453 && let Some(end) = selected[start + "<objective>".len()..].find("</objective>")
454 {
455 selected = &selected[start + "<objective>".len()..start + "<objective>".len() + end];
456 }
457 let label = truncate_clean(selected.trim_matches(['\'', '"']), 120);
458 if label.is_empty() {
459 "unnamed task".to_string()
460 } else {
461 label
462 }
463}
464
465fn is_continuation_prompt(text: &str) -> bool {
466 let lowered = text.trim().to_lowercase();
467 matches!(
468 lowered.as_str(),
469 "继续"
470 | "继续做"
471 | "去做"
472 | "开始"
473 | "嗯"
474 | "好"
475 | "好的"
476 | "continue"
477 | "go on"
478 | "proceed"
479 | "do it"
480 | "ok"
481 | "okay"
482 )
483}
484
485fn parse_jsonl(
486 agent: &str,
487 path: &Path,
488 updated: SystemTime,
489 content: &str,
490) -> Option<AgentSession> {
491 let mut acc = SessionAccumulator::new(agent, path, updated);
492 let mut codex_model = String::new();
493 let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
494 let mut claude_seen_usage = HashSet::new();
495 let mut events = SessionEvents::default();
496 let mut current_prompt_index = 0usize;
497 let mut call_index = BTreeMap::<String, usize>::new();
498 let mut task_stack = SemanticTaskStack::default();
499 let mut active_skill: Option<String> = None;
500 let mut claude_prompt_id: Option<String> = None;
501 let mut codex_meta_seen = false;
502 let mut codex_owns_events = true;
503 let mut codex_session_started_at = 0.0_f64;
504
505 for line in content.lines() {
506 let Ok(obj) = serde_json::from_str::<Value>(line) else {
507 continue;
508 };
509 let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
510 if agent == AGENT_CODEX && typ == "session_meta" {
511 if !codex_meta_seen {
512 codex_meta_seen = true;
513 let payload = obj.get("payload").unwrap_or(&Value::Null);
514 if let Some(id) = payload
515 .get("id")
516 .or_else(|| payload.get("session_id"))
517 .and_then(Value::as_str)
518 {
519 acc.session_id = id.to_string();
520 }
521 acc.conversation_id = payload
522 .get("session_id")
523 .and_then(Value::as_str)
524 .map(str::to_string);
525 let parent = payload
526 .get("parent_thread_id")
527 .or_else(|| payload.get("forked_from_id"))
528 .and_then(Value::as_str)
529 .or_else(|| {
530 payload
531 .pointer("/source/subagent/thread_spawn/parent_thread_id")
532 .and_then(Value::as_str)
533 });
534 codex_owns_events = parent.is_none_or(str::is_empty);
535 codex_session_started_at = payload
536 .get("timestamp")
537 .or_else(|| obj.get("timestamp"))
538 .and_then(Value::as_str)
539 .and_then(rfc3339_seconds)
540 .unwrap_or_default();
541 if acc.cwd.is_none() {
542 acc.cwd = payload
543 .get("cwd")
544 .and_then(Value::as_str)
545 .filter(|cwd| !cwd.is_empty())
546 .map(str::to_string);
547 }
548 }
549 continue;
550 }
551 if agent == AGENT_CODEX && !codex_owns_events {
552 let payload = obj.get("payload").unwrap_or(&Value::Null);
553 if typ == "event_msg"
554 && payload.get("type").and_then(Value::as_str) == Some("task_started")
555 {
556 let source_start = payload
557 .get("started_at")
558 .and_then(Value::as_f64)
559 .filter(|value| *value > 0.0)
560 .or_else(|| {
561 payload
562 .get("turn_id")
563 .and_then(Value::as_str)
564 .and_then(uuid7_seconds)
565 })
566 .unwrap_or_default();
567 if source_start > 0.0
568 && (codex_session_started_at == 0.0
569 || source_start >= codex_session_started_at.floor())
570 {
571 codex_owns_events = true;
572 }
573 }
574 continue;
575 }
576 let (session_id, conversation_id) = local_session_ids(&obj);
577 if let Some(id) = session_id {
578 acc.session_id = id;
579 }
580 if let Some(id) = conversation_id {
581 acc.conversation_id = Some(id);
582 }
583 if acc.cwd.is_none() {
584 acc.cwd = obj
585 .get("cwd")
586 .and_then(Value::as_str)
587 .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
588 .filter(|s| !s.is_empty())
589 .map(ToString::to_string);
590 }
591 if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
592 acc.last_message_at = Some(ts.to_string());
593 acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
594 }
595 match (agent, typ) {
596 (AGENT_CLAUDE, "result") => {
597 acc.duration_ms = json_u64(&obj, "duration_ms");
598 if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
599 for (name, usage) in model_usage {
600 acc.model.get_or_insert_with(|| name.clone());
601 acc.add_usage(
602 name,
603 json_i64(usage, "inputTokens"),
604 json_i64(usage, "outputTokens"),
605 json_i64(usage, "cacheCreationInputTokens"),
606 json_i64(usage, "cacheReadInputTokens"),
607 0,
608 );
609 }
610 }
611 }
612 (AGENT_CLAUDE, "assistant") => {
613 let response_skill = active_skill.clone().unwrap_or_default();
614 if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
615 acc.model.get_or_insert_with(|| name.to_string());
616 }
617 let model = obj
618 .pointer("/message/model")
619 .and_then(Value::as_str)
620 .or(acc.model.as_deref())
621 .unwrap_or(AGENT_CLAUDE)
622 .to_string();
623 if let Some(usage) = obj.pointer("/message/usage")
624 && claude_seen_usage.insert(claude_usage_key(&obj))
625 {
626 let name = obj
627 .pointer("/message/model")
628 .and_then(Value::as_str)
629 .unwrap_or("unknown");
630 add_usage(
631 &mut claude_message_models,
632 name,
633 json_i64(usage, "input_tokens"),
634 json_i64(usage, "output_tokens"),
635 json_i64(usage, "cache_creation_input_tokens"),
636 json_i64(usage, "cache_read_input_tokens"),
637 0,
638 );
639 }
640 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
641 if let Some(items) = content.as_array() {
642 for item in items
643 .iter()
644 .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
645 {
646 let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
647 let input = item.get("input").unwrap_or(&Value::Null);
648 let invoked_skill = exact_claude_skill_invocation(name, input);
649 if let Some(skill) = invoked_skill.as_ref() {
650 active_skill = Some(skill.clone());
651 }
652 acc.add_tool(name);
653 if let Some(fp) = item
654 .pointer("/input/file_path")
655 .and_then(Value::as_str)
656 .filter(|s| !is_noise_path(s))
657 {
658 acc.add_file(fp);
659 }
660 let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
661 let event = tool_event_from_input(
662 acc.cwd.as_deref(),
663 ts_ms_from_event(&obj),
664 current_prompt_index,
665 name,
666 input,
667 call_id.clone(),
668 task_stack.path_for_tool(name, input),
669 );
670 let mut event = event;
671 event.invoked_skill = invoked_skill.unwrap_or_default();
672 event.skill = active_skill.clone().unwrap_or_default();
673 if let Some(id) = call_id {
674 call_index.insert(id, events.tools.len());
675 }
676 events.tools.push(event);
677 }
678 }
679 let text = content_to_text(content);
680 let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
681 if !text.trim().is_empty() || usage.is_object() {
682 let preview_text = if !text.trim().is_empty() {
684 text.clone()
685 } else if let Some(items) = content.as_array() {
686 let tool_names: Vec<_> = items
687 .iter()
688 .filter_map(|item| {
689 if item.get("type").and_then(Value::as_str) == Some("tool_use") {
690 item.get("name").and_then(Value::as_str)
691 } else {
692 None
693 }
694 })
695 .collect();
696 if tool_names.is_empty() {
697 String::new()
698 } else {
699 format!("tool: {}", tool_names.join(", "))
700 }
701 } else {
702 String::new()
703 };
704 events.llm_responses.push(LlmResponse {
705 ts_ms: ts_ms_from_event(&obj),
706 prompt_index: current_prompt_index,
707 model,
708 source_id: claude_source_completion_id(&obj),
709 text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
710 preview: truncate_clean(
711 if preview_text.is_empty() {
712 "token report"
713 } else {
714 &preview_text
715 },
716 140,
717 ),
718 input_tokens: json_u64(usage, "input_tokens"),
719 output_tokens: json_u64(usage, "output_tokens"),
720 cache_tokens: json_u64(usage, "cache_creation_input_tokens")
721 + json_u64(usage, "cache_read_input_tokens"),
722 total_tokens: 0,
723 tag: String::new(),
724 response_phase: if obj
725 .pointer("/message/stop_reason")
726 .and_then(Value::as_str)
727 == Some("end_turn")
728 && !text.trim().is_empty()
729 {
730 "final_answer".to_string()
731 } else {
732 "assistant_message".to_string()
733 },
734 skill: response_skill,
735 task_path: task_stack.path(),
736 });
737 }
738 }
739 (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
740 if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
741 && let Some(text) = obj.get("content").and_then(Value::as_str)
742 && let Some(text) = clean_prompt_text(text)
743 {
744 acc.prompt_preview = Some(text.clone());
745 task_stack.observe_user(&text);
746 current_prompt_index =
747 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
748 }
749 }
750 (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
751 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
752 && let Some(text) = clean_prompt_text(text)
753 {
754 acc.prompt_preview = Some(text.clone());
755 task_stack.observe_user(&text);
756 current_prompt_index =
757 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
758 }
759 }
760 (AGENT_CLAUDE, "user") => {
761 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
762 if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
763 let fallback = obj
764 .pointer("/toolUseResult/is_error")
765 .and_then(Value::as_bool)
766 .unwrap_or(false);
767 for result in content.as_array().into_iter().flatten() {
768 let Some(id) = result.get("tool_use_id").and_then(Value::as_str) else {
769 continue;
770 };
771 if let Some(index) = call_index.get(id).copied()
772 && let Some(tool) = events.tools.get_mut(index)
773 {
774 let failed = result
775 .get("is_error")
776 .and_then(Value::as_bool)
777 .unwrap_or(fallback);
778 tool.status = if failed { "fail" } else { "ok" }.to_string();
779 }
780 }
781 } else if let Some(text) = local_message_preview(content)
782 && claude_user_starts_prompt(&obj, content, &text, claude_prompt_id.as_deref())
783 {
784 if acc.prompt_preview.is_none() {
785 acc.prompt_preview = Some(text.clone());
786 }
787 task_stack.observe_user(&text);
788 active_skill = None;
789 claude_prompt_id = obj
790 .get("promptId")
791 .and_then(Value::as_str)
792 .filter(|value| !value.is_empty())
793 .map(str::to_string);
794 current_prompt_index =
795 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
796 }
797 }
798 (AGENT_CODEX, "turn_context") => {
799 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
800 codex_model = name.to_string();
801 acc.model = Some(name.to_string());
802 }
803 }
804 (AGENT_CODEX, "event_msg") => {
805 let payload = obj.get("payload").unwrap_or(&Value::Null);
806 let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
807 if ptype == "token_count"
808 && let Some(usage) = payload.pointer("/info/total_token_usage")
809 {
810 let name = if codex_model.is_empty() {
811 "unknown"
812 } else {
813 &codex_model
814 };
815 let usage = codex_token_usage(usage);
816 acc.set_usage(
817 name,
818 usage.input_tokens,
819 usage.output_tokens,
820 0,
821 usage.cache_read_tokens,
822 usage.total_tokens,
823 );
824 }
825 if matches!(ptype, "token_count" | "token_usage") {
826 let info = payload
827 .get("info")
828 .or_else(|| payload.get("usage"))
829 .unwrap_or(payload);
830 let token_usage = info
831 .get("last_token_usage")
832 .or_else(|| info.get("total_token_usage"))
833 .unwrap_or(info);
834 let input_tokens = json_u64(token_usage, "input_tokens");
835 let output_tokens = json_u64(token_usage, "output_tokens");
836 let cache_tokens = json_u64(token_usage, "cached_input_tokens");
837 let total_tokens = json_u64(token_usage, "total_tokens")
838 .max(json_u64(info, "total_tokens"))
839 .max(json_u64(info, "tokens"));
840 if total_tokens > 0
841 && let Some(last) = events.llm_responses.last_mut()
842 && last.total_tokens == 0
843 {
844 last.input_tokens = input_tokens;
845 last.output_tokens = output_tokens;
846 last.cache_tokens = cache_tokens;
847 last.total_tokens = total_tokens;
848 }
849 }
850 if ptype == "user_message" {
851 let text = payload
852 .get("message")
853 .or_else(|| payload.get("content"))
854 .and_then(Value::as_str)
855 .unwrap_or("");
856 if let Some(text) = clean_prompt_text(text) {
857 acc.prompt_preview = Some(text.clone());
858 task_stack.observe_user(&text);
859 current_prompt_index =
860 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
861 }
862 }
863 if ptype == "agent_message" {
864 let text = payload
865 .get("message")
866 .or_else(|| payload.get("content"))
867 .and_then(Value::as_str)
868 .unwrap_or("");
869 if let Some(text) = clean_prompt_text(text) {
870 events.llm_responses.push(LlmResponse {
871 ts_ms: ts_ms_from_event(&obj),
872 prompt_index: current_prompt_index,
873 model: if codex_model.is_empty() {
874 AGENT_CODEX.to_string()
875 } else {
876 codex_model.clone()
877 },
878 source_id: String::new(),
879 text_hash: short_hash(&text, 12),
880 preview: truncate_clean(&text, 180),
881 input_tokens: 0,
882 output_tokens: 0,
883 cache_tokens: 0,
884 total_tokens: 0,
885 tag: String::new(),
886 response_phase: payload
887 .get("phase")
888 .and_then(Value::as_str)
889 .unwrap_or("assistant_message")
890 .to_string(),
891 skill: String::new(),
892 task_path: task_stack.path(),
893 });
894 }
895 }
896 }
897 (AGENT_CODEX, "response_item")
898 if obj.pointer("/payload/type").and_then(Value::as_str)
899 == Some("custom_tool_call") =>
900 {
901 let payload = obj.get("payload").unwrap_or(&Value::Null);
902 let outer_name = payload
903 .get("name")
904 .and_then(Value::as_str)
905 .unwrap_or("tool");
906 let raw_input = payload.get("input").and_then(Value::as_str).unwrap_or("");
907 let (name, args) = codex_custom_tool_input(outer_name, raw_input);
908 acc.add_tool(&name);
909 let call_id = payload
910 .get("call_id")
911 .and_then(Value::as_str)
912 .map(str::to_string);
913 let event = tool_event_from_input(
914 acc.cwd.as_deref(),
915 ts_ms_from_event(&obj),
916 current_prompt_index,
917 &name,
918 &args,
919 call_id.clone(),
920 task_stack.path_for_tool(&name, &args),
921 );
922 if name == "update_plan" {
923 task_stack.observe_plan(&args);
924 }
925 if let Some(id) = call_id {
926 call_index.insert(id, events.tools.len());
927 }
928 events.tools.push(event);
929 }
930 (AGENT_CODEX, "response_item")
931 if obj.pointer("/payload/type").and_then(Value::as_str)
932 == Some("custom_tool_call_output") =>
933 {
934 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
935 && let Some(index) = call_index.get(call_id).copied()
936 && let Some(tool) = events.tools.get_mut(index)
937 {
938 let output =
939 content_to_text(obj.pointer("/payload/output").unwrap_or(&Value::Null));
940 tool.status = status_from_output(&output).to_string();
941 }
942 }
943 (AGENT_CODEX, "response_item")
944 if obj.pointer("/payload/type").and_then(Value::as_str)
945 == Some("function_call") =>
946 {
947 let name = obj
948 .pointer("/payload/name")
949 .and_then(Value::as_str)
950 .unwrap_or("?");
951 acc.add_tool(name);
952 let payload = obj.get("payload").unwrap_or(&Value::Null);
953 let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
954 let call_id = payload
955 .get("call_id")
956 .and_then(Value::as_str)
957 .map(str::to_string);
958 let event = tool_event_from_input(
959 acc.cwd.as_deref(),
960 ts_ms_from_event(&obj),
961 current_prompt_index,
962 name,
963 &args,
964 call_id.clone(),
965 task_stack.path_for_tool(name, &args),
966 );
967 if name == "update_plan" {
968 task_stack.observe_plan(&args);
969 }
970 if let Some(id) = call_id {
971 call_index.insert(id, events.tools.len());
972 }
973 events.tools.push(event);
974 }
975 (AGENT_CODEX, "response_item")
976 if obj.pointer("/payload/type").and_then(Value::as_str)
977 == Some("function_call_output") =>
978 {
979 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
980 && let Some(index) = call_index.get(call_id).copied()
981 && let Some(tool) = events.tools.get_mut(index)
982 {
983 let output = obj
984 .pointer("/payload/output")
985 .and_then(Value::as_str)
986 .unwrap_or("");
987 tool.status = status_from_output(output).to_string();
988 }
989 }
990 (AGENT_CODEX, "response_item")
991 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
992 {
993 let payload = obj.get("payload").unwrap_or(&Value::Null);
994 let text = payload
995 .get("message")
996 .and_then(Value::as_str)
997 .map(str::to_string)
998 .unwrap_or_else(|| {
999 content_to_text(payload.get("content").unwrap_or(&Value::Null))
1000 });
1001 if let Some(text) = clean_prompt_text(&text) {
1002 if payload.get("role").and_then(Value::as_str) == Some("user") {
1003 acc.prompt_preview = Some(text.clone());
1004 task_stack.observe_user(&text);
1005 current_prompt_index =
1006 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1007 continue;
1008 }
1009 let role = payload.get("role").and_then(Value::as_str);
1010 let legacy_assistant = role.is_none()
1011 && payload
1012 .get("content")
1013 .and_then(Value::as_array)
1014 .is_some_and(|items| {
1015 items.iter().any(|item| {
1016 item.get("type").and_then(Value::as_str) == Some("output_text")
1017 })
1018 });
1019 if role != Some("assistant") && !legacy_assistant {
1020 continue;
1021 }
1022 events.llm_responses.push(LlmResponse {
1023 ts_ms: ts_ms_from_event(&obj),
1024 prompt_index: current_prompt_index,
1025 model: if codex_model.is_empty() {
1026 AGENT_CODEX.to_string()
1027 } else {
1028 codex_model.clone()
1029 },
1030 source_id: String::new(),
1031 text_hash: short_hash(&text, 12),
1032 preview: truncate_clean(&text, 180),
1033 input_tokens: 0,
1034 output_tokens: 0,
1035 cache_tokens: 0,
1036 total_tokens: 0,
1037 tag: String::new(),
1038 response_phase: payload
1039 .get("phase")
1040 .and_then(Value::as_str)
1041 .unwrap_or("assistant_message")
1042 .to_string(),
1043 skill: String::new(),
1044 task_path: task_stack.path(),
1045 });
1046 }
1047 }
1048 (AGENT_CODEX, "message" | "input" | "user") => {
1049 if let Some(text) = local_message_preview(&obj) {
1050 acc.prompt_preview = Some(text.clone());
1051 task_stack.observe_user(&text);
1052 current_prompt_index =
1053 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1054 }
1055 }
1056 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
1057 if let Some(text) = local_message_preview(&obj) {
1058 acc.prompt_preview = Some(text.clone());
1059 task_stack.observe_user(&text);
1060 current_prompt_index =
1061 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1062 }
1063 }
1064 _ => {}
1065 }
1066 }
1067
1068 if acc.model_usage.is_empty() {
1069 acc.model_usage = claude_message_models;
1070 }
1071 deduplicate_llm_responses(&mut events);
1072 acc.finish_with_events(events)
1073}
1074
1075fn deduplicate_llm_responses(events: &mut SessionEvents) {
1076 let mut unique: Vec<LlmResponse> = Vec::with_capacity(events.llm_responses.len());
1077 let mut by_source_id = BTreeMap::<(usize, String), usize>::new();
1078 for response in events.llm_responses.drain(..) {
1079 let source_key = (!response.source_id.is_empty())
1080 .then(|| (response.prompt_index, response.source_id.clone()));
1081 let duplicate_index = source_key
1082 .as_ref()
1083 .and_then(|key| by_source_id.get(key).copied())
1084 .or_else(|| {
1085 unique.len().checked_sub(1).filter(|index| {
1086 let previous = &unique[*index];
1087 response.source_id.is_empty()
1088 && previous.source_id.is_empty()
1089 && previous.prompt_index == response.prompt_index
1090 && previous.text_hash == response.text_hash
1091 && previous
1092 .ts_ms
1093 .zip(response.ts_ms)
1094 .is_some_and(|(left, right)| left.abs_diff(right) <= 1_000)
1095 })
1096 });
1097 if let Some(index) = duplicate_index {
1098 merge_llm_response(&mut unique[index], response);
1099 continue;
1100 }
1101 let index = unique.len();
1102 if let Some(key) = source_key {
1103 by_source_id.insert(key, index);
1104 }
1105 unique.push(response);
1106 }
1107 events.llm_responses = unique;
1108}
1109
1110fn merge_llm_response(previous: &mut LlmResponse, response: LlmResponse) {
1111 previous.input_tokens = previous.input_tokens.max(response.input_tokens);
1112 previous.output_tokens = previous.output_tokens.max(response.output_tokens);
1113 previous.cache_tokens = previous.cache_tokens.max(response.cache_tokens);
1114 previous.total_tokens = previous.total_tokens.max(response.total_tokens);
1115 if response_phase_priority(&response.response_phase)
1116 > response_phase_priority(&previous.response_phase)
1117 {
1118 previous.response_phase = response.response_phase;
1119 }
1120 if previous.preview.starts_with("tool: ") && !response.preview.starts_with("tool: ") {
1121 previous.preview = response.preview;
1122 previous.text_hash = response.text_hash;
1123 }
1124}
1125
1126fn response_phase_priority(phase: &str) -> u8 {
1127 match phase {
1128 "final_answer" => 3,
1129 "commentary" => 2,
1130 "assistant_message" => 1,
1131 _ => 0,
1132 }
1133}
1134
1135fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
1136 let root: Value = serde_json::from_str(content).ok()?;
1137 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
1138 let mut events = SessionEvents::default();
1139 let mut current_prompt_index = 0usize;
1140 let mut task_stack = SemanticTaskStack::default();
1141 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
1142 acc.session_id = id.to_string();
1143 acc.conversation_id = Some(id.to_string());
1144 }
1145 acc.start_timestamp_ms = root
1146 .get("startTime")
1147 .and_then(Value::as_str)
1148 .and_then(iso_ms);
1149 acc.end_timestamp_ms = root
1150 .get("lastUpdated")
1151 .and_then(Value::as_str)
1152 .and_then(iso_ms)
1153 .or(acc.start_timestamp_ms);
1154 acc.duration_ms = acc
1155 .start_timestamp_ms
1156 .zip(acc.end_timestamp_ms)
1157 .map(|(start, end)| end.saturating_sub(start))
1158 .unwrap_or_default();
1159
1160 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
1161 return acc.finish_with_events(events);
1162 };
1163 for msg in messages {
1164 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
1165 acc.last_message_at = Some(ts.to_string());
1166 }
1167 let ts_ms = msg
1168 .get("timestamp")
1169 .and_then(Value::as_str)
1170 .and_then(parse_ts_ms);
1171 match msg.get("type").and_then(Value::as_str) {
1172 Some("user") if acc.prompt_preview.is_none() => {
1173 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1174 acc.prompt_preview = Some(text.clone());
1175 task_stack.observe_user(&text);
1176 current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1177 }
1178 }
1179 Some("user") => {
1180 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1181 task_stack.observe_user(&text);
1182 current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1183 }
1184 }
1185 Some("gemini") | Some("assistant") | Some("model") => {
1186 let mut llm_model = AGENT_GEMINI.to_string();
1187 if let Some(model) = msg.get("model").and_then(Value::as_str) {
1188 llm_model = model.to_string();
1189 acc.model.get_or_insert_with(|| model.to_string());
1190 if let Some(tokens) = msg.get("tokens") {
1191 acc.add_usage(
1192 model,
1193 json_i64(tokens, "input"),
1194 json_i64(tokens, "output"),
1195 0,
1196 json_i64(tokens, "cached"),
1197 json_i64(tokens, "total"),
1198 );
1199 }
1200 }
1201 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
1202 for call in tool_calls {
1203 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
1204 acc.add_tool(name);
1205 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
1206 {
1207 acc.add_file(path);
1208 }
1209 let mut event = tool_event_from_input(
1210 acc.cwd.as_deref(),
1211 ts_ms,
1212 current_prompt_index,
1213 name,
1214 call,
1215 call.get("id").and_then(Value::as_str).map(str::to_string),
1216 task_stack.path_for_tool(name, call),
1217 );
1218 if let Some(status) = call.get("status").and_then(Value::as_str) {
1219 let lowered = status.to_ascii_lowercase();
1220 event.status = if matches!(
1221 lowered.as_str(),
1222 "error" | "failed" | "fail" | "cancelled" | "canceled"
1223 ) {
1224 "fail".to_string()
1225 } else if matches!(lowered.as_str(), "success" | "ok" | "completed") {
1226 "ok".to_string()
1227 } else {
1228 status.to_string()
1229 };
1230 }
1231 events.tools.push(event);
1232 }
1233 }
1234 let content = msg.get("content").unwrap_or(msg);
1235 let text = content_to_text(content);
1236 let tokens = msg.get("tokens").unwrap_or(&Value::Null);
1237 if !text.trim().is_empty() || tokens.is_object() {
1238 events.llm_responses.push(LlmResponse {
1239 ts_ms,
1240 prompt_index: current_prompt_index,
1241 model: llm_model,
1242 source_id: String::new(),
1243 text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
1244 preview: truncate_clean(
1245 if text.trim().is_empty() {
1246 "gemini response"
1247 } else {
1248 &text
1249 },
1250 140,
1251 ),
1252 input_tokens: json_u64(tokens, "input"),
1253 output_tokens: json_u64(tokens, "output"),
1254 cache_tokens: json_u64(tokens, "cached"),
1255 total_tokens: json_u64(tokens, "total"),
1256 tag: String::new(),
1257 response_phase: if msg
1258 .get("toolCalls")
1259 .and_then(Value::as_array)
1260 .is_some_and(|calls| !calls.is_empty())
1261 {
1262 "assistant_message".to_string()
1263 } else {
1264 "final_answer".to_string()
1265 },
1266 skill: String::new(),
1267 task_path: task_stack.path(),
1268 });
1269 }
1270 }
1271 _ => {}
1272 }
1273 }
1274 acc.finish_with_events(events)
1275}
1276
1277fn read_cursor_subagents(path: &Path) -> Vec<(PathBuf, String)> {
1278 let Some(dir) = path.parent().map(|parent| parent.join("subagents")) else {
1279 return Vec::new();
1280 };
1281 let Ok(entries) = fs::read_dir(&dir) else {
1282 return Vec::new();
1283 };
1284 let mut out: Vec<(PathBuf, String)> = entries
1285 .flatten()
1286 .map(|entry| entry.path())
1287 .filter(|child| child.extension().and_then(|ext| ext.to_str()) == Some("jsonl"))
1288 .filter_map(|child| {
1289 let content = fs::read_to_string(&child).ok()?;
1290 Some((child, content))
1291 })
1292 .collect();
1293 out.sort_by(|left, right| left.0.cmp(&right.0));
1295 out
1296}
1297
1298fn parse_cursor_jsonl(
1299 path: &Path,
1300 updated: SystemTime,
1301 content: &str,
1302 children: &[(PathBuf, String)],
1303) -> Option<AgentSession> {
1304 let mut acc = SessionAccumulator::new(AGENT_CURSOR, path, updated);
1305 acc.conversation_id = Some(acc.session_id.clone());
1306 let mut events = SessionEvents::default();
1307 let mut current_prompt_index = 0usize;
1308
1309 acc.cwd = cursor_session_cwd(content, children);
1311
1312 let mut delegations = Vec::new();
1313 cursor_absorb_transcript(
1314 content,
1315 CursorScope::Parent,
1316 &mut acc,
1317 &mut events,
1318 &mut current_prompt_index,
1319 &mut delegations,
1320 );
1321 for (_, child_content) in children {
1322 let mut index = cursor_delegating_prompt_index(child_content, &delegations)
1324 .unwrap_or(current_prompt_index);
1325 cursor_absorb_transcript(
1326 child_content,
1327 CursorScope::Subagent,
1328 &mut acc,
1329 &mut events,
1330 &mut index,
1331 &mut Vec::new(),
1332 );
1333 }
1334
1335 acc.finish_with_events(events)
1336}
1337
1338#[derive(Clone, Copy, PartialEq, Eq)]
1340enum CursorScope {
1341 Parent,
1342 Subagent,
1343}
1344
1345fn cursor_absorb_transcript(
1346 content: &str,
1347 scope: CursorScope,
1348 acc: &mut SessionAccumulator,
1349 events: &mut SessionEvents,
1350 current_prompt_index: &mut usize,
1351 delegations: &mut Vec<(usize, String)>,
1352) {
1353 let mut turn_start = events.tools.len();
1355 let mut current_ts_ms: Option<i64> = None;
1357 for line in content.lines() {
1358 let line = line.trim();
1359 if line.is_empty() {
1360 continue;
1361 }
1362 let Ok(record) = serde_json::from_str::<Value>(line) else {
1365 continue;
1366 };
1367
1368 if record.get("type").and_then(Value::as_str) == Some("turn_ended") {
1370 let failed = record
1371 .get("status")
1372 .and_then(Value::as_str)
1373 .is_some_and(|status| {
1374 matches!(
1375 status,
1376 "error" | "failed" | "fail" | "cancelled" | "canceled"
1377 )
1378 });
1379 if failed {
1380 for tool in events.tools.iter_mut().skip(turn_start) {
1381 tool.status = "fail".to_string();
1382 }
1383 }
1384 turn_start = events.tools.len();
1385 continue;
1386 }
1387
1388 match record.get("role").and_then(Value::as_str) {
1389 Some("user") => {
1390 let raw = cursor_text_of(&record);
1391 if let Some(ts) = cursor_wrapper_ts_ms(&raw) {
1393 current_ts_ms = Some(ts);
1394 }
1395 if scope == CursorScope::Parent {
1396 let text = cursor_user_query(&raw);
1397 if !text.is_empty() {
1398 *current_prompt_index =
1399 events.upsert_prompt(current_ts_ms, &text, Vec::new());
1400 if acc.prompt_preview.is_none() {
1401 acc.prompt_preview = Some(truncate_clean(&text, 180));
1402 }
1403 }
1404 }
1405 }
1406 Some("assistant") => {
1407 for part in cursor_tool_uses(&record) {
1408 if scope == CursorScope::Parent
1409 && part.get("name").and_then(Value::as_str) == Some("Task")
1410 && let Some(prompt) = part
1411 .get("input")
1412 .and_then(|input| input.get("prompt"))
1413 .and_then(Value::as_str)
1414 .filter(|prompt| !prompt.trim().is_empty())
1415 {
1416 delegations.push((*current_prompt_index, prompt.trim().to_string()));
1417 }
1418 cursor_push_tool_event(part, acc, events, *current_prompt_index, current_ts_ms);
1419 }
1420 let text = cursor_text_of(&record);
1421 if !text.is_empty() {
1422 events.llm_responses.push(LlmResponse {
1423 ts_ms: current_ts_ms,
1424 prompt_index: *current_prompt_index,
1425 model: String::new(),
1428 source_id: String::new(),
1429 text_hash: short_hash(&text, 12),
1430 preview: truncate_clean(&text, 140),
1431 input_tokens: 0,
1432 output_tokens: 0,
1433 cache_tokens: 0,
1434 total_tokens: 0,
1435 tag: String::new(),
1436 response_phase: String::new(),
1437 skill: String::new(),
1438 task_path: Vec::new(),
1439 });
1440 }
1441 }
1442 _ => {}
1443 }
1444 }
1445}
1446
1447fn cursor_session_cwd(content: &str, children: &[(PathBuf, String)]) -> Option<String> {
1448 let mut absolute: Vec<PathBuf> = Vec::new();
1449 for transcript in std::iter::once(content).chain(children.iter().map(|(_, body)| body.as_str()))
1450 {
1451 for line in transcript.lines() {
1452 let Ok(record) = serde_json::from_str::<Value>(line.trim()) else {
1453 continue;
1454 };
1455 for part in cursor_tool_uses(&record) {
1456 let Some(input) = part.get("input") else {
1457 continue;
1458 };
1459 if let Some(dir) = input.get("working_directory").and_then(Value::as_str)
1460 && Path::new(dir).is_absolute()
1461 {
1462 return Some(dir.to_string());
1463 }
1464 for key in ["path", "paths"] {
1465 match input.get(key) {
1466 Some(Value::String(value)) => absolute.push(PathBuf::from(value)),
1467 Some(Value::Array(values)) => absolute
1468 .extend(values.iter().filter_map(Value::as_str).map(PathBuf::from)),
1469 _ => {}
1470 }
1471 }
1472 }
1473 }
1474 }
1475 common_parent_dir(&absolute)
1476}
1477
1478fn common_parent_dir(paths: &[PathBuf]) -> Option<String> {
1479 let mut dirs = paths
1480 .iter()
1481 .filter(|path| path.is_absolute())
1482 .filter_map(|path| path.parent())
1483 .map(|dir| {
1484 dir.components()
1485 .map(|part| part.as_os_str().to_string_lossy().into_owned())
1486 .collect::<Vec<_>>()
1487 });
1488 let mut shared = dirs.next()?;
1489 for candidate in dirs {
1490 let keep = shared
1491 .iter()
1492 .zip(candidate.iter())
1493 .take_while(|(left, right)| left == right)
1494 .count();
1495 shared.truncate(keep);
1496 }
1497 (shared.len() > 1).then(|| {
1499 let mut out = PathBuf::new();
1500 for part in shared {
1501 out.push(part);
1502 }
1503 out.to_string_lossy().into_owned()
1504 })
1505}
1506
1507fn cursor_delegating_prompt_index(
1508 child_content: &str,
1509 delegations: &[(usize, String)],
1510) -> Option<usize> {
1511 if delegations.is_empty() {
1512 return None;
1513 }
1514 let opening = cursor_first_user_text(child_content)?;
1515 delegations
1516 .iter()
1517 .find(|(_, prompt)| opening.contains(prompt.as_str()))
1518 .map(|(index, _)| *index)
1519}
1520
1521fn cursor_wrapper_ts_ms(text: &str) -> Option<i64> {
1522 const OPEN: &str = "<timestamp>";
1523 const CLOSE: &str = "</timestamp>";
1524 let start = text.find(OPEN)? + OPEN.len();
1525 let rest = &text[start..];
1526 let raw = rest[..rest.find(CLOSE)?].trim();
1527
1528 let (stamp, offset_hours) = match raw.rfind("(UTC") {
1530 Some(index) => {
1531 let hours = raw[index + 4..]
1532 .trim_end_matches(')')
1533 .trim()
1534 .parse::<i64>()
1535 .unwrap_or(0);
1536 (raw[..index].trim(), hours)
1537 }
1538 None => (raw, 0),
1539 };
1540 let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%A, %b %d, %Y, %I:%M %p").ok()?;
1541 Some(naive.and_utc().timestamp_millis() - offset_hours * 3_600_000)
1542}
1543
1544fn cursor_user_query(text: &str) -> String {
1545 const OPEN: &str = "<user_query>";
1546 const CLOSE: &str = "</user_query>";
1547 let Some(start) = text.find(OPEN) else {
1548 return text.trim().to_string();
1549 };
1550 let rest = &text[start + OPEN.len()..];
1551 let inner = match rest.find(CLOSE) {
1552 Some(end) => &rest[..end],
1553 None => rest,
1555 };
1556 inner.trim().to_string()
1557}
1558
1559fn cursor_first_user_text(content: &str) -> Option<String> {
1560 content.lines().find_map(|line| {
1561 let record = serde_json::from_str::<Value>(line.trim()).ok()?;
1562 (record.get("role").and_then(Value::as_str) == Some("user"))
1563 .then(|| cursor_text_of(&record))
1564 .filter(|text| !text.is_empty())
1565 })
1566}
1567
1568fn cursor_tool_uses(record: &Value) -> Vec<&Value> {
1569 record
1570 .get("message")
1571 .and_then(|message| message.get("content"))
1572 .and_then(Value::as_array)
1573 .map(|parts| {
1574 parts
1575 .iter()
1576 .filter(|part| part.get("type").and_then(Value::as_str) == Some("tool_use"))
1577 .collect()
1578 })
1579 .unwrap_or_default()
1580}
1581
1582fn cursor_push_tool_event(
1583 part: &Value,
1584 acc: &mut SessionAccumulator,
1585 events: &mut SessionEvents,
1586 prompt_index: usize,
1587 ts_ms: Option<i64>,
1588) {
1589 let Some(name) = part
1590 .get("name")
1591 .and_then(Value::as_str)
1592 .filter(|n| !n.is_empty())
1593 else {
1594 return;
1595 };
1596 let input = part.get("input").cloned().unwrap_or(Value::Null);
1597
1598 acc.add_tool(name);
1599 let event = tool_event_from_input(
1600 acc.cwd.as_deref(),
1601 ts_ms,
1603 prompt_index,
1604 name,
1605 &input,
1606 None,
1607 Vec::new(),
1608 );
1609 for path in &event.paths {
1610 acc.add_file(&path.path);
1611 }
1612 events.tools.push(event);
1613}
1614
1615fn cursor_text_of(record: &Value) -> String {
1616 let Some(parts) = record
1617 .get("message")
1618 .and_then(|message| message.get("content"))
1619 .and_then(Value::as_array)
1620 else {
1621 return String::new();
1622 };
1623 parts
1624 .iter()
1625 .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
1626 .filter_map(|part| part.get("text").and_then(Value::as_str))
1627 .collect::<Vec<_>>()
1628 .join("\n")
1629 .trim()
1630 .to_string()
1631}
1632
1633struct SessionAccumulator {
1634 agent_type: String,
1635 session_id: String,
1636 conversation_id: Option<String>,
1637 path: PathBuf,
1638 updated: SystemTime,
1639 start_timestamp_ms: Option<u64>,
1640 end_timestamp_ms: Option<u64>,
1641 model: Option<String>,
1642 model_usage: BTreeMap<String, TokenUsage>,
1643 tools: BTreeMap<String, usize>,
1644 files: BTreeMap<String, usize>,
1645 prompt_preview: Option<String>,
1646 duration_ms: u64,
1647 cwd: Option<String>,
1648 last_message_at: Option<String>,
1649}
1650
1651impl SessionAccumulator {
1652 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
1653 let normalized = normalize_session_log_path(path);
1654 let session_id = path
1655 .file_stem()
1656 .and_then(|stem| stem.to_str())
1657 .unwrap_or("session")
1658 .to_string();
1659 Self {
1660 agent_type: agent.to_string(),
1661 session_id,
1662 conversation_id: None,
1663 path: normalized.clone(),
1664 updated,
1665 start_timestamp_ms: None,
1666 end_timestamp_ms: Some(system_time_ms(updated)),
1667 model: None,
1668 model_usage: BTreeMap::new(),
1669 tools: BTreeMap::new(),
1670 files: BTreeMap::new(),
1671 prompt_preview: None,
1672 duration_ms: 0,
1673 cwd: None,
1674 last_message_at: None,
1675 }
1676 }
1677
1678 fn add_usage(
1679 &mut self,
1680 model: &str,
1681 input: i64,
1682 output: i64,
1683 cache_creation: i64,
1684 cache_read: i64,
1685 total: i64,
1686 ) {
1687 add_usage(
1688 &mut self.model_usage,
1689 model,
1690 input,
1691 output,
1692 cache_creation,
1693 cache_read,
1694 total,
1695 );
1696 }
1697
1698 fn set_usage(
1699 &mut self,
1700 model: &str,
1701 input: i64,
1702 output: i64,
1703 cache_creation: i64,
1704 cache_read: i64,
1705 total: i64,
1706 ) {
1707 let mut usage = TokenUsage::default();
1708 usage.add(input, output, cache_creation, cache_read, total);
1709 self.model_usage.insert(model.to_string(), usage);
1710 }
1711
1712 fn add_tool(&mut self, name: &str) {
1713 *self.tools.entry(name.to_string()).or_default() += 1;
1714 }
1715
1716 fn add_file(&mut self, path: &str) {
1717 *self.files.entry(path.to_string()).or_default() += 1;
1718 }
1719
1720 fn finish(self) -> Option<AgentSession> {
1721 let token_usage =
1722 self.model_usage
1723 .values()
1724 .fold(TokenUsage::default(), |mut total, usage| {
1725 total.input_tokens += usage.input_tokens;
1726 total.output_tokens += usage.output_tokens;
1727 total.cache_creation_tokens += usage.cache_creation_tokens;
1728 total.cache_read_tokens += usage.cache_read_tokens;
1729 total.total_tokens += usage.total_tokens;
1730 total
1731 });
1732 if token_usage.total_tokens == 0
1733 && self.tools.is_empty()
1734 && self.prompt_preview.is_none()
1735 && self.model.is_none()
1736 {
1737 return None;
1738 }
1739 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
1740 Some(AgentSession {
1741 agent_type: self.agent_type,
1742 session_id: self.session_id,
1743 conversation_id: self.conversation_id,
1744 display_id,
1745 path: self.path,
1746 updated: self.updated,
1747 start_timestamp_ms: self
1748 .start_timestamp_ms
1749 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
1750 end_timestamp_ms: self.end_timestamp_ms,
1751 model: self.model,
1752 usage: token_usage,
1753 model_usage: self.model_usage,
1754 tools: self.tools,
1755 files: self.files,
1756 prompt_preview: self.prompt_preview,
1757 duration_ms: self.duration_ms,
1758 cwd: self.cwd,
1759 last_message_at: self.last_message_at,
1760 events: SessionEvents::default(),
1761 })
1762 }
1763
1764 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
1765 self.finish().map(|mut session| {
1766 session.events = events;
1767 session
1768 })
1769 }
1770}
1771
1772fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
1777 let Ok(entries) = fs::read_dir(dir) else {
1778 return;
1779 };
1780 for entry in entries.flatten() {
1781 let path = entry.path();
1782 if path.is_dir() {
1783 walk_agent_files(agent, &path, f);
1784 } else if is_agent_file_for(agent, &path)
1785 && let Ok(meta) = path.metadata()
1786 {
1787 f(&path, &meta);
1788 }
1789 }
1790}
1791
1792fn is_agent_session_file(path: &Path) -> bool {
1793 agent_source_for_path(path).is_some()
1794}
1795
1796fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1797 match agent {
1798 AGENT_CLAUDE | AGENT_CODEX => {
1799 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1800 }
1801 AGENT_GEMINI => {
1802 path.extension().and_then(|ext| ext.to_str()) == Some("json")
1803 && path
1804 .file_name()
1805 .and_then(|name| name.to_str())
1806 .is_some_and(|name| name.starts_with("session-"))
1807 && path.to_string_lossy().contains("/chats/")
1808 }
1809 AGENT_CURSOR => is_cursor_parent_transcript(path),
1810 _ => false,
1811 }
1812}
1813
1814pub(crate) fn user_home_dir() -> Option<PathBuf> {
1815 std::env::var("SUDO_USER")
1816 .ok()
1817 .and_then(|user| {
1818 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1819 passwd
1820 .lines()
1821 .find(|line| line.starts_with(&format!("{user}:")))
1822 .and_then(|line| line.split(':').nth(5))
1823 .map(PathBuf::from)
1824 })
1825 })
1826 .or_else(dirs::home_dir)
1827}
1828
1829fn add_usage(
1830 models: &mut BTreeMap<String, TokenUsage>,
1831 model: &str,
1832 input: i64,
1833 output: i64,
1834 cache_creation: i64,
1835 cache_read: i64,
1836 total: i64,
1837) {
1838 models.entry(model.to_string()).or_default().add(
1839 input,
1840 output,
1841 cache_creation,
1842 cache_read,
1843 total,
1844 );
1845}
1846
1847impl SessionEvents {
1848 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str, task_path: Vec<String>) -> usize {
1849 let hash = short_hash(text, 12);
1850 if let Some(existing) = self.prompts.iter().rposition(|prompt| {
1851 prompt.text_hash == hash
1852 && match (prompt.ts_ms, ts_ms) {
1853 (Some(left), Some(right)) => left.abs_diff(right) <= 1_000,
1854 (None, None) => self
1855 .prompts
1856 .last()
1857 .is_some_and(|last| last.index == prompt.index),
1858 _ => false,
1859 }
1860 }) {
1861 return existing;
1862 }
1863 let index = self.prompts.len();
1864 self.prompts.push(UserPrompt {
1865 index,
1866 ts_ms,
1867 text_hash: hash,
1868 preview: truncate_clean(text, 180),
1869 tag: String::new(),
1870 task_path,
1871 });
1872 index
1873 }
1874}
1875
1876fn tool_event_from_input(
1877 cwd: Option<&str>,
1878 ts_ms: Option<i64>,
1879 prompt_index: usize,
1880 name: &str,
1881 input: &Value,
1882 call_id: Option<String>,
1883 task_path: Vec<String>,
1884) -> ToolEvent {
1885 let command = command_from_tool_input(input);
1886 let category = tool_category(name, &command);
1887 let domains = extract_domains(&command);
1888 let command_name = if category == "shell" {
1889 basename_from_command(&command)
1890 } else if category == "network" && !domains.is_empty() {
1891 domains[0]
1892 .split(':')
1893 .next()
1894 .unwrap_or("network")
1895 .to_string()
1896 } else {
1897 one_word(name, "tool")
1898 };
1899 let effect = if name == "apply_patch" || command.contains("*** ") {
1900 "write".to_string()
1901 } else {
1902 command_effect(&command)
1903 };
1904 let cwd = cwd.unwrap_or("");
1905 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1906 let paths = extract_tool_paths(name, input, &command, &effect);
1907 let process_chain = if category == "shell" {
1908 command_process_chain(&command)
1909 } else {
1910 Vec::new()
1911 };
1912 ToolEvent {
1913 ts_ms,
1914 prompt_index,
1915 tool_name: name.to_string(),
1916 category,
1917 command,
1918 command_name,
1919 effect,
1920 process_chain,
1921 status: "observed".to_string(),
1922 path_groups,
1923 paths,
1924 domains,
1925 call_id,
1926 invoked_skill: String::new(),
1927 skill: String::new(),
1928 task_path,
1929 }
1930}
1931
1932fn extract_tool_paths(name: &str, input: &Value, command: &str, effect: &str) -> Vec<ToolPath> {
1933 let lower = name.to_ascii_lowercase();
1934 let is_shell = lower.contains("bash") || lower.contains("exec") || lower.contains("shell");
1935 let default_access = if lower.contains("read")
1936 || lower.contains("grep")
1937 || lower.contains("glob")
1938 || lower.contains("search")
1939 {
1940 "read"
1941 } else if lower.contains("write")
1942 || lower.contains("edit")
1943 || lower.contains("replace")
1944 || lower.contains("patch")
1945 {
1946 "write"
1947 } else if lower.contains("delete") {
1948 "delete"
1951 } else if is_shell {
1952 if effect == "read" { "read" } else { "write" }
1953 } else {
1954 return Vec::new();
1955 };
1956 let mut rows = BTreeMap::<String, (String, Option<String>)>::new();
1957 if !is_shell {
1958 collect_path_fields(input, default_access, &mut rows);
1959 }
1960
1961 let embedded_patch = embedded_json_string(command, "*** Begin Patch");
1962 let patch = input
1963 .get("patch")
1964 .or_else(|| input.get("input"))
1965 .or_else(|| input.get("text"))
1966 .and_then(Value::as_str)
1967 .filter(|value| value.contains("*** Begin Patch") && value.lines().count() > 1)
1968 .or(embedded_patch.as_deref())
1969 .or_else(|| {
1970 (command.contains("*** Begin Patch") && command.lines().count() > 1).then_some(command)
1971 });
1972 let mut has_patch = false;
1973 if let Some(patch) = patch {
1974 let mut pending_update = None;
1975 for line in patch.lines() {
1976 let marker = line.trim();
1977 for (prefix, access) in [
1978 ("*** Add File: ", "create"),
1979 ("*** Update File: ", "write"),
1980 ("*** Delete File: ", "delete"),
1981 ("*** Move to: ", "rename"),
1982 ] {
1983 if let Some(path) = marker.strip_prefix(prefix) {
1984 let path = clean_path_token(path);
1985 if !path.is_empty() {
1986 has_patch = true;
1987 if access == "write" {
1988 pending_update = Some(path.clone());
1989 } else if access == "rename"
1990 && let Some(source) = pending_update.take()
1991 {
1992 rows.remove(&source);
1993 rows.insert(path.clone(), ("rename".to_string(), Some(source)));
1994 continue;
1995 }
1996 rows.insert(path, (access.to_string(), None));
1997 }
1998 }
1999 }
2000 }
2001 }
2002
2003 if is_shell && !has_patch {
2004 for (path, access, previous_path) in shell_file_actions(command, input, 0) {
2005 rows.insert(path, (access, previous_path));
2006 }
2007 for nested in embedded_json_objects(command, "tools.exec_command(") {
2008 let nested_command = command_from_tool_input(&nested);
2009 for (path, access, previous_path) in shell_file_actions(&nested_command, &nested, 0) {
2010 rows.insert(path, (access, previous_path));
2011 }
2012 }
2013 }
2014 rows.into_iter()
2015 .map(|(path, (access, previous_path))| ToolPath {
2016 path,
2017 access,
2018 previous_path,
2019 })
2020 .collect()
2021}
2022
2023fn embedded_json_objects(text: &str, marker: &str) -> Vec<Value> {
2024 let mut rows = Vec::new();
2025 let mut offset = 0;
2026 while let Some(found) = text[offset..].find(marker) {
2027 let start = offset + found + marker.len();
2028 let Some(open) = text[start..].find('{').map(|value| start + value) else {
2029 break;
2030 };
2031 let mut depth = 0;
2032 let mut quote = false;
2033 let mut escaped = false;
2034 let mut end = None;
2035 for (index, ch) in text[open..].char_indices() {
2036 if escaped {
2037 escaped = false;
2038 } else if ch == '\\' && quote {
2039 escaped = true;
2040 } else if ch == '"' {
2041 quote = !quote;
2042 } else if !quote && ch == '{' {
2043 depth += 1;
2044 } else if !quote && ch == '}' {
2045 depth -= 1;
2046 if depth == 0 {
2047 end = Some(open + index + 1);
2048 break;
2049 }
2050 }
2051 }
2052 let Some(end) = end else { break };
2053 if let Ok(value) = serde_json::from_str(&text[open..end]) {
2054 rows.push(value);
2055 }
2056 offset = end;
2057 }
2058 rows
2059}
2060
2061fn embedded_json_string(text: &str, needle: &str) -> Option<String> {
2062 let needle = text.find(needle)?;
2063 let start = text[..needle].rfind('"')?;
2064 let mut escaped = false;
2065 for (offset, ch) in text[start + 1..].char_indices() {
2066 if escaped {
2067 escaped = false;
2068 } else if ch == '\\' {
2069 escaped = true;
2070 } else if ch == '"' {
2071 return serde_json::from_str(&text[start..start + offset + 2]).ok();
2072 }
2073 }
2074 None
2075}
2076
2077fn shell_file_actions(
2078 command: &str,
2079 input: &Value,
2080 depth: usize,
2081) -> Vec<(String, String, Option<String>)> {
2082 if depth > 2 {
2083 return Vec::new();
2084 }
2085 let mut cwd = ["workdir", "cwd", "working_directory"]
2088 .iter()
2089 .find_map(|key| input.get(*key).and_then(Value::as_str))
2090 .map(PathBuf::from);
2091 let mut rows = Vec::new();
2092 for parts in shell_segments(command) {
2093 let Some(command_index) = shell_command_index(&parts) else {
2094 continue;
2095 };
2096 let name = process_name_from_part(&parts[command_index]).unwrap_or_default();
2097 let operands = &parts[command_index + 1..];
2098 if name == "cd" {
2099 if let Some(path) = operands.iter().find(|value| !value.starts_with('-')) {
2100 let path = PathBuf::from(path);
2101 cwd = Some(if path.is_absolute() {
2102 path
2103 } else {
2104 cwd.take().unwrap_or_default().join(path)
2105 });
2106 }
2107 continue;
2108 }
2109 let mut actions = shell_segment_actions(&name, operands, input, depth);
2110 for (path, _, previous_path) in &mut actions {
2111 if !path.starts_with(['~', '$'])
2112 && !Path::new(path).is_absolute()
2113 && let Some(base) = &cwd
2114 {
2115 *path = base.join(&*path).to_string_lossy().into_owned();
2116 }
2117 *path = clean_path_token(path);
2118 if let Some(previous) = previous_path {
2119 if !previous.starts_with(['~', '$'])
2120 && !Path::new(previous).is_absolute()
2121 && let Some(base) = &cwd
2122 {
2123 *previous = base.join(&*previous).to_string_lossy().into_owned();
2124 }
2125 *previous = clean_path_token(previous);
2126 }
2127 }
2128 rows.extend(actions.into_iter().filter(|(path, _, _)| !path.is_empty()));
2129 }
2130 rows
2131}
2132
2133fn shell_segment_actions(
2134 name: &str,
2135 operands: &[String],
2136 input: &Value,
2137 depth: usize,
2138) -> Vec<(String, String, Option<String>)> {
2139 let mut rows = Vec::new();
2140 let mut values = Vec::new();
2141 let mut index = 0;
2142 while index < operands.len() {
2143 if is_redirection_token(&operands[index]) {
2144 if let Some(path) = operands.get(index + 1)
2145 && plausible_path_operand(path)
2146 {
2147 let access = if [">", ">>", "&>", "&>>"].contains(&operands[index].as_str()) {
2148 "write"
2149 } else if ["<", "<>"].contains(&operands[index].as_str()) {
2150 "read"
2151 } else {
2152 index += 2;
2153 continue;
2154 };
2155 rows.push((path.clone(), access.into(), None));
2156 }
2157 index += 2;
2158 continue;
2159 }
2160 values.push(operands[index].clone());
2161 index += 1;
2162 }
2163 let paths = |items: &[String]| {
2164 items
2165 .iter()
2166 .filter(|value| !value.starts_with('-') && plausible_path_operand(value))
2167 .cloned()
2168 .collect::<Vec<_>>()
2169 };
2170 match name {
2171 "bash" | "sh" | "zsh" => {
2172 for index in 0..values.len().saturating_sub(1) {
2173 if ["-c", "-lc", "-cl"].contains(&values[index].as_str()) {
2174 rows.extend(shell_file_actions(&values[index + 1], input, depth + 1));
2175 break;
2176 }
2177 }
2178 }
2179 "cp" => {
2180 let paths = paths(&values);
2181 if let Some((target, sources)) = paths.split_last() {
2182 for source in sources {
2183 rows.push((source.clone(), "read".into(), None));
2184 let destination = destination_path(target, source, sources.len() > 1);
2185 rows.push((destination, "create".into(), None));
2186 }
2187 }
2188 }
2189 "mv" => {
2190 let paths = paths(&values);
2191 if let Some((target, sources)) = paths.split_last() {
2192 for source in sources {
2193 rows.push((
2194 destination_path(target, source, sources.len() > 1),
2195 "rename".into(),
2196 Some(source.clone()),
2197 ));
2198 }
2199 }
2200 }
2201 "rm" => rows.extend(
2202 paths(&values)
2203 .into_iter()
2204 .map(|path| (path, "delete".into(), None)),
2205 ),
2206 "touch" | "install" => rows.extend(
2207 paths(&values)
2208 .into_iter()
2209 .map(|path| (path, "create".into(), None)),
2210 ),
2211 "tee" => rows.extend(
2212 paths(&values)
2213 .into_iter()
2214 .map(|path| (path, "write".into(), None)),
2215 ),
2216 "cat" | "head" | "tail" | "nl" | "wc" | "source" | "." => rows.extend(
2217 paths(&values)
2218 .into_iter()
2219 .map(|path| (path, "read".into(), None)),
2220 ),
2221 "sed" => {
2222 let in_place = values.iter().any(|value| {
2223 value == "-i" || value.starts_with("-i") || value.starts_with("--in-place")
2224 });
2225 let mut script_seen = false;
2226 for value in &values {
2227 if value.starts_with('-') {
2228 continue;
2229 }
2230 if !script_seen {
2231 script_seen = true;
2232 } else if plausible_path_operand(value) {
2233 rows.push((
2234 value.clone(),
2235 if in_place { "write" } else { "read" }.into(),
2236 None,
2237 ));
2238 }
2239 }
2240 }
2241 "find" => rows.extend(
2242 values
2243 .iter()
2244 .take_while(|value| !value.starts_with('-') && value.as_str() != "!")
2245 .filter(|value| plausible_path_operand(value))
2246 .cloned()
2247 .map(|path| (path, "read".into(), None)),
2248 ),
2249 "rg" | "grep" | "jq" => {
2250 let mut expression_seen = values.iter().any(|value| value == "--files");
2251 for value in &values {
2252 if value.starts_with('-') {
2253 continue;
2254 }
2255 if !expression_seen {
2256 expression_seen = true;
2257 } else if plausible_path_operand(value) {
2258 rows.push((value.clone(), "read".into(), None));
2259 }
2260 }
2261 }
2262 _ => {}
2263 }
2264 rows
2265}
2266
2267fn destination_path(target: &str, source: &str, multiple: bool) -> String {
2268 if multiple || target.ends_with('/') {
2269 Path::new(target)
2270 .join(Path::new(source).file_name().unwrap_or_default())
2271 .to_string_lossy()
2272 .into_owned()
2273 } else {
2274 target.to_string()
2275 }
2276}
2277
2278fn collect_path_fields(
2279 value: &Value,
2280 access: &str,
2281 out: &mut BTreeMap<String, (String, Option<String>)>,
2282) {
2283 match value {
2284 Value::Object(object) => {
2285 for (key, value) in object {
2286 let key = key.to_ascii_lowercase();
2287 if matches!(
2288 key.as_str(),
2289 "path" | "file_path" | "filepath" | "notebook_path" | "old_path" | "new_path"
2290 ) && let Some(path) = value.as_str()
2291 {
2292 let path = clean_path_token(path);
2293 if !path.is_empty() {
2294 out.insert(path, (access.to_string(), None));
2295 }
2296 } else if matches!(key.as_str(), "paths" | "file_paths" | "filepaths")
2297 && let Some(items) = value.as_array()
2298 {
2299 for item in items.iter().filter_map(Value::as_str) {
2302 let path = clean_path_token(item);
2303 if !path.is_empty() {
2304 out.insert(path, (access.to_string(), None));
2305 }
2306 }
2307 } else if value.is_object() || value.is_array() {
2308 collect_path_fields(value, access, out);
2309 }
2310 }
2311 }
2312 Value::Array(values) => {
2313 for value in values {
2314 collect_path_fields(value, access, out);
2315 }
2316 }
2317 _ => {}
2318 }
2319}
2320
2321fn clean_path_token(value: &str) -> String {
2322 value
2323 .trim()
2324 .trim_matches(['"', '\'', '`', ',', ':'])
2325 .trim_start_matches("file://")
2326 .to_string()
2327}
2328
2329fn strip_heredoc_bodies(command: &str) -> String {
2330 fn delimiters(line: &str) -> Vec<String> {
2331 let bytes = line.as_bytes();
2332 let mut output = Vec::new();
2333 let mut index = 0;
2334 while index + 1 < bytes.len() {
2335 if bytes[index] != b'<' || bytes[index + 1] != b'<' {
2336 index += 1;
2337 continue;
2338 }
2339 index += 2;
2340 if bytes.get(index) == Some(&b'<') {
2341 index += 1;
2342 continue;
2343 }
2344 if bytes.get(index) == Some(&b'-') {
2345 index += 1;
2346 }
2347 while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
2348 index += 1;
2349 }
2350 let quote = bytes
2351 .get(index)
2352 .copied()
2353 .filter(|value| *value == b'\'' || *value == b'"');
2354 if quote.is_some() {
2355 index += 1;
2356 }
2357 let start = index;
2358 while let Some(value) = bytes.get(index) {
2359 if quote.is_some_and(|quote| *value == quote)
2360 || (quote.is_none()
2361 && (value.is_ascii_whitespace() || b";|&><".contains(value)))
2362 {
2363 break;
2364 }
2365 index += 1;
2366 }
2367 if start < index {
2368 output.push(line[start..index].to_string());
2369 }
2370 }
2371 output
2372 }
2373
2374 let mut pending = VecDeque::<String>::new();
2375 let mut output = Vec::new();
2376 for line in command.lines() {
2377 if let Some(delimiter) = pending.front() {
2378 if line.trim_start_matches('\t').trim_end() == delimiter {
2379 pending.pop_front();
2380 }
2381 continue;
2382 }
2383 output.push(line);
2384 pending.extend(delimiters(line));
2385 }
2386 output.join("\n")
2387}
2388
2389fn is_redirection_token(token: &str) -> bool {
2390 [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
2391}
2392
2393fn shell_command_index(parts: &[String]) -> Option<usize> {
2394 let mut index = 0;
2395 while index < parts.len() {
2396 let part = parts[index].as_str();
2397 if ["then", "do", "else"].contains(&part)
2398 || part.split_once('=').is_some_and(|(name, _)| {
2399 !name.is_empty()
2400 && name
2401 .chars()
2402 .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
2403 })
2404 {
2405 index += 1;
2406 continue;
2407 }
2408 if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
2409 index += 1;
2410 while index < parts.len() && parts[index].starts_with('-') {
2411 index += 1;
2412 }
2413 continue;
2414 }
2415 return Some(index);
2416 }
2417 None
2418}
2419
2420fn shell_segments(command: &str) -> Vec<Vec<String>> {
2421 fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
2422 if !current.is_empty() {
2423 tokens.push(std::mem::take(current));
2424 }
2425 }
2426 fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
2427 if !tokens.is_empty() {
2428 segments.push(std::mem::take(tokens));
2429 }
2430 }
2431
2432 let command = strip_heredoc_bodies(command);
2433 let mut segments = Vec::new();
2434 let mut tokens = Vec::new();
2435 let mut current = String::new();
2436 let mut quote = None;
2437 let mut escaped = false;
2438 let mut chars = command.chars().peekable();
2439 while let Some(ch) = chars.next() {
2440 if escaped {
2441 current.push(ch);
2442 escaped = false;
2443 } else if ch == '\\' {
2444 escaped = true;
2445 } else if quote == Some(ch) {
2446 quote = None;
2447 } else if quote.is_some() {
2448 current.push(ch);
2449 } else if ch == '\'' || ch == '"' {
2450 quote = Some(ch);
2451 } else if ch == '#' && current.is_empty() {
2452 for next in chars.by_ref() {
2453 if next == '\n' {
2454 flush_segment(&mut segments, &mut tokens);
2455 break;
2456 }
2457 }
2458 } else if ch.is_whitespace() {
2459 flush_word(&mut tokens, &mut current);
2460 if ch == '\n' {
2461 flush_segment(&mut segments, &mut tokens);
2462 }
2463 } else if ch == '&' && chars.peek() == Some(&'>') {
2464 flush_word(&mut tokens, &mut current);
2465 chars.next();
2466 let operator = if chars.peek() == Some(&'>') {
2467 chars.next();
2468 "&>>"
2469 } else {
2470 "&>"
2471 };
2472 tokens.push(operator.into());
2473 } else if matches!(ch, ';' | '|' | '(' | ')') || ch == '&' {
2474 flush_word(&mut tokens, &mut current);
2475 if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
2476 chars.next();
2477 }
2478 flush_segment(&mut segments, &mut tokens);
2479 } else if ch == '>' || ch == '<' {
2480 flush_word(&mut tokens, &mut current);
2481 let mut operator = ch.to_string();
2482 while chars.peek() == Some(&ch) && operator.len() < 3 {
2483 operator.push(chars.next().expect("peeked redirection"));
2484 }
2485 tokens.push(operator);
2486 } else {
2487 current.push(ch);
2488 }
2489 }
2490 flush_word(&mut tokens, &mut current);
2491 flush_segment(&mut segments, &mut tokens);
2492 segments
2493}
2494
2495fn codex_token_usage(value: &Value) -> TokenUsage {
2496 let input = json_i64(value, "input_tokens").max(0);
2497 let output = json_i64(value, "output_tokens").max(0);
2498 let cache = json_i64(value, "cached_input_tokens").max(0);
2499 let input = input.saturating_sub(cache);
2500 TokenUsage {
2501 input_tokens: input,
2502 output_tokens: output,
2503 cache_creation_tokens: 0,
2504 cache_read_tokens: cache,
2505 total_tokens: input + output + cache,
2506 }
2507}
2508
2509pub fn codex_total_token_usage(content: &str) -> Option<TokenUsage> {
2510 content.lines().rev().find_map(|line| {
2511 let obj: Value = serde_json::from_str(line).ok()?;
2512 let payload = obj.get("payload")?;
2513 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2514 return None;
2515 }
2516 payload
2517 .pointer("/info/total_token_usage")
2518 .map(codex_token_usage)
2519 })
2520}
2521
2522fn exact_claude_skill_invocation(name: &str, input: &Value) -> Option<String> {
2523 (name == "Skill")
2524 .then(|| input.get("skill").and_then(Value::as_str))
2525 .flatten()
2526 .map(str::trim)
2527 .filter(|skill| !skill.is_empty())
2528 .map(str::to_string)
2529}
2530
2531fn codex_custom_tool_input(outer_name: &str, raw: &str) -> (String, Value) {
2532 let nested_calls = codex_custom_tool_calls(raw);
2533 let nested_name = if raw.contains("Promise.all") || nested_calls.len() > 1 {
2534 "composite".to_string()
2535 } else {
2536 nested_calls
2537 .first()
2538 .cloned()
2539 .unwrap_or_else(|| outer_name.to_string())
2540 };
2541
2542 let commands = extract_js_string_fields(raw, &["command", "cmd"]);
2543 let paths = extract_js_string_fields(raw, &["file_path", "path"]);
2544 let workdirs = extract_js_string_fields(raw, &["workdir"]);
2545 let mut input = serde_json::Map::new();
2546 if !commands.is_empty() {
2547 input.insert("command".to_string(), Value::String(commands.join("\n")));
2548 } else if !raw.trim().is_empty() {
2549 input.insert("text".to_string(), Value::String(truncate_clean(raw, 600)));
2550 }
2551 if let Some(path) = paths.first() {
2552 input.insert("path".to_string(), Value::String(path.clone()));
2553 }
2554 if let Some(workdir) = workdirs.first() {
2555 input.insert("workdir".to_string(), Value::String(workdir.clone()));
2556 }
2557 for key in ["task_name", "target", "message"] {
2558 if let Some(value) = extract_js_string_fields(raw, &[key]).first() {
2559 input.insert(key.to_string(), Value::String(value.clone()));
2560 }
2561 }
2562 if nested_name == "update_plan" {
2563 let steps = extract_js_string_fields(raw, &["step"]);
2564 let statuses = extract_js_string_fields(raw, &["status"]);
2565 let plan = steps
2566 .into_iter()
2567 .enumerate()
2568 .map(|(index, step)| {
2569 serde_json::json!({
2570 "step": step,
2571 "status": statuses.get(index).map(String::as_str).unwrap_or("pending")
2572 })
2573 })
2574 .collect::<Vec<_>>();
2575 input.insert("plan".to_string(), Value::Array(plan));
2576 }
2577 (nested_name, Value::Object(input))
2578}
2579
2580fn codex_custom_tool_calls(raw: &str) -> Vec<String> {
2581 let mut calls = Vec::new();
2582 let mut offset = 0usize;
2583 while let Some(relative) = raw[offset..].find("tools.") {
2584 let start = offset + relative + "tools.".len();
2585 let tail = &raw[start..];
2586 let name = tail
2587 .chars()
2588 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
2589 .collect::<String>();
2590 let name_len = name.len();
2591 let after_name = tail[name.len()..].trim_start();
2592 if !name.is_empty() && after_name.starts_with('(') {
2593 calls.push(name);
2594 }
2595 offset = if name_len > 0 {
2596 start + name_len
2597 } else {
2598 raw[start..]
2602 .chars()
2603 .next()
2604 .map_or(raw.len(), |ch| start + ch.len_utf8())
2605 };
2606 }
2607 calls
2608}
2609
2610fn extract_js_string_fields(raw: &str, keys: &[&str]) -> Vec<String> {
2611 let mut values = Vec::new();
2612 for key in keys {
2613 let mut offset = 0usize;
2614 while let Some(relative) = raw[offset..].find(key) {
2615 let start = offset + relative;
2616 let before = raw[..start].chars().next_back();
2617 let after = raw[start + key.len()..].chars().next();
2618 if before.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2619 || after.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2620 {
2621 offset = start + key.len();
2622 continue;
2623 }
2624 let tail = &raw[start + key.len()..];
2625 let Some(colon) = tail.find(':').filter(|index| *index <= 4) else {
2626 offset = start + key.len();
2627 continue;
2628 };
2629 let value = tail[colon + 1..].trim_start();
2630 let Some(quote) = value
2631 .chars()
2632 .next()
2633 .filter(|ch| ['\'', '"', '`'].contains(ch))
2634 else {
2635 offset = start + key.len();
2636 continue;
2637 };
2638 if let Some((decoded, consumed)) = parse_js_string(&value[quote.len_utf8()..], quote) {
2639 if !decoded.is_empty() && !values.contains(&decoded) {
2640 values.push(decoded);
2641 }
2642 offset = start + key.len() + colon + 1 + consumed;
2643 } else {
2644 offset = start + key.len();
2645 }
2646 }
2647 }
2648 values
2649}
2650
2651fn parse_js_string(raw: &str, quote: char) -> Option<(String, usize)> {
2652 let mut decoded = String::new();
2653 let mut escaped = false;
2654 for (index, ch) in raw.char_indices() {
2655 if escaped {
2656 decoded.push(match ch {
2657 'n' => '\n',
2658 'r' => '\r',
2659 't' => '\t',
2660 other => other,
2661 });
2662 escaped = false;
2663 } else if ch == '\\' {
2664 escaped = true;
2665 } else if ch == quote {
2666 return Some((decoded, index + ch.len_utf8() + quote.len_utf8()));
2667 } else {
2668 decoded.push(ch);
2669 }
2670 }
2671 None
2672}
2673
2674fn command_from_tool_input(input: &Value) -> String {
2675 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
2676 if let Some(value) = input.get(key).and_then(Value::as_str)
2677 && !value.is_empty()
2678 {
2679 return if key == "pattern" {
2680 format!("search {value}")
2681 } else {
2682 value.to_string()
2683 };
2684 }
2685 }
2686 if input.is_null() {
2687 String::new()
2688 } else {
2689 truncate_clean(&input.to_string(), 300)
2690 }
2691}
2692
2693fn parse_tool_args(value: &Value) -> Value {
2694 if let Some(text) = value.as_str() {
2695 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
2696 } else {
2697 value.clone()
2698 }
2699}
2700
2701fn status_from_output(output: &str) -> &'static str {
2702 let lowered = output.to_ascii_lowercase();
2703 let exit_codes = explicit_exit_codes(&lowered);
2704 if exit_codes.iter().any(|code| *code != 0) {
2705 return "fail";
2706 }
2707 if !exit_codes.is_empty() {
2708 return "ok";
2709 }
2710 if lowered.contains("\"is_error\":false") || lowered.contains("\"success\":true") {
2711 return "ok";
2712 }
2713 if lowered.contains("\"is_error\":true") || lowered.contains("\"success\":false") {
2714 return "fail";
2715 }
2716 if lowered.lines().any(|line| line.trim() == "script failed") {
2717 return "fail";
2718 }
2719 if lowered
2720 .lines()
2721 .any(|line| line.trim() == "script completed")
2722 {
2723 return "ok";
2724 }
2725 "observed"
2726}
2727
2728fn explicit_exit_codes(output: &str) -> Vec<i32> {
2729 output
2730 .lines()
2731 .filter_map(|line| {
2732 let line = line.trim();
2733 let value = if let Some(rest) = line.strip_prefix("exit code:") {
2734 rest
2735 } else if let Some((_, rest)) = line.split_once("process exited with code") {
2736 rest.strip_prefix(':').unwrap_or(rest)
2737 } else {
2738 return None;
2739 };
2740 let digits = value
2741 .trim_start()
2742 .chars()
2743 .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
2744 .collect::<String>();
2745 digits.parse().ok()
2746 })
2747 .collect()
2748}
2749
2750pub fn tool_category(name: &str, command: &str) -> String {
2751 let n = name.to_ascii_lowercase();
2752 if n.ends_with("exec_command") || n.ends_with("shell_command") || n == "bash" || n == "shell" {
2753 "shell"
2754 } else if [
2755 "apply_patch",
2756 "edit",
2757 "write",
2758 "multiedit",
2759 "notebookedit",
2760 "strreplace",
2761 "delete",
2762 ]
2763 .contains(&n.as_str())
2764 {
2765 "edit"
2766 } else if ["read", "grep", "glob", "ls", "readlints"].contains(&n.as_str()) {
2767 "read"
2768 } else if n.contains("web")
2769 || n.contains("browser")
2770 || n.contains("search")
2771 || command.contains("http")
2772 {
2773 "network"
2774 } else if n.contains("plan") || n.contains("todo") {
2775 "plan"
2776 } else if n.contains("task") || n.contains("agent") {
2777 "subagent"
2778 } else {
2779 "tool"
2780 }
2781 .to_string()
2782}
2783
2784fn command_effect(command: &str) -> String {
2785 let cmd = basename_from_command(command);
2786 let text = command.to_ascii_lowercase();
2787 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
2788 && any_word(&text, &["test", "check", "build", "clippy"])
2789 {
2790 "test"
2791 } else if cmd == "git"
2792 && any_word(
2793 &text,
2794 &["commit", "push", "add", "checkout", "merge", "rebase"],
2795 )
2796 {
2797 "repo"
2798 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
2799 && (any_word(
2800 &text,
2801 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
2802 ) || text.contains("http://")
2803 || text.contains("https://"))
2804 {
2805 "network"
2806 } else if [
2807 "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
2808 ]
2809 .contains(&cmd.as_str())
2810 && (text.contains('>')
2811 || text.contains("--write")
2812 || text.contains(" rm ")
2813 || text.contains(" mkdir ")
2814 || text.contains(" touch ")
2815 || text.contains(" cp ")
2816 || text.contains(" mv "))
2817 {
2818 "write"
2819 } else if [
2820 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
2821 ]
2822 .contains(&cmd.as_str())
2823 {
2824 "read"
2825 } else if text.contains("http://")
2826 || text.contains("https://")
2827 || text.contains("crates.io")
2828 || text.contains("github.com")
2829 {
2830 "network"
2831 } else {
2832 "process"
2833 }
2834 .to_string()
2835}
2836
2837fn any_word(text: &str, words: &[&str]) -> bool {
2838 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2839 .any(|part| words.contains(&part))
2840}
2841
2842fn basename_from_command(command: &str) -> String {
2843 let parts = split_shell(command);
2844 let mut idx = 0;
2845 while idx < parts.len()
2846 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2847 &Path::new(&parts[idx])
2848 .file_name()
2849 .and_then(|v| v.to_str())
2850 .unwrap_or(""),
2851 )
2852 {
2853 idx += 1;
2854 if idx < parts.len() && parts[idx].starts_with('-') {
2855 idx += 1;
2856 }
2857 }
2858 parts
2859 .get(idx)
2860 .and_then(|part| process_name_from_part(part))
2861 .unwrap_or_else(|| "none".to_string())
2862}
2863
2864pub fn command_process_chain(command: &str) -> Vec<String> {
2865 process_chain_from_parts(&split_shell(command))
2866}
2867
2868fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
2869 if parts.is_empty() {
2870 return Vec::new();
2871 }
2872 let mut idx = 0;
2873 while idx < parts.len()
2874 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2875 &Path::new(&parts[idx])
2876 .file_name()
2877 .and_then(|v| v.to_str())
2878 .unwrap_or(""),
2879 )
2880 {
2881 idx += 1;
2882 if idx < parts.len() && parts[idx].starts_with('-') {
2883 idx += 1;
2884 }
2885 }
2886 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
2887 return Vec::new();
2888 };
2889 let mut chain = vec![proc_name.clone()];
2890 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
2891 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
2892 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
2893 chain.extend(command_process_chain(&parts[flag_idx + 1]));
2894 break;
2895 }
2896 }
2897 }
2898 chain
2899}
2900
2901fn process_name_from_part(part: &str) -> Option<String> {
2902 let raw = part.trim_matches(['"', '\'']);
2903 if raw.is_empty() {
2904 return None;
2905 }
2906 let path = Path::new(raw);
2907 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
2908 let parts = path_component_strings(path);
2909 if looks_like_home_directory(&parts) && parts.len() <= 2 {
2910 return Some("external".to_string());
2911 }
2912 if contains_private_marker(file_name) {
2913 return Some("external".to_string());
2914 }
2915 Some(file_name.to_string())
2916}
2917
2918fn split_shell(command: &str) -> Vec<String> {
2919 let mut parts = Vec::new();
2920 let mut current = String::new();
2921 let mut quote = None;
2922 let mut escaped = false;
2923 for ch in command.chars() {
2924 if escaped {
2925 current.push(ch);
2926 escaped = false;
2927 } else if ch == '\\' {
2928 escaped = true;
2929 } else if quote == Some(ch) {
2930 quote = None;
2931 } else if quote.is_some() {
2932 current.push(ch);
2933 } else if ch == '\'' || ch == '"' {
2934 quote = Some(ch);
2935 } else if ch.is_whitespace() {
2936 if !current.is_empty() {
2937 parts.push(std::mem::take(&mut current));
2938 }
2939 } else {
2940 current.push(ch);
2941 }
2942 }
2943 if !current.is_empty() {
2944 parts.push(current);
2945 }
2946 parts
2947}
2948
2949fn extract_domains(text: &str) -> Vec<String> {
2950 let mut domains = BTreeSet::new();
2951 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
2952 let stripped = part
2953 .strip_prefix("https://")
2954 .or_else(|| part.strip_prefix("http://"));
2955 if let Some(rest) = stripped
2956 && let Some(domain) = rest.split('/').next()
2957 && !domain.is_empty()
2958 {
2959 domains.insert(domain.to_ascii_lowercase());
2960 }
2961 for known in [
2962 "github.com",
2963 "crates.io",
2964 "huggingface.co",
2965 "hf.co",
2966 "openai.com",
2967 "anthropic.com",
2968 ] {
2969 if part.contains(known) {
2970 domains.insert(known.to_string());
2971 }
2972 }
2973 }
2974 domains.into_iter().collect()
2975}
2976
2977fn extract_path_groups(
2978 project_root: &Path,
2979 name: &str,
2980 input: &Value,
2981 command: &str,
2982) -> Vec<String> {
2983 let mut groups = BTreeSet::new();
2984 if ["write", "edit", "multiedit", "notebookedit", "read"]
2985 .contains(&name.to_ascii_lowercase().as_str())
2986 {
2987 for key in ["file_path", "path"] {
2988 if let Some(path) = input.get(key).and_then(Value::as_str) {
2989 groups.insert(path_group(path, project_root));
2990 }
2991 }
2992 }
2993 for part in split_shell(command) {
2994 if plausible_path_token(&part) {
2995 groups.insert(path_group(&part, project_root));
2996 }
2997 }
2998 groups.into_iter().filter(|v| v != "none").collect()
2999}
3000
3001fn plausible_path_operand(part: &str) -> bool {
3002 let part = part.trim_matches(['"', '\'']);
3003 !part.is_empty() && !part.chars().all(|c| c.is_ascii_digit()) && !definitely_not_a_path(part)
3005}
3006
3007fn plausible_path_token(part: &str) -> bool {
3008 let part = part.trim_matches(['"', '\'']);
3009 if definitely_not_a_path(part) {
3010 return false;
3011 }
3012 let suffix = Path::new(part)
3013 .extension()
3014 .and_then(|value| value.to_str())
3015 .unwrap_or("");
3016 part.contains('/')
3017 || [
3018 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
3019 "css",
3020 ]
3021 .contains(&suffix)
3022}
3023
3024fn definitely_not_a_path(part: &str) -> bool {
3025 let part = part.trim_matches(['"', '\'']);
3026 let lower = part.to_ascii_lowercase();
3027 let components = part.split('/').collect::<Vec<_>>();
3028 let looks_like_sed_expression = part.starts_with("s/")
3029 && part.rsplit('/').next().is_some_and(|flags| {
3030 flags.is_empty() || flags.chars().all(|flag| "gimpe".contains(flag))
3031 });
3032 let looks_like_slash_separated_phrase = components.len() >= 3
3033 && components.iter().all(|component| {
3034 component.chars().all(char::is_alphabetic)
3035 && component.chars().next().is_some_and(char::is_uppercase)
3036 });
3037 if part.is_empty()
3038 || part.starts_with('-')
3039 || part.starts_with('$')
3040 || part.starts_with('~')
3041 || part.starts_with("http://")
3042 || part.starts_with("https://")
3043 || lower.starts_with("origin/")
3044 || lower.starts_with("refs/")
3045 || lower.starts_with("repos/")
3046 || part == "HEAD"
3047 || part.starts_with("HEAD.")
3048 || part.contains("...")
3049 || looks_like_slash_separated_phrase
3050 || looks_like_sed_expression
3051 || part.len() > 140
3052 || part.chars().any(char::is_whitespace)
3053 || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
3054 {
3055 return true;
3056 }
3057 false
3058}
3059
3060pub fn path_group(path: &str, project_root: &Path) -> String {
3061 let path = path.trim_matches(['"', '\'']);
3062 if path.is_empty() {
3063 return "none".to_string();
3064 }
3065 let p = Path::new(path);
3066 let parts = if p.is_absolute() {
3067 if let Ok(rel) = p.strip_prefix(project_root) {
3068 path_component_strings(rel)
3069 } else {
3070 return external_path_group(path, &path_component_strings(p));
3071 }
3072 } else {
3073 let parts = path_component_strings(p);
3074 if let Some(group) = sensitive_relative_path_group(path, &parts) {
3075 return group;
3076 }
3077 parts
3078 };
3079 collapse_project_path(parts)
3080}
3081
3082pub fn path_component_strings(path: &Path) -> Vec<String> {
3083 path.components()
3084 .filter_map(|c| {
3085 let part = c.as_os_str().to_string_lossy();
3086 let part = part.as_ref();
3087 if part == "." || part == "/" || part.is_empty() {
3088 None
3089 } else {
3090 Some(part.to_string())
3091 }
3092 })
3093 .collect()
3094}
3095
3096pub fn collapse_project_path(parts: Vec<String>) -> String {
3097 let parts = parts
3098 .into_iter()
3099 .filter(|part| part != "." && !part.is_empty())
3100 .map(|part| truncate_path_component(&part))
3101 .collect::<Vec<_>>();
3102 if parts.is_empty() {
3103 "repo".to_string()
3104 } else if [
3105 "collector",
3106 "frontend",
3107 "docs",
3108 "bpf",
3109 "agentpprof",
3110 "agent-session",
3111 ]
3112 .contains(&parts[0].as_str())
3113 {
3114 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
3115 } else {
3116 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
3117 }
3118}
3119
3120fn truncate_path_component(part: &str) -> String {
3121 if part.chars().count() > 48 {
3122 format!("{}...", part.chars().take(45).collect::<String>())
3123 } else {
3124 part.to_string()
3125 }
3126}
3127
3128fn external_path_group(raw: &str, parts: &[String]) -> String {
3129 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
3130}
3131
3132fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
3133 let lowered = raw.to_ascii_lowercase();
3134 let lower_parts = parts
3135 .iter()
3136 .map(|part| part.to_ascii_lowercase())
3137 .collect::<Vec<_>>();
3138 if lower_parts.iter().any(|part| part == ".codex") {
3139 Some("external/codex".to_string())
3140 } else if lower_parts.iter().any(|part| part == ".claude") {
3141 Some("external/claude".to_string())
3142 } else if lower_parts.first().is_some_and(|part| part == "tmp")
3143 || lowered.contains("/tmp")
3144 || lowered.contains("_/tmp")
3145 || lower_parts
3146 .windows(2)
3147 .any(|window| window[0] == "var" && window[1] == "tmp")
3148 {
3149 Some("external/tmp".to_string())
3150 } else if lowered.starts_with("~/")
3151 || lowered == "~"
3152 || lowered.contains("/home")
3153 || lowered.contains("_/home")
3154 || lowered.contains("-home-")
3155 || lowered.contains("/users")
3156 || lowered.contains("_/users")
3157 || looks_like_home_directory(&lower_parts)
3158 || contains_private_marker(&lowered)
3159 {
3160 Some("external/home".to_string())
3161 } else {
3162 None
3163 }
3164}
3165
3166pub fn looks_like_home_directory(parts: &[String]) -> bool {
3167 parts
3168 .first()
3169 .is_some_and(|part| part == "home" || part == "users")
3170}
3171
3172fn current_username() -> Option<String> {
3173 dirs::home_dir()
3174 .and_then(|home| {
3175 home.file_name()
3176 .map(|part| part.to_string_lossy().to_string())
3177 })
3178 .filter(|name| !name.is_empty())
3179}
3180
3181pub fn contains_private_marker(text: &str) -> bool {
3182 let lowered = text.to_ascii_lowercase();
3183 current_username()
3184 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
3185 .unwrap_or(false)
3186}
3187
3188fn content_to_text(value: &Value) -> String {
3189 match value {
3190 Value::String(s) => s.clone(),
3191 Value::Array(items) => items
3192 .iter()
3193 .filter_map(|item| {
3194 if let Some(text) = item.as_str() {
3195 return Some(text.to_string());
3196 }
3197 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
3198 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
3199 return None;
3200 }
3201 if typ == "thinking" {
3203 return item
3204 .get("thinking")
3205 .and_then(Value::as_str)
3206 .filter(|s| !s.is_empty())
3207 .map(str::to_string);
3208 }
3209 item.get("text")
3210 .or_else(|| item.get("content"))
3211 .and_then(Value::as_str)
3212 .map(str::to_string)
3213 })
3214 .collect::<Vec<_>>()
3215 .join("\n"),
3216 Value::Object(_) => value
3217 .get("text")
3218 .or_else(|| value.get("content"))
3219 .and_then(Value::as_str)
3220 .unwrap_or("")
3221 .to_string(),
3222 _ => String::new(),
3223 }
3224}
3225
3226fn claude_is_tool_result(content: &Value) -> bool {
3227 content.as_array().is_some_and(|items| {
3228 !items.is_empty()
3229 && items
3230 .iter()
3231 .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3232 })
3233}
3234
3235fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
3236 let session_id = first_json_string(
3237 obj,
3238 &["sessionId", "session_id"],
3239 &["/payload/session_id", "/payload/sessionId"],
3240 );
3241 let conversation_id = first_json_string(
3242 obj,
3243 &["conversation_id", "conversationId", "thread_id", "threadId"],
3244 &[
3245 "/payload/conversation_id",
3246 "/payload/conversationId",
3247 "/payload/thread_id",
3248 "/payload/threadId",
3249 ],
3250 )
3251 .or_else(|| session_id.clone());
3252 (
3253 session_id.or_else(|| conversation_id.clone()),
3254 conversation_id,
3255 )
3256}
3257
3258fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
3259 keys.iter()
3260 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
3261 .chain(
3262 pointers
3263 .iter()
3264 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
3265 )
3266 .find(|value| !value.is_empty())
3267 .map(str::to_string)
3268}
3269
3270fn claude_usage_key(obj: &Value) -> String {
3271 obj.get("requestId")
3272 .or_else(|| obj.pointer("/message/id"))
3273 .or_else(|| obj.get("uuid"))
3274 .and_then(Value::as_str)
3275 .unwrap_or("usage")
3276 .to_string()
3277}
3278
3279fn claude_source_completion_id(obj: &Value) -> String {
3280 obj.pointer("/message/id")
3281 .or_else(|| obj.get("requestId"))
3282 .or_else(|| obj.get("uuid"))
3283 .and_then(Value::as_str)
3284 .unwrap_or("")
3285 .to_string()
3286}
3287
3288fn claude_user_starts_prompt(
3289 obj: &Value,
3290 content: &Value,
3291 text: &str,
3292 active_prompt_id: Option<&str>,
3293) -> bool {
3294 if obj.get("isMeta").and_then(Value::as_bool) == Some(true)
3295 || obj.get("sourceToolUseID").is_some()
3296 || obj.get("sourceToolAssistantUUID").is_some()
3297 || ["attachment", "attachments", "image", "images"]
3298 .iter()
3299 .any(|key| obj.get(*key).is_some())
3300 || content.as_array().is_some_and(|items| {
3301 !items.is_empty()
3302 && items.iter().all(|item| {
3303 matches!(
3304 item.get("type").and_then(Value::as_str),
3305 Some("attachment" | "document" | "file" | "image")
3306 )
3307 })
3308 })
3309 || [
3310 "<local-command-caveat>",
3311 "<local-command-stdout>",
3312 "<system-reminder>",
3313 "<ide_opened_file>",
3314 "<ide_selection>",
3315 ]
3316 .iter()
3317 .any(|prefix| text.starts_with(prefix))
3318 {
3319 return false;
3320 }
3321 match obj
3322 .get("promptId")
3323 .and_then(Value::as_str)
3324 .filter(|value| !value.is_empty())
3325 {
3326 Some(prompt_id) => active_prompt_id != Some(prompt_id),
3327 None => active_prompt_id.is_none(),
3328 }
3329}
3330
3331fn local_message_preview(value: &Value) -> Option<String> {
3332 let mut parts = Vec::new();
3333 collect_local_text(value, &mut parts);
3334 clean_prompt_text(&parts.join(" "))
3335}
3336
3337fn collect_local_text(value: &Value, out: &mut Vec<String>) {
3338 match value {
3339 Value::String(text) => out.push(text.clone()),
3340 Value::Array(items) => {
3341 for item in items {
3342 collect_local_text(item, out);
3343 }
3344 }
3345 Value::Object(obj) => {
3346 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
3347 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
3348 }) {
3349 return;
3350 }
3351 for key in ["text", "content", "message", "input", "prompt"] {
3352 if let Some(value) = obj.get(key) {
3353 collect_local_text(value, out);
3354 }
3355 }
3356 }
3357 _ => {}
3358 }
3359}
3360
3361fn is_claude_tool_result(obj: &Value) -> bool {
3362 obj.get("toolUseResult").is_some()
3363 || obj.get("tool_use_result").is_some()
3364 || obj
3365 .pointer("/message/content")
3366 .and_then(Value::as_array)
3367 .is_some_and(|items| {
3368 items
3369 .iter()
3370 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3371 })
3372}
3373
3374fn find_file_arg(value: &Value) -> Option<&str> {
3375 match value {
3376 Value::Object(obj) => {
3377 for key in ["file_path", "path", "filepath"] {
3378 if let Some(path) = obj.get(key).and_then(Value::as_str) {
3379 return Some(path);
3380 }
3381 }
3382 obj.values().find_map(find_file_arg)
3383 }
3384 Value::Array(items) => items.iter().find_map(find_file_arg),
3385 _ => None,
3386 }
3387}
3388
3389fn is_noise_path(path: &str) -> bool {
3390 const NOISE: &[&str] = &[
3391 "/.claude/",
3392 "/.codex/",
3393 "/.gemini/",
3394 "/.git/",
3395 "/node_modules/",
3396 "/.npm/",
3397 "/.cache/",
3398 "CLAUDE.md",
3399 "AGENTS.md",
3400 ];
3401 NOISE.iter().any(|pat| path.contains(pat))
3402}
3403
3404fn clean_prompt_text(text: &str) -> Option<String> {
3405 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3406 let text = text
3407 .strip_prefix("<session>")
3408 .and_then(|text| text.strip_suffix("</session>"))
3409 .unwrap_or(&text)
3410 .trim();
3411 (!text.is_empty()).then(|| text.to_string())
3412}
3413
3414pub fn short_hash(text: &str, n: usize) -> String {
3415 let digest = Sha256::digest(text.as_bytes());
3416 hex::encode(digest).chars().take(n).collect()
3417}
3418
3419pub fn truncate_clean(text: &str, limit: usize) -> String {
3420 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3421 if text.chars().count() <= limit {
3422 return text;
3423 }
3424 text.chars()
3425 .take(limit.saturating_sub(1))
3426 .collect::<String>()
3427 + "."
3428}
3429
3430pub fn one_word(text: &str, default: &str) -> String {
3431 let mut cur = String::new();
3432 for ch in text.to_ascii_lowercase().chars() {
3433 if ch.is_ascii_alphanumeric() {
3434 cur.push(ch);
3435 } else if cur.len() >= 2 {
3436 break;
3437 } else {
3438 cur.clear();
3439 }
3440 }
3441 if cur.len() >= 2 {
3442 cur
3443 } else {
3444 default.to_string()
3445 }
3446}
3447
3448fn short_session_id(id: &str) -> String {
3449 let id = id.trim();
3450 if id.is_empty() {
3451 return "session".to_string();
3452 }
3453 let compact = id
3454 .rsplit(['/', '\\'])
3455 .next()
3456 .unwrap_or(id)
3457 .trim_end_matches(".jsonl");
3458 const MAX_SESSION_ID_CHARS: usize = 12;
3459 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
3460 return compact.to_string();
3461 }
3462 let head = compact.chars().take(6).collect::<String>();
3463 let tail = compact
3464 .chars()
3465 .rev()
3466 .take(5)
3467 .collect::<Vec<_>>()
3468 .into_iter()
3469 .rev()
3470 .collect::<String>();
3471 format!("{head}.{tail}")
3472}
3473
3474fn json_i64(value: &Value, key: &str) -> i64 {
3475 value.get(key).and_then(Value::as_i64).unwrap_or(0)
3476}
3477
3478fn json_u64(value: &Value, key: &str) -> u64 {
3479 value.get(key).and_then(Value::as_u64).unwrap_or(0)
3480}
3481
3482fn ts_ms_from_event(value: &Value) -> Option<i64> {
3483 value
3484 .get("timestamp")
3485 .and_then(Value::as_str)
3486 .and_then(parse_ts_ms)
3487}
3488
3489fn parse_ts_ms(value: &str) -> Option<i64> {
3490 chrono::DateTime::parse_from_rfc3339(value)
3491 .ok()
3492 .map(|ts| ts.timestamp_millis())
3493}
3494
3495fn rfc3339_seconds(value: &str) -> Option<f64> {
3496 chrono::DateTime::parse_from_rfc3339(value)
3497 .ok()
3498 .map(|ts| ts.timestamp_millis() as f64 / 1000.0)
3499}
3500
3501fn uuid7_seconds(value: &str) -> Option<f64> {
3502 let mut parts = value.split('-');
3503 let high = parts.next()?;
3504 let low = parts.next()?;
3505 let version = parts.next()?;
3506 if !version.starts_with('7') {
3507 return None;
3508 }
3509 u64::from_str_radix(&format!("{high}{low}"), 16)
3510 .ok()
3511 .map(|milliseconds| milliseconds as f64 / 1000.0)
3512}
3513
3514fn iso_ms(value: &str) -> Option<u64> {
3515 chrono::DateTime::parse_from_rfc3339(value)
3516 .ok()
3517 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
3518}
3519
3520fn system_time_ms(value: SystemTime) -> u64 {
3521 value
3522 .duration_since(UNIX_EPOCH)
3523 .unwrap_or_default()
3524 .as_millis() as u64
3525}
3526
3527#[cfg(test)]
3528mod tests {
3529 use super::*;
3530 use serde_json::json;
3531 use std::time::UNIX_EPOCH;
3532
3533 fn cursor_parent_fixture() -> String {
3535 [
3536 r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\ncreate hello.py\n</user_query>"}]}}"#,
3538 r#"{"role":"assistant","message":{"content":[{"type":"text","text":"delegating"},{"type":"tool_use","name":"Task","input":{"description":"Create hello.py","prompt":"make it","subagent_type":"generalPurpose"}}]}}"#,
3539 r#"{"type":"turn_ended","status":"success"}"#,
3540 r#"{"role":"user","message":{"content":[{"type":"text","text":"now delete it"}]}}"#,
3541 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/hello.py"}},{"type":"text","text":"deleted"}]}}"#,
3542 r#"{"type":"turn_ended","status":"success"}"#,
3543 ]
3544 .join("\n")
3545 }
3546
3547 fn cursor_subagent_fixture() -> String {
3549 [
3550 r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\nmake it\n</user_query>"}]}}"#,
3552 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/hello.py","contents":"print(1)\n"}},{"type":"text","text":"written"}]}}"#,
3553 r#"{"type":"turn_ended","status":"success"}"#,
3554 ]
3555 .join("\n")
3556 }
3557
3558 #[test]
3559 fn cursor_transcript_counts_prompts_and_responses() {
3560 let session = parse_session_content(
3561 AGENT_CURSOR,
3562 &PathBuf::from("/tmp/session.jsonl"),
3563 UNIX_EPOCH,
3564 &cursor_parent_fixture(),
3565 )
3566 .expect("session");
3567
3568 assert_eq!(session.agent_type, AGENT_CURSOR);
3569 assert_eq!(session.events.prompts.len(), 2);
3570 assert_eq!(session.events.llm_responses.len(), 2);
3571 assert_eq!(session.events.prompts[0].preview, "create hello.py");
3574 assert_eq!(session.events.prompts[1].preview, "now delete it");
3575 assert_eq!(session.events.prompts[1].index, 1);
3576 assert_eq!(session.events.llm_responses[1].prompt_index, 1);
3577 assert_eq!(session.prompt_preview.as_deref(), Some("create hello.py"));
3578 }
3579
3580 #[test]
3581 fn cursor_tool_uses_become_events_and_unknown_names_are_kept() {
3582 let content = [
3583 r#"{"role":"user","message":{"content":[{"type":"text","text":"do work"}]}}"#,
3584 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cargo test","description":"run tests"}}]}}"#,
3585 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/a.rs","old_string":"x","new_string":"y"}}]}}"#,
3586 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/a.rs"]}}]}}"#,
3587 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"SomeToolWeHaveNeverSeen","input":{"whatever":1}}]}}"#,
3588 ]
3589 .join("\n");
3590
3591 let session = parse_session_content(
3592 AGENT_CURSOR,
3593 &PathBuf::from("/tmp/session.jsonl"),
3594 UNIX_EPOCH,
3595 &content,
3596 )
3597 .expect("session");
3598
3599 let named = |name: &str| {
3600 session
3601 .events
3602 .tools
3603 .iter()
3604 .find(|tool| tool.tool_name == name)
3605 .unwrap_or_else(|| panic!("{name} missing"))
3606 };
3607 assert_eq!(session.events.tools.len(), 4);
3608 assert_eq!(named("Shell").category, "shell");
3609 assert_eq!(named("Shell").command, "cargo test");
3610 assert_eq!(named("Shell").command_name, "cargo");
3611 assert_eq!(named("StrReplace").category, "edit");
3612 assert_eq!(named("ReadLints").category, "read");
3613 assert_eq!(named("SomeToolWeHaveNeverSeen").category, "tool");
3615 assert!(named("Shell").call_id.is_none());
3618 assert_eq!(named("Shell").status, "observed");
3619 assert_eq!(session.tools.get("Shell"), Some(&1));
3620 }
3621
3622 #[test]
3623 fn cursor_file_tools_map_to_access_kinds() {
3624 let content = [
3625 r#"{"role":"user","message":{"content":[{"type":"text","text":"work"}]}}"#,
3626 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3627 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/b.rs","contents":"fn main() {}"}}]}}"#,
3628 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/c.rs","old_string":"x","new_string":"y"}}]}}"#,
3629 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/d.rs"}}]}}"#,
3630 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/e.rs","/repo/f.rs"]}}]}}"#,
3631 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"timeout","path":"/repo","-i":true}}]}}"#,
3632 ]
3633 .join("\n");
3634
3635 let session = parse_session_content(
3636 AGENT_CURSOR,
3637 &PathBuf::from("/tmp/session.jsonl"),
3638 UNIX_EPOCH,
3639 &content,
3640 )
3641 .expect("session");
3642
3643 let access_of = |path: &str| {
3644 session
3645 .events
3646 .tools
3647 .iter()
3648 .flat_map(|tool| tool.paths.iter())
3649 .find(|candidate| candidate.path == path)
3650 .unwrap_or_else(|| panic!("{path} missing"))
3651 .access
3652 .clone()
3653 };
3654 assert_eq!(access_of("/repo/a.rs"), "read");
3655 assert_eq!(access_of("/repo/b.rs"), "write");
3656 assert_eq!(access_of("/repo/c.rs"), "write");
3657 assert_eq!(access_of("/repo/d.rs"), "delete");
3658 assert_eq!(access_of("/repo/e.rs"), "read");
3660 assert_eq!(access_of("/repo/f.rs"), "read");
3661 assert_eq!(access_of("/repo"), "read");
3663 assert_eq!(session.files.get("/repo/d.rs"), Some(&1));
3664 }
3665
3666 #[test]
3667 fn cursor_shell_mv_yields_rename_with_previous_path() {
3668 let content = [
3671 r#"{"role":"user","message":{"content":[{"type":"text","text":"tidy up"}]}}"#,
3672 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cd /repo && mv hello.py greet.py","description":"rename it"}}]}}"#,
3673 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"rm /repo/stale.txt","description":"drop it"}}]}}"#,
3674 ]
3675 .join("\n");
3676
3677 let session = parse_session_content(
3678 AGENT_CURSOR,
3679 &PathBuf::from("/tmp/session.jsonl"),
3680 UNIX_EPOCH,
3681 &content,
3682 )
3683 .expect("session");
3684
3685 let all: Vec<&ToolPath> = session
3686 .events
3687 .tools
3688 .iter()
3689 .flat_map(|tool| tool.paths.iter())
3690 .collect();
3691 let renamed = all
3692 .iter()
3693 .find(|path| path.access == "rename")
3694 .expect("rename");
3695 assert_eq!(renamed.path, "/repo/greet.py");
3696 assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3697 assert!(
3698 all.iter()
3699 .any(|path| path.access == "delete" && path.path == "/repo/stale.txt")
3700 );
3701 assert_eq!(session.events.tools[0].category, "shell");
3702 assert_eq!(
3703 session.events.tools[0].command,
3704 "cd /repo && mv hello.py greet.py"
3705 );
3706 assert_eq!(session.events.tools[0].command_name, "cd");
3708 assert!(
3709 !session.events.tools[0].process_chain.is_empty(),
3710 "Shell events must carry a process chain"
3711 );
3712 }
3713
3714 #[test]
3715 fn cursor_shell_working_directory_resolves_relative_paths() {
3716 let content = [
3718 r#"{"role":"user","message":{"content":[{"type":"text","text":"move it"}]}}"#,
3719 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"mv hello.py archive/greet.py","working_directory":"/repo","description":"move"}}]}}"#,
3720 ]
3721 .join("\n");
3722
3723 let session = parse_session_content(
3724 AGENT_CURSOR,
3725 &PathBuf::from("/tmp/session.jsonl"),
3726 UNIX_EPOCH,
3727 &content,
3728 )
3729 .expect("session");
3730
3731 let renamed = session.events.tools[0]
3732 .paths
3733 .iter()
3734 .find(|path| path.access == "rename")
3735 .expect("rename");
3736 assert_eq!(renamed.path, "/repo/archive/greet.py");
3737 assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3738 }
3739
3740 #[test]
3741 fn cursor_subagent_work_folds_into_the_delegating_prompt() {
3742 let children = vec![(
3743 PathBuf::from("/tmp/subagents/child.jsonl"),
3744 cursor_subagent_fixture(),
3745 )];
3746 let session = parse_cursor_jsonl(
3747 &PathBuf::from("/tmp/session.jsonl"),
3748 UNIX_EPOCH,
3749 &cursor_parent_fixture(),
3750 &children,
3751 )
3752 .expect("session");
3753
3754 assert_eq!(session.tools.get("Task"), Some(&1));
3757 assert_eq!(session.tools.get("Delete"), Some(&1));
3758 assert_eq!(session.tools.get("Write"), Some(&1));
3759 assert_eq!(session.events.tools.len(), 3);
3761 assert_eq!(session.tools.values().sum::<usize>(), 3);
3762 assert_eq!(session.files.get("/repo/hello.py"), Some(&2));
3763
3764 let tool_at = |name: &str| {
3765 session
3766 .events
3767 .tools
3768 .iter()
3769 .find(|tool| tool.tool_name == name)
3770 .unwrap_or_else(|| panic!("{name} missing"))
3771 .prompt_index
3772 };
3773 assert_eq!(tool_at("Write"), 0);
3776 assert_eq!(tool_at("Delete"), 1);
3777 }
3778
3779 #[test]
3780 fn cursor_cwd_prefers_working_directory_then_common_path_prefix() {
3781 let with_dir = [
3782 r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3783 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"ls","working_directory":"/repo/app"}}]}}"#,
3784 ]
3785 .join("\n");
3786 let session = parse_session_content(
3787 AGENT_CURSOR,
3788 &PathBuf::from("/tmp/session.jsonl"),
3789 UNIX_EPOCH,
3790 &with_dir,
3791 )
3792 .expect("session");
3793 assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3794
3795 let paths_only = [
3798 r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3799 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/app/src/main.rs","contents":"x"}}]}}"#,
3800 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/app/README.md"}}]}}"#,
3801 ]
3802 .join("\n");
3803 let session = parse_session_content(
3804 AGENT_CURSOR,
3805 &PathBuf::from("/tmp/session.jsonl"),
3806 UNIX_EPOCH,
3807 &paths_only,
3808 )
3809 .expect("session");
3810 assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3811
3812 let bare = [
3815 r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#,
3816 r#"{"role":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#,
3817 ]
3818 .join("\n");
3819 let session = parse_session_content(
3820 AGENT_CURSOR,
3821 &PathBuf::from("/tmp/projects/Users-user-cursor-test/agent-transcripts/a/a.jsonl"),
3822 UNIX_EPOCH,
3823 &bare,
3824 )
3825 .expect("session");
3826 assert_eq!(session.cwd, None);
3827 }
3828
3829 #[test]
3830 fn cursor_truncated_and_empty_transcripts_degrade_without_error() {
3831 let torn = concat!(
3834 r#"{"role":"user","message":{"content":[{"type":"text","text":"start"}]}}"#,
3835 "\n",
3836 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3837 "\n",
3838 r#"{"role":"assistant","message":{"content":[{"type":"tool_"#,
3839 );
3840 let session = parse_session_content(
3841 AGENT_CURSOR,
3842 &PathBuf::from("/tmp/session.jsonl"),
3843 UNIX_EPOCH,
3844 torn,
3845 )
3846 .expect("session");
3847 assert_eq!(session.events.prompts.len(), 1);
3848 assert_eq!(session.tools.get("Read"), Some(&1));
3849
3850 let one_prompt =
3853 r#"{"role":"user","message":{"content":[{"type":"text","text":"just asking"}]}}"#;
3854 let session = parse_session_content(
3855 AGENT_CURSOR,
3856 &PathBuf::from("/tmp/session.jsonl"),
3857 UNIX_EPOCH,
3858 one_prompt,
3859 )
3860 .expect("a lone prompt is still a session");
3861 assert!(session.events.tools.is_empty());
3862 assert_eq!(session.prompt_preview.as_deref(), Some("just asking"));
3863
3864 for empty in [
3868 "",
3869 "\n\n",
3870 r#"{"type":"turn_ended","status":"error","error":"aborted"}"#,
3871 "not json at all",
3872 ] {
3873 assert!(
3874 parse_session_content(
3875 AGENT_CURSOR,
3876 &PathBuf::from("/tmp/session.jsonl"),
3877 UNIX_EPOCH,
3878 empty,
3879 )
3880 .is_none(),
3881 "expected no session for {empty:?}"
3882 );
3883 }
3884
3885 let orphan = vec![(
3888 PathBuf::from("/tmp/subagents/orphan.jsonl"),
3889 [
3890 r#"{"role":"user","message":{"content":[{"type":"text","text":"unrelated wording"}]}}"#,
3891 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/z.rs","contents":"x"}}]}}"#,
3892 ]
3893 .join("\n"),
3894 )];
3895 let session = parse_cursor_jsonl(
3896 &PathBuf::from("/tmp/session.jsonl"),
3897 UNIX_EPOCH,
3898 &cursor_parent_fixture(),
3899 &orphan,
3900 )
3901 .expect("session");
3902 assert_eq!(session.tools.get("Write"), Some(&1));
3903 }
3904
3905 #[test]
3906 fn cursor_failed_turn_marks_its_tool_calls() {
3907 let content = [
3908 r#"{"role":"user","message":{"content":[{"type":"text","text":"first"}]}}"#,
3909 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/ok.rs"}}]}}"#,
3910 r#"{"type":"turn_ended","status":"success"}"#,
3911 r#"{"role":"user","message":{"content":[{"type":"text","text":"second"}]}}"#,
3912 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cat /repo/missing.rs"}}]}}"#,
3913 r#"{"type":"turn_ended","status":"error","error":"command failed"}"#,
3914 ]
3915 .join("\n");
3916
3917 let session = parse_session_content(
3918 AGENT_CURSOR,
3919 &PathBuf::from("/tmp/session.jsonl"),
3920 UNIX_EPOCH,
3921 &content,
3922 )
3923 .expect("session");
3924
3925 let status_of = |name: &str| {
3926 session
3927 .events
3928 .tools
3929 .iter()
3930 .find(|tool| tool.tool_name == name)
3931 .unwrap_or_else(|| panic!("{name} missing"))
3932 .status
3933 .clone()
3934 };
3935 assert_eq!(status_of("Read"), "observed");
3938 assert_eq!(status_of("Shell"), "fail");
3939 }
3940
3941 #[test]
3942 fn cursor_wrapper_timestamp_becomes_the_event_clock() {
3943 let content = [
3947 r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\ngo\n</user_query>"}]}}"#,
3948 r#"{"role":"assistant","message":{"content":[{"type":"text","text":"reading it"},{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3949 ]
3950 .join("\n");
3951
3952 let session = parse_session_content(
3953 AGENT_CURSOR,
3954 &PathBuf::from("/tmp/session.jsonl"),
3955 UNIX_EPOCH,
3956 &content,
3957 )
3958 .expect("session");
3959
3960 const EXPECTED_MS: i64 = 1_786_158_720_000;
3964 assert_eq!(session.events.prompts[0].ts_ms, Some(EXPECTED_MS));
3965 assert_eq!(session.events.tools[0].ts_ms, Some(EXPECTED_MS));
3966 assert_eq!(session.events.llm_responses[0].ts_ms, Some(EXPECTED_MS));
3967
3968 let bare = [
3970 r#"{"role":"user","message":{"content":[{"type":"text","text":"no wrapper here"}]}}"#,
3971 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/b.rs"}}]}}"#,
3972 ]
3973 .join("\n");
3974 let session = parse_session_content(
3975 AGENT_CURSOR,
3976 &PathBuf::from("/tmp/session.jsonl"),
3977 UNIX_EPOCH,
3978 &bare,
3979 )
3980 .expect("session");
3981 assert_eq!(session.events.tools[0].ts_ms, None);
3982 }
3983
3984 #[test]
3985 fn cursor_subagent_prompts_are_not_user_prompts() {
3986 let children = vec![(
3987 PathBuf::from("/tmp/subagents/child.jsonl"),
3988 cursor_subagent_fixture(),
3989 )];
3990 let session = parse_cursor_jsonl(
3991 &PathBuf::from("/tmp/session.jsonl"),
3992 UNIX_EPOCH,
3993 &cursor_parent_fixture(),
3994 &children,
3995 )
3996 .expect("session");
3997
3998 assert_eq!(session.events.prompts.len(), 2);
4001 assert_eq!(session.events.llm_responses.len(), 3);
4002 }
4003
4004 #[test]
4005 fn cursor_paths_classify_but_only_parents_discover() {
4006 let home = PathBuf::from("/home/dev");
4007 let parent = home.join(".cursor/projects/repo/agent-transcripts/abc/abc.jsonl");
4008 let subagent = home.join(".cursor/projects/repo/agent-transcripts/abc/subagents/def.jsonl");
4009 let vendored = home.join(".cursor/projects/repo/canvases/node_modules/pkg/data.jsonl");
4010
4011 assert_eq!(agent_source_for_path(&parent), Some(AGENT_CURSOR));
4014 assert_eq!(agent_source_for_path(&subagent), Some(AGENT_CURSOR));
4015 assert_eq!(agent_source_for_path(&vendored), None);
4016
4017 assert!(is_agent_file_for(AGENT_CURSOR, &parent));
4019 assert!(!is_agent_file_for(AGENT_CURSOR, &subagent));
4020 assert!(!is_agent_file_for(AGENT_CURSOR, &vendored));
4021 assert!(!is_agent_file_for(
4022 AGENT_CURSOR,
4023 &home.join(".cursor/projects/repo/agent-transcripts/abc/other.jsonl")
4024 ));
4025
4026 assert!(cursor_is_empty_window(&home.join(
4027 ".cursor/projects/empty-window/agent-transcripts/abc/abc.jsonl"
4028 )));
4029 assert!(!cursor_is_empty_window(&parent));
4030
4031 let fixture = fixture_session_path(AGENT_CURSOR, &home).expect("fixture");
4032 assert!(is_agent_file_for(AGENT_CURSOR, &fixture));
4033 }
4034
4035 #[test]
4036 fn local_session_ids_keep_distinct_conversation_id() {
4037 assert_eq!(
4038 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
4039 (Some("run".to_string()), Some("conv".to_string()))
4040 );
4041 assert_eq!(
4042 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
4043 (Some("thread".to_string()), Some("thread".to_string()))
4044 );
4045 assert_eq!(
4046 local_session_ids(&json!({"payload": {"model": "gpt"}})),
4047 (None, None)
4048 );
4049 }
4050
4051 #[test]
4052 fn agent_jsonl_events_share_one_ir() {
4053 let codex = concat!(
4054 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
4055 "\n",
4056 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
4057 "\n",
4058 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4059 "\n",
4060 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"tests passed"}}"#,
4061 "\n",
4062 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
4063 );
4064 let claude = concat!(
4065 r#"{"type":"user","message":{"content":"check build"}}"#,
4066 "\n",
4067 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"cmd":"cargo check"}},{"type":"text","text":"checking"}],"usage":{"input_tokens":7,"cache_creation_input_tokens":2,"output_tokens":3}}}"#,
4068 );
4069
4070 for (agent, content, tool, model, tokens) in [
4071 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
4072 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
4073 ] {
4074 let session = parse_session_content(
4075 agent,
4076 &PathBuf::from("/tmp/session.jsonl"),
4077 UNIX_EPOCH,
4078 content,
4079 )
4080 .expect("session");
4081 assert_eq!(session.events.tools[0].tool_name, tool);
4082 assert_eq!(session.events.tools[0].category, "shell");
4083 assert_eq!(session.events.llm_responses[0].model, model);
4084 let usage = &session.events.llm_responses[0];
4085 let total = usage
4086 .total_tokens
4087 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
4088 assert_eq!(total, tokens);
4089 }
4090 }
4091
4092 #[test]
4093 fn claude_exact_skill_calls_create_prompt_bounded_latest_wins_scopes() {
4094 let claude = [
4095 r#"{"type":"system","skill_listing":["availability only"]}"#,
4096 r#"{"type":"user","message":{"content":"review the paper"}}"#,
4097 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"check-paper-citations","args":""}}],"usage":{"input_tokens":10,"output_tokens":1}}}"#,
4098 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"cmd":"rg citation paper.tex"}}],"usage":{"input_tokens":20,"output_tokens":2}}}"#,
4099 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s2","name":"Skill","input":{"skill":"iter-refine-writing","args":""}}],"usage":{"input_tokens":30,"output_tokens":3}}}"#,
4100 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"paper.tex"}}],"usage":{"input_tokens":40,"output_tokens":4}}}"#,
4101 r#"{"type":"user","message":{"content":"now summarize"}}"#,
4102 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":50,"output_tokens":5}}}"#,
4103 ]
4104 .join("\n");
4105
4106 let session = parse_session_content(
4107 AGENT_CLAUDE,
4108 &PathBuf::from("/tmp/session.jsonl"),
4109 UNIX_EPOCH,
4110 &claude,
4111 )
4112 .expect("session");
4113
4114 assert_eq!(
4115 session
4116 .events
4117 .tools
4118 .iter()
4119 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4120 .collect::<Vec<_>>(),
4121 [
4122 ("Skill", "check-paper-citations"),
4123 ("Bash", "check-paper-citations"),
4124 ("Skill", "iter-refine-writing"),
4125 ("Read", "iter-refine-writing"),
4126 ]
4127 );
4128 assert_eq!(
4129 session
4130 .events
4131 .tools
4132 .iter()
4133 .map(|tool| tool.invoked_skill.as_str())
4134 .collect::<Vec<_>>(),
4135 ["check-paper-citations", "", "iter-refine-writing", ""]
4136 );
4137 assert_eq!(
4138 session
4139 .events
4140 .llm_responses
4141 .iter()
4142 .map(|response| response.skill.as_str())
4143 .collect::<Vec<_>>(),
4144 [
4145 "",
4146 "check-paper-citations",
4147 "check-paper-citations",
4148 "iter-refine-writing",
4149 "",
4150 ]
4151 );
4152 }
4153
4154 #[test]
4155 fn codex_source_controls_build_sparse_semantic_task_paths() {
4156 let codex = concat!(
4157 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"write a paper"}]}}"#,
4158 "\n",
4159 r#"{"type":"response_item","payload":{"type":"function_call","name":"update_plan","call_id":"p1","arguments":"{\"plan\":[{\"step\":\"write abstract\",\"status\":\"in_progress\"}]}"}}"#,
4160 "\n",
4161 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"sed -n 1,80p paper.tex\"}"}}"#,
4162 "\n",
4163 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 0\n0 tests failed"}}"#,
4164 "\n",
4165 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"继续"}]}}"#,
4166 "\n",
4167 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c2","arguments":"{\"cmd\":\"rg error paper.tex\"}"}}"#,
4168 "\n",
4169 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c2","output":"review error handling documentation"}}"#,
4170 );
4171
4172 let session = parse_session_content(
4173 AGENT_CODEX,
4174 &PathBuf::from("/tmp/session.jsonl"),
4175 UNIX_EPOCH,
4176 codex,
4177 )
4178 .expect("session");
4179
4180 assert_eq!(session.events.prompts.len(), 2);
4181 assert!(session.events.llm_responses.is_empty());
4182 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4183 assert_eq!(
4184 session.events.tools[1].task_path,
4185 vec!["write a paper", "write abstract"]
4186 );
4187 assert_eq!(
4188 session.events.tools[2].task_path,
4189 session.events.tools[1].task_path
4190 );
4191 assert_eq!(session.events.tools[1].status, "ok");
4192 assert_eq!(session.events.tools[2].status, "observed");
4193 }
4194
4195 #[test]
4196 fn codex_custom_exec_is_a_real_source_tool_event() {
4197 let codex = [
4198 json!({
4199 "timestamp": "2026-07-21T00:00:00.000Z",
4200 "type": "response_item",
4201 "payload": {
4202 "type": "message",
4203 "role": "user",
4204 "content": [{"type": "input_text", "text": "test the parser"}]
4205 }
4206 }),
4207 json!({
4208 "timestamp": "2026-07-21T00:00:01.000Z",
4209 "type": "response_item",
4210 "payload": {
4211 "type": "custom_tool_call",
4212 "name": "exec",
4213 "call_id": "custom-1",
4214 "input": "const r = await tools.shell_command({command:\"cargo test\",workdir:\"/repo\"}); text(r);"
4215 }
4216 }),
4217 json!({
4218 "timestamp": "2026-07-21T00:00:02.000Z",
4219 "type": "response_item",
4220 "payload": {
4221 "type": "custom_tool_call_output",
4222 "call_id": "custom-1",
4223 "output": [{"type": "input_text", "text": "Script completed\nExit code: 0\nOutput:\nall tests passed"}]
4224 }
4225 }),
4226 ]
4227 .into_iter()
4228 .map(|line| line.to_string())
4229 .collect::<Vec<_>>()
4230 .join("\n");
4231
4232 let session = parse_session_content(
4233 AGENT_CODEX,
4234 &PathBuf::from("/tmp/session.jsonl"),
4235 UNIX_EPOCH,
4236 &codex,
4237 )
4238 .expect("session");
4239
4240 assert_eq!(session.events.tools.len(), 1);
4241 let event = &session.events.tools[0];
4242 assert_eq!(event.tool_name, "shell_command");
4243 assert_eq!(event.category, "shell");
4244 assert_eq!(event.effect, "test");
4245 assert_eq!(event.command, "cargo test");
4246 assert_eq!(event.status, "ok");
4247 assert_eq!(event.task_path, vec!["test the parser"]);
4248 }
4249
4250 #[test]
4251 fn custom_update_plan_changes_only_later_operation_paths() {
4252 let codex = [
4253 json!({
4254 "timestamp": "2026-07-21T00:00:00.000Z",
4255 "type": "response_item",
4256 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "write a paper"}]}
4257 }),
4258 json!({
4259 "timestamp": "2026-07-21T00:00:01.000Z",
4260 "type": "response_item",
4261 "payload": {
4262 "type": "custom_tool_call",
4263 "name": "exec",
4264 "call_id": "plan-1",
4265 "input": "const r = await tools.update_plan({plan:[{step:\"write abstract\",status:\"in_progress\"},{step:\"write evaluation\",status:\"pending\"}]}); text(r);"
4266 }
4267 }),
4268 json!({
4269 "timestamp": "2026-07-21T00:00:02.000Z",
4270 "type": "response_item",
4271 "payload": {
4272 "type": "custom_tool_call",
4273 "name": "exec",
4274 "call_id": "shell-1",
4275 "input": "const r = await tools.shell_command({command:\"sed -n 1,80p paper.tex\",workdir:\"/repo\"}); text(r);"
4276 }
4277 }),
4278 ]
4279 .into_iter()
4280 .map(|line| line.to_string())
4281 .collect::<Vec<_>>()
4282 .join("\n");
4283 let session = parse_session_content(
4284 AGENT_CODEX,
4285 &PathBuf::from("/tmp/session.jsonl"),
4286 UNIX_EPOCH,
4287 &codex,
4288 )
4289 .expect("session");
4290
4291 assert_eq!(session.events.tools.len(), 2);
4292 assert_eq!(session.events.tools[0].tool_name, "update_plan");
4293 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4294 assert_eq!(
4295 session.events.tools[1].task_path,
4296 vec!["write a paper", "write abstract"]
4297 );
4298 }
4299
4300 #[test]
4301 fn prompt_dedup_is_local_and_continuations_keep_the_current_task() {
4302 let codex = [
4303 ("2026-07-21T00:00:00.000Z", "write a paper"),
4304 ("2026-07-21T00:00:00.500Z", "write a paper"),
4305 ("2026-07-21T00:00:03.000Z", "write a paper"),
4306 ("2026-07-21T00:00:06.000Z", "继续"),
4307 ]
4308 .into_iter()
4309 .map(|(timestamp, text)| {
4310 json!({
4311 "timestamp": timestamp,
4312 "type": "response_item",
4313 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
4314 })
4315 .to_string()
4316 })
4317 .collect::<Vec<_>>()
4318 .join("\n");
4319 let session = parse_session_content(
4320 AGENT_CODEX,
4321 &PathBuf::from("/tmp/session.jsonl"),
4322 UNIX_EPOCH,
4323 &codex,
4324 )
4325 .expect("session");
4326
4327 assert_eq!(session.events.prompts.len(), 3);
4328 assert_eq!(session.events.prompts[2].preview, "继续");
4329 assert_eq!(session.events.prompts[2].task_path, vec!["write a paper"]);
4330 }
4331
4332 #[test]
4333 fn developer_messages_are_not_agent_responses() {
4334 let codex = concat!(
4335 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review"}]}}"#,
4336 "\n",
4337 r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"internal instruction"}]}}"#,
4338 "\n",
4339 r#"{"timestamp":"2026-07-21T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"review complete"}]}}"#,
4340 );
4341 let session = parse_session_content(
4342 AGENT_CODEX,
4343 &PathBuf::from("/tmp/session.jsonl"),
4344 UNIX_EPOCH,
4345 codex,
4346 )
4347 .expect("session");
4348 assert_eq!(session.events.llm_responses.len(), 1);
4349 assert_eq!(session.events.llm_responses[0].preview, "review complete");
4350 }
4351
4352 #[test]
4353 fn mixed_batch_exit_codes_fail_if_any_command_failed() {
4354 assert_eq!(
4355 status_from_output("Script completed\nExit code: 0\nExit code: 7"),
4356 "fail"
4357 );
4358 assert_eq!(
4359 status_from_output(
4360 "Process exited with code 0\nProcess exited with code 0\n0 tests failed"
4361 ),
4362 "ok"
4363 );
4364 }
4365
4366 #[test]
4367 fn codex_preserves_commentary_and_final_response_phases() {
4368 let codex = concat!(
4369 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the code"}]}}"#,
4370 "\n",
4371 r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"I am checking it"}]}}"#,
4372 "\n",
4373 r#"{"timestamp":"2026-07-21T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"The code is correct"}]}}"#,
4374 );
4375 let session = parse_session_content(
4376 AGENT_CODEX,
4377 &PathBuf::from("/tmp/session.jsonl"),
4378 UNIX_EPOCH,
4379 codex,
4380 )
4381 .expect("session");
4382
4383 assert_eq!(session.events.llm_responses.len(), 2);
4384 assert_eq!(session.events.llm_responses[0].response_phase, "commentary");
4385 assert_eq!(
4386 session.events.llm_responses[1].response_phase,
4387 "final_answer"
4388 );
4389 }
4390
4391 #[test]
4392 fn semantic_task_label_prefers_explicit_goal_payload() {
4393 let raw = "prefix <objective>write a paper and evaluate it</objective> suffix";
4394 assert_eq!(semantic_task_label(raw), "write a paper and evaluate it");
4395 }
4396
4397 #[test]
4398 fn codex_fork_excludes_copied_parent_history_before_ownership_boundary() {
4399 let codex = concat!(
4400 r#"{"timestamp":"1970-01-01T00:00:01Z","type":"session_meta","payload":{"id":"child","session_id":"parent","parent_thread_id":"parent","timestamp":"1970-01-01T00:00:01Z","cwd":"/repo"}}"#,
4401 "\n",
4402 r#"{"type":"event_msg","payload":{"type":"user_message","message":"copied parent task"}}"#,
4403 "\n",
4404 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"copied","arguments":"{\"cmd\":\"false\"}"}}"#,
4405 "\n",
4406 r#"{"type":"event_msg","payload":{"type":"task_started","started_at":2.0}}"#,
4407 "\n",
4408 r#"{"type":"event_msg","payload":{"type":"user_message","message":"review child result"}}"#,
4409 "\n",
4410 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"owned","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4411 );
4412
4413 let session = parse_session_content(
4414 AGENT_CODEX,
4415 &PathBuf::from("/tmp/child.jsonl"),
4416 UNIX_EPOCH,
4417 codex,
4418 )
4419 .expect("child session");
4420
4421 assert_eq!(session.session_id, "child");
4422 assert_eq!(session.conversation_id.as_deref(), Some("parent"));
4423 assert_eq!(session.events.prompts.len(), 1);
4424 assert_eq!(session.events.prompts[0].preview, "review child result");
4425 assert_eq!(session.events.tools.len(), 1);
4426 assert_eq!(session.events.tools[0].call_id.as_deref(), Some("owned"));
4427 }
4428
4429 #[test]
4430 fn file_actions_ignore_patch_and_heredoc_bodies() {
4431 let patch = tool_event_from_input(
4432 Some("/repo"),
4433 Some(1),
4434 0,
4435 "exec",
4436 &json!({"text": r#"const patch = "*** Begin Patch\n*** Update File: src/lib.rs\n+#!/bin/sh\n+docs/not-a-file.md\n*** End Patch"; tools.apply_patch(patch)"#}),
4437 None,
4438 Vec::new(),
4439 );
4440 assert_eq!(
4441 patch.paths,
4442 vec![ToolPath {
4443 path: "src/lib.rs".into(),
4444 access: "write".into(),
4445 previous_path: None,
4446 }]
4447 );
4448
4449 let heredoc = tool_event_from_input(
4450 Some("/repo"),
4451 Some(1),
4452 0,
4453 "exec_command",
4454 &json!({"cmd": "cat <<'EOF'\n#!/bin/sh\nsrc/not-a-file.rs\nEOF\ncat src/real.rs"}),
4455 None,
4456 Vec::new(),
4457 );
4458 assert_eq!(heredoc.paths.len(), 1);
4459 assert_eq!(heredoc.paths[0].path, "src/real.rs");
4460 }
4461
4462 #[test]
4463 fn shell_path_operands_are_not_limited_to_known_extensions() {
4464 let paths_of = |command: &str| {
4465 shell_file_actions(command, &json!({"cwd": "/repo"}), 0)
4466 .into_iter()
4467 .map(|(path, access, _)| (path, access))
4468 .collect::<Vec<_>>()
4469 };
4470
4471 for (command, expected, access) in [
4475 ("rm build.sh", "/repo/build.sh", "delete"),
4476 ("rm main.go", "/repo/main.go", "delete"),
4477 ("rm Dockerfile", "/repo/Dockerfile", "delete"),
4478 ("mv notes.txt archive.txt", "/repo/archive.txt", "rename"),
4479 (
4480 "mv conf.yaml conf.bak.yaml",
4481 "/repo/conf.bak.yaml",
4482 "rename",
4483 ),
4484 ("touch schema.sql", "/repo/schema.sql", "create"),
4485 ] {
4486 assert!(
4487 paths_of(command)
4488 .iter()
4489 .any(|(path, kind)| path == expected && kind == access),
4490 "{command} should record {expected} as {access}, got {:?}",
4491 paths_of(command)
4492 );
4493 }
4494
4495 for command in [
4498 "rm origin/main",
4499 "rm HEAD",
4500 "rm *.log",
4501 "rm https://example.com/x",
4502 "rm s/foo/bar/g",
4503 "rm $TARGET",
4504 "rm -rf",
4505 ] {
4506 assert!(
4507 paths_of(command).is_empty(),
4508 "{command} should record nothing, got {:?}",
4509 paths_of(command)
4510 );
4511 }
4512
4513 let redirected = paths_of("cat notes.txt 2>&1");
4516 assert!(
4517 redirected.iter().any(|(path, _)| path == "/repo/notes.txt"),
4518 "the real file should still be recorded, got {redirected:?}"
4519 );
4520 assert!(
4521 !redirected.iter().any(|(path, _)| path.ends_with("/2")),
4522 "a file descriptor is not a file, got {redirected:?}"
4523 );
4524
4525 for command in ["rm 2", "cat 1"] {
4526 assert!(
4527 paths_of(command).is_empty(),
4528 "{command} should record nothing, got {:?}",
4529 paths_of(command)
4530 );
4531 }
4532 }
4533
4534 #[test]
4535 fn scanned_command_tokens_still_need_evidence_of_being_a_path() {
4536 let event = tool_event_from_input(
4540 Some("/repo"),
4541 Some(1),
4542 0,
4543 "exec_command",
4544 &json!({"cmd": "curl example.com && echo 1.2.3"}),
4545 None,
4546 Vec::new(),
4547 );
4548 assert!(
4549 event.path_groups.is_empty(),
4550 "hostname and version should not become path groups, got {:?}",
4551 event.path_groups
4552 );
4553 }
4554
4555 #[test]
4556 fn file_actions_are_conservative_for_unknown_and_write_tools() {
4557 let unknown = tool_event_from_input(
4558 Some("/repo"),
4559 Some(1),
4560 0,
4561 "mcp_resource",
4562 &json!({"path": "src/not-a-file.rs"}),
4563 None,
4564 Vec::new(),
4565 );
4566 assert!(unknown.paths.is_empty());
4567
4568 let write = tool_event_from_input(
4569 Some("/repo"),
4570 Some(1),
4571 0,
4572 "Write",
4573 &json!({"file_path": "src/existing.rs", "content": "changed"}),
4574 None,
4575 Vec::new(),
4576 );
4577 assert_eq!(write.paths[0].access, "write");
4578 }
4579
4580 #[test]
4581 fn patch_move_keeps_the_immediately_preceding_source() {
4582 let event = tool_event_from_input(
4583 Some("/repo"),
4584 Some(1),
4585 0,
4586 "apply_patch",
4587 &json!({"patch": "*** Begin Patch\n*** Update File: src/a.rs\n*** Move to: src/b.rs\n*** Update File: src/c.rs\n*** End Patch"}),
4588 None,
4589 Vec::new(),
4590 );
4591 assert!(event.paths.contains(&ToolPath {
4592 path: "src/b.rs".into(),
4593 access: "rename".into(),
4594 previous_path: Some("src/a.rs".into()),
4595 }));
4596 assert!(event.paths.contains(&ToolPath {
4597 path: "src/c.rs".into(),
4598 access: "write".into(),
4599 previous_path: None,
4600 }));
4601
4602 let event = tool_event_from_input(
4603 Some("/repo"),
4604 Some(1),
4605 0,
4606 "apply_patch",
4607 &json!({"patch": "*** Begin Patch\n*** Update File: a.rs\n*** Move to: x.rs\n*** Update File: b.rs\n*** Move to: y.rs\n*** End Patch"}),
4608 None,
4609 Vec::new(),
4610 );
4611 assert_eq!(
4612 event
4613 .paths
4614 .iter()
4615 .map(|row| (row.path.as_str(), row.previous_path.as_deref()))
4616 .collect::<Vec<_>>(),
4617 vec![("x.rs", Some("a.rs")), ("y.rs", Some("b.rs"))]
4618 );
4619 }
4620
4621 #[test]
4622 fn tool_outputs_mark_failed_file_actions() {
4623 let content = concat!(
4624 r#"{"type":"turn_context","payload":{"cwd":"/repo"}}"#,
4625 "\n",
4626 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"rm src/lib.rs\"}"}}"#,
4627 "\n",
4628 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 1"}}"#,
4629 );
4630 let session = parse_session_content(
4631 AGENT_CODEX,
4632 Path::new("/tmp/session.jsonl"),
4633 UNIX_EPOCH,
4634 content,
4635 )
4636 .expect("session");
4637 assert_eq!(session.events.tools[0].status, "fail");
4638 assert_eq!(session.events.tools[0].paths[0].access, "delete");
4639
4640 let claude = concat!(
4641 r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t0","name":"Read","input":{"file_path":"src/main.rs"}},{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"src/lib.rs"}}]}}"#,
4642 "\n",
4643 r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t0","is_error":false,"content":"ok"},{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"failed"}]}}"#,
4644 );
4645 let gemini = r#"{"messages":[{"type":"gemini","timestamp":"2026-01-01T00:00:00Z","toolCalls":[{"id":"t1","name":"write_file","args":{"file_path":"src/lib.rs"},"status":"error"}]}]}"#;
4646 for (agent, content, expected) in [
4647 (AGENT_CLAUDE, claude, &["ok", "fail"][..]),
4648 (AGENT_GEMINI, gemini, &["fail"][..]),
4649 ] {
4650 let session =
4651 parse_session_content(agent, Path::new("/tmp/session.jsonl"), UNIX_EPOCH, content)
4652 .unwrap();
4653 let statuses = session
4654 .events
4655 .tools
4656 .iter()
4657 .map(|row| row.status.as_str())
4658 .collect::<Vec<_>>();
4659 assert_eq!(statuses, expected);
4660 }
4661 }
4662
4663 #[test]
4664 fn codex_exec_prompt_handles_latest_cli_options() {
4665 let command = concat!(
4666 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
4667 "-c model_provider=\"agentsight-mock\" ",
4668 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
4669 "--sandbox read-only --model gpt-agentsight-mock ",
4670 "agentsight mock prompt collect this exact text"
4671 );
4672
4673 assert_eq!(
4674 codex_exec_prompt(command).as_deref(),
4675 Some("agentsight mock prompt collect this exact text")
4676 );
4677 }
4678
4679 #[test]
4680 fn codex_cumulative_usage_separates_cached_input() {
4681 let content = concat!(
4682 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}"#,
4683 "\n",
4684 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":19184,"cached_input_tokens":9984,"output_tokens":11,"total_tokens":19195}}}}"#,
4685 );
4686
4687 let session = parse_session_content(
4688 AGENT_CODEX,
4689 &PathBuf::from("/tmp/session.jsonl"),
4690 UNIX_EPOCH,
4691 content,
4692 )
4693 .expect("session");
4694
4695 assert_eq!(session.usage.input_tokens, 9_200);
4696 assert_eq!(session.usage.cache_read_tokens, 9_984);
4697 assert_eq!(session.usage.output_tokens, 11);
4698 assert_eq!(session.usage.total_tokens, 19_195);
4699 }
4700
4701 #[test]
4702 fn codex_exec_wrapper_projects_nested_shell_actions() {
4703 let event = tool_event_from_input(
4704 Some("/repo"),
4705 Some(1),
4706 0,
4707 "exec",
4708 &json!({"text": r#"const r = await tools.exec_command({"cmd":"cat src/lib.rs && sed -i 's/a/b/' src/main.rs","workdir":"/repo"});"#}),
4709 None,
4710 Vec::new(),
4711 );
4712 assert_eq!(
4713 event
4714 .paths
4715 .iter()
4716 .map(|path| (path.path.as_str(), path.access.as_str()))
4717 .collect::<Vec<_>>(),
4718 vec![("/repo/src/lib.rs", "read"), ("/repo/src/main.rs", "write")]
4719 );
4720 }
4721
4722 #[test]
4723 fn claude_uuid_only_fragments_share_one_completion_identity() {
4724 let claude = [
4725 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4726 r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"text","text":"I will use a skill."}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4727 r#"{"type":"system","subtype":"internal-marker"}"#,
4728 r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"paper-writing-style","args":""}}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4729 r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"text","text":"later fragment"}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4730 ]
4731 .join("\n");
4732
4733 let session = parse_session_content(
4734 AGENT_CLAUDE,
4735 &PathBuf::from("/tmp/session.jsonl"),
4736 UNIX_EPOCH,
4737 &claude,
4738 )
4739 .expect("session");
4740
4741 assert_eq!(session.events.llm_responses.len(), 1);
4742 assert_eq!(session.events.llm_responses[0].source_id, "completion-1");
4743 assert_eq!(session.events.llm_responses[0].skill, "");
4744 assert_eq!(
4745 session.events.llm_responses[0]
4746 .token_components()
4747 .into_iter()
4748 .map(|(_, value)| value)
4749 .sum::<u64>(),
4750 113
4751 );
4752 assert_eq!(session.events.tools[0].skill, "paper-writing-style");
4753 assert_eq!(session.events.tools[0].invoked_skill, "paper-writing-style");
4754 }
4755
4756 #[test]
4757 fn claude_skill_scope_ignores_metadata_and_deduplicates_split_completion() {
4758 let claude = [
4759 r#"{"type":"system","skill_listing":["availability only"]}"#,
4760 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4761 r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"text","text":"I will apply the citation skill."}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4762 r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"check-paper-citations","args":""}}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4763 r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"text","text":"same completion after emitting Skill"}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4764 r#"{"type":"user","promptId":"p1","isMeta":true,"sourceToolUseID":"s1","message":{"content":[{"type":"text","text":"skill payload"}]}}"#,
4765 r#"{"type":"last-prompt","lastPrompt":"review the paper"}"#,
4766 r#"{"type":"user","message":{"content":"<local-command-stdout>metadata</local-command-stdout>"}}"#,
4767 r#"{"type":"user","promptId":"attachment-only","attachments":[{"file_name":"paper.pdf"}],"message":{"content":"attached context"}}"#,
4768 r#"{"type":"assistant","requestId":"req-2","message":{"id":"msg-2","model":"claude-opus","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"cmd":"rg citation paper.tex"}}],"usage":{"input_tokens":2,"cache_read_input_tokens":200,"output_tokens":20}}}"#,
4769 r#"{"type":"user","promptId":"p1","sourceToolAssistantUUID":"assistant-2","message":{"content":[{"type":"tool_result","tool_use_id":"b1","content":"ok"}]}}"#,
4770 r#"{"type":"assistant","requestId":"req-3","message":{"id":"msg-3","model":"claude-opus","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"paper.tex"}}],"usage":{"input_tokens":3,"cache_read_input_tokens":300,"output_tokens":30}}}"#,
4771 r#"{"type":"user","promptId":"p2","message":{"content":"now summarize"}}"#,
4772 r#"{"type":"assistant","requestId":"req-4","message":{"id":"msg-4","model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":4,"cache_read_input_tokens":400,"output_tokens":40}}}"#,
4773 ]
4774 .join("\n");
4775
4776 let session = parse_session_content(
4777 AGENT_CLAUDE,
4778 &PathBuf::from("/tmp/session.jsonl"),
4779 UNIX_EPOCH,
4780 &claude,
4781 )
4782 .expect("session");
4783
4784 assert_eq!(session.events.prompts.len(), 2);
4785 assert_eq!(session.events.llm_responses.len(), 4);
4786 assert_eq!(session.events.llm_responses[0].source_id, "msg-1");
4787 assert_eq!(
4788 session.events.llm_responses[0]
4789 .token_components()
4790 .into_iter()
4791 .map(|(_, value)| value)
4792 .sum::<u64>(),
4793 113
4794 );
4795 assert_eq!(
4796 session
4797 .events
4798 .tools
4799 .iter()
4800 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4801 .collect::<Vec<_>>(),
4802 [
4803 ("Skill", "check-paper-citations"),
4804 ("Bash", "check-paper-citations"),
4805 ("Read", "check-paper-citations"),
4806 ]
4807 );
4808 assert_eq!(
4809 session
4810 .events
4811 .llm_responses
4812 .iter()
4813 .map(|response| response.skill.as_str())
4814 .collect::<Vec<_>>(),
4815 ["", "check-paper-citations", "check-paper-citations", ""]
4816 );
4817 }
4818}