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