1use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::types::{
14 AgentSession, LlmResponse, SessionCandidate, SessionDirStat, SessionEvents, TokenUsage,
15 ToolEvent, ToolPath, UserPrompt,
16};
17use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_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 ];
34 let mut out = Vec::new();
35 for (agent, dir) in roots {
36 walk_agent_files(agent, &dir, &mut |path, meta| {
37 out.push(SessionCandidate {
38 agent,
39 path: path.to_path_buf(),
40 updated: meta.modified().unwrap_or(UNIX_EPOCH),
41 });
42 });
43 }
44 out
45}
46
47pub fn discover_session_files_in_dir(agent: &'static str, dir: &Path) -> Vec<SessionCandidate> {
48 let mut out = Vec::new();
49 walk_agent_files(agent, dir, &mut |path, meta| {
50 out.push(SessionCandidate {
51 agent,
52 path: path.to_path_buf(),
53 updated: meta.modified().unwrap_or(UNIX_EPOCH),
54 });
55 });
56 out
57}
58
59pub fn count_session_dirs() -> Vec<SessionDirStat> {
61 let Some(home) = user_home_dir() else {
62 return Vec::new();
63 };
64 [
65 (AGENT_CLAUDE, home.join(".claude/projects")),
66 (AGENT_CODEX, home.join(".codex/sessions")),
67 (AGENT_GEMINI, home.join(".gemini/tmp")),
68 ]
69 .into_iter()
70 .filter_map(|(agent, dir)| {
71 let (mut sessions, mut bytes) = (0usize, 0u64);
72 walk_agent_files(agent, &dir, &mut |_, meta| {
73 sessions += 1;
74 bytes += meta.len();
75 });
76 (sessions > 0).then_some(SessionDirStat {
77 agent,
78 dir,
79 sessions,
80 bytes,
81 })
82 })
83 .collect()
84}
85
86pub fn session_candidate_from_path(path: &Path) -> Option<SessionCandidate> {
87 let agent = agent_source_for_path(path).or_else(|| loose_agent_source_for_path(path))?;
88 let updated = fs::metadata(path)
89 .and_then(|metadata| metadata.modified())
90 .unwrap_or(UNIX_EPOCH);
91 Some(SessionCandidate {
92 agent,
93 path: path.to_path_buf(),
94 updated,
95 })
96}
97
98pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
100 let content = fs::read_to_string(&candidate.path).ok()?;
101 parse_session_content(
102 candidate.agent,
103 &candidate.path,
104 candidate.updated,
105 &content,
106 )
107}
108
109pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
111 parse_session_file(&session_candidate_from_path(path)?)
112}
113
114pub fn parse_session_content(
116 agent: &str,
117 path: &Path,
118 updated: SystemTime,
119 content: &str,
120) -> Option<AgentSession> {
121 parse_session_impl(agent, path, updated, content)
122}
123
124fn parse_session_impl(
125 agent: &str,
126 path: &Path,
127 updated: SystemTime,
128 content: &str,
129) -> Option<AgentSession> {
130 if agent == AGENT_GEMINI {
131 parse_gemini_json(path, updated, content)
132 } else {
133 parse_jsonl(agent, path, updated, content)
134 }
135}
136
137pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
139 let trimmed = raw.trim().trim_end_matches(" (deleted)");
140 if trimmed.is_empty() {
141 return None;
142 }
143 let path = Path::new(trimmed);
144 if !path.is_absolute() || !is_agent_session_file(path) {
145 return None;
146 }
147 agent_source_for_path(path).map(|_| normalize_session_log_path(path))
148}
149
150pub fn normalize_session_log_path(path: &Path) -> PathBuf {
152 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
153}
154
155pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
157 let value = path.to_string_lossy();
158 if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
159 {
160 Some(AGENT_CLAUDE)
161 } else if value.contains("/.codex/")
162 && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
163 {
164 Some(AGENT_CODEX)
165 } else if value.contains("/.gemini/")
166 && path.extension().and_then(|ext| ext.to_str()) == Some("json")
167 {
168 Some(AGENT_GEMINI)
169 } else {
170 None
171 }
172}
173
174fn loose_agent_source_for_path(path: &Path) -> Option<&'static str> {
175 let value = path.to_string_lossy();
176 if value.contains("/codex/") && value.contains("sessions") {
177 Some(AGENT_CODEX)
178 } else if value.contains("/claude/") && value.contains("projects") {
179 Some(AGENT_CLAUDE)
180 } else {
181 None
182 }
183}
184
185pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
187 match agent {
188 AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
189 AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
190 AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
191 _ => None,
192 }
193}
194
195pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
197 target.is_some_and(|target| {
198 Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
199 && !target.contains("/node_modules/")
200 })
201}
202
203pub fn codex_exec_prompt(command: &str) -> Option<String> {
205 let args = shell_words(command.split_once(" exec ")?.1.trim())?;
206 let mut index = 0usize;
207 while index < args.len() {
208 let arg = args[index].as_str();
209 if arg == "--" {
210 index += 1;
211 break;
212 }
213 if !arg.starts_with('-') {
214 break;
215 }
216 let consumed = codex_exec_option_arity(arg)?;
217 index += consumed;
218 }
219 (index < args.len())
220 .then(|| args[index..].join(" "))
221 .and_then(|prompt| clean_prompt_text(&prompt))
222}
223
224fn codex_exec_option_arity(arg: &str) -> Option<usize> {
225 if arg.contains('=') && arg.starts_with("--") {
226 return Some(1);
227 }
228
229 match arg {
230 "--json"
231 | "--skip-git-repo-check"
232 | "--ephemeral"
233 | "--ignore-user-config"
234 | "--full-auto"
235 | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
236 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
237 | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
238 | "--output-format" | "--color" => Some(2),
239 _ => None,
240 }
241}
242
243fn shell_words(input: &str) -> Option<Vec<String>> {
244 let mut words = Vec::new();
245 let mut current = String::new();
246 let mut quote = None::<char>;
247 let mut chars = input.chars().peekable();
248
249 while let Some(ch) = chars.next() {
250 match (quote, ch) {
251 (None, c) if c.is_whitespace() => {
252 if !current.is_empty() {
253 words.push(std::mem::take(&mut current));
254 }
255 }
256 (None, '\'' | '"') => quote = Some(ch),
257 (Some(q), c) if c == q => quote = None,
258 (_, '\\') => {
259 if let Some(next) = chars.next() {
260 current.push(next);
261 }
262 }
263 _ => current.push(ch),
264 }
265 }
266 if quote.is_some() {
267 return None;
268 }
269 if !current.is_empty() {
270 words.push(current);
271 }
272 Some(words)
273}
274
275#[derive(Default)]
280struct SemanticTaskStack {
281 root: Option<String>,
282 active_plan: Option<String>,
283}
284
285impl SemanticTaskStack {
286 fn observe_user(&mut self, text: &str) {
287 let label = semantic_task_label(text);
288 if self.root.is_some() && is_continuation_prompt(&label) {
289 return;
290 }
291 self.root = Some(label);
292 self.active_plan = None;
293 }
294
295 fn observe_plan(&mut self, input: &Value) {
296 let active = input
297 .get("plan")
298 .and_then(Value::as_array)
299 .into_iter()
300 .flatten()
301 .filter_map(|item| {
302 (item.get("status").and_then(Value::as_str) == Some("in_progress"))
303 .then(|| item.get("step").and_then(Value::as_str))
304 .flatten()
305 .map(semantic_task_label)
306 })
307 .collect::<Vec<_>>();
308 self.active_plan = match active.as_slice() {
309 [] => None,
310 [only] => Some(only.clone()),
311 many => self
312 .active_plan
313 .as_ref()
314 .filter(|current| many.contains(current))
315 .cloned()
316 .or_else(|| many.first().cloned()),
317 };
318 }
319
320 fn path(&self) -> Vec<String> {
321 self.root
322 .iter()
323 .chain(self.active_plan.iter())
324 .cloned()
325 .collect()
326 }
327
328 fn path_for_tool(&self, name: &str, input: &Value) -> Vec<String> {
329 let mut path = self.path();
330 if name == "spawn_agent"
331 && let Some(label) = input
332 .get("task_name")
333 .or_else(|| input.get("message"))
334 .and_then(Value::as_str)
335 {
336 path.push(semantic_task_label(label));
337 }
338 path
339 }
340}
341
342pub fn semantic_task_label(text: &str) -> String {
343 let mut selected = text.trim();
344 if let Some(start) = selected.rfind("## My request for Codex:") {
345 selected = &selected[start + "## My request for Codex:".len()..];
346 } else if let Some(start) = selected.find("<objective>")
347 && let Some(end) = selected[start + "<objective>".len()..].find("</objective>")
348 {
349 selected = &selected[start + "<objective>".len()..start + "<objective>".len() + end];
350 }
351 let label = truncate_clean(selected.trim_matches(['\'', '"']), 120);
352 if label.is_empty() {
353 "unnamed task".to_string()
354 } else {
355 label
356 }
357}
358
359fn is_continuation_prompt(text: &str) -> bool {
360 let lowered = text.trim().to_lowercase();
361 matches!(
362 lowered.as_str(),
363 "继续"
364 | "继续做"
365 | "去做"
366 | "开始"
367 | "嗯"
368 | "好"
369 | "好的"
370 | "continue"
371 | "go on"
372 | "proceed"
373 | "do it"
374 | "ok"
375 | "okay"
376 )
377}
378
379fn parse_jsonl(
380 agent: &str,
381 path: &Path,
382 updated: SystemTime,
383 content: &str,
384) -> Option<AgentSession> {
385 let mut acc = SessionAccumulator::new(agent, path, updated);
386 let mut codex_model = String::new();
387 let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
388 let mut claude_seen_usage = HashSet::new();
389 let mut events = SessionEvents::default();
390 let mut current_prompt_index = 0usize;
391 let mut call_index = BTreeMap::<String, usize>::new();
392 let mut task_stack = SemanticTaskStack::default();
393 let mut active_skill: Option<String> = None;
394 let mut claude_prompt_id: Option<String> = None;
395 let mut codex_meta_seen = false;
396 let mut codex_owns_events = true;
397 let mut codex_session_started_at = 0.0_f64;
398
399 for line in content.lines() {
400 let Ok(obj) = serde_json::from_str::<Value>(line) else {
401 continue;
402 };
403 let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
404 if agent == AGENT_CODEX && typ == "session_meta" {
405 if !codex_meta_seen {
406 codex_meta_seen = true;
407 let payload = obj.get("payload").unwrap_or(&Value::Null);
408 if let Some(id) = payload
409 .get("id")
410 .or_else(|| payload.get("session_id"))
411 .and_then(Value::as_str)
412 {
413 acc.session_id = id.to_string();
414 }
415 acc.conversation_id = payload
416 .get("session_id")
417 .and_then(Value::as_str)
418 .map(str::to_string);
419 let parent = payload
420 .get("parent_thread_id")
421 .or_else(|| payload.get("forked_from_id"))
422 .and_then(Value::as_str)
423 .or_else(|| {
424 payload
425 .pointer("/source/subagent/thread_spawn/parent_thread_id")
426 .and_then(Value::as_str)
427 });
428 codex_owns_events = parent.is_none_or(str::is_empty);
429 codex_session_started_at = payload
430 .get("timestamp")
431 .or_else(|| obj.get("timestamp"))
432 .and_then(Value::as_str)
433 .and_then(rfc3339_seconds)
434 .unwrap_or_default();
435 if acc.cwd.is_none() {
436 acc.cwd = payload
437 .get("cwd")
438 .and_then(Value::as_str)
439 .filter(|cwd| !cwd.is_empty())
440 .map(str::to_string);
441 }
442 }
443 continue;
444 }
445 if agent == AGENT_CODEX && !codex_owns_events {
446 let payload = obj.get("payload").unwrap_or(&Value::Null);
447 if typ == "event_msg"
448 && payload.get("type").and_then(Value::as_str) == Some("task_started")
449 {
450 let source_start = payload
451 .get("started_at")
452 .and_then(Value::as_f64)
453 .filter(|value| *value > 0.0)
454 .or_else(|| {
455 payload
456 .get("turn_id")
457 .and_then(Value::as_str)
458 .and_then(uuid7_seconds)
459 })
460 .unwrap_or_default();
461 if source_start > 0.0
462 && (codex_session_started_at == 0.0
463 || source_start >= codex_session_started_at.floor())
464 {
465 codex_owns_events = true;
466 }
467 }
468 continue;
469 }
470 let (session_id, conversation_id) = local_session_ids(&obj);
471 if let Some(id) = session_id {
472 acc.session_id = id;
473 }
474 if let Some(id) = conversation_id {
475 acc.conversation_id = Some(id);
476 }
477 if acc.cwd.is_none() {
478 acc.cwd = obj
479 .get("cwd")
480 .and_then(Value::as_str)
481 .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
482 .filter(|s| !s.is_empty())
483 .map(ToString::to_string);
484 }
485 if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
486 acc.last_message_at = Some(ts.to_string());
487 acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
488 }
489 match (agent, typ) {
490 (AGENT_CLAUDE, "result") => {
491 acc.duration_ms = json_u64(&obj, "duration_ms");
492 if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
493 for (name, usage) in model_usage {
494 acc.model.get_or_insert_with(|| name.clone());
495 acc.add_usage(
496 name,
497 json_i64(usage, "inputTokens"),
498 json_i64(usage, "outputTokens"),
499 json_i64(usage, "cacheCreationInputTokens"),
500 json_i64(usage, "cacheReadInputTokens"),
501 0,
502 );
503 }
504 }
505 }
506 (AGENT_CLAUDE, "assistant") => {
507 let response_skill = active_skill.clone().unwrap_or_default();
508 if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
509 acc.model.get_or_insert_with(|| name.to_string());
510 }
511 let model = obj
512 .pointer("/message/model")
513 .and_then(Value::as_str)
514 .or(acc.model.as_deref())
515 .unwrap_or(AGENT_CLAUDE)
516 .to_string();
517 if let Some(usage) = obj.pointer("/message/usage")
518 && claude_seen_usage.insert(claude_usage_key(&obj))
519 {
520 let name = obj
521 .pointer("/message/model")
522 .and_then(Value::as_str)
523 .unwrap_or("unknown");
524 add_usage(
525 &mut claude_message_models,
526 name,
527 json_i64(usage, "input_tokens"),
528 json_i64(usage, "output_tokens"),
529 json_i64(usage, "cache_creation_input_tokens"),
530 json_i64(usage, "cache_read_input_tokens"),
531 0,
532 );
533 }
534 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
535 if let Some(items) = content.as_array() {
536 for item in items
537 .iter()
538 .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
539 {
540 let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
541 let input = item.get("input").unwrap_or(&Value::Null);
542 let invoked_skill = exact_claude_skill_invocation(name, input);
543 if let Some(skill) = invoked_skill.as_ref() {
544 active_skill = Some(skill.clone());
545 }
546 acc.add_tool(name);
547 if let Some(fp) = item
548 .pointer("/input/file_path")
549 .and_then(Value::as_str)
550 .filter(|s| !is_noise_path(s))
551 {
552 acc.add_file(fp);
553 }
554 let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
555 let event = tool_event_from_input(
556 acc.cwd.as_deref(),
557 ts_ms_from_event(&obj),
558 current_prompt_index,
559 name,
560 input,
561 call_id.clone(),
562 task_stack.path_for_tool(name, input),
563 );
564 let mut event = event;
565 event.invoked_skill = invoked_skill.unwrap_or_default();
566 event.skill = active_skill.clone().unwrap_or_default();
567 if let Some(id) = call_id {
568 call_index.insert(id, events.tools.len());
569 }
570 events.tools.push(event);
571 }
572 }
573 let text = content_to_text(content);
574 let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
575 if !text.trim().is_empty() || usage.is_object() {
576 let preview_text = if !text.trim().is_empty() {
578 text.clone()
579 } else if let Some(items) = content.as_array() {
580 let tool_names: Vec<_> = items
581 .iter()
582 .filter_map(|item| {
583 if item.get("type").and_then(Value::as_str) == Some("tool_use") {
584 item.get("name").and_then(Value::as_str)
585 } else {
586 None
587 }
588 })
589 .collect();
590 if tool_names.is_empty() {
591 String::new()
592 } else {
593 format!("tool: {}", tool_names.join(", "))
594 }
595 } else {
596 String::new()
597 };
598 events.llm_responses.push(LlmResponse {
599 ts_ms: ts_ms_from_event(&obj),
600 prompt_index: current_prompt_index,
601 model,
602 source_id: claude_source_completion_id(&obj),
603 text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
604 preview: truncate_clean(
605 if preview_text.is_empty() {
606 "token report"
607 } else {
608 &preview_text
609 },
610 140,
611 ),
612 input_tokens: json_u64(usage, "input_tokens"),
613 output_tokens: json_u64(usage, "output_tokens"),
614 cache_tokens: json_u64(usage, "cache_creation_input_tokens")
615 + json_u64(usage, "cache_read_input_tokens"),
616 total_tokens: 0,
617 tag: String::new(),
618 response_phase: if obj
619 .pointer("/message/stop_reason")
620 .and_then(Value::as_str)
621 == Some("end_turn")
622 && !text.trim().is_empty()
623 {
624 "final_answer".to_string()
625 } else {
626 "assistant_message".to_string()
627 },
628 skill: response_skill,
629 task_path: task_stack.path(),
630 });
631 }
632 }
633 (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
634 if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
635 && let Some(text) = obj.get("content").and_then(Value::as_str)
636 && let Some(text) = clean_prompt_text(text)
637 {
638 acc.prompt_preview = Some(text.clone());
639 task_stack.observe_user(&text);
640 current_prompt_index =
641 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
642 }
643 }
644 (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
645 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
646 && let Some(text) = clean_prompt_text(text)
647 {
648 acc.prompt_preview = Some(text.clone());
649 task_stack.observe_user(&text);
650 current_prompt_index =
651 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
652 }
653 }
654 (AGENT_CLAUDE, "user") => {
655 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
656 if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
657 let fallback = obj
658 .pointer("/toolUseResult/is_error")
659 .and_then(Value::as_bool)
660 .unwrap_or(false);
661 for result in content.as_array().into_iter().flatten() {
662 let Some(id) = result.get("tool_use_id").and_then(Value::as_str) else {
663 continue;
664 };
665 if let Some(index) = call_index.get(id).copied()
666 && let Some(tool) = events.tools.get_mut(index)
667 {
668 let failed = result
669 .get("is_error")
670 .and_then(Value::as_bool)
671 .unwrap_or(fallback);
672 tool.status = if failed { "fail" } else { "ok" }.to_string();
673 }
674 }
675 } else if let Some(text) = local_message_preview(content)
676 && claude_user_starts_prompt(&obj, content, &text, claude_prompt_id.as_deref())
677 {
678 if acc.prompt_preview.is_none() {
679 acc.prompt_preview = Some(text.clone());
680 }
681 task_stack.observe_user(&text);
682 active_skill = None;
683 claude_prompt_id = obj
684 .get("promptId")
685 .and_then(Value::as_str)
686 .filter(|value| !value.is_empty())
687 .map(str::to_string);
688 current_prompt_index =
689 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
690 }
691 }
692 (AGENT_CODEX, "turn_context") => {
693 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
694 codex_model = name.to_string();
695 acc.model = Some(name.to_string());
696 }
697 }
698 (AGENT_CODEX, "event_msg") => {
699 let payload = obj.get("payload").unwrap_or(&Value::Null);
700 let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
701 if ptype == "token_count"
702 && let Some(usage) = payload.pointer("/info/total_token_usage")
703 {
704 let name = if codex_model.is_empty() {
705 "unknown"
706 } else {
707 &codex_model
708 };
709 let usage = codex_token_usage(usage);
710 acc.set_usage(
711 name,
712 usage.input_tokens,
713 usage.output_tokens,
714 0,
715 usage.cache_read_tokens,
716 usage.total_tokens,
717 );
718 }
719 if matches!(ptype, "token_count" | "token_usage") {
720 let info = payload
721 .get("info")
722 .or_else(|| payload.get("usage"))
723 .unwrap_or(payload);
724 let token_usage = info
725 .get("last_token_usage")
726 .or_else(|| info.get("total_token_usage"))
727 .unwrap_or(info);
728 let input_tokens = json_u64(token_usage, "input_tokens");
729 let output_tokens = json_u64(token_usage, "output_tokens");
730 let cache_tokens = json_u64(token_usage, "cached_input_tokens");
731 let total_tokens = json_u64(token_usage, "total_tokens")
732 .max(json_u64(info, "total_tokens"))
733 .max(json_u64(info, "tokens"));
734 if total_tokens > 0
735 && let Some(last) = events.llm_responses.last_mut()
736 && last.total_tokens == 0
737 {
738 last.input_tokens = input_tokens;
739 last.output_tokens = output_tokens;
740 last.cache_tokens = cache_tokens;
741 last.total_tokens = total_tokens;
742 }
743 }
744 if ptype == "user_message" {
745 let text = payload
746 .get("message")
747 .or_else(|| payload.get("content"))
748 .and_then(Value::as_str)
749 .unwrap_or("");
750 if let Some(text) = clean_prompt_text(text) {
751 acc.prompt_preview = Some(text.clone());
752 task_stack.observe_user(&text);
753 current_prompt_index =
754 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
755 }
756 }
757 if ptype == "agent_message" {
758 let text = payload
759 .get("message")
760 .or_else(|| payload.get("content"))
761 .and_then(Value::as_str)
762 .unwrap_or("");
763 if let Some(text) = clean_prompt_text(text) {
764 events.llm_responses.push(LlmResponse {
765 ts_ms: ts_ms_from_event(&obj),
766 prompt_index: current_prompt_index,
767 model: if codex_model.is_empty() {
768 AGENT_CODEX.to_string()
769 } else {
770 codex_model.clone()
771 },
772 source_id: String::new(),
773 text_hash: short_hash(&text, 12),
774 preview: truncate_clean(&text, 180),
775 input_tokens: 0,
776 output_tokens: 0,
777 cache_tokens: 0,
778 total_tokens: 0,
779 tag: String::new(),
780 response_phase: payload
781 .get("phase")
782 .and_then(Value::as_str)
783 .unwrap_or("assistant_message")
784 .to_string(),
785 skill: String::new(),
786 task_path: task_stack.path(),
787 });
788 }
789 }
790 }
791 (AGENT_CODEX, "response_item")
792 if obj.pointer("/payload/type").and_then(Value::as_str)
793 == Some("custom_tool_call") =>
794 {
795 let payload = obj.get("payload").unwrap_or(&Value::Null);
796 let outer_name = payload
797 .get("name")
798 .and_then(Value::as_str)
799 .unwrap_or("tool");
800 let raw_input = payload.get("input").and_then(Value::as_str).unwrap_or("");
801 let (name, args) = codex_custom_tool_input(outer_name, raw_input);
802 acc.add_tool(&name);
803 let call_id = payload
804 .get("call_id")
805 .and_then(Value::as_str)
806 .map(str::to_string);
807 let event = tool_event_from_input(
808 acc.cwd.as_deref(),
809 ts_ms_from_event(&obj),
810 current_prompt_index,
811 &name,
812 &args,
813 call_id.clone(),
814 task_stack.path_for_tool(&name, &args),
815 );
816 if name == "update_plan" {
817 task_stack.observe_plan(&args);
818 }
819 if let Some(id) = call_id {
820 call_index.insert(id, events.tools.len());
821 }
822 events.tools.push(event);
823 }
824 (AGENT_CODEX, "response_item")
825 if obj.pointer("/payload/type").and_then(Value::as_str)
826 == Some("custom_tool_call_output") =>
827 {
828 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
829 && let Some(index) = call_index.get(call_id).copied()
830 && let Some(tool) = events.tools.get_mut(index)
831 {
832 let output =
833 content_to_text(obj.pointer("/payload/output").unwrap_or(&Value::Null));
834 tool.status = status_from_output(&output).to_string();
835 }
836 }
837 (AGENT_CODEX, "response_item")
838 if obj.pointer("/payload/type").and_then(Value::as_str)
839 == Some("function_call") =>
840 {
841 let name = obj
842 .pointer("/payload/name")
843 .and_then(Value::as_str)
844 .unwrap_or("?");
845 acc.add_tool(name);
846 let payload = obj.get("payload").unwrap_or(&Value::Null);
847 let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
848 let call_id = payload
849 .get("call_id")
850 .and_then(Value::as_str)
851 .map(str::to_string);
852 let event = tool_event_from_input(
853 acc.cwd.as_deref(),
854 ts_ms_from_event(&obj),
855 current_prompt_index,
856 name,
857 &args,
858 call_id.clone(),
859 task_stack.path_for_tool(name, &args),
860 );
861 if name == "update_plan" {
862 task_stack.observe_plan(&args);
863 }
864 if let Some(id) = call_id {
865 call_index.insert(id, events.tools.len());
866 }
867 events.tools.push(event);
868 }
869 (AGENT_CODEX, "response_item")
870 if obj.pointer("/payload/type").and_then(Value::as_str)
871 == Some("function_call_output") =>
872 {
873 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
874 && let Some(index) = call_index.get(call_id).copied()
875 && let Some(tool) = events.tools.get_mut(index)
876 {
877 let output = obj
878 .pointer("/payload/output")
879 .and_then(Value::as_str)
880 .unwrap_or("");
881 tool.status = status_from_output(output).to_string();
882 }
883 }
884 (AGENT_CODEX, "response_item")
885 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
886 {
887 let payload = obj.get("payload").unwrap_or(&Value::Null);
888 let text = payload
889 .get("message")
890 .and_then(Value::as_str)
891 .map(str::to_string)
892 .unwrap_or_else(|| {
893 content_to_text(payload.get("content").unwrap_or(&Value::Null))
894 });
895 if let Some(text) = clean_prompt_text(&text) {
896 if payload.get("role").and_then(Value::as_str) == Some("user") {
897 acc.prompt_preview = Some(text.clone());
898 task_stack.observe_user(&text);
899 current_prompt_index =
900 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
901 continue;
902 }
903 let role = payload.get("role").and_then(Value::as_str);
904 let legacy_assistant = role.is_none()
905 && payload
906 .get("content")
907 .and_then(Value::as_array)
908 .is_some_and(|items| {
909 items.iter().any(|item| {
910 item.get("type").and_then(Value::as_str) == Some("output_text")
911 })
912 });
913 if role != Some("assistant") && !legacy_assistant {
914 continue;
915 }
916 events.llm_responses.push(LlmResponse {
917 ts_ms: ts_ms_from_event(&obj),
918 prompt_index: current_prompt_index,
919 model: if codex_model.is_empty() {
920 AGENT_CODEX.to_string()
921 } else {
922 codex_model.clone()
923 },
924 source_id: String::new(),
925 text_hash: short_hash(&text, 12),
926 preview: truncate_clean(&text, 180),
927 input_tokens: 0,
928 output_tokens: 0,
929 cache_tokens: 0,
930 total_tokens: 0,
931 tag: String::new(),
932 response_phase: payload
933 .get("phase")
934 .and_then(Value::as_str)
935 .unwrap_or("assistant_message")
936 .to_string(),
937 skill: String::new(),
938 task_path: task_stack.path(),
939 });
940 }
941 }
942 (AGENT_CODEX, "message" | "input" | "user") => {
943 if let Some(text) = local_message_preview(&obj) {
944 acc.prompt_preview = Some(text.clone());
945 task_stack.observe_user(&text);
946 current_prompt_index =
947 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
948 }
949 }
950 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
951 if let Some(text) = local_message_preview(&obj) {
952 acc.prompt_preview = Some(text.clone());
953 task_stack.observe_user(&text);
954 current_prompt_index =
955 events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
956 }
957 }
958 _ => {}
959 }
960 }
961
962 if acc.model_usage.is_empty() {
963 acc.model_usage = claude_message_models;
964 }
965 deduplicate_llm_responses(&mut events);
966 acc.finish_with_events(events)
967}
968
969fn deduplicate_llm_responses(events: &mut SessionEvents) {
970 let mut unique: Vec<LlmResponse> = Vec::with_capacity(events.llm_responses.len());
971 let mut by_source_id = BTreeMap::<(usize, String), usize>::new();
972 for response in events.llm_responses.drain(..) {
973 let source_key = (!response.source_id.is_empty())
974 .then(|| (response.prompt_index, response.source_id.clone()));
975 let duplicate_index = source_key
976 .as_ref()
977 .and_then(|key| by_source_id.get(key).copied())
978 .or_else(|| {
979 unique.len().checked_sub(1).filter(|index| {
980 let previous = &unique[*index];
981 response.source_id.is_empty()
982 && previous.source_id.is_empty()
983 && previous.prompt_index == response.prompt_index
984 && previous.text_hash == response.text_hash
985 && previous
986 .ts_ms
987 .zip(response.ts_ms)
988 .is_some_and(|(left, right)| left.abs_diff(right) <= 1_000)
989 })
990 });
991 if let Some(index) = duplicate_index {
992 merge_llm_response(&mut unique[index], response);
993 continue;
994 }
995 let index = unique.len();
996 if let Some(key) = source_key {
997 by_source_id.insert(key, index);
998 }
999 unique.push(response);
1000 }
1001 events.llm_responses = unique;
1002}
1003
1004fn merge_llm_response(previous: &mut LlmResponse, response: LlmResponse) {
1005 previous.input_tokens = previous.input_tokens.max(response.input_tokens);
1006 previous.output_tokens = previous.output_tokens.max(response.output_tokens);
1007 previous.cache_tokens = previous.cache_tokens.max(response.cache_tokens);
1008 previous.total_tokens = previous.total_tokens.max(response.total_tokens);
1009 if response_phase_priority(&response.response_phase)
1010 > response_phase_priority(&previous.response_phase)
1011 {
1012 previous.response_phase = response.response_phase;
1013 }
1014 if previous.preview.starts_with("tool: ") && !response.preview.starts_with("tool: ") {
1015 previous.preview = response.preview;
1016 previous.text_hash = response.text_hash;
1017 }
1018}
1019
1020fn response_phase_priority(phase: &str) -> u8 {
1021 match phase {
1022 "final_answer" => 3,
1023 "commentary" => 2,
1024 "assistant_message" => 1,
1025 _ => 0,
1026 }
1027}
1028
1029fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
1030 let root: Value = serde_json::from_str(content).ok()?;
1031 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
1032 let mut events = SessionEvents::default();
1033 let mut current_prompt_index = 0usize;
1034 let mut task_stack = SemanticTaskStack::default();
1035 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
1036 acc.session_id = id.to_string();
1037 acc.conversation_id = Some(id.to_string());
1038 }
1039 acc.start_timestamp_ms = root
1040 .get("startTime")
1041 .and_then(Value::as_str)
1042 .and_then(iso_ms);
1043 acc.end_timestamp_ms = root
1044 .get("lastUpdated")
1045 .and_then(Value::as_str)
1046 .and_then(iso_ms)
1047 .or(acc.start_timestamp_ms);
1048 acc.duration_ms = acc
1049 .start_timestamp_ms
1050 .zip(acc.end_timestamp_ms)
1051 .map(|(start, end)| end.saturating_sub(start))
1052 .unwrap_or_default();
1053
1054 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
1055 return acc.finish_with_events(events);
1056 };
1057 for msg in messages {
1058 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
1059 acc.last_message_at = Some(ts.to_string());
1060 }
1061 let ts_ms = msg
1062 .get("timestamp")
1063 .and_then(Value::as_str)
1064 .and_then(parse_ts_ms);
1065 match msg.get("type").and_then(Value::as_str) {
1066 Some("user") if acc.prompt_preview.is_none() => {
1067 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1068 acc.prompt_preview = Some(text.clone());
1069 task_stack.observe_user(&text);
1070 current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1071 }
1072 }
1073 Some("user") => {
1074 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1075 task_stack.observe_user(&text);
1076 current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1077 }
1078 }
1079 Some("gemini") | Some("assistant") | Some("model") => {
1080 let mut llm_model = AGENT_GEMINI.to_string();
1081 if let Some(model) = msg.get("model").and_then(Value::as_str) {
1082 llm_model = model.to_string();
1083 acc.model.get_or_insert_with(|| model.to_string());
1084 if let Some(tokens) = msg.get("tokens") {
1085 acc.add_usage(
1086 model,
1087 json_i64(tokens, "input"),
1088 json_i64(tokens, "output"),
1089 0,
1090 json_i64(tokens, "cached"),
1091 json_i64(tokens, "total"),
1092 );
1093 }
1094 }
1095 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
1096 for call in tool_calls {
1097 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
1098 acc.add_tool(name);
1099 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
1100 {
1101 acc.add_file(path);
1102 }
1103 let mut event = tool_event_from_input(
1104 acc.cwd.as_deref(),
1105 ts_ms,
1106 current_prompt_index,
1107 name,
1108 call,
1109 call.get("id").and_then(Value::as_str).map(str::to_string),
1110 task_stack.path_for_tool(name, call),
1111 );
1112 if let Some(status) = call.get("status").and_then(Value::as_str) {
1113 let lowered = status.to_ascii_lowercase();
1114 event.status = if matches!(
1115 lowered.as_str(),
1116 "error" | "failed" | "fail" | "cancelled" | "canceled"
1117 ) {
1118 "fail".to_string()
1119 } else if matches!(lowered.as_str(), "success" | "ok" | "completed") {
1120 "ok".to_string()
1121 } else {
1122 status.to_string()
1123 };
1124 }
1125 events.tools.push(event);
1126 }
1127 }
1128 let content = msg.get("content").unwrap_or(msg);
1129 let text = content_to_text(content);
1130 let tokens = msg.get("tokens").unwrap_or(&Value::Null);
1131 if !text.trim().is_empty() || tokens.is_object() {
1132 events.llm_responses.push(LlmResponse {
1133 ts_ms,
1134 prompt_index: current_prompt_index,
1135 model: llm_model,
1136 source_id: String::new(),
1137 text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
1138 preview: truncate_clean(
1139 if text.trim().is_empty() {
1140 "gemini response"
1141 } else {
1142 &text
1143 },
1144 140,
1145 ),
1146 input_tokens: json_u64(tokens, "input"),
1147 output_tokens: json_u64(tokens, "output"),
1148 cache_tokens: json_u64(tokens, "cached"),
1149 total_tokens: json_u64(tokens, "total"),
1150 tag: String::new(),
1151 response_phase: if msg
1152 .get("toolCalls")
1153 .and_then(Value::as_array)
1154 .is_some_and(|calls| !calls.is_empty())
1155 {
1156 "assistant_message".to_string()
1157 } else {
1158 "final_answer".to_string()
1159 },
1160 skill: String::new(),
1161 task_path: task_stack.path(),
1162 });
1163 }
1164 }
1165 _ => {}
1166 }
1167 }
1168 acc.finish_with_events(events)
1169}
1170
1171struct SessionAccumulator {
1172 agent_type: String,
1173 session_id: String,
1174 conversation_id: Option<String>,
1175 path: PathBuf,
1176 updated: SystemTime,
1177 start_timestamp_ms: Option<u64>,
1178 end_timestamp_ms: Option<u64>,
1179 model: Option<String>,
1180 model_usage: BTreeMap<String, TokenUsage>,
1181 tools: BTreeMap<String, usize>,
1182 files: BTreeMap<String, usize>,
1183 prompt_preview: Option<String>,
1184 duration_ms: u64,
1185 cwd: Option<String>,
1186 last_message_at: Option<String>,
1187}
1188
1189impl SessionAccumulator {
1190 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
1191 let normalized = normalize_session_log_path(path);
1192 let session_id = path
1193 .file_stem()
1194 .and_then(|stem| stem.to_str())
1195 .unwrap_or("session")
1196 .to_string();
1197 Self {
1198 agent_type: agent.to_string(),
1199 session_id,
1200 conversation_id: None,
1201 path: normalized.clone(),
1202 updated,
1203 start_timestamp_ms: None,
1204 end_timestamp_ms: Some(system_time_ms(updated)),
1205 model: None,
1206 model_usage: BTreeMap::new(),
1207 tools: BTreeMap::new(),
1208 files: BTreeMap::new(),
1209 prompt_preview: None,
1210 duration_ms: 0,
1211 cwd: None,
1212 last_message_at: None,
1213 }
1214 }
1215
1216 fn add_usage(
1217 &mut self,
1218 model: &str,
1219 input: i64,
1220 output: i64,
1221 cache_creation: i64,
1222 cache_read: i64,
1223 total: i64,
1224 ) {
1225 add_usage(
1226 &mut self.model_usage,
1227 model,
1228 input,
1229 output,
1230 cache_creation,
1231 cache_read,
1232 total,
1233 );
1234 }
1235
1236 fn set_usage(
1237 &mut self,
1238 model: &str,
1239 input: i64,
1240 output: i64,
1241 cache_creation: i64,
1242 cache_read: i64,
1243 total: i64,
1244 ) {
1245 let mut usage = TokenUsage::default();
1246 usage.add(input, output, cache_creation, cache_read, total);
1247 self.model_usage.insert(model.to_string(), usage);
1248 }
1249
1250 fn add_tool(&mut self, name: &str) {
1251 *self.tools.entry(name.to_string()).or_default() += 1;
1252 }
1253
1254 fn add_file(&mut self, path: &str) {
1255 *self.files.entry(path.to_string()).or_default() += 1;
1256 }
1257
1258 fn finish(self) -> Option<AgentSession> {
1259 let token_usage =
1260 self.model_usage
1261 .values()
1262 .fold(TokenUsage::default(), |mut total, usage| {
1263 total.input_tokens += usage.input_tokens;
1264 total.output_tokens += usage.output_tokens;
1265 total.cache_creation_tokens += usage.cache_creation_tokens;
1266 total.cache_read_tokens += usage.cache_read_tokens;
1267 total.total_tokens += usage.total_tokens;
1268 total
1269 });
1270 if token_usage.total_tokens == 0
1271 && self.tools.is_empty()
1272 && self.prompt_preview.is_none()
1273 && self.model.is_none()
1274 {
1275 return None;
1276 }
1277 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
1278 Some(AgentSession {
1279 agent_type: self.agent_type,
1280 session_id: self.session_id,
1281 conversation_id: self.conversation_id,
1282 display_id,
1283 path: self.path,
1284 updated: self.updated,
1285 start_timestamp_ms: self
1286 .start_timestamp_ms
1287 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
1288 end_timestamp_ms: self.end_timestamp_ms,
1289 model: self.model,
1290 usage: token_usage,
1291 model_usage: self.model_usage,
1292 tools: self.tools,
1293 files: self.files,
1294 prompt_preview: self.prompt_preview,
1295 duration_ms: self.duration_ms,
1296 cwd: self.cwd,
1297 last_message_at: self.last_message_at,
1298 events: SessionEvents::default(),
1299 })
1300 }
1301
1302 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
1303 self.finish().map(|mut session| {
1304 session.events = events;
1305 session
1306 })
1307 }
1308}
1309
1310fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
1315 let Ok(entries) = fs::read_dir(dir) else {
1316 return;
1317 };
1318 for entry in entries.flatten() {
1319 let path = entry.path();
1320 if path.is_dir() {
1321 walk_agent_files(agent, &path, f);
1322 } else if is_agent_file_for(agent, &path)
1323 && let Ok(meta) = path.metadata()
1324 {
1325 f(&path, &meta);
1326 }
1327 }
1328}
1329
1330fn is_agent_session_file(path: &Path) -> bool {
1331 agent_source_for_path(path).is_some()
1332}
1333
1334fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1335 match agent {
1336 AGENT_CLAUDE | AGENT_CODEX => {
1337 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1338 }
1339 AGENT_GEMINI => {
1340 path.extension().and_then(|ext| ext.to_str()) == Some("json")
1341 && path
1342 .file_name()
1343 .and_then(|name| name.to_str())
1344 .is_some_and(|name| name.starts_with("session-"))
1345 && path.to_string_lossy().contains("/chats/")
1346 }
1347 _ => false,
1348 }
1349}
1350
1351pub(crate) fn user_home_dir() -> Option<PathBuf> {
1352 std::env::var("SUDO_USER")
1353 .ok()
1354 .and_then(|user| {
1355 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1356 passwd
1357 .lines()
1358 .find(|line| line.starts_with(&format!("{user}:")))
1359 .and_then(|line| line.split(':').nth(5))
1360 .map(PathBuf::from)
1361 })
1362 })
1363 .or_else(dirs::home_dir)
1364}
1365
1366fn add_usage(
1367 models: &mut BTreeMap<String, TokenUsage>,
1368 model: &str,
1369 input: i64,
1370 output: i64,
1371 cache_creation: i64,
1372 cache_read: i64,
1373 total: i64,
1374) {
1375 models.entry(model.to_string()).or_default().add(
1376 input,
1377 output,
1378 cache_creation,
1379 cache_read,
1380 total,
1381 );
1382}
1383
1384impl SessionEvents {
1385 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str, task_path: Vec<String>) -> usize {
1386 let hash = short_hash(text, 12);
1387 if let Some(existing) = self.prompts.iter().rposition(|prompt| {
1388 prompt.text_hash == hash
1389 && match (prompt.ts_ms, ts_ms) {
1390 (Some(left), Some(right)) => left.abs_diff(right) <= 1_000,
1391 (None, None) => self
1392 .prompts
1393 .last()
1394 .is_some_and(|last| last.index == prompt.index),
1395 _ => false,
1396 }
1397 }) {
1398 return existing;
1399 }
1400 let index = self.prompts.len();
1401 self.prompts.push(UserPrompt {
1402 index,
1403 ts_ms,
1404 text_hash: hash,
1405 preview: truncate_clean(text, 180),
1406 tag: String::new(),
1407 task_path,
1408 });
1409 index
1410 }
1411}
1412
1413fn tool_event_from_input(
1414 cwd: Option<&str>,
1415 ts_ms: Option<i64>,
1416 prompt_index: usize,
1417 name: &str,
1418 input: &Value,
1419 call_id: Option<String>,
1420 task_path: Vec<String>,
1421) -> ToolEvent {
1422 let command = command_from_tool_input(input);
1423 let category = tool_category(name, &command);
1424 let domains = extract_domains(&command);
1425 let command_name = if category == "shell" {
1426 basename_from_command(&command)
1427 } else if category == "network" && !domains.is_empty() {
1428 domains[0]
1429 .split(':')
1430 .next()
1431 .unwrap_or("network")
1432 .to_string()
1433 } else {
1434 one_word(name, "tool")
1435 };
1436 let effect = if name == "apply_patch" || command.contains("*** ") {
1437 "write".to_string()
1438 } else {
1439 command_effect(&command)
1440 };
1441 let cwd = cwd.unwrap_or("");
1442 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1443 let paths = extract_tool_paths(name, input, &command, &effect);
1444 let process_chain = if category == "shell" {
1445 command_process_chain(&command)
1446 } else {
1447 Vec::new()
1448 };
1449 ToolEvent {
1450 ts_ms,
1451 prompt_index,
1452 tool_name: name.to_string(),
1453 category,
1454 command,
1455 command_name,
1456 effect,
1457 process_chain,
1458 status: "observed".to_string(),
1459 path_groups,
1460 paths,
1461 domains,
1462 call_id,
1463 invoked_skill: String::new(),
1464 skill: String::new(),
1465 task_path,
1466 }
1467}
1468
1469fn extract_tool_paths(name: &str, input: &Value, command: &str, effect: &str) -> Vec<ToolPath> {
1470 let lower = name.to_ascii_lowercase();
1471 let is_shell = lower.contains("bash") || lower.contains("exec") || lower.contains("shell");
1472 let default_access = if lower.contains("read")
1473 || lower.contains("grep")
1474 || lower.contains("glob")
1475 || lower.contains("search")
1476 {
1477 "read"
1478 } else if lower.contains("write")
1479 || lower.contains("edit")
1480 || lower.contains("replace")
1481 || lower.contains("patch")
1482 {
1483 "write"
1484 } else if is_shell {
1485 if effect == "read" { "read" } else { "write" }
1486 } else {
1487 return Vec::new();
1488 };
1489 let mut rows = BTreeMap::<String, (String, Option<String>)>::new();
1490 if !is_shell {
1491 collect_path_fields(input, default_access, &mut rows);
1492 }
1493
1494 let embedded_patch = embedded_json_string(command, "*** Begin Patch");
1495 let patch = input
1496 .get("patch")
1497 .or_else(|| input.get("input"))
1498 .or_else(|| input.get("text"))
1499 .and_then(Value::as_str)
1500 .filter(|value| value.contains("*** Begin Patch") && value.lines().count() > 1)
1501 .or(embedded_patch.as_deref())
1502 .or_else(|| {
1503 (command.contains("*** Begin Patch") && command.lines().count() > 1).then_some(command)
1504 });
1505 let mut has_patch = false;
1506 if let Some(patch) = patch {
1507 let mut pending_update = None;
1508 for line in patch.lines() {
1509 let marker = line.trim();
1510 for (prefix, access) in [
1511 ("*** Add File: ", "create"),
1512 ("*** Update File: ", "write"),
1513 ("*** Delete File: ", "delete"),
1514 ("*** Move to: ", "rename"),
1515 ] {
1516 if let Some(path) = marker.strip_prefix(prefix) {
1517 let path = clean_path_token(path);
1518 if !path.is_empty() {
1519 has_patch = true;
1520 if access == "write" {
1521 pending_update = Some(path.clone());
1522 } else if access == "rename"
1523 && let Some(source) = pending_update.take()
1524 {
1525 rows.remove(&source);
1526 rows.insert(path.clone(), ("rename".to_string(), Some(source)));
1527 continue;
1528 }
1529 rows.insert(path, (access.to_string(), None));
1530 }
1531 }
1532 }
1533 }
1534 }
1535
1536 if is_shell && !has_patch {
1537 for (path, access, previous_path) in shell_file_actions(command, input, 0) {
1538 rows.insert(path, (access, previous_path));
1539 }
1540 for nested in embedded_json_objects(command, "tools.exec_command(") {
1541 let nested_command = command_from_tool_input(&nested);
1542 for (path, access, previous_path) in shell_file_actions(&nested_command, &nested, 0) {
1543 rows.insert(path, (access, previous_path));
1544 }
1545 }
1546 }
1547 rows.into_iter()
1548 .map(|(path, (access, previous_path))| ToolPath {
1549 path,
1550 access,
1551 previous_path,
1552 })
1553 .collect()
1554}
1555
1556fn embedded_json_objects(text: &str, marker: &str) -> Vec<Value> {
1557 let mut rows = Vec::new();
1558 let mut offset = 0;
1559 while let Some(found) = text[offset..].find(marker) {
1560 let start = offset + found + marker.len();
1561 let Some(open) = text[start..].find('{').map(|value| start + value) else {
1562 break;
1563 };
1564 let mut depth = 0;
1565 let mut quote = false;
1566 let mut escaped = false;
1567 let mut end = None;
1568 for (index, ch) in text[open..].char_indices() {
1569 if escaped {
1570 escaped = false;
1571 } else if ch == '\\' && quote {
1572 escaped = true;
1573 } else if ch == '"' {
1574 quote = !quote;
1575 } else if !quote && ch == '{' {
1576 depth += 1;
1577 } else if !quote && ch == '}' {
1578 depth -= 1;
1579 if depth == 0 {
1580 end = Some(open + index + 1);
1581 break;
1582 }
1583 }
1584 }
1585 let Some(end) = end else { break };
1586 if let Ok(value) = serde_json::from_str(&text[open..end]) {
1587 rows.push(value);
1588 }
1589 offset = end;
1590 }
1591 rows
1592}
1593
1594fn embedded_json_string(text: &str, needle: &str) -> Option<String> {
1595 let needle = text.find(needle)?;
1596 let start = text[..needle].rfind('"')?;
1597 let mut escaped = false;
1598 for (offset, ch) in text[start + 1..].char_indices() {
1599 if escaped {
1600 escaped = false;
1601 } else if ch == '\\' {
1602 escaped = true;
1603 } else if ch == '"' {
1604 return serde_json::from_str(&text[start..start + offset + 2]).ok();
1605 }
1606 }
1607 None
1608}
1609
1610fn shell_file_actions(
1611 command: &str,
1612 input: &Value,
1613 depth: usize,
1614) -> Vec<(String, String, Option<String>)> {
1615 if depth > 2 {
1616 return Vec::new();
1617 }
1618 let mut cwd = ["workdir", "cwd"]
1619 .iter()
1620 .find_map(|key| input.get(*key).and_then(Value::as_str))
1621 .map(PathBuf::from);
1622 let mut rows = Vec::new();
1623 for parts in shell_segments(command) {
1624 let Some(command_index) = shell_command_index(&parts) else {
1625 continue;
1626 };
1627 let name = process_name_from_part(&parts[command_index]).unwrap_or_default();
1628 let operands = &parts[command_index + 1..];
1629 if name == "cd" {
1630 if let Some(path) = operands.iter().find(|value| !value.starts_with('-')) {
1631 let path = PathBuf::from(path);
1632 cwd = Some(if path.is_absolute() {
1633 path
1634 } else {
1635 cwd.take().unwrap_or_default().join(path)
1636 });
1637 }
1638 continue;
1639 }
1640 let mut actions = shell_segment_actions(&name, operands, input, depth);
1641 for (path, _, previous_path) in &mut actions {
1642 if !path.starts_with(['~', '$'])
1643 && !Path::new(path).is_absolute()
1644 && let Some(base) = &cwd
1645 {
1646 *path = base.join(&*path).to_string_lossy().into_owned();
1647 }
1648 *path = clean_path_token(path);
1649 if let Some(previous) = previous_path {
1650 if !previous.starts_with(['~', '$'])
1651 && !Path::new(previous).is_absolute()
1652 && let Some(base) = &cwd
1653 {
1654 *previous = base.join(&*previous).to_string_lossy().into_owned();
1655 }
1656 *previous = clean_path_token(previous);
1657 }
1658 }
1659 rows.extend(actions.into_iter().filter(|(path, _, _)| !path.is_empty()));
1660 }
1661 rows
1662}
1663
1664fn shell_segment_actions(
1665 name: &str,
1666 operands: &[String],
1667 input: &Value,
1668 depth: usize,
1669) -> Vec<(String, String, Option<String>)> {
1670 let mut rows = Vec::new();
1671 let mut values = Vec::new();
1672 let mut index = 0;
1673 while index < operands.len() {
1674 if is_redirection_token(&operands[index]) {
1675 if let Some(path) = operands.get(index + 1)
1676 && plausible_path_token(path)
1677 {
1678 let access = if [">", ">>", "&>", "&>>"].contains(&operands[index].as_str()) {
1679 "write"
1680 } else if ["<", "<>"].contains(&operands[index].as_str()) {
1681 "read"
1682 } else {
1683 index += 2;
1684 continue;
1685 };
1686 rows.push((path.clone(), access.into(), None));
1687 }
1688 index += 2;
1689 continue;
1690 }
1691 values.push(operands[index].clone());
1692 index += 1;
1693 }
1694 let paths = |items: &[String]| {
1695 items
1696 .iter()
1697 .filter(|value| !value.starts_with('-') && plausible_path_token(value))
1698 .cloned()
1699 .collect::<Vec<_>>()
1700 };
1701 match name {
1702 "bash" | "sh" | "zsh" => {
1703 for index in 0..values.len().saturating_sub(1) {
1704 if ["-c", "-lc", "-cl"].contains(&values[index].as_str()) {
1705 rows.extend(shell_file_actions(&values[index + 1], input, depth + 1));
1706 break;
1707 }
1708 }
1709 }
1710 "cp" => {
1711 let paths = paths(&values);
1712 if let Some((target, sources)) = paths.split_last() {
1713 for source in sources {
1714 rows.push((source.clone(), "read".into(), None));
1715 let destination = destination_path(target, source, sources.len() > 1);
1716 rows.push((destination, "create".into(), None));
1717 }
1718 }
1719 }
1720 "mv" => {
1721 let paths = paths(&values);
1722 if let Some((target, sources)) = paths.split_last() {
1723 for source in sources {
1724 rows.push((
1725 destination_path(target, source, sources.len() > 1),
1726 "rename".into(),
1727 Some(source.clone()),
1728 ));
1729 }
1730 }
1731 }
1732 "rm" => rows.extend(
1733 paths(&values)
1734 .into_iter()
1735 .map(|path| (path, "delete".into(), None)),
1736 ),
1737 "touch" | "install" => rows.extend(
1738 paths(&values)
1739 .into_iter()
1740 .map(|path| (path, "create".into(), None)),
1741 ),
1742 "tee" => rows.extend(
1743 paths(&values)
1744 .into_iter()
1745 .map(|path| (path, "write".into(), None)),
1746 ),
1747 "cat" | "head" | "tail" | "nl" | "wc" | "source" | "." => rows.extend(
1748 paths(&values)
1749 .into_iter()
1750 .map(|path| (path, "read".into(), None)),
1751 ),
1752 "sed" => {
1753 let in_place = values.iter().any(|value| {
1754 value == "-i" || value.starts_with("-i") || value.starts_with("--in-place")
1755 });
1756 let mut script_seen = false;
1757 for value in &values {
1758 if value.starts_with('-') {
1759 continue;
1760 }
1761 if !script_seen {
1762 script_seen = true;
1763 } else if plausible_path_token(value) {
1764 rows.push((
1765 value.clone(),
1766 if in_place { "write" } else { "read" }.into(),
1767 None,
1768 ));
1769 }
1770 }
1771 }
1772 "find" => rows.extend(
1773 values
1774 .iter()
1775 .take_while(|value| !value.starts_with('-') && value.as_str() != "!")
1776 .filter(|value| plausible_path_token(value))
1777 .cloned()
1778 .map(|path| (path, "read".into(), None)),
1779 ),
1780 "rg" | "grep" | "jq" => {
1781 let mut expression_seen = values.iter().any(|value| value == "--files");
1782 for value in &values {
1783 if value.starts_with('-') {
1784 continue;
1785 }
1786 if !expression_seen {
1787 expression_seen = true;
1788 } else if plausible_path_token(value) {
1789 rows.push((value.clone(), "read".into(), None));
1790 }
1791 }
1792 }
1793 _ => {}
1794 }
1795 rows
1796}
1797
1798fn destination_path(target: &str, source: &str, multiple: bool) -> String {
1799 if multiple || target.ends_with('/') {
1800 Path::new(target)
1801 .join(Path::new(source).file_name().unwrap_or_default())
1802 .to_string_lossy()
1803 .into_owned()
1804 } else {
1805 target.to_string()
1806 }
1807}
1808
1809fn collect_path_fields(
1810 value: &Value,
1811 access: &str,
1812 out: &mut BTreeMap<String, (String, Option<String>)>,
1813) {
1814 match value {
1815 Value::Object(object) => {
1816 for (key, value) in object {
1817 let key = key.to_ascii_lowercase();
1818 if matches!(
1819 key.as_str(),
1820 "path" | "file_path" | "filepath" | "notebook_path" | "old_path" | "new_path"
1821 ) && let Some(path) = value.as_str()
1822 {
1823 let path = clean_path_token(path);
1824 if !path.is_empty() {
1825 out.insert(path, (access.to_string(), None));
1826 }
1827 } else if value.is_object() || value.is_array() {
1828 collect_path_fields(value, access, out);
1829 }
1830 }
1831 }
1832 Value::Array(values) => {
1833 for value in values {
1834 collect_path_fields(value, access, out);
1835 }
1836 }
1837 _ => {}
1838 }
1839}
1840
1841fn clean_path_token(value: &str) -> String {
1842 value
1843 .trim()
1844 .trim_matches(['"', '\'', '`', ',', ':'])
1845 .trim_start_matches("file://")
1846 .to_string()
1847}
1848
1849fn strip_heredoc_bodies(command: &str) -> String {
1850 fn delimiters(line: &str) -> Vec<String> {
1851 let bytes = line.as_bytes();
1852 let mut output = Vec::new();
1853 let mut index = 0;
1854 while index + 1 < bytes.len() {
1855 if bytes[index] != b'<' || bytes[index + 1] != b'<' {
1856 index += 1;
1857 continue;
1858 }
1859 index += 2;
1860 if bytes.get(index) == Some(&b'<') {
1861 index += 1;
1862 continue;
1863 }
1864 if bytes.get(index) == Some(&b'-') {
1865 index += 1;
1866 }
1867 while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
1868 index += 1;
1869 }
1870 let quote = bytes
1871 .get(index)
1872 .copied()
1873 .filter(|value| *value == b'\'' || *value == b'"');
1874 if quote.is_some() {
1875 index += 1;
1876 }
1877 let start = index;
1878 while let Some(value) = bytes.get(index) {
1879 if quote.is_some_and(|quote| *value == quote)
1880 || (quote.is_none()
1881 && (value.is_ascii_whitespace() || b";|&><".contains(value)))
1882 {
1883 break;
1884 }
1885 index += 1;
1886 }
1887 if start < index {
1888 output.push(line[start..index].to_string());
1889 }
1890 }
1891 output
1892 }
1893
1894 let mut pending = VecDeque::<String>::new();
1895 let mut output = Vec::new();
1896 for line in command.lines() {
1897 if let Some(delimiter) = pending.front() {
1898 if line.trim_start_matches('\t').trim_end() == delimiter {
1899 pending.pop_front();
1900 }
1901 continue;
1902 }
1903 output.push(line);
1904 pending.extend(delimiters(line));
1905 }
1906 output.join("\n")
1907}
1908
1909fn is_redirection_token(token: &str) -> bool {
1910 [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
1911}
1912
1913fn shell_command_index(parts: &[String]) -> Option<usize> {
1914 let mut index = 0;
1915 while index < parts.len() {
1916 let part = parts[index].as_str();
1917 if ["then", "do", "else"].contains(&part)
1918 || part.split_once('=').is_some_and(|(name, _)| {
1919 !name.is_empty()
1920 && name
1921 .chars()
1922 .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1923 })
1924 {
1925 index += 1;
1926 continue;
1927 }
1928 if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
1929 index += 1;
1930 while index < parts.len() && parts[index].starts_with('-') {
1931 index += 1;
1932 }
1933 continue;
1934 }
1935 return Some(index);
1936 }
1937 None
1938}
1939
1940fn shell_segments(command: &str) -> Vec<Vec<String>> {
1941 fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
1942 if !current.is_empty() {
1943 tokens.push(std::mem::take(current));
1944 }
1945 }
1946 fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
1947 if !tokens.is_empty() {
1948 segments.push(std::mem::take(tokens));
1949 }
1950 }
1951
1952 let command = strip_heredoc_bodies(command);
1953 let mut segments = Vec::new();
1954 let mut tokens = Vec::new();
1955 let mut current = String::new();
1956 let mut quote = None;
1957 let mut escaped = false;
1958 let mut chars = command.chars().peekable();
1959 while let Some(ch) = chars.next() {
1960 if escaped {
1961 current.push(ch);
1962 escaped = false;
1963 } else if ch == '\\' {
1964 escaped = true;
1965 } else if quote == Some(ch) {
1966 quote = None;
1967 } else if quote.is_some() {
1968 current.push(ch);
1969 } else if ch == '\'' || ch == '"' {
1970 quote = Some(ch);
1971 } else if ch == '#' && current.is_empty() {
1972 for next in chars.by_ref() {
1973 if next == '\n' {
1974 flush_segment(&mut segments, &mut tokens);
1975 break;
1976 }
1977 }
1978 } else if ch.is_whitespace() {
1979 flush_word(&mut tokens, &mut current);
1980 if ch == '\n' {
1981 flush_segment(&mut segments, &mut tokens);
1982 }
1983 } else if ch == '&' && chars.peek() == Some(&'>') {
1984 flush_word(&mut tokens, &mut current);
1985 chars.next();
1986 let operator = if chars.peek() == Some(&'>') {
1987 chars.next();
1988 "&>>"
1989 } else {
1990 "&>"
1991 };
1992 tokens.push(operator.into());
1993 } else if matches!(ch, ';' | '|' | '(' | ')') || ch == '&' {
1994 flush_word(&mut tokens, &mut current);
1995 if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
1996 chars.next();
1997 }
1998 flush_segment(&mut segments, &mut tokens);
1999 } else if ch == '>' || ch == '<' {
2000 flush_word(&mut tokens, &mut current);
2001 let mut operator = ch.to_string();
2002 while chars.peek() == Some(&ch) && operator.len() < 3 {
2003 operator.push(chars.next().expect("peeked redirection"));
2004 }
2005 tokens.push(operator);
2006 } else {
2007 current.push(ch);
2008 }
2009 }
2010 flush_word(&mut tokens, &mut current);
2011 flush_segment(&mut segments, &mut tokens);
2012 segments
2013}
2014
2015fn codex_token_usage(value: &Value) -> TokenUsage {
2016 let input = json_i64(value, "input_tokens").max(0);
2017 let output = json_i64(value, "output_tokens").max(0);
2018 let cache = json_i64(value, "cached_input_tokens").max(0);
2019 let input = input.saturating_sub(cache);
2020 TokenUsage {
2021 input_tokens: input,
2022 output_tokens: output,
2023 cache_creation_tokens: 0,
2024 cache_read_tokens: cache,
2025 total_tokens: input + output + cache,
2026 }
2027}
2028
2029pub fn codex_total_token_usage(content: &str) -> Option<TokenUsage> {
2030 content.lines().rev().find_map(|line| {
2031 let obj: Value = serde_json::from_str(line).ok()?;
2032 let payload = obj.get("payload")?;
2033 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2034 return None;
2035 }
2036 payload
2037 .pointer("/info/total_token_usage")
2038 .map(codex_token_usage)
2039 })
2040}
2041
2042fn exact_claude_skill_invocation(name: &str, input: &Value) -> Option<String> {
2043 (name == "Skill")
2044 .then(|| input.get("skill").and_then(Value::as_str))
2045 .flatten()
2046 .map(str::trim)
2047 .filter(|skill| !skill.is_empty())
2048 .map(str::to_string)
2049}
2050
2051fn codex_custom_tool_input(outer_name: &str, raw: &str) -> (String, Value) {
2052 let nested_calls = codex_custom_tool_calls(raw);
2053 let nested_name = if raw.contains("Promise.all") || nested_calls.len() > 1 {
2054 "composite".to_string()
2055 } else {
2056 nested_calls
2057 .first()
2058 .cloned()
2059 .unwrap_or_else(|| outer_name.to_string())
2060 };
2061
2062 let commands = extract_js_string_fields(raw, &["command", "cmd"]);
2063 let paths = extract_js_string_fields(raw, &["file_path", "path"]);
2064 let workdirs = extract_js_string_fields(raw, &["workdir"]);
2065 let mut input = serde_json::Map::new();
2066 if !commands.is_empty() {
2067 input.insert("command".to_string(), Value::String(commands.join("\n")));
2068 } else if !raw.trim().is_empty() {
2069 input.insert("text".to_string(), Value::String(truncate_clean(raw, 600)));
2070 }
2071 if let Some(path) = paths.first() {
2072 input.insert("path".to_string(), Value::String(path.clone()));
2073 }
2074 if let Some(workdir) = workdirs.first() {
2075 input.insert("workdir".to_string(), Value::String(workdir.clone()));
2076 }
2077 for key in ["task_name", "target", "message"] {
2078 if let Some(value) = extract_js_string_fields(raw, &[key]).first() {
2079 input.insert(key.to_string(), Value::String(value.clone()));
2080 }
2081 }
2082 if nested_name == "update_plan" {
2083 let steps = extract_js_string_fields(raw, &["step"]);
2084 let statuses = extract_js_string_fields(raw, &["status"]);
2085 let plan = steps
2086 .into_iter()
2087 .enumerate()
2088 .map(|(index, step)| {
2089 serde_json::json!({
2090 "step": step,
2091 "status": statuses.get(index).map(String::as_str).unwrap_or("pending")
2092 })
2093 })
2094 .collect::<Vec<_>>();
2095 input.insert("plan".to_string(), Value::Array(plan));
2096 }
2097 (nested_name, Value::Object(input))
2098}
2099
2100fn codex_custom_tool_calls(raw: &str) -> Vec<String> {
2101 let mut calls = Vec::new();
2102 let mut offset = 0usize;
2103 while let Some(relative) = raw[offset..].find("tools.") {
2104 let start = offset + relative + "tools.".len();
2105 let tail = &raw[start..];
2106 let name = tail
2107 .chars()
2108 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
2109 .collect::<String>();
2110 let name_len = name.len();
2111 let after_name = tail[name.len()..].trim_start();
2112 if !name.is_empty() && after_name.starts_with('(') {
2113 calls.push(name);
2114 }
2115 offset = if name_len > 0 {
2116 start + name_len
2117 } else {
2118 raw[start..]
2122 .chars()
2123 .next()
2124 .map_or(raw.len(), |ch| start + ch.len_utf8())
2125 };
2126 }
2127 calls
2128}
2129
2130fn extract_js_string_fields(raw: &str, keys: &[&str]) -> Vec<String> {
2131 let mut values = Vec::new();
2132 for key in keys {
2133 let mut offset = 0usize;
2134 while let Some(relative) = raw[offset..].find(key) {
2135 let start = offset + relative;
2136 let before = raw[..start].chars().next_back();
2137 let after = raw[start + key.len()..].chars().next();
2138 if before.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2139 || after.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2140 {
2141 offset = start + key.len();
2142 continue;
2143 }
2144 let tail = &raw[start + key.len()..];
2145 let Some(colon) = tail.find(':').filter(|index| *index <= 4) else {
2146 offset = start + key.len();
2147 continue;
2148 };
2149 let value = tail[colon + 1..].trim_start();
2150 let Some(quote) = value
2151 .chars()
2152 .next()
2153 .filter(|ch| ['\'', '"', '`'].contains(ch))
2154 else {
2155 offset = start + key.len();
2156 continue;
2157 };
2158 if let Some((decoded, consumed)) = parse_js_string(&value[quote.len_utf8()..], quote) {
2159 if !decoded.is_empty() && !values.contains(&decoded) {
2160 values.push(decoded);
2161 }
2162 offset = start + key.len() + colon + 1 + consumed;
2163 } else {
2164 offset = start + key.len();
2165 }
2166 }
2167 }
2168 values
2169}
2170
2171fn parse_js_string(raw: &str, quote: char) -> Option<(String, usize)> {
2172 let mut decoded = String::new();
2173 let mut escaped = false;
2174 for (index, ch) in raw.char_indices() {
2175 if escaped {
2176 decoded.push(match ch {
2177 'n' => '\n',
2178 'r' => '\r',
2179 't' => '\t',
2180 other => other,
2181 });
2182 escaped = false;
2183 } else if ch == '\\' {
2184 escaped = true;
2185 } else if ch == quote {
2186 return Some((decoded, index + ch.len_utf8() + quote.len_utf8()));
2187 } else {
2188 decoded.push(ch);
2189 }
2190 }
2191 None
2192}
2193
2194fn command_from_tool_input(input: &Value) -> String {
2195 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
2196 if let Some(value) = input.get(key).and_then(Value::as_str)
2197 && !value.is_empty()
2198 {
2199 return if key == "pattern" {
2200 format!("search {value}")
2201 } else {
2202 value.to_string()
2203 };
2204 }
2205 }
2206 if input.is_null() {
2207 String::new()
2208 } else {
2209 truncate_clean(&input.to_string(), 300)
2210 }
2211}
2212
2213fn parse_tool_args(value: &Value) -> Value {
2214 if let Some(text) = value.as_str() {
2215 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
2216 } else {
2217 value.clone()
2218 }
2219}
2220
2221fn status_from_output(output: &str) -> &'static str {
2222 let lowered = output.to_ascii_lowercase();
2223 let exit_codes = explicit_exit_codes(&lowered);
2224 if exit_codes.iter().any(|code| *code != 0) {
2225 return "fail";
2226 }
2227 if !exit_codes.is_empty() {
2228 return "ok";
2229 }
2230 if lowered.contains("\"is_error\":false") || lowered.contains("\"success\":true") {
2231 return "ok";
2232 }
2233 if lowered.contains("\"is_error\":true") || lowered.contains("\"success\":false") {
2234 return "fail";
2235 }
2236 if lowered.lines().any(|line| line.trim() == "script failed") {
2237 return "fail";
2238 }
2239 if lowered
2240 .lines()
2241 .any(|line| line.trim() == "script completed")
2242 {
2243 return "ok";
2244 }
2245 "observed"
2246}
2247
2248fn explicit_exit_codes(output: &str) -> Vec<i32> {
2249 output
2250 .lines()
2251 .filter_map(|line| {
2252 let line = line.trim();
2253 let value = if let Some(rest) = line.strip_prefix("exit code:") {
2254 rest
2255 } else if let Some((_, rest)) = line.split_once("process exited with code") {
2256 rest.strip_prefix(':').unwrap_or(rest)
2257 } else {
2258 return None;
2259 };
2260 let digits = value
2261 .trim_start()
2262 .chars()
2263 .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
2264 .collect::<String>();
2265 digits.parse().ok()
2266 })
2267 .collect()
2268}
2269
2270pub fn tool_category(name: &str, command: &str) -> String {
2271 let n = name.to_ascii_lowercase();
2272 if n.ends_with("exec_command") || n.ends_with("shell_command") || n == "bash" {
2273 "shell"
2274 } else if ["apply_patch", "edit", "write", "multiedit", "notebookedit"].contains(&n.as_str()) {
2275 "edit"
2276 } else if ["read", "grep", "glob", "ls"].contains(&n.as_str()) {
2277 "read"
2278 } else if n.contains("web")
2279 || n.contains("browser")
2280 || n.contains("search")
2281 || command.contains("http")
2282 {
2283 "network"
2284 } else if n.contains("plan") || n.contains("todo") {
2285 "plan"
2286 } else if n.contains("task") || n.contains("agent") {
2287 "subagent"
2288 } else {
2289 "tool"
2290 }
2291 .to_string()
2292}
2293
2294fn command_effect(command: &str) -> String {
2295 let cmd = basename_from_command(command);
2296 let text = command.to_ascii_lowercase();
2297 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
2298 && any_word(&text, &["test", "check", "build", "clippy"])
2299 {
2300 "test"
2301 } else if cmd == "git"
2302 && any_word(
2303 &text,
2304 &["commit", "push", "add", "checkout", "merge", "rebase"],
2305 )
2306 {
2307 "repo"
2308 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
2309 && (any_word(
2310 &text,
2311 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
2312 ) || text.contains("http://")
2313 || text.contains("https://"))
2314 {
2315 "network"
2316 } else if [
2317 "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
2318 ]
2319 .contains(&cmd.as_str())
2320 && (text.contains('>')
2321 || text.contains("--write")
2322 || text.contains(" rm ")
2323 || text.contains(" mkdir ")
2324 || text.contains(" touch ")
2325 || text.contains(" cp ")
2326 || text.contains(" mv "))
2327 {
2328 "write"
2329 } else if [
2330 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
2331 ]
2332 .contains(&cmd.as_str())
2333 {
2334 "read"
2335 } else if text.contains("http://")
2336 || text.contains("https://")
2337 || text.contains("crates.io")
2338 || text.contains("github.com")
2339 {
2340 "network"
2341 } else {
2342 "process"
2343 }
2344 .to_string()
2345}
2346
2347fn any_word(text: &str, words: &[&str]) -> bool {
2348 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2349 .any(|part| words.contains(&part))
2350}
2351
2352fn basename_from_command(command: &str) -> String {
2353 let parts = split_shell(command);
2354 let mut idx = 0;
2355 while idx < parts.len()
2356 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2357 &Path::new(&parts[idx])
2358 .file_name()
2359 .and_then(|v| v.to_str())
2360 .unwrap_or(""),
2361 )
2362 {
2363 idx += 1;
2364 if idx < parts.len() && parts[idx].starts_with('-') {
2365 idx += 1;
2366 }
2367 }
2368 parts
2369 .get(idx)
2370 .and_then(|part| process_name_from_part(part))
2371 .unwrap_or_else(|| "none".to_string())
2372}
2373
2374pub fn command_process_chain(command: &str) -> Vec<String> {
2375 process_chain_from_parts(&split_shell(command))
2376}
2377
2378fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
2379 if parts.is_empty() {
2380 return Vec::new();
2381 }
2382 let mut idx = 0;
2383 while idx < parts.len()
2384 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2385 &Path::new(&parts[idx])
2386 .file_name()
2387 .and_then(|v| v.to_str())
2388 .unwrap_or(""),
2389 )
2390 {
2391 idx += 1;
2392 if idx < parts.len() && parts[idx].starts_with('-') {
2393 idx += 1;
2394 }
2395 }
2396 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
2397 return Vec::new();
2398 };
2399 let mut chain = vec![proc_name.clone()];
2400 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
2401 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
2402 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
2403 chain.extend(command_process_chain(&parts[flag_idx + 1]));
2404 break;
2405 }
2406 }
2407 }
2408 chain
2409}
2410
2411fn process_name_from_part(part: &str) -> Option<String> {
2412 let raw = part.trim_matches(['"', '\'']);
2413 if raw.is_empty() {
2414 return None;
2415 }
2416 let path = Path::new(raw);
2417 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
2418 let parts = path_component_strings(path);
2419 if looks_like_home_directory(&parts) && parts.len() <= 2 {
2420 return Some("external".to_string());
2421 }
2422 if contains_private_marker(file_name) {
2423 return Some("external".to_string());
2424 }
2425 Some(file_name.to_string())
2426}
2427
2428fn split_shell(command: &str) -> Vec<String> {
2429 let mut parts = Vec::new();
2430 let mut current = String::new();
2431 let mut quote = None;
2432 let mut escaped = false;
2433 for ch in command.chars() {
2434 if escaped {
2435 current.push(ch);
2436 escaped = false;
2437 } else if ch == '\\' {
2438 escaped = true;
2439 } else if quote == Some(ch) {
2440 quote = None;
2441 } else if quote.is_some() {
2442 current.push(ch);
2443 } else if ch == '\'' || ch == '"' {
2444 quote = Some(ch);
2445 } else if ch.is_whitespace() {
2446 if !current.is_empty() {
2447 parts.push(std::mem::take(&mut current));
2448 }
2449 } else {
2450 current.push(ch);
2451 }
2452 }
2453 if !current.is_empty() {
2454 parts.push(current);
2455 }
2456 parts
2457}
2458
2459fn extract_domains(text: &str) -> Vec<String> {
2460 let mut domains = BTreeSet::new();
2461 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
2462 let stripped = part
2463 .strip_prefix("https://")
2464 .or_else(|| part.strip_prefix("http://"));
2465 if let Some(rest) = stripped
2466 && let Some(domain) = rest.split('/').next()
2467 && !domain.is_empty()
2468 {
2469 domains.insert(domain.to_ascii_lowercase());
2470 }
2471 for known in [
2472 "github.com",
2473 "crates.io",
2474 "huggingface.co",
2475 "hf.co",
2476 "openai.com",
2477 "anthropic.com",
2478 ] {
2479 if part.contains(known) {
2480 domains.insert(known.to_string());
2481 }
2482 }
2483 }
2484 domains.into_iter().collect()
2485}
2486
2487fn extract_path_groups(
2488 project_root: &Path,
2489 name: &str,
2490 input: &Value,
2491 command: &str,
2492) -> Vec<String> {
2493 let mut groups = BTreeSet::new();
2494 if ["write", "edit", "multiedit", "notebookedit", "read"]
2495 .contains(&name.to_ascii_lowercase().as_str())
2496 {
2497 for key in ["file_path", "path"] {
2498 if let Some(path) = input.get(key).and_then(Value::as_str) {
2499 groups.insert(path_group(path, project_root));
2500 }
2501 }
2502 }
2503 for part in split_shell(command) {
2504 if plausible_path_token(&part) {
2505 groups.insert(path_group(&part, project_root));
2506 }
2507 }
2508 groups.into_iter().filter(|v| v != "none").collect()
2509}
2510
2511fn plausible_path_token(part: &str) -> bool {
2512 let part = part.trim_matches(['"', '\'']);
2513 let lower = part.to_ascii_lowercase();
2514 let components = part.split('/').collect::<Vec<_>>();
2515 let looks_like_sed_expression = part.starts_with("s/")
2516 && part.rsplit('/').next().is_some_and(|flags| {
2517 flags.is_empty() || flags.chars().all(|flag| "gimpe".contains(flag))
2518 });
2519 let looks_like_slash_separated_phrase = components.len() >= 3
2520 && components.iter().all(|component| {
2521 component.chars().all(char::is_alphabetic)
2522 && component.chars().next().is_some_and(char::is_uppercase)
2523 });
2524 if part.is_empty()
2525 || part.starts_with('-')
2526 || part.starts_with('$')
2527 || part.starts_with('~')
2528 || part.starts_with("http://")
2529 || part.starts_with("https://")
2530 || lower.starts_with("origin/")
2531 || lower.starts_with("refs/")
2532 || lower.starts_with("repos/")
2533 || part == "HEAD"
2534 || part.starts_with("HEAD.")
2535 || part.contains("...")
2536 || looks_like_slash_separated_phrase
2537 || looks_like_sed_expression
2538 || part.len() > 140
2539 || part.chars().any(char::is_whitespace)
2540 || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
2541 {
2542 return false;
2543 }
2544 let suffix = Path::new(part)
2545 .extension()
2546 .and_then(|v| v.to_str())
2547 .unwrap_or("");
2548 part.contains('/')
2549 || [
2550 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
2551 "css",
2552 ]
2553 .contains(&suffix)
2554}
2555
2556pub fn path_group(path: &str, project_root: &Path) -> String {
2557 let path = path.trim_matches(['"', '\'']);
2558 if path.is_empty() {
2559 return "none".to_string();
2560 }
2561 let p = Path::new(path);
2562 let parts = if p.is_absolute() {
2563 if let Ok(rel) = p.strip_prefix(project_root) {
2564 path_component_strings(rel)
2565 } else {
2566 return external_path_group(path, &path_component_strings(p));
2567 }
2568 } else {
2569 let parts = path_component_strings(p);
2570 if let Some(group) = sensitive_relative_path_group(path, &parts) {
2571 return group;
2572 }
2573 parts
2574 };
2575 collapse_project_path(parts)
2576}
2577
2578pub fn path_component_strings(path: &Path) -> Vec<String> {
2579 path.components()
2580 .filter_map(|c| {
2581 let part = c.as_os_str().to_string_lossy();
2582 let part = part.as_ref();
2583 if part == "." || part == "/" || part.is_empty() {
2584 None
2585 } else {
2586 Some(part.to_string())
2587 }
2588 })
2589 .collect()
2590}
2591
2592pub fn collapse_project_path(parts: Vec<String>) -> String {
2593 let parts = parts
2594 .into_iter()
2595 .filter(|part| part != "." && !part.is_empty())
2596 .map(|part| truncate_path_component(&part))
2597 .collect::<Vec<_>>();
2598 if parts.is_empty() {
2599 "repo".to_string()
2600 } else if [
2601 "collector",
2602 "frontend",
2603 "docs",
2604 "bpf",
2605 "agentpprof",
2606 "agent-session",
2607 ]
2608 .contains(&parts[0].as_str())
2609 {
2610 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
2611 } else {
2612 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
2613 }
2614}
2615
2616fn truncate_path_component(part: &str) -> String {
2617 if part.chars().count() > 48 {
2618 format!("{}...", part.chars().take(45).collect::<String>())
2619 } else {
2620 part.to_string()
2621 }
2622}
2623
2624fn external_path_group(raw: &str, parts: &[String]) -> String {
2625 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
2626}
2627
2628fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
2629 let lowered = raw.to_ascii_lowercase();
2630 let lower_parts = parts
2631 .iter()
2632 .map(|part| part.to_ascii_lowercase())
2633 .collect::<Vec<_>>();
2634 if lower_parts.iter().any(|part| part == ".codex") {
2635 Some("external/codex".to_string())
2636 } else if lower_parts.iter().any(|part| part == ".claude") {
2637 Some("external/claude".to_string())
2638 } else if lower_parts.first().is_some_and(|part| part == "tmp")
2639 || lowered.contains("/tmp")
2640 || lowered.contains("_/tmp")
2641 || lower_parts
2642 .windows(2)
2643 .any(|window| window[0] == "var" && window[1] == "tmp")
2644 {
2645 Some("external/tmp".to_string())
2646 } else if lowered.starts_with("~/")
2647 || lowered == "~"
2648 || lowered.contains("/home")
2649 || lowered.contains("_/home")
2650 || lowered.contains("-home-")
2651 || lowered.contains("/users")
2652 || lowered.contains("_/users")
2653 || looks_like_home_directory(&lower_parts)
2654 || contains_private_marker(&lowered)
2655 {
2656 Some("external/home".to_string())
2657 } else {
2658 None
2659 }
2660}
2661
2662pub fn looks_like_home_directory(parts: &[String]) -> bool {
2663 parts
2664 .first()
2665 .is_some_and(|part| part == "home" || part == "users")
2666}
2667
2668fn current_username() -> Option<String> {
2669 dirs::home_dir()
2670 .and_then(|home| {
2671 home.file_name()
2672 .map(|part| part.to_string_lossy().to_string())
2673 })
2674 .filter(|name| !name.is_empty())
2675}
2676
2677pub fn contains_private_marker(text: &str) -> bool {
2678 let lowered = text.to_ascii_lowercase();
2679 current_username()
2680 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
2681 .unwrap_or(false)
2682}
2683
2684fn content_to_text(value: &Value) -> String {
2685 match value {
2686 Value::String(s) => s.clone(),
2687 Value::Array(items) => items
2688 .iter()
2689 .filter_map(|item| {
2690 if let Some(text) = item.as_str() {
2691 return Some(text.to_string());
2692 }
2693 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
2694 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
2695 return None;
2696 }
2697 if typ == "thinking" {
2699 return item
2700 .get("thinking")
2701 .and_then(Value::as_str)
2702 .filter(|s| !s.is_empty())
2703 .map(str::to_string);
2704 }
2705 item.get("text")
2706 .or_else(|| item.get("content"))
2707 .and_then(Value::as_str)
2708 .map(str::to_string)
2709 })
2710 .collect::<Vec<_>>()
2711 .join("\n"),
2712 Value::Object(_) => value
2713 .get("text")
2714 .or_else(|| value.get("content"))
2715 .and_then(Value::as_str)
2716 .unwrap_or("")
2717 .to_string(),
2718 _ => String::new(),
2719 }
2720}
2721
2722fn claude_is_tool_result(content: &Value) -> bool {
2723 content.as_array().is_some_and(|items| {
2724 !items.is_empty()
2725 && items
2726 .iter()
2727 .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2728 })
2729}
2730
2731fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
2732 let session_id = first_json_string(
2733 obj,
2734 &["sessionId", "session_id"],
2735 &["/payload/session_id", "/payload/sessionId"],
2736 );
2737 let conversation_id = first_json_string(
2738 obj,
2739 &["conversation_id", "conversationId", "thread_id", "threadId"],
2740 &[
2741 "/payload/conversation_id",
2742 "/payload/conversationId",
2743 "/payload/thread_id",
2744 "/payload/threadId",
2745 ],
2746 )
2747 .or_else(|| session_id.clone());
2748 (
2749 session_id.or_else(|| conversation_id.clone()),
2750 conversation_id,
2751 )
2752}
2753
2754fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
2755 keys.iter()
2756 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
2757 .chain(
2758 pointers
2759 .iter()
2760 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
2761 )
2762 .find(|value| !value.is_empty())
2763 .map(str::to_string)
2764}
2765
2766fn claude_usage_key(obj: &Value) -> String {
2767 obj.get("requestId")
2768 .or_else(|| obj.pointer("/message/id"))
2769 .or_else(|| obj.get("uuid"))
2770 .and_then(Value::as_str)
2771 .unwrap_or("usage")
2772 .to_string()
2773}
2774
2775fn claude_source_completion_id(obj: &Value) -> String {
2776 obj.pointer("/message/id")
2777 .or_else(|| obj.get("requestId"))
2778 .or_else(|| obj.get("uuid"))
2779 .and_then(Value::as_str)
2780 .unwrap_or("")
2781 .to_string()
2782}
2783
2784fn claude_user_starts_prompt(
2785 obj: &Value,
2786 content: &Value,
2787 text: &str,
2788 active_prompt_id: Option<&str>,
2789) -> bool {
2790 if obj.get("isMeta").and_then(Value::as_bool) == Some(true)
2791 || obj.get("sourceToolUseID").is_some()
2792 || obj.get("sourceToolAssistantUUID").is_some()
2793 || ["attachment", "attachments", "image", "images"]
2794 .iter()
2795 .any(|key| obj.get(*key).is_some())
2796 || content.as_array().is_some_and(|items| {
2797 !items.is_empty()
2798 && items.iter().all(|item| {
2799 matches!(
2800 item.get("type").and_then(Value::as_str),
2801 Some("attachment" | "document" | "file" | "image")
2802 )
2803 })
2804 })
2805 || [
2806 "<local-command-caveat>",
2807 "<local-command-stdout>",
2808 "<system-reminder>",
2809 "<ide_opened_file>",
2810 "<ide_selection>",
2811 ]
2812 .iter()
2813 .any(|prefix| text.starts_with(prefix))
2814 {
2815 return false;
2816 }
2817 match obj
2818 .get("promptId")
2819 .and_then(Value::as_str)
2820 .filter(|value| !value.is_empty())
2821 {
2822 Some(prompt_id) => active_prompt_id != Some(prompt_id),
2823 None => active_prompt_id.is_none(),
2824 }
2825}
2826
2827fn local_message_preview(value: &Value) -> Option<String> {
2828 let mut parts = Vec::new();
2829 collect_local_text(value, &mut parts);
2830 clean_prompt_text(&parts.join(" "))
2831}
2832
2833fn collect_local_text(value: &Value, out: &mut Vec<String>) {
2834 match value {
2835 Value::String(text) => out.push(text.clone()),
2836 Value::Array(items) => {
2837 for item in items {
2838 collect_local_text(item, out);
2839 }
2840 }
2841 Value::Object(obj) => {
2842 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
2843 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
2844 }) {
2845 return;
2846 }
2847 for key in ["text", "content", "message", "input", "prompt"] {
2848 if let Some(value) = obj.get(key) {
2849 collect_local_text(value, out);
2850 }
2851 }
2852 }
2853 _ => {}
2854 }
2855}
2856
2857fn is_claude_tool_result(obj: &Value) -> bool {
2858 obj.get("toolUseResult").is_some()
2859 || obj.get("tool_use_result").is_some()
2860 || obj
2861 .pointer("/message/content")
2862 .and_then(Value::as_array)
2863 .is_some_and(|items| {
2864 items
2865 .iter()
2866 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2867 })
2868}
2869
2870fn find_file_arg(value: &Value) -> Option<&str> {
2871 match value {
2872 Value::Object(obj) => {
2873 for key in ["file_path", "path", "filepath"] {
2874 if let Some(path) = obj.get(key).and_then(Value::as_str) {
2875 return Some(path);
2876 }
2877 }
2878 obj.values().find_map(find_file_arg)
2879 }
2880 Value::Array(items) => items.iter().find_map(find_file_arg),
2881 _ => None,
2882 }
2883}
2884
2885fn is_noise_path(path: &str) -> bool {
2886 const NOISE: &[&str] = &[
2887 "/.claude/",
2888 "/.codex/",
2889 "/.gemini/",
2890 "/.git/",
2891 "/node_modules/",
2892 "/.npm/",
2893 "/.cache/",
2894 "CLAUDE.md",
2895 "AGENTS.md",
2896 ];
2897 NOISE.iter().any(|pat| path.contains(pat))
2898}
2899
2900fn clean_prompt_text(text: &str) -> Option<String> {
2901 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2902 let text = text
2903 .strip_prefix("<session>")
2904 .and_then(|text| text.strip_suffix("</session>"))
2905 .unwrap_or(&text)
2906 .trim();
2907 (!text.is_empty()).then(|| text.to_string())
2908}
2909
2910pub fn short_hash(text: &str, n: usize) -> String {
2911 let digest = Sha256::digest(text.as_bytes());
2912 hex::encode(digest).chars().take(n).collect()
2913}
2914
2915pub fn truncate_clean(text: &str, limit: usize) -> String {
2916 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2917 if text.chars().count() <= limit {
2918 return text;
2919 }
2920 text.chars()
2921 .take(limit.saturating_sub(1))
2922 .collect::<String>()
2923 + "."
2924}
2925
2926pub fn one_word(text: &str, default: &str) -> String {
2927 let mut cur = String::new();
2928 for ch in text.to_ascii_lowercase().chars() {
2929 if ch.is_ascii_alphanumeric() {
2930 cur.push(ch);
2931 } else if cur.len() >= 2 {
2932 break;
2933 } else {
2934 cur.clear();
2935 }
2936 }
2937 if cur.len() >= 2 {
2938 cur
2939 } else {
2940 default.to_string()
2941 }
2942}
2943
2944fn short_session_id(id: &str) -> String {
2945 let id = id.trim();
2946 if id.is_empty() {
2947 return "session".to_string();
2948 }
2949 let compact = id
2950 .rsplit(['/', '\\'])
2951 .next()
2952 .unwrap_or(id)
2953 .trim_end_matches(".jsonl");
2954 const MAX_SESSION_ID_CHARS: usize = 12;
2955 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
2956 return compact.to_string();
2957 }
2958 let head = compact.chars().take(6).collect::<String>();
2959 let tail = compact
2960 .chars()
2961 .rev()
2962 .take(5)
2963 .collect::<Vec<_>>()
2964 .into_iter()
2965 .rev()
2966 .collect::<String>();
2967 format!("{head}.{tail}")
2968}
2969
2970fn json_i64(value: &Value, key: &str) -> i64 {
2971 value.get(key).and_then(Value::as_i64).unwrap_or(0)
2972}
2973
2974fn json_u64(value: &Value, key: &str) -> u64 {
2975 value.get(key).and_then(Value::as_u64).unwrap_or(0)
2976}
2977
2978fn ts_ms_from_event(value: &Value) -> Option<i64> {
2979 value
2980 .get("timestamp")
2981 .and_then(Value::as_str)
2982 .and_then(parse_ts_ms)
2983}
2984
2985fn parse_ts_ms(value: &str) -> Option<i64> {
2986 chrono::DateTime::parse_from_rfc3339(value)
2987 .ok()
2988 .map(|ts| ts.timestamp_millis())
2989}
2990
2991fn rfc3339_seconds(value: &str) -> Option<f64> {
2992 chrono::DateTime::parse_from_rfc3339(value)
2993 .ok()
2994 .map(|ts| ts.timestamp_millis() as f64 / 1000.0)
2995}
2996
2997fn uuid7_seconds(value: &str) -> Option<f64> {
2998 let mut parts = value.split('-');
2999 let high = parts.next()?;
3000 let low = parts.next()?;
3001 let version = parts.next()?;
3002 if !version.starts_with('7') {
3003 return None;
3004 }
3005 u64::from_str_radix(&format!("{high}{low}"), 16)
3006 .ok()
3007 .map(|milliseconds| milliseconds as f64 / 1000.0)
3008}
3009
3010fn iso_ms(value: &str) -> Option<u64> {
3011 chrono::DateTime::parse_from_rfc3339(value)
3012 .ok()
3013 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
3014}
3015
3016fn system_time_ms(value: SystemTime) -> u64 {
3017 value
3018 .duration_since(UNIX_EPOCH)
3019 .unwrap_or_default()
3020 .as_millis() as u64
3021}
3022
3023#[cfg(test)]
3024mod tests {
3025 use super::*;
3026 use serde_json::json;
3027 use std::time::UNIX_EPOCH;
3028
3029 #[test]
3030 fn local_session_ids_keep_distinct_conversation_id() {
3031 assert_eq!(
3032 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
3033 (Some("run".to_string()), Some("conv".to_string()))
3034 );
3035 assert_eq!(
3036 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
3037 (Some("thread".to_string()), Some("thread".to_string()))
3038 );
3039 assert_eq!(
3040 local_session_ids(&json!({"payload": {"model": "gpt"}})),
3041 (None, None)
3042 );
3043 }
3044
3045 #[test]
3046 fn agent_jsonl_events_share_one_ir() {
3047 let codex = concat!(
3048 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3049 "\n",
3050 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
3051 "\n",
3052 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3053 "\n",
3054 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"tests passed"}}"#,
3055 "\n",
3056 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
3057 );
3058 let claude = concat!(
3059 r#"{"type":"user","message":{"content":"check build"}}"#,
3060 "\n",
3061 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}}}"#,
3062 );
3063
3064 for (agent, content, tool, model, tokens) in [
3065 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
3066 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
3067 ] {
3068 let session = parse_session_content(
3069 agent,
3070 &PathBuf::from("/tmp/session.jsonl"),
3071 UNIX_EPOCH,
3072 content,
3073 )
3074 .expect("session");
3075 assert_eq!(session.events.tools[0].tool_name, tool);
3076 assert_eq!(session.events.tools[0].category, "shell");
3077 assert_eq!(session.events.llm_responses[0].model, model);
3078 let usage = &session.events.llm_responses[0];
3079 let total = usage
3080 .total_tokens
3081 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
3082 assert_eq!(total, tokens);
3083 }
3084 }
3085
3086 #[test]
3087 fn claude_exact_skill_calls_create_prompt_bounded_latest_wins_scopes() {
3088 let claude = [
3089 r#"{"type":"system","skill_listing":["availability only"]}"#,
3090 r#"{"type":"user","message":{"content":"review the paper"}}"#,
3091 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}}}"#,
3092 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}}}"#,
3093 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}}}"#,
3094 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}}}"#,
3095 r#"{"type":"user","message":{"content":"now summarize"}}"#,
3096 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":50,"output_tokens":5}}}"#,
3097 ]
3098 .join("\n");
3099
3100 let session = parse_session_content(
3101 AGENT_CLAUDE,
3102 &PathBuf::from("/tmp/session.jsonl"),
3103 UNIX_EPOCH,
3104 &claude,
3105 )
3106 .expect("session");
3107
3108 assert_eq!(
3109 session
3110 .events
3111 .tools
3112 .iter()
3113 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
3114 .collect::<Vec<_>>(),
3115 [
3116 ("Skill", "check-paper-citations"),
3117 ("Bash", "check-paper-citations"),
3118 ("Skill", "iter-refine-writing"),
3119 ("Read", "iter-refine-writing"),
3120 ]
3121 );
3122 assert_eq!(
3123 session
3124 .events
3125 .tools
3126 .iter()
3127 .map(|tool| tool.invoked_skill.as_str())
3128 .collect::<Vec<_>>(),
3129 ["check-paper-citations", "", "iter-refine-writing", ""]
3130 );
3131 assert_eq!(
3132 session
3133 .events
3134 .llm_responses
3135 .iter()
3136 .map(|response| response.skill.as_str())
3137 .collect::<Vec<_>>(),
3138 [
3139 "",
3140 "check-paper-citations",
3141 "check-paper-citations",
3142 "iter-refine-writing",
3143 "",
3144 ]
3145 );
3146 }
3147
3148 #[test]
3149 fn codex_source_controls_build_sparse_semantic_task_paths() {
3150 let codex = concat!(
3151 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"write a paper"}]}}"#,
3152 "\n",
3153 r#"{"type":"response_item","payload":{"type":"function_call","name":"update_plan","call_id":"p1","arguments":"{\"plan\":[{\"step\":\"write abstract\",\"status\":\"in_progress\"}]}"}}"#,
3154 "\n",
3155 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"sed -n 1,80p paper.tex\"}"}}"#,
3156 "\n",
3157 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 0\n0 tests failed"}}"#,
3158 "\n",
3159 r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"继续"}]}}"#,
3160 "\n",
3161 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c2","arguments":"{\"cmd\":\"rg error paper.tex\"}"}}"#,
3162 "\n",
3163 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c2","output":"review error handling documentation"}}"#,
3164 );
3165
3166 let session = parse_session_content(
3167 AGENT_CODEX,
3168 &PathBuf::from("/tmp/session.jsonl"),
3169 UNIX_EPOCH,
3170 codex,
3171 )
3172 .expect("session");
3173
3174 assert_eq!(session.events.prompts.len(), 2);
3175 assert!(session.events.llm_responses.is_empty());
3176 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
3177 assert_eq!(
3178 session.events.tools[1].task_path,
3179 vec!["write a paper", "write abstract"]
3180 );
3181 assert_eq!(
3182 session.events.tools[2].task_path,
3183 session.events.tools[1].task_path
3184 );
3185 assert_eq!(session.events.tools[1].status, "ok");
3186 assert_eq!(session.events.tools[2].status, "observed");
3187 }
3188
3189 #[test]
3190 fn codex_custom_exec_is_a_real_source_tool_event() {
3191 let codex = [
3192 json!({
3193 "timestamp": "2026-07-21T00:00:00.000Z",
3194 "type": "response_item",
3195 "payload": {
3196 "type": "message",
3197 "role": "user",
3198 "content": [{"type": "input_text", "text": "test the parser"}]
3199 }
3200 }),
3201 json!({
3202 "timestamp": "2026-07-21T00:00:01.000Z",
3203 "type": "response_item",
3204 "payload": {
3205 "type": "custom_tool_call",
3206 "name": "exec",
3207 "call_id": "custom-1",
3208 "input": "const r = await tools.shell_command({command:\"cargo test\",workdir:\"/repo\"}); text(r);"
3209 }
3210 }),
3211 json!({
3212 "timestamp": "2026-07-21T00:00:02.000Z",
3213 "type": "response_item",
3214 "payload": {
3215 "type": "custom_tool_call_output",
3216 "call_id": "custom-1",
3217 "output": [{"type": "input_text", "text": "Script completed\nExit code: 0\nOutput:\nall tests passed"}]
3218 }
3219 }),
3220 ]
3221 .into_iter()
3222 .map(|line| line.to_string())
3223 .collect::<Vec<_>>()
3224 .join("\n");
3225
3226 let session = parse_session_content(
3227 AGENT_CODEX,
3228 &PathBuf::from("/tmp/session.jsonl"),
3229 UNIX_EPOCH,
3230 &codex,
3231 )
3232 .expect("session");
3233
3234 assert_eq!(session.events.tools.len(), 1);
3235 let event = &session.events.tools[0];
3236 assert_eq!(event.tool_name, "shell_command");
3237 assert_eq!(event.category, "shell");
3238 assert_eq!(event.effect, "test");
3239 assert_eq!(event.command, "cargo test");
3240 assert_eq!(event.status, "ok");
3241 assert_eq!(event.task_path, vec!["test the parser"]);
3242 }
3243
3244 #[test]
3245 fn custom_update_plan_changes_only_later_operation_paths() {
3246 let codex = [
3247 json!({
3248 "timestamp": "2026-07-21T00:00:00.000Z",
3249 "type": "response_item",
3250 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "write a paper"}]}
3251 }),
3252 json!({
3253 "timestamp": "2026-07-21T00:00:01.000Z",
3254 "type": "response_item",
3255 "payload": {
3256 "type": "custom_tool_call",
3257 "name": "exec",
3258 "call_id": "plan-1",
3259 "input": "const r = await tools.update_plan({plan:[{step:\"write abstract\",status:\"in_progress\"},{step:\"write evaluation\",status:\"pending\"}]}); text(r);"
3260 }
3261 }),
3262 json!({
3263 "timestamp": "2026-07-21T00:00:02.000Z",
3264 "type": "response_item",
3265 "payload": {
3266 "type": "custom_tool_call",
3267 "name": "exec",
3268 "call_id": "shell-1",
3269 "input": "const r = await tools.shell_command({command:\"sed -n 1,80p paper.tex\",workdir:\"/repo\"}); text(r);"
3270 }
3271 }),
3272 ]
3273 .into_iter()
3274 .map(|line| line.to_string())
3275 .collect::<Vec<_>>()
3276 .join("\n");
3277 let session = parse_session_content(
3278 AGENT_CODEX,
3279 &PathBuf::from("/tmp/session.jsonl"),
3280 UNIX_EPOCH,
3281 &codex,
3282 )
3283 .expect("session");
3284
3285 assert_eq!(session.events.tools.len(), 2);
3286 assert_eq!(session.events.tools[0].tool_name, "update_plan");
3287 assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
3288 assert_eq!(
3289 session.events.tools[1].task_path,
3290 vec!["write a paper", "write abstract"]
3291 );
3292 }
3293
3294 #[test]
3295 fn prompt_dedup_is_local_and_continuations_keep_the_current_task() {
3296 let codex = [
3297 ("2026-07-21T00:00:00.000Z", "write a paper"),
3298 ("2026-07-21T00:00:00.500Z", "write a paper"),
3299 ("2026-07-21T00:00:03.000Z", "write a paper"),
3300 ("2026-07-21T00:00:06.000Z", "继续"),
3301 ]
3302 .into_iter()
3303 .map(|(timestamp, text)| {
3304 json!({
3305 "timestamp": timestamp,
3306 "type": "response_item",
3307 "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
3308 })
3309 .to_string()
3310 })
3311 .collect::<Vec<_>>()
3312 .join("\n");
3313 let session = parse_session_content(
3314 AGENT_CODEX,
3315 &PathBuf::from("/tmp/session.jsonl"),
3316 UNIX_EPOCH,
3317 &codex,
3318 )
3319 .expect("session");
3320
3321 assert_eq!(session.events.prompts.len(), 3);
3322 assert_eq!(session.events.prompts[2].preview, "继续");
3323 assert_eq!(session.events.prompts[2].task_path, vec!["write a paper"]);
3324 }
3325
3326 #[test]
3327 fn developer_messages_are_not_agent_responses() {
3328 let codex = concat!(
3329 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review"}]}}"#,
3330 "\n",
3331 r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"internal instruction"}]}}"#,
3332 "\n",
3333 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"}]}}"#,
3334 );
3335 let session = parse_session_content(
3336 AGENT_CODEX,
3337 &PathBuf::from("/tmp/session.jsonl"),
3338 UNIX_EPOCH,
3339 codex,
3340 )
3341 .expect("session");
3342 assert_eq!(session.events.llm_responses.len(), 1);
3343 assert_eq!(session.events.llm_responses[0].preview, "review complete");
3344 }
3345
3346 #[test]
3347 fn mixed_batch_exit_codes_fail_if_any_command_failed() {
3348 assert_eq!(
3349 status_from_output("Script completed\nExit code: 0\nExit code: 7"),
3350 "fail"
3351 );
3352 assert_eq!(
3353 status_from_output(
3354 "Process exited with code 0\nProcess exited with code 0\n0 tests failed"
3355 ),
3356 "ok"
3357 );
3358 }
3359
3360 #[test]
3361 fn codex_preserves_commentary_and_final_response_phases() {
3362 let codex = concat!(
3363 r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the code"}]}}"#,
3364 "\n",
3365 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"}]}}"#,
3366 "\n",
3367 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"}]}}"#,
3368 );
3369 let session = parse_session_content(
3370 AGENT_CODEX,
3371 &PathBuf::from("/tmp/session.jsonl"),
3372 UNIX_EPOCH,
3373 codex,
3374 )
3375 .expect("session");
3376
3377 assert_eq!(session.events.llm_responses.len(), 2);
3378 assert_eq!(session.events.llm_responses[0].response_phase, "commentary");
3379 assert_eq!(
3380 session.events.llm_responses[1].response_phase,
3381 "final_answer"
3382 );
3383 }
3384
3385 #[test]
3386 fn semantic_task_label_prefers_explicit_goal_payload() {
3387 let raw = "prefix <objective>write a paper and evaluate it</objective> suffix";
3388 assert_eq!(semantic_task_label(raw), "write a paper and evaluate it");
3389 }
3390
3391 #[test]
3392 fn codex_fork_excludes_copied_parent_history_before_ownership_boundary() {
3393 let codex = concat!(
3394 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"}}"#,
3395 "\n",
3396 r#"{"type":"event_msg","payload":{"type":"user_message","message":"copied parent task"}}"#,
3397 "\n",
3398 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"copied","arguments":"{\"cmd\":\"false\"}"}}"#,
3399 "\n",
3400 r#"{"type":"event_msg","payload":{"type":"task_started","started_at":2.0}}"#,
3401 "\n",
3402 r#"{"type":"event_msg","payload":{"type":"user_message","message":"review child result"}}"#,
3403 "\n",
3404 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"owned","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3405 );
3406
3407 let session = parse_session_content(
3408 AGENT_CODEX,
3409 &PathBuf::from("/tmp/child.jsonl"),
3410 UNIX_EPOCH,
3411 codex,
3412 )
3413 .expect("child session");
3414
3415 assert_eq!(session.session_id, "child");
3416 assert_eq!(session.conversation_id.as_deref(), Some("parent"));
3417 assert_eq!(session.events.prompts.len(), 1);
3418 assert_eq!(session.events.prompts[0].preview, "review child result");
3419 assert_eq!(session.events.tools.len(), 1);
3420 assert_eq!(session.events.tools[0].call_id.as_deref(), Some("owned"));
3421 }
3422
3423 #[test]
3424 fn file_actions_ignore_patch_and_heredoc_bodies() {
3425 let patch = tool_event_from_input(
3426 Some("/repo"),
3427 Some(1),
3428 0,
3429 "exec",
3430 &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)"#}),
3431 None,
3432 Vec::new(),
3433 );
3434 assert_eq!(
3435 patch.paths,
3436 vec![ToolPath {
3437 path: "src/lib.rs".into(),
3438 access: "write".into(),
3439 previous_path: None,
3440 }]
3441 );
3442
3443 let heredoc = tool_event_from_input(
3444 Some("/repo"),
3445 Some(1),
3446 0,
3447 "exec_command",
3448 &json!({"cmd": "cat <<'EOF'\n#!/bin/sh\nsrc/not-a-file.rs\nEOF\ncat src/real.rs"}),
3449 None,
3450 Vec::new(),
3451 );
3452 assert_eq!(heredoc.paths.len(), 1);
3453 assert_eq!(heredoc.paths[0].path, "src/real.rs");
3454 }
3455
3456 #[test]
3457 fn file_actions_are_conservative_for_unknown_and_write_tools() {
3458 let unknown = tool_event_from_input(
3459 Some("/repo"),
3460 Some(1),
3461 0,
3462 "mcp_resource",
3463 &json!({"path": "src/not-a-file.rs"}),
3464 None,
3465 Vec::new(),
3466 );
3467 assert!(unknown.paths.is_empty());
3468
3469 let write = tool_event_from_input(
3470 Some("/repo"),
3471 Some(1),
3472 0,
3473 "Write",
3474 &json!({"file_path": "src/existing.rs", "content": "changed"}),
3475 None,
3476 Vec::new(),
3477 );
3478 assert_eq!(write.paths[0].access, "write");
3479 }
3480
3481 #[test]
3482 fn patch_move_keeps_the_immediately_preceding_source() {
3483 let event = tool_event_from_input(
3484 Some("/repo"),
3485 Some(1),
3486 0,
3487 "apply_patch",
3488 &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"}),
3489 None,
3490 Vec::new(),
3491 );
3492 assert!(event.paths.contains(&ToolPath {
3493 path: "src/b.rs".into(),
3494 access: "rename".into(),
3495 previous_path: Some("src/a.rs".into()),
3496 }));
3497 assert!(event.paths.contains(&ToolPath {
3498 path: "src/c.rs".into(),
3499 access: "write".into(),
3500 previous_path: None,
3501 }));
3502
3503 let event = tool_event_from_input(
3504 Some("/repo"),
3505 Some(1),
3506 0,
3507 "apply_patch",
3508 &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"}),
3509 None,
3510 Vec::new(),
3511 );
3512 assert_eq!(
3513 event
3514 .paths
3515 .iter()
3516 .map(|row| (row.path.as_str(), row.previous_path.as_deref()))
3517 .collect::<Vec<_>>(),
3518 vec![("x.rs", Some("a.rs")), ("y.rs", Some("b.rs"))]
3519 );
3520 }
3521
3522 #[test]
3523 fn tool_outputs_mark_failed_file_actions() {
3524 let content = concat!(
3525 r#"{"type":"turn_context","payload":{"cwd":"/repo"}}"#,
3526 "\n",
3527 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"rm src/lib.rs\"}"}}"#,
3528 "\n",
3529 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 1"}}"#,
3530 );
3531 let session = parse_session_content(
3532 AGENT_CODEX,
3533 Path::new("/tmp/session.jsonl"),
3534 UNIX_EPOCH,
3535 content,
3536 )
3537 .expect("session");
3538 assert_eq!(session.events.tools[0].status, "fail");
3539 assert_eq!(session.events.tools[0].paths[0].access, "delete");
3540
3541 let claude = concat!(
3542 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"}}]}}"#,
3543 "\n",
3544 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"}]}}"#,
3545 );
3546 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"}]}]}"#;
3547 for (agent, content, expected) in [
3548 (AGENT_CLAUDE, claude, &["ok", "fail"][..]),
3549 (AGENT_GEMINI, gemini, &["fail"][..]),
3550 ] {
3551 let session =
3552 parse_session_content(agent, Path::new("/tmp/session.jsonl"), UNIX_EPOCH, content)
3553 .unwrap();
3554 let statuses = session
3555 .events
3556 .tools
3557 .iter()
3558 .map(|row| row.status.as_str())
3559 .collect::<Vec<_>>();
3560 assert_eq!(statuses, expected);
3561 }
3562 }
3563
3564 #[test]
3565 fn codex_exec_prompt_handles_latest_cli_options() {
3566 let command = concat!(
3567 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
3568 "-c model_provider=\"agentsight-mock\" ",
3569 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
3570 "--sandbox read-only --model gpt-agentsight-mock ",
3571 "agentsight mock prompt collect this exact text"
3572 );
3573
3574 assert_eq!(
3575 codex_exec_prompt(command).as_deref(),
3576 Some("agentsight mock prompt collect this exact text")
3577 );
3578 }
3579
3580 #[test]
3581 fn codex_cumulative_usage_separates_cached_input() {
3582 let content = concat!(
3583 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}"#,
3584 "\n",
3585 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}}}}"#,
3586 );
3587
3588 let session = parse_session_content(
3589 AGENT_CODEX,
3590 &PathBuf::from("/tmp/session.jsonl"),
3591 UNIX_EPOCH,
3592 content,
3593 )
3594 .expect("session");
3595
3596 assert_eq!(session.usage.input_tokens, 9_200);
3597 assert_eq!(session.usage.cache_read_tokens, 9_984);
3598 assert_eq!(session.usage.output_tokens, 11);
3599 assert_eq!(session.usage.total_tokens, 19_195);
3600 }
3601
3602 #[test]
3603 fn codex_exec_wrapper_projects_nested_shell_actions() {
3604 let event = tool_event_from_input(
3605 Some("/repo"),
3606 Some(1),
3607 0,
3608 "exec",
3609 &json!({"text": r#"const r = await tools.exec_command({"cmd":"cat src/lib.rs && sed -i 's/a/b/' src/main.rs","workdir":"/repo"});"#}),
3610 None,
3611 Vec::new(),
3612 );
3613 assert_eq!(
3614 event
3615 .paths
3616 .iter()
3617 .map(|path| (path.path.as_str(), path.access.as_str()))
3618 .collect::<Vec<_>>(),
3619 vec![("/repo/src/lib.rs", "read"), ("/repo/src/main.rs", "write")]
3620 );
3621 }
3622
3623 #[test]
3624 fn claude_uuid_only_fragments_share_one_completion_identity() {
3625 let claude = [
3626 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
3627 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}}}"#,
3628 r#"{"type":"system","subtype":"internal-marker"}"#,
3629 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}}}"#,
3630 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}}}"#,
3631 ]
3632 .join("\n");
3633
3634 let session = parse_session_content(
3635 AGENT_CLAUDE,
3636 &PathBuf::from("/tmp/session.jsonl"),
3637 UNIX_EPOCH,
3638 &claude,
3639 )
3640 .expect("session");
3641
3642 assert_eq!(session.events.llm_responses.len(), 1);
3643 assert_eq!(session.events.llm_responses[0].source_id, "completion-1");
3644 assert_eq!(session.events.llm_responses[0].skill, "");
3645 assert_eq!(
3646 session.events.llm_responses[0]
3647 .token_components()
3648 .into_iter()
3649 .map(|(_, value)| value)
3650 .sum::<u64>(),
3651 113
3652 );
3653 assert_eq!(session.events.tools[0].skill, "paper-writing-style");
3654 assert_eq!(session.events.tools[0].invoked_skill, "paper-writing-style");
3655 }
3656
3657 #[test]
3658 fn claude_skill_scope_ignores_metadata_and_deduplicates_split_completion() {
3659 let claude = [
3660 r#"{"type":"system","skill_listing":["availability only"]}"#,
3661 r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
3662 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}}}"#,
3663 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}}}"#,
3664 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}}}"#,
3665 r#"{"type":"user","promptId":"p1","isMeta":true,"sourceToolUseID":"s1","message":{"content":[{"type":"text","text":"skill payload"}]}}"#,
3666 r#"{"type":"last-prompt","lastPrompt":"review the paper"}"#,
3667 r#"{"type":"user","message":{"content":"<local-command-stdout>metadata</local-command-stdout>"}}"#,
3668 r#"{"type":"user","promptId":"attachment-only","attachments":[{"file_name":"paper.pdf"}],"message":{"content":"attached context"}}"#,
3669 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}}}"#,
3670 r#"{"type":"user","promptId":"p1","sourceToolAssistantUUID":"assistant-2","message":{"content":[{"type":"tool_result","tool_use_id":"b1","content":"ok"}]}}"#,
3671 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}}}"#,
3672 r#"{"type":"user","promptId":"p2","message":{"content":"now summarize"}}"#,
3673 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}}}"#,
3674 ]
3675 .join("\n");
3676
3677 let session = parse_session_content(
3678 AGENT_CLAUDE,
3679 &PathBuf::from("/tmp/session.jsonl"),
3680 UNIX_EPOCH,
3681 &claude,
3682 )
3683 .expect("session");
3684
3685 assert_eq!(session.events.prompts.len(), 2);
3686 assert_eq!(session.events.llm_responses.len(), 4);
3687 assert_eq!(session.events.llm_responses[0].source_id, "msg-1");
3688 assert_eq!(
3689 session.events.llm_responses[0]
3690 .token_components()
3691 .into_iter()
3692 .map(|(_, value)| value)
3693 .sum::<u64>(),
3694 113
3695 );
3696 assert_eq!(
3697 session
3698 .events
3699 .tools
3700 .iter()
3701 .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
3702 .collect::<Vec<_>>(),
3703 [
3704 ("Skill", "check-paper-citations"),
3705 ("Bash", "check-paper-citations"),
3706 ("Read", "check-paper-citations"),
3707 ]
3708 );
3709 assert_eq!(
3710 session
3711 .events
3712 .llm_responses
3713 .iter()
3714 .map(|response| response.skill.as_str())
3715 .collect::<Vec<_>>(),
3716 ["", "check-paper-citations", "check-paper-citations", ""]
3717 );
3718 }
3719}