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