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