Skip to main content

agent_session/
parser.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Session file parsing for Claude Code, Codex, and Gemini CLI.
5
6use 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
19/// Discover all session files in the user's home directory.
20pub 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
27/// Discover session files under a specific home directory.
28pub 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
59/// Count sessions and bytes per agent directory.
60pub 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
98/// Parse a session file from a candidate.
99pub 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
109/// Parse a session file by path, detecting the agent type automatically.
110pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
111    parse_session_file(&session_candidate_from_path(path)?)
112}
113
114/// Parse session content given raw content string.
115pub 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
137/// Extract a session log path from a string (e.g., from /proc/fd).
138pub 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
150/// Canonicalize a session log path.
151pub fn normalize_session_log_path(path: &Path) -> PathBuf {
152    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
153}
154
155/// Detect which agent a session file belongs to based on its path.
156pub 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
185/// Generate a fixture session path for testing.
186pub 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
195/// Check if a target path is the Codex CLI entrypoint.
196pub 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
203/// Extract the prompt from a Codex exec command.
204pub 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// ---------------------------------------------------------------------------
276// Internal parsing implementation
277// ---------------------------------------------------------------------------
278
279#[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                    // Build preview: prefer text content, fall back to tool names
577                    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
1310// ---------------------------------------------------------------------------
1311// Helper functions
1312// ---------------------------------------------------------------------------
1313
1314fn 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            // "tools." was not followed by an identifier. Step past one
2119            // character rather than one byte, so multibyte text in the
2120            // surrounding source cannot leave offset inside a char.
2121            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                // For thinking blocks, extract the thinking field
2698                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}