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