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 !is_absolute_path_text(trimmed) || !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 = normalize_path_text(&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 && normalize_path_text(&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 = normalize_path_text(&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::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 && is_absolute_path_text(dir)
1461 {
1462 return Some(normalize_path_text(dir));
1463 }
1464 for key in ["path", "paths"] {
1465 match input.get(key) {
1466 Some(Value::String(value)) => absolute.push(value.clone()),
1467 Some(Value::Array(values)) => absolute
1468 .extend(values.iter().filter_map(Value::as_str).map(str::to_string)),
1469 _ => {}
1470 }
1471 }
1472 }
1473 }
1474 }
1475 common_parent_dir(&absolute)
1476}
1477
1478fn common_parent_dir(paths: &[String]) -> Option<String> {
1479 let mut dirs = paths
1480 .iter()
1481 .filter(|path| is_absolute_path_text(path))
1482 .map(|path| normalize_path_text(path))
1483 .map(|path| {
1484 let (root, remainder) = path_root(&path);
1485 let mut parts = remainder
1486 .split('/')
1487 .filter(|part| !part.is_empty())
1488 .map(str::to_string)
1489 .collect::<Vec<_>>();
1490 parts.pop();
1491 (root.to_string(), parts)
1492 });
1493 let (root, mut shared) = dirs.next()?;
1494 for (candidate_root, candidate) in dirs {
1495 if candidate_root != root {
1496 return None;
1497 }
1498 let keep = shared
1499 .iter()
1500 .zip(candidate.iter())
1501 .take_while(|(left, right)| left == right)
1502 .count();
1503 shared.truncate(keep);
1504 }
1505 if root == "//" && shared.len() < 2 {
1506 return None;
1507 }
1508 if shared.is_empty() {
1509 return (root != "/").then_some(root);
1510 }
1511 Some(format!("{root}{}", shared.join("/")))
1512}
1513
1514fn path_root(path: &str) -> (&str, &str) {
1515 if let Some(remainder) = path.strip_prefix("//") {
1516 ("//", remainder)
1517 } else if path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/') {
1518 (&path[..3], &path[3..])
1519 } else if let Some(remainder) = path.strip_prefix('/') {
1520 ("/", remainder)
1521 } else {
1522 ("", path)
1523 }
1524}
1525
1526fn cursor_delegating_prompt_index(
1527 child_content: &str,
1528 delegations: &[(usize, String)],
1529) -> Option<usize> {
1530 if delegations.is_empty() {
1531 return None;
1532 }
1533 let opening = cursor_first_user_text(child_content)?;
1534 delegations
1535 .iter()
1536 .find(|(_, prompt)| opening.contains(prompt.as_str()))
1537 .map(|(index, _)| *index)
1538}
1539
1540fn cursor_wrapper_ts_ms(text: &str) -> Option<i64> {
1541 const OPEN: &str = "<timestamp>";
1542 const CLOSE: &str = "</timestamp>";
1543 let start = text.find(OPEN)? + OPEN.len();
1544 let rest = &text[start..];
1545 let raw = rest[..rest.find(CLOSE)?].trim();
1546
1547 let (stamp, offset_hours) = match raw.rfind("(UTC") {
1549 Some(index) => {
1550 let hours = raw[index + 4..]
1551 .trim_end_matches(')')
1552 .trim()
1553 .parse::<i64>()
1554 .unwrap_or(0);
1555 (raw[..index].trim(), hours)
1556 }
1557 None => (raw, 0),
1558 };
1559 let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%A, %b %d, %Y, %I:%M %p").ok()?;
1560 Some(naive.and_utc().timestamp_millis() - offset_hours * 3_600_000)
1561}
1562
1563fn cursor_user_query(text: &str) -> String {
1564 const OPEN: &str = "<user_query>";
1565 const CLOSE: &str = "</user_query>";
1566 let Some(start) = text.find(OPEN) else {
1567 return text.trim().to_string();
1568 };
1569 let rest = &text[start + OPEN.len()..];
1570 let inner = match rest.find(CLOSE) {
1571 Some(end) => &rest[..end],
1572 None => rest,
1574 };
1575 inner.trim().to_string()
1576}
1577
1578fn cursor_first_user_text(content: &str) -> Option<String> {
1579 content.lines().find_map(|line| {
1580 let record = serde_json::from_str::<Value>(line.trim()).ok()?;
1581 (record.get("role").and_then(Value::as_str) == Some("user"))
1582 .then(|| cursor_text_of(&record))
1583 .filter(|text| !text.is_empty())
1584 })
1585}
1586
1587fn cursor_tool_uses(record: &Value) -> Vec<&Value> {
1588 record
1589 .get("message")
1590 .and_then(|message| message.get("content"))
1591 .and_then(Value::as_array)
1592 .map(|parts| {
1593 parts
1594 .iter()
1595 .filter(|part| part.get("type").and_then(Value::as_str) == Some("tool_use"))
1596 .collect()
1597 })
1598 .unwrap_or_default()
1599}
1600
1601fn cursor_push_tool_event(
1602 part: &Value,
1603 acc: &mut SessionAccumulator,
1604 events: &mut SessionEvents,
1605 prompt_index: usize,
1606 ts_ms: Option<i64>,
1607) {
1608 let Some(name) = part
1609 .get("name")
1610 .and_then(Value::as_str)
1611 .filter(|n| !n.is_empty())
1612 else {
1613 return;
1614 };
1615 let input = part.get("input").cloned().unwrap_or(Value::Null);
1616
1617 acc.add_tool(name);
1618 let event = tool_event_from_input(
1619 acc.cwd.as_deref(),
1620 ts_ms,
1622 prompt_index,
1623 name,
1624 &input,
1625 None,
1626 Vec::new(),
1627 );
1628 for path in &event.paths {
1629 acc.add_file(&path.path);
1630 }
1631 events.tools.push(event);
1632}
1633
1634fn cursor_text_of(record: &Value) -> String {
1635 let Some(parts) = record
1636 .get("message")
1637 .and_then(|message| message.get("content"))
1638 .and_then(Value::as_array)
1639 else {
1640 return String::new();
1641 };
1642 parts
1643 .iter()
1644 .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
1645 .filter_map(|part| part.get("text").and_then(Value::as_str))
1646 .collect::<Vec<_>>()
1647 .join("\n")
1648 .trim()
1649 .to_string()
1650}
1651
1652struct SessionAccumulator {
1653 agent_type: String,
1654 session_id: String,
1655 conversation_id: Option<String>,
1656 path: PathBuf,
1657 updated: SystemTime,
1658 start_timestamp_ms: Option<u64>,
1659 end_timestamp_ms: Option<u64>,
1660 model: Option<String>,
1661 model_usage: BTreeMap<String, TokenUsage>,
1662 tools: BTreeMap<String, usize>,
1663 files: BTreeMap<String, usize>,
1664 prompt_preview: Option<String>,
1665 duration_ms: u64,
1666 cwd: Option<String>,
1667 last_message_at: Option<String>,
1668}
1669
1670impl SessionAccumulator {
1671 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
1672 let normalized = normalize_session_log_path(path);
1673 let session_id = path
1674 .file_stem()
1675 .and_then(|stem| stem.to_str())
1676 .unwrap_or("session")
1677 .to_string();
1678 Self {
1679 agent_type: agent.to_string(),
1680 session_id,
1681 conversation_id: None,
1682 path: normalized.clone(),
1683 updated,
1684 start_timestamp_ms: None,
1685 end_timestamp_ms: Some(system_time_ms(updated)),
1686 model: None,
1687 model_usage: BTreeMap::new(),
1688 tools: BTreeMap::new(),
1689 files: BTreeMap::new(),
1690 prompt_preview: None,
1691 duration_ms: 0,
1692 cwd: None,
1693 last_message_at: None,
1694 }
1695 }
1696
1697 fn add_usage(
1698 &mut self,
1699 model: &str,
1700 input: i64,
1701 output: i64,
1702 cache_creation: i64,
1703 cache_read: i64,
1704 total: i64,
1705 ) {
1706 add_usage(
1707 &mut self.model_usage,
1708 model,
1709 input,
1710 output,
1711 cache_creation,
1712 cache_read,
1713 total,
1714 );
1715 }
1716
1717 fn set_usage(
1718 &mut self,
1719 model: &str,
1720 input: i64,
1721 output: i64,
1722 cache_creation: i64,
1723 cache_read: i64,
1724 total: i64,
1725 ) {
1726 let mut usage = TokenUsage::default();
1727 usage.add(input, output, cache_creation, cache_read, total);
1728 self.model_usage.insert(model.to_string(), usage);
1729 }
1730
1731 fn add_tool(&mut self, name: &str) {
1732 *self.tools.entry(name.to_string()).or_default() += 1;
1733 }
1734
1735 fn add_file(&mut self, path: &str) {
1736 *self.files.entry(path.to_string()).or_default() += 1;
1737 }
1738
1739 fn finish(self) -> Option<AgentSession> {
1740 let token_usage =
1741 self.model_usage
1742 .values()
1743 .fold(TokenUsage::default(), |mut total, usage| {
1744 total.input_tokens += usage.input_tokens;
1745 total.output_tokens += usage.output_tokens;
1746 total.cache_creation_tokens += usage.cache_creation_tokens;
1747 total.cache_read_tokens += usage.cache_read_tokens;
1748 total.total_tokens += usage.total_tokens;
1749 total
1750 });
1751 if token_usage.total_tokens == 0
1752 && self.tools.is_empty()
1753 && self.prompt_preview.is_none()
1754 && self.model.is_none()
1755 {
1756 return None;
1757 }
1758 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
1759 Some(AgentSession {
1760 agent_type: self.agent_type,
1761 session_id: self.session_id,
1762 conversation_id: self.conversation_id,
1763 display_id,
1764 path: self.path,
1765 updated: self.updated,
1766 start_timestamp_ms: self
1767 .start_timestamp_ms
1768 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
1769 end_timestamp_ms: self.end_timestamp_ms,
1770 model: self.model,
1771 usage: token_usage,
1772 model_usage: self.model_usage,
1773 tools: self.tools,
1774 files: self.files,
1775 prompt_preview: self.prompt_preview,
1776 duration_ms: self.duration_ms,
1777 cwd: self.cwd,
1778 last_message_at: self.last_message_at,
1779 events: SessionEvents::default(),
1780 })
1781 }
1782
1783 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
1784 self.finish().map(|mut session| {
1785 session.events = events;
1786 session
1787 })
1788 }
1789}
1790
1791fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
1796 let Ok(entries) = fs::read_dir(dir) else {
1797 return;
1798 };
1799 for entry in entries.flatten() {
1800 let path = entry.path();
1801 if path.is_dir() {
1802 walk_agent_files(agent, &path, f);
1803 } else if is_agent_file_for(agent, &path)
1804 && let Ok(meta) = path.metadata()
1805 {
1806 f(&path, &meta);
1807 }
1808 }
1809}
1810
1811fn is_agent_session_file(path: &Path) -> bool {
1812 agent_source_for_path(path).is_some()
1813}
1814
1815fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1816 match agent {
1817 AGENT_CLAUDE | AGENT_CODEX => {
1818 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1819 }
1820 AGENT_GEMINI => {
1821 path.extension().and_then(|ext| ext.to_str()) == Some("json")
1822 && path
1823 .file_name()
1824 .and_then(|name| name.to_str())
1825 .is_some_and(|name| name.starts_with("session-"))
1826 && normalize_path_text(&path.to_string_lossy()).contains("/chats/")
1827 }
1828 AGENT_CURSOR => is_cursor_parent_transcript(path),
1829 _ => false,
1830 }
1831}
1832
1833pub(crate) fn user_home_dir() -> Option<PathBuf> {
1834 std::env::var("SUDO_USER")
1835 .ok()
1836 .and_then(|user| {
1837 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1838 passwd
1839 .lines()
1840 .find(|line| line.starts_with(&format!("{user}:")))
1841 .and_then(|line| line.split(':').nth(5))
1842 .map(PathBuf::from)
1843 })
1844 })
1845 .or_else(|| {
1846 std::env::var_os("HOME")
1847 .map(PathBuf::from)
1848 .filter(|home| home.is_absolute())
1849 })
1850 .or_else(dirs::home_dir)
1851}
1852
1853fn add_usage(
1854 models: &mut BTreeMap<String, TokenUsage>,
1855 model: &str,
1856 input: i64,
1857 output: i64,
1858 cache_creation: i64,
1859 cache_read: i64,
1860 total: i64,
1861) {
1862 models.entry(model.to_string()).or_default().add(
1863 input,
1864 output,
1865 cache_creation,
1866 cache_read,
1867 total,
1868 );
1869}
1870
1871impl SessionEvents {
1872 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str, task_path: Vec<String>) -> usize {
1873 let hash = short_hash(text, 12);
1874 if let Some(existing) = self.prompts.iter().rposition(|prompt| {
1875 prompt.text_hash == hash
1876 && match (prompt.ts_ms, ts_ms) {
1877 (Some(left), Some(right)) => left.abs_diff(right) <= 1_000,
1878 (None, None) => self
1879 .prompts
1880 .last()
1881 .is_some_and(|last| last.index == prompt.index),
1882 _ => false,
1883 }
1884 }) {
1885 return existing;
1886 }
1887 let index = self.prompts.len();
1888 self.prompts.push(UserPrompt {
1889 index,
1890 ts_ms,
1891 text_hash: hash,
1892 preview: truncate_clean(text, 180),
1893 tag: String::new(),
1894 task_path,
1895 });
1896 index
1897 }
1898}
1899
1900fn tool_event_from_input(
1901 cwd: Option<&str>,
1902 ts_ms: Option<i64>,
1903 prompt_index: usize,
1904 name: &str,
1905 input: &Value,
1906 call_id: Option<String>,
1907 task_path: Vec<String>,
1908) -> ToolEvent {
1909 let command = command_from_tool_input(input);
1910 let category = tool_category(name, &command);
1911 let domains = extract_domains(&command);
1912 let command_name = if category == "shell" {
1913 basename_from_command(&command)
1914 } else if category == "network" && !domains.is_empty() {
1915 domains[0]
1916 .split(':')
1917 .next()
1918 .unwrap_or("network")
1919 .to_string()
1920 } else {
1921 one_word(name, "tool")
1922 };
1923 let effect = if name == "apply_patch" || command.contains("*** ") {
1924 "write".to_string()
1925 } else {
1926 command_effect(&command)
1927 };
1928 let cwd = cwd.unwrap_or("");
1929 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1930 let paths = extract_tool_paths(name, input, &command, &effect);
1931 let process_chain = if category == "shell" {
1932 command_process_chain(&command)
1933 } else {
1934 Vec::new()
1935 };
1936 ToolEvent {
1937 ts_ms,
1938 prompt_index,
1939 tool_name: name.to_string(),
1940 category,
1941 command,
1942 command_name,
1943 effect,
1944 process_chain,
1945 status: "observed".to_string(),
1946 path_groups,
1947 paths,
1948 domains,
1949 call_id,
1950 invoked_skill: String::new(),
1951 skill: String::new(),
1952 task_path,
1953 }
1954}
1955
1956fn extract_tool_paths(name: &str, input: &Value, command: &str, effect: &str) -> Vec<ToolPath> {
1957 let lower = name.to_ascii_lowercase();
1958 let is_shell = lower.contains("bash") || lower.contains("exec") || lower.contains("shell");
1959 let default_access = if lower.contains("read")
1960 || lower.contains("grep")
1961 || lower.contains("glob")
1962 || lower.contains("search")
1963 {
1964 "read"
1965 } else if lower.contains("write")
1966 || lower.contains("edit")
1967 || lower.contains("replace")
1968 || lower.contains("patch")
1969 {
1970 "write"
1971 } else if lower.contains("delete") {
1972 "delete"
1975 } else if is_shell {
1976 if effect == "read" { "read" } else { "write" }
1977 } else {
1978 return Vec::new();
1979 };
1980 let mut rows = BTreeMap::<String, (String, Option<String>)>::new();
1981 if !is_shell {
1982 collect_path_fields(input, default_access, &mut rows);
1983 }
1984
1985 let embedded_patch = embedded_json_string(command, "*** Begin Patch");
1986 let patch = input
1987 .get("patch")
1988 .or_else(|| input.get("input"))
1989 .or_else(|| input.get("text"))
1990 .and_then(Value::as_str)
1991 .filter(|value| value.contains("*** Begin Patch") && value.lines().count() > 1)
1992 .or(embedded_patch.as_deref())
1993 .or_else(|| {
1994 (command.contains("*** Begin Patch") && command.lines().count() > 1).then_some(command)
1995 });
1996 let mut has_patch = false;
1997 if let Some(patch) = patch {
1998 let mut pending_update = None;
1999 for line in patch.lines() {
2000 let marker = line.trim();
2001 for (prefix, access) in [
2002 ("*** Add File: ", "create"),
2003 ("*** Update File: ", "write"),
2004 ("*** Delete File: ", "delete"),
2005 ("*** Move to: ", "rename"),
2006 ] {
2007 if let Some(path) = marker.strip_prefix(prefix) {
2008 let path = clean_path_token(path);
2009 if !path.is_empty() {
2010 has_patch = true;
2011 if access == "write" {
2012 pending_update = Some(path.clone());
2013 } else if access == "rename"
2014 && let Some(source) = pending_update.take()
2015 {
2016 rows.remove(&source);
2017 rows.insert(path.clone(), ("rename".to_string(), Some(source)));
2018 continue;
2019 }
2020 rows.insert(path, (access.to_string(), None));
2021 }
2022 }
2023 }
2024 }
2025 }
2026
2027 if is_shell && !has_patch {
2028 for (path, access, previous_path) in shell_file_actions(command, input, 0) {
2029 rows.insert(path, (access, previous_path));
2030 }
2031 for nested in embedded_json_objects(command, "tools.exec_command(") {
2032 let nested_command = command_from_tool_input(&nested);
2033 for (path, access, previous_path) in shell_file_actions(&nested_command, &nested, 0) {
2034 rows.insert(path, (access, previous_path));
2035 }
2036 }
2037 }
2038 rows.into_iter()
2039 .map(|(path, (access, previous_path))| ToolPath {
2040 path,
2041 access,
2042 previous_path,
2043 })
2044 .collect()
2045}
2046
2047fn embedded_json_objects(text: &str, marker: &str) -> Vec<Value> {
2048 let mut rows = Vec::new();
2049 let mut offset = 0;
2050 while let Some(found) = text[offset..].find(marker) {
2051 let start = offset + found + marker.len();
2052 let Some(open) = text[start..].find('{').map(|value| start + value) else {
2053 break;
2054 };
2055 let mut depth = 0;
2056 let mut quote = false;
2057 let mut escaped = false;
2058 let mut end = None;
2059 for (index, ch) in text[open..].char_indices() {
2060 if escaped {
2061 escaped = false;
2062 } else if ch == '\\' && quote {
2063 escaped = true;
2064 } else if ch == '"' {
2065 quote = !quote;
2066 } else if !quote && ch == '{' {
2067 depth += 1;
2068 } else if !quote && ch == '}' {
2069 depth -= 1;
2070 if depth == 0 {
2071 end = Some(open + index + 1);
2072 break;
2073 }
2074 }
2075 }
2076 let Some(end) = end else { break };
2077 if let Ok(value) = serde_json::from_str(&text[open..end]) {
2078 rows.push(value);
2079 }
2080 offset = end;
2081 }
2082 rows
2083}
2084
2085fn embedded_json_string(text: &str, needle: &str) -> Option<String> {
2086 let needle = text.find(needle)?;
2087 let start = text[..needle].rfind('"')?;
2088 let mut escaped = false;
2089 for (offset, ch) in text[start + 1..].char_indices() {
2090 if escaped {
2091 escaped = false;
2092 } else if ch == '\\' {
2093 escaped = true;
2094 } else if ch == '"' {
2095 return serde_json::from_str(&text[start..start + offset + 2]).ok();
2096 }
2097 }
2098 None
2099}
2100
2101fn shell_file_actions(
2102 command: &str,
2103 input: &Value,
2104 depth: usize,
2105) -> Vec<(String, String, Option<String>)> {
2106 if depth > 2 {
2107 return Vec::new();
2108 }
2109 let mut cwd = ["workdir", "cwd", "working_directory"]
2112 .iter()
2113 .find_map(|key| input.get(*key).and_then(Value::as_str))
2114 .map(normalize_path_text);
2115 let mut rows = Vec::new();
2116 for parts in shell_segments(command) {
2117 let Some(command_index) = shell_command_index(&parts) else {
2118 continue;
2119 };
2120 let name = process_name_from_part(&parts[command_index]).unwrap_or_default();
2121 let operands = &parts[command_index + 1..];
2122 if name == "cd" {
2123 if let Some(path) = operands.iter().find(|value| !value.starts_with('-')) {
2124 cwd = Some(if is_absolute_path_text(path) {
2125 normalize_path_text(path)
2126 } else {
2127 join_path_text(cwd.as_deref().unwrap_or_default(), path)
2128 });
2129 }
2130 continue;
2131 }
2132 let mut actions = shell_segment_actions(&name, operands, input, depth);
2133 for (path, _, previous_path) in &mut actions {
2134 if !path.starts_with(['~', '$'])
2135 && !is_absolute_path_text(path)
2136 && let Some(base) = &cwd
2137 {
2138 *path = join_path_text(base, path);
2139 }
2140 *path = clean_path_token(path);
2141 if let Some(previous) = previous_path {
2142 if !previous.starts_with(['~', '$'])
2143 && !is_absolute_path_text(previous)
2144 && let Some(base) = &cwd
2145 {
2146 *previous = join_path_text(base, previous);
2147 }
2148 *previous = clean_path_token(previous);
2149 }
2150 }
2151 rows.extend(actions.into_iter().filter(|(path, _, _)| !path.is_empty()));
2152 }
2153 rows
2154}
2155
2156fn shell_segment_actions(
2157 name: &str,
2158 operands: &[String],
2159 input: &Value,
2160 depth: usize,
2161) -> Vec<(String, String, Option<String>)> {
2162 let mut rows = Vec::new();
2163 let mut values = Vec::new();
2164 let mut index = 0;
2165 while index < operands.len() {
2166 if is_redirection_token(&operands[index]) {
2167 if let Some(path) = operands.get(index + 1)
2168 && plausible_path_operand(path)
2169 {
2170 let access = if [">", ">>", "&>", "&>>"].contains(&operands[index].as_str()) {
2171 "write"
2172 } else if ["<", "<>"].contains(&operands[index].as_str()) {
2173 "read"
2174 } else {
2175 index += 2;
2176 continue;
2177 };
2178 rows.push((path.clone(), access.into(), None));
2179 }
2180 index += 2;
2181 continue;
2182 }
2183 values.push(operands[index].clone());
2184 index += 1;
2185 }
2186 let paths = |items: &[String]| {
2187 items
2188 .iter()
2189 .filter(|value| !value.starts_with('-') && plausible_path_operand(value))
2190 .cloned()
2191 .collect::<Vec<_>>()
2192 };
2193 match name {
2194 "bash" | "sh" | "zsh" => {
2195 for index in 0..values.len().saturating_sub(1) {
2196 if ["-c", "-lc", "-cl"].contains(&values[index].as_str()) {
2197 rows.extend(shell_file_actions(&values[index + 1], input, depth + 1));
2198 break;
2199 }
2200 }
2201 }
2202 "cp" => {
2203 let paths = paths(&values);
2204 if let Some((target, sources)) = paths.split_last() {
2205 for source in sources {
2206 rows.push((source.clone(), "read".into(), None));
2207 let destination = destination_path(target, source, sources.len() > 1);
2208 rows.push((destination, "create".into(), None));
2209 }
2210 }
2211 }
2212 "mv" => {
2213 let paths = paths(&values);
2214 if let Some((target, sources)) = paths.split_last() {
2215 for source in sources {
2216 rows.push((
2217 destination_path(target, source, sources.len() > 1),
2218 "rename".into(),
2219 Some(source.clone()),
2220 ));
2221 }
2222 }
2223 }
2224 "rm" => rows.extend(
2225 paths(&values)
2226 .into_iter()
2227 .map(|path| (path, "delete".into(), None)),
2228 ),
2229 "touch" | "install" => rows.extend(
2230 paths(&values)
2231 .into_iter()
2232 .map(|path| (path, "create".into(), None)),
2233 ),
2234 "tee" => rows.extend(
2235 paths(&values)
2236 .into_iter()
2237 .map(|path| (path, "write".into(), None)),
2238 ),
2239 "cat" | "head" | "tail" | "nl" | "wc" | "source" | "." => rows.extend(
2240 paths(&values)
2241 .into_iter()
2242 .map(|path| (path, "read".into(), None)),
2243 ),
2244 "sed" => {
2245 let in_place = values.iter().any(|value| {
2246 value == "-i" || value.starts_with("-i") || value.starts_with("--in-place")
2247 });
2248 let mut script_seen = false;
2249 for value in &values {
2250 if value.starts_with('-') {
2251 continue;
2252 }
2253 if !script_seen {
2254 script_seen = true;
2255 } else if plausible_path_operand(value) {
2256 rows.push((
2257 value.clone(),
2258 if in_place { "write" } else { "read" }.into(),
2259 None,
2260 ));
2261 }
2262 }
2263 }
2264 "find" => rows.extend(
2265 values
2266 .iter()
2267 .take_while(|value| !value.starts_with('-') && value.as_str() != "!")
2268 .filter(|value| plausible_path_operand(value))
2269 .cloned()
2270 .map(|path| (path, "read".into(), None)),
2271 ),
2272 "rg" | "grep" | "jq" => {
2273 let mut expression_seen = values.iter().any(|value| value == "--files");
2274 for value in &values {
2275 if value.starts_with('-') {
2276 continue;
2277 }
2278 if !expression_seen {
2279 expression_seen = true;
2280 } else if plausible_path_operand(value) {
2281 rows.push((value.clone(), "read".into(), None));
2282 }
2283 }
2284 }
2285 _ => {}
2286 }
2287 rows
2288}
2289
2290fn destination_path(target: &str, source: &str, multiple: bool) -> String {
2291 if multiple || target.ends_with(['/', '\\']) {
2292 join_path_text(target, path_basename(source))
2293 } else {
2294 normalize_path_text(target)
2295 }
2296}
2297
2298fn normalize_path_text(path: &str) -> String {
2299 path.replace('\\', "/")
2300}
2301
2302fn is_absolute_path_text(path: &str) -> bool {
2303 let path = normalize_path_text(path);
2304 path.starts_with('/')
2305 || path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')
2306}
2307
2308fn join_path_text(base: &str, child: &str) -> String {
2309 let base = normalize_path_text(base);
2310 let child = normalize_path_text(child);
2311 if base.is_empty() || is_absolute_path_text(&child) {
2312 child
2313 } else {
2314 format!(
2315 "{}/{}",
2316 base.trim_end_matches('/'),
2317 child.trim_start_matches('/')
2318 )
2319 }
2320}
2321
2322fn path_basename(path: &str) -> &str {
2323 path.rsplit(['/', '\\']).next().unwrap_or(path)
2324}
2325
2326fn collect_path_fields(
2327 value: &Value,
2328 access: &str,
2329 out: &mut BTreeMap<String, (String, Option<String>)>,
2330) {
2331 match value {
2332 Value::Object(object) => {
2333 for (key, value) in object {
2334 let key = key.to_ascii_lowercase();
2335 if matches!(
2336 key.as_str(),
2337 "path" | "file_path" | "filepath" | "notebook_path" | "old_path" | "new_path"
2338 ) && let Some(path) = value.as_str()
2339 {
2340 let path = clean_path_token(path);
2341 if !path.is_empty() {
2342 out.insert(path, (access.to_string(), None));
2343 }
2344 } else if matches!(key.as_str(), "paths" | "file_paths" | "filepaths")
2345 && let Some(items) = value.as_array()
2346 {
2347 for item in items.iter().filter_map(Value::as_str) {
2350 let path = clean_path_token(item);
2351 if !path.is_empty() {
2352 out.insert(path, (access.to_string(), None));
2353 }
2354 }
2355 } else if value.is_object() || value.is_array() {
2356 collect_path_fields(value, access, out);
2357 }
2358 }
2359 }
2360 Value::Array(values) => {
2361 for value in values {
2362 collect_path_fields(value, access, out);
2363 }
2364 }
2365 _ => {}
2366 }
2367}
2368
2369fn clean_path_token(value: &str) -> String {
2370 value
2371 .trim()
2372 .trim_matches(['"', '\'', '`', ',', ':'])
2373 .trim_start_matches("file://")
2374 .to_string()
2375}
2376
2377fn strip_heredoc_bodies(command: &str) -> String {
2378 fn delimiters(line: &str) -> Vec<String> {
2379 let bytes = line.as_bytes();
2380 let mut output = Vec::new();
2381 let mut index = 0;
2382 while index + 1 < bytes.len() {
2383 if bytes[index] != b'<' || bytes[index + 1] != b'<' {
2384 index += 1;
2385 continue;
2386 }
2387 index += 2;
2388 if bytes.get(index) == Some(&b'<') {
2389 index += 1;
2390 continue;
2391 }
2392 if bytes.get(index) == Some(&b'-') {
2393 index += 1;
2394 }
2395 while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
2396 index += 1;
2397 }
2398 let quote = bytes
2399 .get(index)
2400 .copied()
2401 .filter(|value| *value == b'\'' || *value == b'"');
2402 if quote.is_some() {
2403 index += 1;
2404 }
2405 let start = index;
2406 while let Some(value) = bytes.get(index) {
2407 if quote.is_some_and(|quote| *value == quote)
2408 || (quote.is_none()
2409 && (value.is_ascii_whitespace() || b";|&><".contains(value)))
2410 {
2411 break;
2412 }
2413 index += 1;
2414 }
2415 if start < index {
2416 output.push(line[start..index].to_string());
2417 }
2418 }
2419 output
2420 }
2421
2422 let mut pending = VecDeque::<String>::new();
2423 let mut output = Vec::new();
2424 for line in command.lines() {
2425 if let Some(delimiter) = pending.front() {
2426 if line.trim_start_matches('\t').trim_end() == delimiter {
2427 pending.pop_front();
2428 }
2429 continue;
2430 }
2431 output.push(line);
2432 pending.extend(delimiters(line));
2433 }
2434 output.join("\n")
2435}
2436
2437fn is_redirection_token(token: &str) -> bool {
2438 [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
2439}
2440
2441fn shell_command_index(parts: &[String]) -> Option<usize> {
2442 let mut index = 0;
2443 while index < parts.len() {
2444 let part = parts[index].as_str();
2445 if ["then", "do", "else"].contains(&part)
2446 || part.split_once('=').is_some_and(|(name, _)| {
2447 !name.is_empty()
2448 && name
2449 .chars()
2450 .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
2451 })
2452 {
2453 index += 1;
2454 continue;
2455 }
2456 if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
2457 index += 1;
2458 while index < parts.len() && parts[index].starts_with('-') {
2459 index += 1;
2460 }
2461 continue;
2462 }
2463 return Some(index);
2464 }
2465 None
2466}
2467
2468fn shell_segments(command: &str) -> Vec<Vec<String>> {
2469 fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
2470 if !current.is_empty() {
2471 tokens.push(std::mem::take(current));
2472 }
2473 }
2474 fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
2475 if !tokens.is_empty() {
2476 segments.push(std::mem::take(tokens));
2477 }
2478 }
2479
2480 let command = strip_heredoc_bodies(command);
2481 let mut segments = Vec::new();
2482 let mut tokens = Vec::new();
2483 let mut current = String::new();
2484 let mut quote = None;
2485 let mut escaped = false;
2486 let mut chars = command.chars().peekable();
2487 while let Some(ch) = chars.next() {
2488 if escaped {
2489 current.push(ch);
2490 escaped = false;
2491 } else if ch == '\\' {
2492 escaped = true;
2493 } else if quote == Some(ch) {
2494 quote = None;
2495 } else if quote.is_some() {
2496 current.push(ch);
2497 } else if ch == '\'' || ch == '"' {
2498 quote = Some(ch);
2499 } else if ch == '#' && current.is_empty() {
2500 for next in chars.by_ref() {
2501 if next == '\n' {
2502 flush_segment(&mut segments, &mut tokens);
2503 break;
2504 }
2505 }
2506 } else if ch.is_whitespace() {
2507 flush_word(&mut tokens, &mut current);
2508 if ch == '\n' {
2509 flush_segment(&mut segments, &mut tokens);
2510 }
2511 } else if ch == '&' && chars.peek() == Some(&'>') {
2512 flush_word(&mut tokens, &mut current);
2513 chars.next();
2514 let operator = if chars.peek() == Some(&'>') {
2515 chars.next();
2516 "&>>"
2517 } else {
2518 "&>"
2519 };
2520 tokens.push(operator.into());
2521 } else if matches!(ch, ';' | '|' | '(' | ')') || ch == '&' {
2522 flush_word(&mut tokens, &mut current);
2523 if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
2524 chars.next();
2525 }
2526 flush_segment(&mut segments, &mut tokens);
2527 } else if ch == '>' || ch == '<' {
2528 flush_word(&mut tokens, &mut current);
2529 let mut operator = ch.to_string();
2530 while chars.peek() == Some(&ch) && operator.len() < 3 {
2531 operator.push(chars.next().expect("peeked redirection"));
2532 }
2533 tokens.push(operator);
2534 } else {
2535 current.push(ch);
2536 }
2537 }
2538 flush_word(&mut tokens, &mut current);
2539 flush_segment(&mut segments, &mut tokens);
2540 segments
2541}
2542
2543fn codex_token_usage(value: &Value) -> TokenUsage {
2544 let input = json_i64(value, "input_tokens").max(0);
2545 let output = json_i64(value, "output_tokens").max(0);
2546 let cache = json_i64(value, "cached_input_tokens").max(0);
2547 let input = input.saturating_sub(cache);
2548 TokenUsage {
2549 input_tokens: input,
2550 output_tokens: output,
2551 cache_creation_tokens: 0,
2552 cache_read_tokens: cache,
2553 total_tokens: input + output + cache,
2554 }
2555}
2556
2557pub fn codex_total_token_usage(content: &str) -> Option<TokenUsage> {
2558 content.lines().rev().find_map(|line| {
2559 let obj: Value = serde_json::from_str(line).ok()?;
2560 let payload = obj.get("payload")?;
2561 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2562 return None;
2563 }
2564 payload
2565 .pointer("/info/total_token_usage")
2566 .map(codex_token_usage)
2567 })
2568}
2569
2570fn exact_claude_skill_invocation(name: &str, input: &Value) -> Option<String> {
2571 (name == "Skill")
2572 .then(|| input.get("skill").and_then(Value::as_str))
2573 .flatten()
2574 .map(str::trim)
2575 .filter(|skill| !skill.is_empty())
2576 .map(str::to_string)
2577}
2578
2579fn codex_custom_tool_input(outer_name: &str, raw: &str) -> (String, Value) {
2580 let nested_calls = codex_custom_tool_calls(raw);
2581 let nested_name = if raw.contains("Promise.all") || nested_calls.len() > 1 {
2582 "composite".to_string()
2583 } else {
2584 nested_calls
2585 .first()
2586 .cloned()
2587 .unwrap_or_else(|| outer_name.to_string())
2588 };
2589
2590 let commands = extract_js_string_fields(raw, &["command", "cmd"]);
2591 let paths = extract_js_string_fields(raw, &["file_path", "path"]);
2592 let workdirs = extract_js_string_fields(raw, &["workdir"]);
2593 let mut input = serde_json::Map::new();
2594 if !commands.is_empty() {
2595 input.insert("command".to_string(), Value::String(commands.join("\n")));
2596 } else if !raw.trim().is_empty() {
2597 input.insert("text".to_string(), Value::String(truncate_clean(raw, 600)));
2598 }
2599 if let Some(path) = paths.first() {
2600 input.insert("path".to_string(), Value::String(path.clone()));
2601 }
2602 if let Some(workdir) = workdirs.first() {
2603 input.insert("workdir".to_string(), Value::String(workdir.clone()));
2604 }
2605 for key in ["task_name", "target", "message"] {
2606 if let Some(value) = extract_js_string_fields(raw, &[key]).first() {
2607 input.insert(key.to_string(), Value::String(value.clone()));
2608 }
2609 }
2610 if nested_name == "update_plan" {
2611 let steps = extract_js_string_fields(raw, &["step"]);
2612 let statuses = extract_js_string_fields(raw, &["status"]);
2613 let plan = steps
2614 .into_iter()
2615 .enumerate()
2616 .map(|(index, step)| {
2617 serde_json::json!({
2618 "step": step,
2619 "status": statuses.get(index).map(String::as_str).unwrap_or("pending")
2620 })
2621 })
2622 .collect::<Vec<_>>();
2623 input.insert("plan".to_string(), Value::Array(plan));
2624 }
2625 (nested_name, Value::Object(input))
2626}
2627
2628fn codex_custom_tool_calls(raw: &str) -> Vec<String> {
2629 let mut calls = Vec::new();
2630 let mut offset = 0usize;
2631 while let Some(relative) = raw[offset..].find("tools.") {
2632 let start = offset + relative + "tools.".len();
2633 let tail = &raw[start..];
2634 let name = tail
2635 .chars()
2636 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
2637 .collect::<String>();
2638 let name_len = name.len();
2639 let after_name = tail[name.len()..].trim_start();
2640 if !name.is_empty() && after_name.starts_with('(') {
2641 calls.push(name);
2642 }
2643 offset = if name_len > 0 {
2644 start + name_len
2645 } else {
2646 raw[start..]
2650 .chars()
2651 .next()
2652 .map_or(raw.len(), |ch| start + ch.len_utf8())
2653 };
2654 }
2655 calls
2656}
2657
2658fn extract_js_string_fields(raw: &str, keys: &[&str]) -> Vec<String> {
2659 let mut values = Vec::new();
2660 for key in keys {
2661 let mut offset = 0usize;
2662 while let Some(relative) = raw[offset..].find(key) {
2663 let start = offset + relative;
2664 let before = raw[..start].chars().next_back();
2665 let after = raw[start + key.len()..].chars().next();
2666 if before.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2667 || after.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2668 {
2669 offset = start + key.len();
2670 continue;
2671 }
2672 let tail = &raw[start + key.len()..];
2673 let Some(colon) = tail.find(':').filter(|index| *index <= 4) else {
2674 offset = start + key.len();
2675 continue;
2676 };
2677 let value = tail[colon + 1..].trim_start();
2678 let Some(quote) = value
2679 .chars()
2680 .next()
2681 .filter(|ch| ['\'', '"', '`'].contains(ch))
2682 else {
2683 offset = start + key.len();
2684 continue;
2685 };
2686 if let Some((decoded, consumed)) = parse_js_string(&value[quote.len_utf8()..], quote) {
2687 if !decoded.is_empty() && !values.contains(&decoded) {
2688 values.push(decoded);
2689 }
2690 offset = start + key.len() + colon + 1 + consumed;
2691 } else {
2692 offset = start + key.len();
2693 }
2694 }
2695 }
2696 values
2697}
2698
2699fn parse_js_string(raw: &str, quote: char) -> Option<(String, usize)> {
2700 let mut decoded = String::new();
2701 let mut escaped = false;
2702 for (index, ch) in raw.char_indices() {
2703 if escaped {
2704 decoded.push(match ch {
2705 'n' => '\n',
2706 'r' => '\r',
2707 't' => '\t',
2708 other => other,
2709 });
2710 escaped = false;
2711 } else if ch == '\\' {
2712 escaped = true;
2713 } else if ch == quote {
2714 return Some((decoded, index + ch.len_utf8() + quote.len_utf8()));
2715 } else {
2716 decoded.push(ch);
2717 }
2718 }
2719 None
2720}
2721
2722fn command_from_tool_input(input: &Value) -> String {
2723 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
2724 if let Some(value) = input.get(key).and_then(Value::as_str)
2725 && !value.is_empty()
2726 {
2727 return if key == "pattern" {
2728 format!("search {value}")
2729 } else {
2730 value.to_string()
2731 };
2732 }
2733 }
2734 if input.is_null() {
2735 String::new()
2736 } else {
2737 truncate_clean(&input.to_string(), 300)
2738 }
2739}
2740
2741fn parse_tool_args(value: &Value) -> Value {
2742 if let Some(text) = value.as_str() {
2743 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
2744 } else {
2745 value.clone()
2746 }
2747}
2748
2749fn status_from_output(output: &str) -> &'static str {
2750 let lowered = output.to_ascii_lowercase();
2751 let exit_codes = explicit_exit_codes(&lowered);
2752 if exit_codes.iter().any(|code| *code != 0) {
2753 return "fail";
2754 }
2755 if !exit_codes.is_empty() {
2756 return "ok";
2757 }
2758 if lowered.contains("\"is_error\":false") || lowered.contains("\"success\":true") {
2759 return "ok";
2760 }
2761 if lowered.contains("\"is_error\":true") || lowered.contains("\"success\":false") {
2762 return "fail";
2763 }
2764 if lowered.lines().any(|line| line.trim() == "script failed") {
2765 return "fail";
2766 }
2767 if lowered
2768 .lines()
2769 .any(|line| line.trim() == "script completed")
2770 {
2771 return "ok";
2772 }
2773 "observed"
2774}
2775
2776fn explicit_exit_codes(output: &str) -> Vec<i32> {
2777 output
2778 .lines()
2779 .filter_map(|line| {
2780 let line = line.trim();
2781 let value = if let Some(rest) = line.strip_prefix("exit code:") {
2782 rest
2783 } else if let Some((_, rest)) = line.split_once("process exited with code") {
2784 rest.strip_prefix(':').unwrap_or(rest)
2785 } else {
2786 return None;
2787 };
2788 let digits = value
2789 .trim_start()
2790 .chars()
2791 .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
2792 .collect::<String>();
2793 digits.parse().ok()
2794 })
2795 .collect()
2796}
2797
2798pub fn tool_category(name: &str, command: &str) -> String {
2799 let n = name.to_ascii_lowercase();
2800 if n.ends_with("exec_command") || n.ends_with("shell_command") || n == "bash" || n == "shell" {
2801 "shell"
2802 } else if [
2803 "apply_patch",
2804 "edit",
2805 "write",
2806 "multiedit",
2807 "notebookedit",
2808 "strreplace",
2809 "delete",
2810 ]
2811 .contains(&n.as_str())
2812 {
2813 "edit"
2814 } else if ["read", "grep", "glob", "ls", "readlints"].contains(&n.as_str()) {
2815 "read"
2816 } else if n.contains("web")
2817 || n.contains("browser")
2818 || n.contains("search")
2819 || command.contains("http")
2820 {
2821 "network"
2822 } else if n.contains("plan") || n.contains("todo") {
2823 "plan"
2824 } else if n.contains("task") || n.contains("agent") {
2825 "subagent"
2826 } else {
2827 "tool"
2828 }
2829 .to_string()
2830}
2831
2832fn command_effect(command: &str) -> String {
2833 let cmd = basename_from_command(command);
2834 let text = command.to_ascii_lowercase();
2835 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
2836 && any_word(&text, &["test", "check", "build", "clippy"])
2837 {
2838 "test"
2839 } else if cmd == "git"
2840 && any_word(
2841 &text,
2842 &["commit", "push", "add", "checkout", "merge", "rebase"],
2843 )
2844 {
2845 "repo"
2846 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
2847 && (any_word(
2848 &text,
2849 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
2850 ) || text.contains("http://")
2851 || text.contains("https://"))
2852 {
2853 "network"
2854 } else if [
2855 "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
2856 ]
2857 .contains(&cmd.as_str())
2858 && (text.contains('>')
2859 || text.contains("--write")
2860 || text.contains(" rm ")
2861 || text.contains(" mkdir ")
2862 || text.contains(" touch ")
2863 || text.contains(" cp ")
2864 || text.contains(" mv "))
2865 {
2866 "write"
2867 } else if [
2868 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
2869 ]
2870 .contains(&cmd.as_str())
2871 {
2872 "read"
2873 } else if text.contains("http://")
2874 || text.contains("https://")
2875 || text.contains("crates.io")
2876 || text.contains("github.com")
2877 {
2878 "network"
2879 } else {
2880 "process"
2881 }
2882 .to_string()
2883}
2884
2885fn any_word(text: &str, words: &[&str]) -> bool {
2886 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2887 .any(|part| words.contains(&part))
2888}
2889
2890fn basename_from_command(command: &str) -> String {
2891 let parts = split_shell(command);
2892 let mut idx = 0;
2893 while idx < parts.len()
2894 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2895 &Path::new(&parts[idx])
2896 .file_name()
2897 .and_then(|v| v.to_str())
2898 .unwrap_or(""),
2899 )
2900 {
2901 idx += 1;
2902 if idx < parts.len() && parts[idx].starts_with('-') {
2903 idx += 1;
2904 }
2905 }
2906 parts
2907 .get(idx)
2908 .and_then(|part| process_name_from_part(part))
2909 .unwrap_or_else(|| "none".to_string())
2910}
2911
2912pub fn command_process_chain(command: &str) -> Vec<String> {
2913 process_chain_from_parts(&split_shell(command))
2914}
2915
2916fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
2917 if parts.is_empty() {
2918 return Vec::new();
2919 }
2920 let mut idx = 0;
2921 while idx < parts.len()
2922 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2923 &Path::new(&parts[idx])
2924 .file_name()
2925 .and_then(|v| v.to_str())
2926 .unwrap_or(""),
2927 )
2928 {
2929 idx += 1;
2930 if idx < parts.len() && parts[idx].starts_with('-') {
2931 idx += 1;
2932 }
2933 }
2934 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
2935 return Vec::new();
2936 };
2937 let mut chain = vec![proc_name.clone()];
2938 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
2939 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
2940 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
2941 chain.extend(command_process_chain(&parts[flag_idx + 1]));
2942 break;
2943 }
2944 }
2945 }
2946 chain
2947}
2948
2949fn process_name_from_part(part: &str) -> Option<String> {
2950 let raw = part.trim_matches(['"', '\'']);
2951 if raw.is_empty() {
2952 return None;
2953 }
2954 let path = Path::new(raw);
2955 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
2956 let parts = path_component_strings(path);
2957 if looks_like_home_directory(&parts) && parts.len() <= 2 {
2958 return Some("external".to_string());
2959 }
2960 if contains_private_marker(file_name) {
2961 return Some("external".to_string());
2962 }
2963 Some(file_name.to_string())
2964}
2965
2966fn split_shell(command: &str) -> Vec<String> {
2967 let mut parts = Vec::new();
2968 let mut current = String::new();
2969 let mut quote = None;
2970 let mut escaped = false;
2971 for ch in command.chars() {
2972 if escaped {
2973 current.push(ch);
2974 escaped = false;
2975 } else if ch == '\\' {
2976 escaped = true;
2977 } else if quote == Some(ch) {
2978 quote = None;
2979 } else if quote.is_some() {
2980 current.push(ch);
2981 } else if ch == '\'' || ch == '"' {
2982 quote = Some(ch);
2983 } else if ch.is_whitespace() {
2984 if !current.is_empty() {
2985 parts.push(std::mem::take(&mut current));
2986 }
2987 } else {
2988 current.push(ch);
2989 }
2990 }
2991 if !current.is_empty() {
2992 parts.push(current);
2993 }
2994 parts
2995}
2996
2997fn extract_domains(text: &str) -> Vec<String> {
2998 let mut domains = BTreeSet::new();
2999 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
3000 let stripped = part
3001 .strip_prefix("https://")
3002 .or_else(|| part.strip_prefix("http://"));
3003 if let Some(rest) = stripped
3004 && let Some(domain) = rest.split('/').next()
3005 && !domain.is_empty()
3006 {
3007 domains.insert(domain.to_ascii_lowercase());
3008 }
3009 for known in [
3010 "github.com",
3011 "crates.io",
3012 "huggingface.co",
3013 "hf.co",
3014 "openai.com",
3015 "anthropic.com",
3016 ] {
3017 if part.contains(known) {
3018 domains.insert(known.to_string());
3019 }
3020 }
3021 }
3022 domains.into_iter().collect()
3023}
3024
3025fn extract_path_groups(
3026 project_root: &Path,
3027 name: &str,
3028 input: &Value,
3029 command: &str,
3030) -> Vec<String> {
3031 let mut groups = BTreeSet::new();
3032 if ["write", "edit", "multiedit", "notebookedit", "read"]
3033 .contains(&name.to_ascii_lowercase().as_str())
3034 {
3035 for key in ["file_path", "path"] {
3036 if let Some(path) = input.get(key).and_then(Value::as_str) {
3037 groups.insert(path_group(path, project_root));
3038 }
3039 }
3040 }
3041 for part in split_shell(command) {
3042 if plausible_path_token(&part) {
3043 groups.insert(path_group(&part, project_root));
3044 }
3045 }
3046 groups.into_iter().filter(|v| v != "none").collect()
3047}
3048
3049fn plausible_path_operand(part: &str) -> bool {
3050 let part = part.trim_matches(['"', '\'']);
3051 !part.is_empty() && !part.chars().all(|c| c.is_ascii_digit()) && !definitely_not_a_path(part)
3053}
3054
3055fn plausible_path_token(part: &str) -> bool {
3056 let part = part.trim_matches(['"', '\'']);
3057 if definitely_not_a_path(part) {
3058 return false;
3059 }
3060 let suffix = Path::new(part)
3061 .extension()
3062 .and_then(|value| value.to_str())
3063 .unwrap_or("");
3064 part.contains('/')
3065 || [
3066 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
3067 "css",
3068 ]
3069 .contains(&suffix)
3070}
3071
3072fn definitely_not_a_path(part: &str) -> bool {
3073 let part = part.trim_matches(['"', '\'']);
3074 let lower = part.to_ascii_lowercase();
3075 let components = part.split('/').collect::<Vec<_>>();
3076 let looks_like_sed_expression = part.starts_with("s/")
3077 && part.rsplit('/').next().is_some_and(|flags| {
3078 flags.is_empty() || flags.chars().all(|flag| "gimpe".contains(flag))
3079 });
3080 let looks_like_slash_separated_phrase = components.len() >= 3
3081 && components.iter().all(|component| {
3082 component.chars().all(char::is_alphabetic)
3083 && component.chars().next().is_some_and(char::is_uppercase)
3084 });
3085 if part.is_empty()
3086 || part.starts_with('-')
3087 || part.starts_with('$')
3088 || part.starts_with('~')
3089 || part.starts_with("http://")
3090 || part.starts_with("https://")
3091 || lower.starts_with("origin/")
3092 || lower.starts_with("refs/")
3093 || lower.starts_with("repos/")
3094 || part == "HEAD"
3095 || part.starts_with("HEAD.")
3096 || part.contains("...")
3097 || looks_like_slash_separated_phrase
3098 || looks_like_sed_expression
3099 || part.len() > 140
3100 || part.chars().any(char::is_whitespace)
3101 || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
3102 {
3103 return true;
3104 }
3105 false
3106}
3107
3108pub fn path_group(path: &str, project_root: &Path) -> String {
3109 let path = path.trim_matches(['"', '\'']);
3110 if path.is_empty() {
3111 return "none".to_string();
3112 }
3113 let p = Path::new(path);
3114 let parts = if p.is_absolute() {
3115 if let Ok(rel) = p.strip_prefix(project_root) {
3116 path_component_strings(rel)
3117 } else {
3118 return external_path_group(path, &path_component_strings(p));
3119 }
3120 } else {
3121 let parts = path_component_strings(p);
3122 if let Some(group) = sensitive_relative_path_group(path, &parts) {
3123 return group;
3124 }
3125 parts
3126 };
3127 collapse_project_path(parts)
3128}
3129
3130pub fn path_component_strings(path: &Path) -> Vec<String> {
3131 path.components()
3132 .filter_map(|c| {
3133 let part = c.as_os_str().to_string_lossy();
3134 let part = part.as_ref();
3135 if part == "." || part == "/" || part.is_empty() {
3136 None
3137 } else {
3138 Some(part.to_string())
3139 }
3140 })
3141 .collect()
3142}
3143
3144pub fn collapse_project_path(parts: Vec<String>) -> String {
3145 let parts = parts
3146 .into_iter()
3147 .filter(|part| part != "." && !part.is_empty())
3148 .map(|part| truncate_path_component(&part))
3149 .collect::<Vec<_>>();
3150 if parts.is_empty() {
3151 "repo".to_string()
3152 } else if [
3153 "collector",
3154 "frontend",
3155 "docs",
3156 "bpf",
3157 "agentpprof",
3158 "agent-session",
3159 ]
3160 .contains(&parts[0].as_str())
3161 {
3162 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
3163 } else {
3164 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
3165 }
3166}
3167
3168fn truncate_path_component(part: &str) -> String {
3169 if part.chars().count() > 48 {
3170 format!("{}...", part.chars().take(45).collect::<String>())
3171 } else {
3172 part.to_string()
3173 }
3174}
3175
3176fn external_path_group(raw: &str, parts: &[String]) -> String {
3177 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
3178}
3179
3180fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
3181 let lowered = raw.to_ascii_lowercase();
3182 let lower_parts = parts
3183 .iter()
3184 .map(|part| part.to_ascii_lowercase())
3185 .collect::<Vec<_>>();
3186 if lower_parts.iter().any(|part| part == ".codex") {
3187 Some("external/codex".to_string())
3188 } else if lower_parts.iter().any(|part| part == ".claude") {
3189 Some("external/claude".to_string())
3190 } else if lower_parts.first().is_some_and(|part| part == "tmp")
3191 || lowered.contains("/tmp")
3192 || lowered.contains("_/tmp")
3193 || lower_parts
3194 .windows(2)
3195 .any(|window| window[0] == "var" && window[1] == "tmp")
3196 {
3197 Some("external/tmp".to_string())
3198 } else if lowered.starts_with("~/")
3199 || lowered == "~"
3200 || lowered.contains("/home")
3201 || lowered.contains("_/home")
3202 || lowered.contains("-home-")
3203 || lowered.contains("/users")
3204 || lowered.contains("_/users")
3205 || looks_like_home_directory(&lower_parts)
3206 || contains_private_marker(&lowered)
3207 {
3208 Some("external/home".to_string())
3209 } else {
3210 None
3211 }
3212}
3213
3214pub fn looks_like_home_directory(parts: &[String]) -> bool {
3215 parts
3216 .first()
3217 .is_some_and(|part| part == "home" || part == "users")
3218}
3219
3220fn current_username() -> Option<String> {
3221 dirs::home_dir()
3222 .and_then(|home| {
3223 home.file_name()
3224 .map(|part| part.to_string_lossy().to_string())
3225 })
3226 .filter(|name| !name.is_empty())
3227}
3228
3229pub fn contains_private_marker(text: &str) -> bool {
3230 let lowered = text.to_ascii_lowercase();
3231 current_username()
3232 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
3233 .unwrap_or(false)
3234}
3235
3236fn content_to_text(value: &Value) -> String {
3237 match value {
3238 Value::String(s) => s.clone(),
3239 Value::Array(items) => items
3240 .iter()
3241 .filter_map(|item| {
3242 if let Some(text) = item.as_str() {
3243 return Some(text.to_string());
3244 }
3245 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
3246 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
3247 return None;
3248 }
3249 if typ == "thinking" {
3251 return item
3252 .get("thinking")
3253 .and_then(Value::as_str)
3254 .filter(|s| !s.is_empty())
3255 .map(str::to_string);
3256 }
3257 item.get("text")
3258 .or_else(|| item.get("content"))
3259 .and_then(Value::as_str)
3260 .map(str::to_string)
3261 })
3262 .collect::<Vec<_>>()
3263 .join("\n"),
3264 Value::Object(_) => value
3265 .get("text")
3266 .or_else(|| value.get("content"))
3267 .and_then(Value::as_str)
3268 .unwrap_or("")
3269 .to_string(),
3270 _ => String::new(),
3271 }
3272}
3273
3274fn claude_is_tool_result(content: &Value) -> bool {
3275 content.as_array().is_some_and(|items| {
3276 !items.is_empty()
3277 && items
3278 .iter()
3279 .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3280 })
3281}
3282
3283fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
3284 let session_id = first_json_string(
3285 obj,
3286 &["sessionId", "session_id"],
3287 &["/payload/session_id", "/payload/sessionId"],
3288 );
3289 let conversation_id = first_json_string(
3290 obj,
3291 &["conversation_id", "conversationId", "thread_id", "threadId"],
3292 &[
3293 "/payload/conversation_id",
3294 "/payload/conversationId",
3295 "/payload/thread_id",
3296 "/payload/threadId",
3297 ],
3298 )
3299 .or_else(|| session_id.clone());
3300 (
3301 session_id.or_else(|| conversation_id.clone()),
3302 conversation_id,
3303 )
3304}
3305
3306fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
3307 keys.iter()
3308 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
3309 .chain(
3310 pointers
3311 .iter()
3312 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
3313 )
3314 .find(|value| !value.is_empty())
3315 .map(str::to_string)
3316}
3317
3318fn claude_usage_key(obj: &Value) -> String {
3319 obj.get("requestId")
3320 .or_else(|| obj.pointer("/message/id"))
3321 .or_else(|| obj.get("uuid"))
3322 .and_then(Value::as_str)
3323 .unwrap_or("usage")
3324 .to_string()
3325}
3326
3327fn claude_source_completion_id(obj: &Value) -> String {
3328 obj.pointer("/message/id")
3329 .or_else(|| obj.get("requestId"))
3330 .or_else(|| obj.get("uuid"))
3331 .and_then(Value::as_str)
3332 .unwrap_or("")
3333 .to_string()
3334}
3335
3336fn claude_user_starts_prompt(
3337 obj: &Value,
3338 content: &Value,
3339 text: &str,
3340 active_prompt_id: Option<&str>,
3341) -> bool {
3342 if obj.get("isMeta").and_then(Value::as_bool) == Some(true)
3343 || obj.get("sourceToolUseID").is_some()
3344 || obj.get("sourceToolAssistantUUID").is_some()
3345 || ["attachment", "attachments", "image", "images"]
3346 .iter()
3347 .any(|key| obj.get(*key).is_some())
3348 || content.as_array().is_some_and(|items| {
3349 !items.is_empty()
3350 && items.iter().all(|item| {
3351 matches!(
3352 item.get("type").and_then(Value::as_str),
3353 Some("attachment" | "document" | "file" | "image")
3354 )
3355 })
3356 })
3357 || [
3358 "<local-command-caveat>",
3359 "<local-command-stdout>",
3360 "<system-reminder>",
3361 "<ide_opened_file>",
3362 "<ide_selection>",
3363 ]
3364 .iter()
3365 .any(|prefix| text.starts_with(prefix))
3366 {
3367 return false;
3368 }
3369 match obj
3370 .get("promptId")
3371 .and_then(Value::as_str)
3372 .filter(|value| !value.is_empty())
3373 {
3374 Some(prompt_id) => active_prompt_id != Some(prompt_id),
3375 None => active_prompt_id.is_none(),
3376 }
3377}
3378
3379fn local_message_preview(value: &Value) -> Option<String> {
3380 let mut parts = Vec::new();
3381 collect_local_text(value, &mut parts);
3382 clean_prompt_text(&parts.join(" "))
3383}
3384
3385fn collect_local_text(value: &Value, out: &mut Vec<String>) {
3386 match value {
3387 Value::String(text) => out.push(text.clone()),
3388 Value::Array(items) => {
3389 for item in items {
3390 collect_local_text(item, out);
3391 }
3392 }
3393 Value::Object(obj) => {
3394 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
3395 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
3396 }) {
3397 return;
3398 }
3399 for key in ["text", "content", "message", "input", "prompt"] {
3400 if let Some(value) = obj.get(key) {
3401 collect_local_text(value, out);
3402 }
3403 }
3404 }
3405 _ => {}
3406 }
3407}
3408
3409fn is_claude_tool_result(obj: &Value) -> bool {
3410 obj.get("toolUseResult").is_some()
3411 || obj.get("tool_use_result").is_some()
3412 || obj
3413 .pointer("/message/content")
3414 .and_then(Value::as_array)
3415 .is_some_and(|items| {
3416 items
3417 .iter()
3418 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3419 })
3420}
3421
3422fn find_file_arg(value: &Value) -> Option<&str> {
3423 match value {
3424 Value::Object(obj) => {
3425 for key in ["file_path", "path", "filepath"] {
3426 if let Some(path) = obj.get(key).and_then(Value::as_str) {
3427 return Some(path);
3428 }
3429 }
3430 obj.values().find_map(find_file_arg)
3431 }
3432 Value::Array(items) => items.iter().find_map(find_file_arg),
3433 _ => None,
3434 }
3435}
3436
3437fn is_noise_path(path: &str) -> bool {
3438 const NOISE: &[&str] = &[
3439 "/.claude/",
3440 "/.codex/",
3441 "/.gemini/",
3442 "/.git/",
3443 "/node_modules/",
3444 "/.npm/",
3445 "/.cache/",
3446 "CLAUDE.md",
3447 "AGENTS.md",
3448 ];
3449 NOISE.iter().any(|pat| path.contains(pat))
3450}
3451
3452fn clean_prompt_text(text: &str) -> Option<String> {
3453 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3454 let text = text
3455 .strip_prefix("<session>")
3456 .and_then(|text| text.strip_suffix("</session>"))
3457 .unwrap_or(&text)
3458 .trim();
3459 (!text.is_empty()).then(|| text.to_string())
3460}
3461
3462pub fn short_hash(text: &str, n: usize) -> String {
3463 let digest = Sha256::digest(text.as_bytes());
3464 hex::encode(digest).chars().take(n).collect()
3465}
3466
3467pub fn truncate_clean(text: &str, limit: usize) -> String {
3468 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3469 if text.chars().count() <= limit {
3470 return text;
3471 }
3472 text.chars()
3473 .take(limit.saturating_sub(1))
3474 .collect::<String>()
3475 + "."
3476}
3477
3478pub fn one_word(text: &str, default: &str) -> String {
3479 let mut cur = String::new();
3480 for ch in text.to_ascii_lowercase().chars() {
3481 if ch.is_ascii_alphanumeric() {
3482 cur.push(ch);
3483 } else if cur.len() >= 2 {
3484 break;
3485 } else {
3486 cur.clear();
3487 }
3488 }
3489 if cur.len() >= 2 {
3490 cur
3491 } else {
3492 default.to_string()
3493 }
3494}
3495
3496fn short_session_id(id: &str) -> String {
3497 let id = id.trim();
3498 if id.is_empty() {
3499 return "session".to_string();
3500 }
3501 let compact = id
3502 .rsplit(['/', '\\'])
3503 .next()
3504 .unwrap_or(id)
3505 .trim_end_matches(".jsonl");
3506 const MAX_SESSION_ID_CHARS: usize = 12;
3507 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
3508 return compact.to_string();
3509 }
3510 let head = compact.chars().take(6).collect::<String>();
3511 let tail = compact
3512 .chars()
3513 .rev()
3514 .take(5)
3515 .collect::<Vec<_>>()
3516 .into_iter()
3517 .rev()
3518 .collect::<String>();
3519 format!("{head}.{tail}")
3520}
3521
3522fn json_i64(value: &Value, key: &str) -> i64 {
3523 value.get(key).and_then(Value::as_i64).unwrap_or(0)
3524}
3525
3526fn json_u64(value: &Value, key: &str) -> u64 {
3527 value.get(key).and_then(Value::as_u64).unwrap_or(0)
3528}
3529
3530fn ts_ms_from_event(value: &Value) -> Option<i64> {
3531 value
3532 .get("timestamp")
3533 .and_then(Value::as_str)
3534 .and_then(parse_ts_ms)
3535}
3536
3537fn parse_ts_ms(value: &str) -> Option<i64> {
3538 chrono::DateTime::parse_from_rfc3339(value)
3539 .ok()
3540 .map(|ts| ts.timestamp_millis())
3541}
3542
3543fn rfc3339_seconds(value: &str) -> Option<f64> {
3544 chrono::DateTime::parse_from_rfc3339(value)
3545 .ok()
3546 .map(|ts| ts.timestamp_millis() as f64 / 1000.0)
3547}
3548
3549fn uuid7_seconds(value: &str) -> Option<f64> {
3550 let mut parts = value.split('-');
3551 let high = parts.next()?;
3552 let low = parts.next()?;
3553 let version = parts.next()?;
3554 if !version.starts_with('7') {
3555 return None;
3556 }
3557 u64::from_str_radix(&format!("{high}{low}"), 16)
3558 .ok()
3559 .map(|milliseconds| milliseconds as f64 / 1000.0)
3560}
3561
3562fn iso_ms(value: &str) -> Option<u64> {
3563 chrono::DateTime::parse_from_rfc3339(value)
3564 .ok()
3565 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
3566}
3567
3568fn system_time_ms(value: SystemTime) -> u64 {
3569 value
3570 .duration_since(UNIX_EPOCH)
3571 .unwrap_or_default()
3572 .as_millis() as u64
3573}
3574
3575#[cfg(test)]
3576mod tests {
3577 use super::*;
3578 use serde_json::json;
3579 use std::time::UNIX_EPOCH;
3580
3581 fn cursor_parent_fixture() -> String {
3583 [
3584 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>"}]}}"#,
3586 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"}}]}}"#,
3587 r#"{"type":"turn_ended","status":"success"}"#,
3588 r#"{"role":"user","message":{"content":[{"type":"text","text":"now delete it"}]}}"#,
3589 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/hello.py"}},{"type":"text","text":"deleted"}]}}"#,
3590 r#"{"type":"turn_ended","status":"success"}"#,
3591 ]
3592 .join("\n")
3593 }
3594
3595 fn cursor_subagent_fixture() -> String {
3597 [
3598 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>"}]}}"#,
3600 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/hello.py","contents":"print(1)\n"}},{"type":"text","text":"written"}]}}"#,
3601 r#"{"type":"turn_ended","status":"success"}"#,
3602 ]
3603 .join("\n")
3604 }
3605
3606 #[test]
3607 fn cursor_transcript_counts_prompts_and_responses() {
3608 let session = parse_session_content(
3609 AGENT_CURSOR,
3610 &PathBuf::from("/tmp/session.jsonl"),
3611 UNIX_EPOCH,
3612 &cursor_parent_fixture(),
3613 )
3614 .expect("session");
3615
3616 assert_eq!(session.agent_type, AGENT_CURSOR);
3617 assert_eq!(session.events.prompts.len(), 2);
3618 assert_eq!(session.events.llm_responses.len(), 2);
3619 assert_eq!(session.events.prompts[0].preview, "create hello.py");
3622 assert_eq!(session.events.prompts[1].preview, "now delete it");
3623 assert_eq!(session.events.prompts[1].index, 1);
3624 assert_eq!(session.events.llm_responses[1].prompt_index, 1);
3625 assert_eq!(session.prompt_preview.as_deref(), Some("create hello.py"));
3626 }
3627
3628 #[test]
3629 fn cursor_tool_uses_become_events_and_unknown_names_are_kept() {
3630 let content = [
3631 r#"{"role":"user","message":{"content":[{"type":"text","text":"do work"}]}}"#,
3632 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cargo test","description":"run tests"}}]}}"#,
3633 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/a.rs","old_string":"x","new_string":"y"}}]}}"#,
3634 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/a.rs"]}}]}}"#,
3635 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"SomeToolWeHaveNeverSeen","input":{"whatever":1}}]}}"#,
3636 ]
3637 .join("\n");
3638
3639 let session = parse_session_content(
3640 AGENT_CURSOR,
3641 &PathBuf::from("/tmp/session.jsonl"),
3642 UNIX_EPOCH,
3643 &content,
3644 )
3645 .expect("session");
3646
3647 let named = |name: &str| {
3648 session
3649 .events
3650 .tools
3651 .iter()
3652 .find(|tool| tool.tool_name == name)
3653 .unwrap_or_else(|| panic!("{name} missing"))
3654 };
3655 assert_eq!(session.events.tools.len(), 4);
3656 assert_eq!(named("Shell").category, "shell");
3657 assert_eq!(named("Shell").command, "cargo test");
3658 assert_eq!(named("Shell").command_name, "cargo");
3659 assert_eq!(named("StrReplace").category, "edit");
3660 assert_eq!(named("ReadLints").category, "read");
3661 assert_eq!(named("SomeToolWeHaveNeverSeen").category, "tool");
3663 assert!(named("Shell").call_id.is_none());
3666 assert_eq!(named("Shell").status, "observed");
3667 assert_eq!(session.tools.get("Shell"), Some(&1));
3668 }
3669
3670 #[test]
3671 fn cursor_file_tools_map_to_access_kinds() {
3672 let content = [
3673 r#"{"role":"user","message":{"content":[{"type":"text","text":"work"}]}}"#,
3674 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3675 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/b.rs","contents":"fn main() {}"}}]}}"#,
3676 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/c.rs","old_string":"x","new_string":"y"}}]}}"#,
3677 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/d.rs"}}]}}"#,
3678 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/e.rs","/repo/f.rs"]}}]}}"#,
3679 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"timeout","path":"/repo","-i":true}}]}}"#,
3680 ]
3681 .join("\n");
3682
3683 let session = parse_session_content(
3684 AGENT_CURSOR,
3685 &PathBuf::from("/tmp/session.jsonl"),
3686 UNIX_EPOCH,
3687 &content,
3688 )
3689 .expect("session");
3690
3691 let access_of = |path: &str| {
3692 session
3693 .events
3694 .tools
3695 .iter()
3696 .flat_map(|tool| tool.paths.iter())
3697 .find(|candidate| candidate.path == path)
3698 .unwrap_or_else(|| panic!("{path} missing"))
3699 .access
3700 .clone()
3701 };
3702 assert_eq!(access_of("/repo/a.rs"), "read");
3703 assert_eq!(access_of("/repo/b.rs"), "write");
3704 assert_eq!(access_of("/repo/c.rs"), "write");
3705 assert_eq!(access_of("/repo/d.rs"), "delete");
3706 assert_eq!(access_of("/repo/e.rs"), "read");
3708 assert_eq!(access_of("/repo/f.rs"), "read");
3709 assert_eq!(access_of("/repo"), "read");
3711 assert_eq!(session.files.get("/repo/d.rs"), Some(&1));
3712 }
3713
3714 #[test]
3715 fn cursor_shell_mv_yields_rename_with_previous_path() {
3716 let content = [
3719 r#"{"role":"user","message":{"content":[{"type":"text","text":"tidy up"}]}}"#,
3720 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cd /repo && mv hello.py greet.py","description":"rename it"}}]}}"#,
3721 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"rm /repo/stale.txt","description":"drop it"}}]}}"#,
3722 ]
3723 .join("\n");
3724
3725 let session = parse_session_content(
3726 AGENT_CURSOR,
3727 &PathBuf::from("/tmp/session.jsonl"),
3728 UNIX_EPOCH,
3729 &content,
3730 )
3731 .expect("session");
3732
3733 let all: Vec<&ToolPath> = session
3734 .events
3735 .tools
3736 .iter()
3737 .flat_map(|tool| tool.paths.iter())
3738 .collect();
3739 let renamed = all
3740 .iter()
3741 .find(|path| path.access == "rename")
3742 .expect("rename");
3743 assert_eq!(renamed.path, "/repo/greet.py");
3744 assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3745 assert!(
3746 all.iter()
3747 .any(|path| path.access == "delete" && path.path == "/repo/stale.txt")
3748 );
3749 assert_eq!(session.events.tools[0].category, "shell");
3750 assert_eq!(
3751 session.events.tools[0].command,
3752 "cd /repo && mv hello.py greet.py"
3753 );
3754 assert_eq!(session.events.tools[0].command_name, "cd");
3756 assert!(
3757 !session.events.tools[0].process_chain.is_empty(),
3758 "Shell events must carry a process chain"
3759 );
3760 }
3761
3762 #[test]
3763 fn cursor_shell_working_directory_resolves_relative_paths() {
3764 let content = [
3766 r#"{"role":"user","message":{"content":[{"type":"text","text":"move it"}]}}"#,
3767 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"mv hello.py archive/greet.py","working_directory":"/repo","description":"move"}}]}}"#,
3768 ]
3769 .join("\n");
3770
3771 let session = parse_session_content(
3772 AGENT_CURSOR,
3773 &PathBuf::from("/tmp/session.jsonl"),
3774 UNIX_EPOCH,
3775 &content,
3776 )
3777 .expect("session");
3778
3779 let renamed = session.events.tools[0]
3780 .paths
3781 .iter()
3782 .find(|path| path.access == "rename")
3783 .expect("rename");
3784 assert_eq!(renamed.path, "/repo/archive/greet.py");
3785 assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3786 }
3787
3788 #[test]
3789 fn cursor_subagent_work_folds_into_the_delegating_prompt() {
3790 let children = vec![(
3791 PathBuf::from("/tmp/subagents/child.jsonl"),
3792 cursor_subagent_fixture(),
3793 )];
3794 let session = parse_cursor_jsonl(
3795 &PathBuf::from("/tmp/session.jsonl"),
3796 UNIX_EPOCH,
3797 &cursor_parent_fixture(),
3798 &children,
3799 )
3800 .expect("session");
3801
3802 assert_eq!(session.tools.get("Task"), Some(&1));
3805 assert_eq!(session.tools.get("Delete"), Some(&1));
3806 assert_eq!(session.tools.get("Write"), Some(&1));
3807 assert_eq!(session.events.tools.len(), 3);
3809 assert_eq!(session.tools.values().sum::<usize>(), 3);
3810 assert_eq!(session.files.get("/repo/hello.py"), Some(&2));
3811
3812 let tool_at = |name: &str| {
3813 session
3814 .events
3815 .tools
3816 .iter()
3817 .find(|tool| tool.tool_name == name)
3818 .unwrap_or_else(|| panic!("{name} missing"))
3819 .prompt_index
3820 };
3821 assert_eq!(tool_at("Write"), 0);
3824 assert_eq!(tool_at("Delete"), 1);
3825 }
3826
3827 #[test]
3828 fn cursor_cwd_prefers_working_directory_then_common_path_prefix() {
3829 let with_dir = [
3830 r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3831 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"ls","working_directory":"/repo/app"}}]}}"#,
3832 ]
3833 .join("\n");
3834 let session = parse_session_content(
3835 AGENT_CURSOR,
3836 &PathBuf::from("/tmp/session.jsonl"),
3837 UNIX_EPOCH,
3838 &with_dir,
3839 )
3840 .expect("session");
3841 assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3842
3843 let paths_only = [
3846 r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3847 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/app/src/main.rs","contents":"x"}}]}}"#,
3848 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/app/README.md"}}]}}"#,
3849 ]
3850 .join("\n");
3851 let session = parse_session_content(
3852 AGENT_CURSOR,
3853 &PathBuf::from("/tmp/session.jsonl"),
3854 UNIX_EPOCH,
3855 &paths_only,
3856 )
3857 .expect("session");
3858 assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3859
3860 let bare = [
3863 r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#,
3864 r#"{"role":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#,
3865 ]
3866 .join("\n");
3867 let session = parse_session_content(
3868 AGENT_CURSOR,
3869 &PathBuf::from("/tmp/projects/Users-user-cursor-test/agent-transcripts/a/a.jsonl"),
3870 UNIX_EPOCH,
3871 &bare,
3872 )
3873 .expect("session");
3874 assert_eq!(session.cwd, None);
3875 }
3876
3877 #[test]
3878 fn cursor_cwd_preserves_windows_drive_and_unc_roots() {
3879 assert_eq!(
3880 common_parent_dir(&[r"C:\file.rs".to_string()]).as_deref(),
3881 Some("C:/")
3882 );
3883 assert_eq!(
3884 common_parent_dir(&[r"\\server\share\file.rs".to_string()]).as_deref(),
3885 Some("//server/share")
3886 );
3887 assert_eq!(
3888 common_parent_dir(&[
3889 r"C:\repo\src\main.rs".to_string(),
3890 r"C:\repo\README.md".to_string(),
3891 ])
3892 .as_deref(),
3893 Some("C:/repo")
3894 );
3895 assert_eq!(
3896 common_parent_dir(&[
3897 r"\\server\share-a\file.rs".to_string(),
3898 r"\\server\share-b\file.rs".to_string(),
3899 ]),
3900 None
3901 );
3902 }
3903
3904 #[test]
3905 fn cursor_truncated_and_empty_transcripts_degrade_without_error() {
3906 let torn = concat!(
3909 r#"{"role":"user","message":{"content":[{"type":"text","text":"start"}]}}"#,
3910 "\n",
3911 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3912 "\n",
3913 r#"{"role":"assistant","message":{"content":[{"type":"tool_"#,
3914 );
3915 let session = parse_session_content(
3916 AGENT_CURSOR,
3917 &PathBuf::from("/tmp/session.jsonl"),
3918 UNIX_EPOCH,
3919 torn,
3920 )
3921 .expect("session");
3922 assert_eq!(session.events.prompts.len(), 1);
3923 assert_eq!(session.tools.get("Read"), Some(&1));
3924
3925 let one_prompt =
3928 r#"{"role":"user","message":{"content":[{"type":"text","text":"just asking"}]}}"#;
3929 let session = parse_session_content(
3930 AGENT_CURSOR,
3931 &PathBuf::from("/tmp/session.jsonl"),
3932 UNIX_EPOCH,
3933 one_prompt,
3934 )
3935 .expect("a lone prompt is still a session");
3936 assert!(session.events.tools.is_empty());
3937 assert_eq!(session.prompt_preview.as_deref(), Some("just asking"));
3938
3939 for empty in [
3943 "",
3944 "\n\n",
3945 r#"{"type":"turn_ended","status":"error","error":"aborted"}"#,
3946 "not json at all",
3947 ] {
3948 assert!(
3949 parse_session_content(
3950 AGENT_CURSOR,
3951 &PathBuf::from("/tmp/session.jsonl"),
3952 UNIX_EPOCH,
3953 empty,
3954 )
3955 .is_none(),
3956 "expected no session for {empty:?}"
3957 );
3958 }
3959
3960 let orphan = vec![(
3963 PathBuf::from("/tmp/subagents/orphan.jsonl"),
3964 [
3965 r#"{"role":"user","message":{"content":[{"type":"text","text":"unrelated wording"}]}}"#,
3966 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/z.rs","contents":"x"}}]}}"#,
3967 ]
3968 .join("\n"),
3969 )];
3970 let session = parse_cursor_jsonl(
3971 &PathBuf::from("/tmp/session.jsonl"),
3972 UNIX_EPOCH,
3973 &cursor_parent_fixture(),
3974 &orphan,
3975 )
3976 .expect("session");
3977 assert_eq!(session.tools.get("Write"), Some(&1));
3978 }
3979
3980 #[test]
3981 fn cursor_failed_turn_marks_its_tool_calls() {
3982 let content = [
3983 r#"{"role":"user","message":{"content":[{"type":"text","text":"first"}]}}"#,
3984 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/ok.rs"}}]}}"#,
3985 r#"{"type":"turn_ended","status":"success"}"#,
3986 r#"{"role":"user","message":{"content":[{"type":"text","text":"second"}]}}"#,
3987 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cat /repo/missing.rs"}}]}}"#,
3988 r#"{"type":"turn_ended","status":"error","error":"command failed"}"#,
3989 ]
3990 .join("\n");
3991
3992 let session = parse_session_content(
3993 AGENT_CURSOR,
3994 &PathBuf::from("/tmp/session.jsonl"),
3995 UNIX_EPOCH,
3996 &content,
3997 )
3998 .expect("session");
3999
4000 let status_of = |name: &str| {
4001 session
4002 .events
4003 .tools
4004 .iter()
4005 .find(|tool| tool.tool_name == name)
4006 .unwrap_or_else(|| panic!("{name} missing"))
4007 .status
4008 .clone()
4009 };
4010 assert_eq!(status_of("Read"), "observed");
4013 assert_eq!(status_of("Shell"), "fail");
4014 }
4015
4016 #[test]
4017 fn cursor_wrapper_timestamp_becomes_the_event_clock() {
4018 let content = [
4022 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>"}]}}"#,
4023 r#"{"role":"assistant","message":{"content":[{"type":"text","text":"reading it"},{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
4024 ]
4025 .join("\n");
4026
4027 let session = parse_session_content(
4028 AGENT_CURSOR,
4029 &PathBuf::from("/tmp/session.jsonl"),
4030 UNIX_EPOCH,
4031 &content,
4032 )
4033 .expect("session");
4034
4035 const EXPECTED_MS: i64 = 1_786_158_720_000;
4039 assert_eq!(session.events.prompts[0].ts_ms, Some(EXPECTED_MS));
4040 assert_eq!(session.events.tools[0].ts_ms, Some(EXPECTED_MS));
4041 assert_eq!(session.events.llm_responses[0].ts_ms, Some(EXPECTED_MS));
4042
4043 let bare = [
4045 r#"{"role":"user","message":{"content":[{"type":"text","text":"no wrapper here"}]}}"#,
4046 r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/b.rs"}}]}}"#,
4047 ]
4048 .join("\n");
4049 let session = parse_session_content(
4050 AGENT_CURSOR,
4051 &PathBuf::from("/tmp/session.jsonl"),
4052 UNIX_EPOCH,
4053 &bare,
4054 )
4055 .expect("session");
4056 assert_eq!(session.events.tools[0].ts_ms, None);
4057 }
4058
4059 #[test]
4060 fn cursor_subagent_prompts_are_not_user_prompts() {
4061 let children = vec![(
4062 PathBuf::from("/tmp/subagents/child.jsonl"),
4063 cursor_subagent_fixture(),
4064 )];
4065 let session = parse_cursor_jsonl(
4066 &PathBuf::from("/tmp/session.jsonl"),
4067 UNIX_EPOCH,
4068 &cursor_parent_fixture(),
4069 &children,
4070 )
4071 .expect("session");
4072
4073 assert_eq!(session.events.prompts.len(), 2);
4076 assert_eq!(session.events.llm_responses.len(), 3);
4077 }
4078
4079 #[test]
4080 fn cursor_paths_classify_but_only_parents_discover() {
4081 let home = PathBuf::from("/home/dev");
4082 let parent = home.join(".cursor/projects/repo/agent-transcripts/abc/abc.jsonl");
4083 let subagent = home.join(".cursor/projects/repo/agent-transcripts/abc/subagents/def.jsonl");
4084 let vendored = home.join(".cursor/projects/repo/canvases/node_modules/pkg/data.jsonl");
4085
4086 assert_eq!(agent_source_for_path(&parent), Some(AGENT_CURSOR));
4089 assert_eq!(agent_source_for_path(&subagent), Some(AGENT_CURSOR));
4090 assert_eq!(agent_source_for_path(&vendored), None);
4091
4092 assert!(is_agent_file_for(AGENT_CURSOR, &parent));
4094 assert!(!is_agent_file_for(AGENT_CURSOR, &subagent));
4095 assert!(!is_agent_file_for(AGENT_CURSOR, &vendored));
4096 assert!(!is_agent_file_for(
4097 AGENT_CURSOR,
4098 &home.join(".cursor/projects/repo/agent-transcripts/abc/other.jsonl")
4099 ));
4100
4101 assert!(cursor_is_empty_window(&home.join(
4102 ".cursor/projects/empty-window/agent-transcripts/abc/abc.jsonl"
4103 )));
4104 assert!(!cursor_is_empty_window(&parent));
4105
4106 let fixture = fixture_session_path(AGENT_CURSOR, &home).expect("fixture");
4107 assert!(is_agent_file_for(AGENT_CURSOR, &fixture));
4108 }
4109
4110 #[test]
4111 fn native_windows_session_paths_classify() {
4112 assert_eq!(
4113 agent_source_for_path(Path::new(
4114 r"C:\Users\dev\.codex\sessions\2026\08\12\session.jsonl"
4115 )),
4116 Some(AGENT_CODEX)
4117 );
4118 assert_eq!(
4119 agent_source_for_path(Path::new(
4120 r"C:\Users\dev\.claude\projects\repo\session.jsonl"
4121 )),
4122 Some(AGENT_CLAUDE)
4123 );
4124 assert_eq!(
4125 agent_source_for_path(Path::new(
4126 r"C:\Users\dev\.cursor\projects\repo\agent-transcripts\id\id.jsonl"
4127 )),
4128 Some(AGENT_CURSOR)
4129 );
4130 let gemini =
4131 Path::new(r"C:\Users\dev\.gemini\tmp\repo\chats\session-2026-08-12T00-00-id.json");
4132 assert_eq!(agent_source_for_path(gemini), Some(AGENT_GEMINI));
4133 assert!(is_agent_file_for(AGENT_GEMINI, gemini));
4134 }
4135
4136 #[test]
4137 fn local_session_ids_keep_distinct_conversation_id() {
4138 assert_eq!(
4139 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
4140 (Some("run".to_string()), Some("conv".to_string()))
4141 );
4142 assert_eq!(
4143 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
4144 (Some("thread".to_string()), Some("thread".to_string()))
4145 );
4146 assert_eq!(
4147 local_session_ids(&json!({"payload": {"model": "gpt"}})),
4148 (None, None)
4149 );
4150 }
4151
4152 #[test]
4153 fn agent_jsonl_events_share_one_ir() {
4154 let codex = concat!(
4155 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
4156 "\n",
4157 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
4158 "\n",
4159 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4160 "\n",
4161 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"tests passed"}}"#,
4162 "\n",
4163 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
4164 );
4165 let claude = concat!(
4166 r#"{"type":"user","message":{"content":"check build"}}"#,
4167 "\n",
4168 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}}}"#,
4169 );
4170
4171 for (agent, content, tool, model, tokens) in [
4172 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
4173 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
4174 ] {
4175 let session = parse_session_content(
4176 agent,
4177 &PathBuf::from("/tmp/session.jsonl"),
4178 UNIX_EPOCH,
4179 content,
4180 )
4181 .expect("session");
4182 assert_eq!(session.events.tools[0].tool_name, tool);
4183 assert_eq!(session.events.tools[0].category, "shell");
4184 assert_eq!(session.events.llm_responses[0].model, model);
4185 let usage = &session.events.llm_responses[0];
4186 let total = usage
4187 .total_tokens
4188 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
4189 assert_eq!(total, tokens);
4190 }
4191 }
4192
4193 #[test]
4194 fn claude_exact_skill_calls_create_prompt_bounded_latest_wins_scopes() {
4195 let claude = [
4196 r#"{"type":"system","skill_listing":["availability only"]}"#,
4197 r#"{"type":"user","message":{"content":"review the paper"}}"#,
4198 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}}}"#,
4199 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}}}"#,
4200 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}}}"#,
4201 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}}}"#,
4202 r#"{"type":"user","message":{"content":"now summarize"}}"#,
4203 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":50,"output_tokens":5}}}"#,
4204 ]
4205 .join("\n");
4206
4207 let session = parse_session_content(
4208 AGENT_CLAUDE,
4209 &PathBuf::from("/tmp/session.jsonl"),
4210 UNIX_EPOCH,
4211 &claude,
4212 )
4213 .expect("session");
4214
4215 assert_eq!(
4216 session
4217 .events
4218 .tools
4219 .iter()
4220 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4221 .collect::<Vec<_>>(),
4222 [
4223 ("Skill", "check-paper-citations"),
4224 ("Bash", "check-paper-citations"),
4225 ("Skill", "iter-refine-writing"),
4226 ("Read", "iter-refine-writing"),
4227 ]
4228 );
4229 assert_eq!(
4230 session
4231 .events
4232 .tools
4233 .iter()
4234 .map(|tool| tool.invoked_skill.as_str())
4235 .collect::<Vec<_>>(),
4236 ["check-paper-citations", "", "iter-refine-writing", ""]
4237 );
4238 assert_eq!(
4239 session
4240 .events
4241 .llm_responses
4242 .iter()
4243 .map(|response| response.skill.as_str())
4244 .collect::<Vec<_>>(),
4245 [
4246 "",
4247 "check-paper-citations",
4248 "check-paper-citations",
4249 "iter-refine-writing",
4250 "",
4251 ]
4252 );
4253 }
4254
4255 #[test]
4256 fn codex_source_controls_build_sparse_semantic_task_paths() {
4257 let codex = concat!(
4258 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"write a paper"}]}}"#,
4259 "\n",
4260 r#"{"type":"response_item","payload":{"type":"function_call","name":"update_plan","call_id":"p1","arguments":"{\"plan\":[{\"step\":\"write abstract\",\"status\":\"in_progress\"}]}"}}"#,
4261 "\n",
4262 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"sed -n 1,80p paper.tex\"}"}}"#,
4263 "\n",
4264 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 0\n0 tests failed"}}"#,
4265 "\n",
4266 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"继续"}]}}"#,
4267 "\n",
4268 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c2","arguments":"{\"cmd\":\"rg error paper.tex\"}"}}"#,
4269 "\n",
4270 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c2","output":"review error handling documentation"}}"#,
4271 );
4272
4273 let session = parse_session_content(
4274 AGENT_CODEX,
4275 &PathBuf::from("/tmp/session.jsonl"),
4276 UNIX_EPOCH,
4277 codex,
4278 )
4279 .expect("session");
4280
4281 assert_eq!(session.events.prompts.len(), 2);
4282 assert!(session.events.llm_responses.is_empty());
4283 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4284 assert_eq!(
4285 session.events.tools[1].task_path,
4286 vec!["write a paper", "write abstract"]
4287 );
4288 assert_eq!(
4289 session.events.tools[2].task_path,
4290 session.events.tools[1].task_path
4291 );
4292 assert_eq!(session.events.tools[1].status, "ok");
4293 assert_eq!(session.events.tools[2].status, "observed");
4294 }
4295
4296 #[test]
4297 fn codex_custom_exec_is_a_real_source_tool_event() {
4298 let codex = [
4299 json!({
4300 "timestamp": "2026-07-21T00:00:00.000Z",
4301 "type": "response_item",
4302 "payload": {
4303 "type": "message",
4304 "role": "user",
4305 "content": [{"type": "input_text", "text": "test the parser"}]
4306 }
4307 }),
4308 json!({
4309 "timestamp": "2026-07-21T00:00:01.000Z",
4310 "type": "response_item",
4311 "payload": {
4312 "type": "custom_tool_call",
4313 "name": "exec",
4314 "call_id": "custom-1",
4315 "input": "const r = await tools.shell_command({command:\"cargo test\",workdir:\"/repo\"}); text(r);"
4316 }
4317 }),
4318 json!({
4319 "timestamp": "2026-07-21T00:00:02.000Z",
4320 "type": "response_item",
4321 "payload": {
4322 "type": "custom_tool_call_output",
4323 "call_id": "custom-1",
4324 "output": [{"type": "input_text", "text": "Script completed\nExit code: 0\nOutput:\nall tests passed"}]
4325 }
4326 }),
4327 ]
4328 .into_iter()
4329 .map(|line| line.to_string())
4330 .collect::<Vec<_>>()
4331 .join("\n");
4332
4333 let session = parse_session_content(
4334 AGENT_CODEX,
4335 &PathBuf::from("/tmp/session.jsonl"),
4336 UNIX_EPOCH,
4337 &codex,
4338 )
4339 .expect("session");
4340
4341 assert_eq!(session.events.tools.len(), 1);
4342 let event = &session.events.tools[0];
4343 assert_eq!(event.tool_name, "shell_command");
4344 assert_eq!(event.category, "shell");
4345 assert_eq!(event.effect, "test");
4346 assert_eq!(event.command, "cargo test");
4347 assert_eq!(event.status, "ok");
4348 assert_eq!(event.task_path, vec!["test the parser"]);
4349 }
4350
4351 #[test]
4352 fn custom_update_plan_changes_only_later_operation_paths() {
4353 let codex = [
4354 json!({
4355 "timestamp": "2026-07-21T00:00:00.000Z",
4356 "type": "response_item",
4357 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "write a paper"}]}
4358 }),
4359 json!({
4360 "timestamp": "2026-07-21T00:00:01.000Z",
4361 "type": "response_item",
4362 "payload": {
4363 "type": "custom_tool_call",
4364 "name": "exec",
4365 "call_id": "plan-1",
4366 "input": "const r = await tools.update_plan({plan:[{step:\"write abstract\",status:\"in_progress\"},{step:\"write evaluation\",status:\"pending\"}]}); text(r);"
4367 }
4368 }),
4369 json!({
4370 "timestamp": "2026-07-21T00:00:02.000Z",
4371 "type": "response_item",
4372 "payload": {
4373 "type": "custom_tool_call",
4374 "name": "exec",
4375 "call_id": "shell-1",
4376 "input": "const r = await tools.shell_command({command:\"sed -n 1,80p paper.tex\",workdir:\"/repo\"}); text(r);"
4377 }
4378 }),
4379 ]
4380 .into_iter()
4381 .map(|line| line.to_string())
4382 .collect::<Vec<_>>()
4383 .join("\n");
4384 let session = parse_session_content(
4385 AGENT_CODEX,
4386 &PathBuf::from("/tmp/session.jsonl"),
4387 UNIX_EPOCH,
4388 &codex,
4389 )
4390 .expect("session");
4391
4392 assert_eq!(session.events.tools.len(), 2);
4393 assert_eq!(session.events.tools[0].tool_name, "update_plan");
4394 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4395 assert_eq!(
4396 session.events.tools[1].task_path,
4397 vec!["write a paper", "write abstract"]
4398 );
4399 }
4400
4401 #[test]
4402 fn prompt_dedup_is_local_and_continuations_keep_the_current_task() {
4403 let codex = [
4404 ("2026-07-21T00:00:00.000Z", "write a paper"),
4405 ("2026-07-21T00:00:00.500Z", "write a paper"),
4406 ("2026-07-21T00:00:03.000Z", "write a paper"),
4407 ("2026-07-21T00:00:06.000Z", "继续"),
4408 ]
4409 .into_iter()
4410 .map(|(timestamp, text)| {
4411 json!({
4412 "timestamp": timestamp,
4413 "type": "response_item",
4414 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
4415 })
4416 .to_string()
4417 })
4418 .collect::<Vec<_>>()
4419 .join("\n");
4420 let session = parse_session_content(
4421 AGENT_CODEX,
4422 &PathBuf::from("/tmp/session.jsonl"),
4423 UNIX_EPOCH,
4424 &codex,
4425 )
4426 .expect("session");
4427
4428 assert_eq!(session.events.prompts.len(), 3);
4429 assert_eq!(session.events.prompts[2].preview, "继续");
4430 assert_eq!(session.events.prompts[2].task_path, vec!["write a paper"]);
4431 }
4432
4433 #[test]
4434 fn developer_messages_are_not_agent_responses() {
4435 let codex = concat!(
4436 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review"}]}}"#,
4437 "\n",
4438 r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"internal instruction"}]}}"#,
4439 "\n",
4440 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"}]}}"#,
4441 );
4442 let session = parse_session_content(
4443 AGENT_CODEX,
4444 &PathBuf::from("/tmp/session.jsonl"),
4445 UNIX_EPOCH,
4446 codex,
4447 )
4448 .expect("session");
4449 assert_eq!(session.events.llm_responses.len(), 1);
4450 assert_eq!(session.events.llm_responses[0].preview, "review complete");
4451 }
4452
4453 #[test]
4454 fn mixed_batch_exit_codes_fail_if_any_command_failed() {
4455 assert_eq!(
4456 status_from_output("Script completed\nExit code: 0\nExit code: 7"),
4457 "fail"
4458 );
4459 assert_eq!(
4460 status_from_output(
4461 "Process exited with code 0\nProcess exited with code 0\n0 tests failed"
4462 ),
4463 "ok"
4464 );
4465 }
4466
4467 #[test]
4468 fn codex_preserves_commentary_and_final_response_phases() {
4469 let codex = concat!(
4470 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the code"}]}}"#,
4471 "\n",
4472 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"}]}}"#,
4473 "\n",
4474 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"}]}}"#,
4475 );
4476 let session = parse_session_content(
4477 AGENT_CODEX,
4478 &PathBuf::from("/tmp/session.jsonl"),
4479 UNIX_EPOCH,
4480 codex,
4481 )
4482 .expect("session");
4483
4484 assert_eq!(session.events.llm_responses.len(), 2);
4485 assert_eq!(session.events.llm_responses[0].response_phase, "commentary");
4486 assert_eq!(
4487 session.events.llm_responses[1].response_phase,
4488 "final_answer"
4489 );
4490 }
4491
4492 #[test]
4493 fn semantic_task_label_prefers_explicit_goal_payload() {
4494 let raw = "prefix <objective>write a paper and evaluate it</objective> suffix";
4495 assert_eq!(semantic_task_label(raw), "write a paper and evaluate it");
4496 }
4497
4498 #[test]
4499 fn codex_fork_excludes_copied_parent_history_before_ownership_boundary() {
4500 let codex = concat!(
4501 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"}}"#,
4502 "\n",
4503 r#"{"type":"event_msg","payload":{"type":"user_message","message":"copied parent task"}}"#,
4504 "\n",
4505 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"copied","arguments":"{\"cmd\":\"false\"}"}}"#,
4506 "\n",
4507 r#"{"type":"event_msg","payload":{"type":"task_started","started_at":2.0}}"#,
4508 "\n",
4509 r#"{"type":"event_msg","payload":{"type":"user_message","message":"review child result"}}"#,
4510 "\n",
4511 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"owned","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4512 );
4513
4514 let session = parse_session_content(
4515 AGENT_CODEX,
4516 &PathBuf::from("/tmp/child.jsonl"),
4517 UNIX_EPOCH,
4518 codex,
4519 )
4520 .expect("child session");
4521
4522 assert_eq!(session.session_id, "child");
4523 assert_eq!(session.conversation_id.as_deref(), Some("parent"));
4524 assert_eq!(session.events.prompts.len(), 1);
4525 assert_eq!(session.events.prompts[0].preview, "review child result");
4526 assert_eq!(session.events.tools.len(), 1);
4527 assert_eq!(session.events.tools[0].call_id.as_deref(), Some("owned"));
4528 }
4529
4530 #[test]
4531 fn file_actions_ignore_patch_and_heredoc_bodies() {
4532 let patch = tool_event_from_input(
4533 Some("/repo"),
4534 Some(1),
4535 0,
4536 "exec",
4537 &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)"#}),
4538 None,
4539 Vec::new(),
4540 );
4541 assert_eq!(
4542 patch.paths,
4543 vec![ToolPath {
4544 path: "src/lib.rs".into(),
4545 access: "write".into(),
4546 previous_path: None,
4547 }]
4548 );
4549
4550 let heredoc = tool_event_from_input(
4551 Some("/repo"),
4552 Some(1),
4553 0,
4554 "exec_command",
4555 &json!({"cmd": "cat <<'EOF'\n#!/bin/sh\nsrc/not-a-file.rs\nEOF\ncat src/real.rs"}),
4556 None,
4557 Vec::new(),
4558 );
4559 assert_eq!(heredoc.paths.len(), 1);
4560 assert_eq!(heredoc.paths[0].path, "src/real.rs");
4561 }
4562
4563 #[test]
4564 fn shell_path_operands_are_not_limited_to_known_extensions() {
4565 let paths_of = |command: &str| {
4566 shell_file_actions(command, &json!({"cwd": "/repo"}), 0)
4567 .into_iter()
4568 .map(|(path, access, _)| (path, access))
4569 .collect::<Vec<_>>()
4570 };
4571
4572 for (command, expected, access) in [
4576 ("rm build.sh", "/repo/build.sh", "delete"),
4577 ("rm main.go", "/repo/main.go", "delete"),
4578 ("rm Dockerfile", "/repo/Dockerfile", "delete"),
4579 ("mv notes.txt archive.txt", "/repo/archive.txt", "rename"),
4580 (
4581 "mv conf.yaml conf.bak.yaml",
4582 "/repo/conf.bak.yaml",
4583 "rename",
4584 ),
4585 ("touch schema.sql", "/repo/schema.sql", "create"),
4586 ] {
4587 assert!(
4588 paths_of(command)
4589 .iter()
4590 .any(|(path, kind)| path == expected && kind == access),
4591 "{command} should record {expected} as {access}, got {:?}",
4592 paths_of(command)
4593 );
4594 }
4595
4596 for command in [
4599 "rm origin/main",
4600 "rm HEAD",
4601 "rm *.log",
4602 "rm https://example.com/x",
4603 "rm s/foo/bar/g",
4604 "rm $TARGET",
4605 "rm -rf",
4606 ] {
4607 assert!(
4608 paths_of(command).is_empty(),
4609 "{command} should record nothing, got {:?}",
4610 paths_of(command)
4611 );
4612 }
4613
4614 let redirected = paths_of("cat notes.txt 2>&1");
4617 assert!(
4618 redirected.iter().any(|(path, _)| path == "/repo/notes.txt"),
4619 "the real file should still be recorded, got {redirected:?}"
4620 );
4621 assert!(
4622 !redirected.iter().any(|(path, _)| path.ends_with("/2")),
4623 "a file descriptor is not a file, got {redirected:?}"
4624 );
4625
4626 for command in ["rm 2", "cat 1"] {
4627 assert!(
4628 paths_of(command).is_empty(),
4629 "{command} should record nothing, got {:?}",
4630 paths_of(command)
4631 );
4632 }
4633 }
4634
4635 #[test]
4636 fn scanned_command_tokens_still_need_evidence_of_being_a_path() {
4637 let event = tool_event_from_input(
4641 Some("/repo"),
4642 Some(1),
4643 0,
4644 "exec_command",
4645 &json!({"cmd": "curl example.com && echo 1.2.3"}),
4646 None,
4647 Vec::new(),
4648 );
4649 assert!(
4650 event.path_groups.is_empty(),
4651 "hostname and version should not become path groups, got {:?}",
4652 event.path_groups
4653 );
4654 }
4655
4656 #[test]
4657 fn file_actions_are_conservative_for_unknown_and_write_tools() {
4658 let unknown = tool_event_from_input(
4659 Some("/repo"),
4660 Some(1),
4661 0,
4662 "mcp_resource",
4663 &json!({"path": "src/not-a-file.rs"}),
4664 None,
4665 Vec::new(),
4666 );
4667 assert!(unknown.paths.is_empty());
4668
4669 let write = tool_event_from_input(
4670 Some("/repo"),
4671 Some(1),
4672 0,
4673 "Write",
4674 &json!({"file_path": "src/existing.rs", "content": "changed"}),
4675 None,
4676 Vec::new(),
4677 );
4678 assert_eq!(write.paths[0].access, "write");
4679 }
4680
4681 #[test]
4682 fn patch_move_keeps_the_immediately_preceding_source() {
4683 let event = tool_event_from_input(
4684 Some("/repo"),
4685 Some(1),
4686 0,
4687 "apply_patch",
4688 &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"}),
4689 None,
4690 Vec::new(),
4691 );
4692 assert!(event.paths.contains(&ToolPath {
4693 path: "src/b.rs".into(),
4694 access: "rename".into(),
4695 previous_path: Some("src/a.rs".into()),
4696 }));
4697 assert!(event.paths.contains(&ToolPath {
4698 path: "src/c.rs".into(),
4699 access: "write".into(),
4700 previous_path: None,
4701 }));
4702
4703 let event = tool_event_from_input(
4704 Some("/repo"),
4705 Some(1),
4706 0,
4707 "apply_patch",
4708 &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"}),
4709 None,
4710 Vec::new(),
4711 );
4712 assert_eq!(
4713 event
4714 .paths
4715 .iter()
4716 .map(|row| (row.path.as_str(), row.previous_path.as_deref()))
4717 .collect::<Vec<_>>(),
4718 vec![("x.rs", Some("a.rs")), ("y.rs", Some("b.rs"))]
4719 );
4720 }
4721
4722 #[test]
4723 fn tool_outputs_mark_failed_file_actions() {
4724 let content = concat!(
4725 r#"{"type":"turn_context","payload":{"cwd":"/repo"}}"#,
4726 "\n",
4727 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"rm src/lib.rs\"}"}}"#,
4728 "\n",
4729 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 1"}}"#,
4730 );
4731 let session = parse_session_content(
4732 AGENT_CODEX,
4733 Path::new("/tmp/session.jsonl"),
4734 UNIX_EPOCH,
4735 content,
4736 )
4737 .expect("session");
4738 assert_eq!(session.events.tools[0].status, "fail");
4739 assert_eq!(session.events.tools[0].paths[0].access, "delete");
4740
4741 let claude = concat!(
4742 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"}}]}}"#,
4743 "\n",
4744 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"}]}}"#,
4745 );
4746 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"}]}]}"#;
4747 for (agent, content, expected) in [
4748 (AGENT_CLAUDE, claude, &["ok", "fail"][..]),
4749 (AGENT_GEMINI, gemini, &["fail"][..]),
4750 ] {
4751 let session =
4752 parse_session_content(agent, Path::new("/tmp/session.jsonl"), UNIX_EPOCH, content)
4753 .unwrap();
4754 let statuses = session
4755 .events
4756 .tools
4757 .iter()
4758 .map(|row| row.status.as_str())
4759 .collect::<Vec<_>>();
4760 assert_eq!(statuses, expected);
4761 }
4762 }
4763
4764 #[test]
4765 fn codex_exec_prompt_handles_latest_cli_options() {
4766 let command = concat!(
4767 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
4768 "-c model_provider=\"agentsight-mock\" ",
4769 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
4770 "--sandbox read-only --model gpt-agentsight-mock ",
4771 "agentsight mock prompt collect this exact text"
4772 );
4773
4774 assert_eq!(
4775 codex_exec_prompt(command).as_deref(),
4776 Some("agentsight mock prompt collect this exact text")
4777 );
4778 }
4779
4780 #[test]
4781 fn codex_cumulative_usage_separates_cached_input() {
4782 let content = concat!(
4783 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}"#,
4784 "\n",
4785 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}}}}"#,
4786 );
4787
4788 let session = parse_session_content(
4789 AGENT_CODEX,
4790 &PathBuf::from("/tmp/session.jsonl"),
4791 UNIX_EPOCH,
4792 content,
4793 )
4794 .expect("session");
4795
4796 assert_eq!(session.usage.input_tokens, 9_200);
4797 assert_eq!(session.usage.cache_read_tokens, 9_984);
4798 assert_eq!(session.usage.output_tokens, 11);
4799 assert_eq!(session.usage.total_tokens, 19_195);
4800 }
4801
4802 #[test]
4803 fn codex_exec_wrapper_projects_nested_shell_actions() {
4804 let event = tool_event_from_input(
4805 Some("/repo"),
4806 Some(1),
4807 0,
4808 "exec",
4809 &json!({"text": r#"const r = await tools.exec_command({"cmd":"cat src/lib.rs && sed -i 's/a/b/' src/main.rs","workdir":"/repo"});"#}),
4810 None,
4811 Vec::new(),
4812 );
4813 assert_eq!(
4814 event
4815 .paths
4816 .iter()
4817 .map(|path| (path.path.as_str(), path.access.as_str()))
4818 .collect::<Vec<_>>(),
4819 vec![("/repo/src/lib.rs", "read"), ("/repo/src/main.rs", "write")]
4820 );
4821 }
4822
4823 #[test]
4824 fn claude_uuid_only_fragments_share_one_completion_identity() {
4825 let claude = [
4826 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4827 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}}}"#,
4828 r#"{"type":"system","subtype":"internal-marker"}"#,
4829 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}}}"#,
4830 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}}}"#,
4831 ]
4832 .join("\n");
4833
4834 let session = parse_session_content(
4835 AGENT_CLAUDE,
4836 &PathBuf::from("/tmp/session.jsonl"),
4837 UNIX_EPOCH,
4838 &claude,
4839 )
4840 .expect("session");
4841
4842 assert_eq!(session.events.llm_responses.len(), 1);
4843 assert_eq!(session.events.llm_responses[0].source_id, "completion-1");
4844 assert_eq!(session.events.llm_responses[0].skill, "");
4845 assert_eq!(
4846 session.events.llm_responses[0]
4847 .token_components()
4848 .into_iter()
4849 .map(|(_, value)| value)
4850 .sum::<u64>(),
4851 113
4852 );
4853 assert_eq!(session.events.tools[0].skill, "paper-writing-style");
4854 assert_eq!(session.events.tools[0].invoked_skill, "paper-writing-style");
4855 }
4856
4857 #[test]
4858 fn claude_skill_scope_ignores_metadata_and_deduplicates_split_completion() {
4859 let claude = [
4860 r#"{"type":"system","skill_listing":["availability only"]}"#,
4861 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4862 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}}}"#,
4863 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}}}"#,
4864 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}}}"#,
4865 r#"{"type":"user","promptId":"p1","isMeta":true,"sourceToolUseID":"s1","message":{"content":[{"type":"text","text":"skill payload"}]}}"#,
4866 r#"{"type":"last-prompt","lastPrompt":"review the paper"}"#,
4867 r#"{"type":"user","message":{"content":"<local-command-stdout>metadata</local-command-stdout>"}}"#,
4868 r#"{"type":"user","promptId":"attachment-only","attachments":[{"file_name":"paper.pdf"}],"message":{"content":"attached context"}}"#,
4869 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}}}"#,
4870 r#"{"type":"user","promptId":"p1","sourceToolAssistantUUID":"assistant-2","message":{"content":[{"type":"tool_result","tool_use_id":"b1","content":"ok"}]}}"#,
4871 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}}}"#,
4872 r#"{"type":"user","promptId":"p2","message":{"content":"now summarize"}}"#,
4873 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}}}"#,
4874 ]
4875 .join("\n");
4876
4877 let session = parse_session_content(
4878 AGENT_CLAUDE,
4879 &PathBuf::from("/tmp/session.jsonl"),
4880 UNIX_EPOCH,
4881 &claude,
4882 )
4883 .expect("session");
4884
4885 assert_eq!(session.events.prompts.len(), 2);
4886 assert_eq!(session.events.llm_responses.len(), 4);
4887 assert_eq!(session.events.llm_responses[0].source_id, "msg-1");
4888 assert_eq!(
4889 session.events.llm_responses[0]
4890 .token_components()
4891 .into_iter()
4892 .map(|(_, value)| value)
4893 .sum::<u64>(),
4894 113
4895 );
4896 assert_eq!(
4897 session
4898 .events
4899 .tools
4900 .iter()
4901 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4902 .collect::<Vec<_>>(),
4903 [
4904 ("Skill", "check-paper-citations"),
4905 ("Bash", "check-paper-citations"),
4906 ("Read", "check-paper-citations"),
4907 ]
4908 );
4909 assert_eq!(
4910 session
4911 .events
4912 .llm_responses
4913 .iter()
4914 .map(|response| response.skill.as_str())
4915 .collect::<Vec<_>>(),
4916 ["", "check-paper-citations", "check-paper-citations", ""]
4917 );
4918 }
4919}