Skip to main content

toolpath_copilot/
provider.rs

1//! Build a provider-agnostic [`ConversationView`] from a Copilot [`Session`].
2//!
3//! ⚠️ The mapping below follows the *inferred* `events.jsonl` semantics in
4//! `docs/agents/formats/copilot-cli/events.md`. Tool-name classification and
5//! file-mutation extraction are best-effort and should be tightened once a
6//! real session is captured.
7
8use crate::io::ConvoIO;
9use crate::paths::PathResolver;
10use crate::types::{CopilotEvent, Session};
11use serde_json::Value;
12use std::collections::HashMap;
13use toolpath_convo::{
14    ConversationEvent, ConversationView, DelegatedWork, FileMutation, ProducerInfo, Role,
15    SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
16};
17
18/// Provider identity used for `path-<provider>-…` ids and dispatch.
19pub const PROVIDER_ID: &str = "copilot";
20/// Producer name recorded on the derived `Path`.
21pub const PRODUCER_NAME: &str = "copilot-cli";
22
23/// Classify a Copilot tool name into toolpath's [`ToolCategory`] ontology.
24///
25/// ⚠️ The Copilot CLI's tool vocabulary is **not yet verified against a real
26/// session**; this errs toward broad, case-insensitive matching and returns
27/// `None` for anything unrecognized (the raw `name`/`input` are still carried
28/// on the [`ToolInvocation`]). Tighten with observed names later.
29pub fn tool_category(name: &str) -> Option<ToolCategory> {
30    let n = name.to_ascii_lowercase();
31    // Exact-ish matches first.
32    match n.as_str() {
33        "shell" | "bash" | "sh" | "run" | "exec" | "execute" | "terminal" | "run_in_terminal"
34        | "run_command" | "run_shell" | "command" => return Some(ToolCategory::Shell),
35        "read" | "read_file" | "readfile" | "view" | "view_file" | "cat" | "open" | "get_file" => {
36            return Some(ToolCategory::FileRead);
37        }
38        "write"
39        | "write_file"
40        | "writefile"
41        | "create"
42        | "create_file"
43        | "edit"
44        | "edit_file"
45        | "apply_patch"
46        | "patch"
47        | "str_replace"
48        | "str_replace_editor"
49        | "replace"
50        | "replace_string_in_file"
51        | "insert"
52        | "delete_file" => {
53            return Some(ToolCategory::FileWrite);
54        }
55        "glob" | "list" | "list_dir" | "list_directory" | "ls" | "find" | "find_files" | "grep"
56        | "search" | "ripgrep" | "rg" | "file_search" | "grep_search" | "semantic_search"
57        | "codebase_search" => return Some(ToolCategory::FileSearch),
58        "fetch" | "web_fetch" | "fetch_url" | "web_search" | "search_web" | "browser"
59        | "open_url" | "http" => return Some(ToolCategory::Network),
60        "subagent" | "delegate" | "spawn_agent" | "task" | "agent" | "dispatch_agent" => {
61            return Some(ToolCategory::Delegation);
62        }
63        _ => {}
64    }
65    // Substring fallbacks for compound / namespaced names.
66    if n.contains("shell") || n.contains("terminal") || n.contains("command") {
67        Some(ToolCategory::Shell)
68    } else if n.contains("search") || n.contains("grep") || n.contains("glob") {
69        Some(ToolCategory::FileSearch)
70    } else if n.contains("write")
71        || n.contains("edit")
72        || n.contains("patch")
73        || n.contains("replace")
74    {
75        Some(ToolCategory::FileWrite)
76    } else if n.contains("read") || n.contains("view") || n.contains("file") {
77        Some(ToolCategory::FileRead)
78    } else if n.contains("web") || n.contains("fetch") || n.contains("http") {
79        Some(ToolCategory::Network)
80    } else {
81        None
82    }
83}
84
85/// Reverse of [`tool_category`]: the Copilot-native tool name for a category.
86///
87/// Used by the projector to remap a foreign harness's tool name (e.g. Claude's
88/// `Bash`, codex's `shell`) into Copilot's vocabulary. `FileWrite`/`FileSearch`
89/// are too coarse alone, so the arg shape disambiguates. Every returned name
90/// re-classifies back to the same category via [`tool_category`], so a
91/// Copilot→Copilot round-trip is stable.
92pub fn native_name(category: ToolCategory, input: &serde_json::Value) -> &'static str {
93    match category {
94        ToolCategory::Shell => "bash",
95        ToolCategory::FileRead => "view",
96        ToolCategory::FileWrite => {
97            // Copilot's real file tools are `edit` (partial replace) and
98            // `create` (new file), disambiguated by the presence of an
99            // old-string arg.
100            if input.get("old_string").is_some() || input.get("old_str").is_some() {
101                "edit"
102            } else {
103                "create"
104            }
105        }
106        ToolCategory::FileSearch => {
107            if input.get("query").is_some()
108                || input.get("pattern").is_some()
109                || input.get("regex").is_some()
110            {
111                "grep"
112            } else {
113                "glob"
114            }
115        }
116        ToolCategory::Network => "fetch",
117        ToolCategory::Delegation => "task",
118    }
119}
120
121/// Fold `add` into `slot` field-wise (summing counts). Used to merge several
122/// assistant messages into one turn's usage, and to sum per-turn usage into the
123/// session total.
124pub(crate) fn merge_turn_usage(slot: &mut Option<TokenUsage>, add: Option<TokenUsage>) {
125    let Some(add) = add else { return };
126    let sum = |a: Option<u32>, b: Option<u32>| match (a, b) {
127        (None, None) => None,
128        (x, y) => Some(x.unwrap_or(0) + y.unwrap_or(0)),
129    };
130    let s = slot.get_or_insert_with(TokenUsage::default);
131    s.input_tokens = sum(s.input_tokens, add.input_tokens);
132    s.output_tokens = sum(s.output_tokens, add.output_tokens);
133    s.cache_read_tokens = sum(s.cache_read_tokens, add.cache_read_tokens);
134    s.cache_write_tokens = sum(s.cache_write_tokens, add.cache_write_tokens);
135}
136
137/// Convert a parsed Copilot [`Session`] into a [`ConversationView`].
138pub fn to_view(session: &Session) -> ConversationView {
139    let start = session.start();
140    let default_model = start.as_ref().and_then(|s| s.model.clone());
141
142    let mut turns: Vec<Turn> = Vec::new();
143    let mut current: Option<Turn> = None;
144    let mut events: Vec<ConversationEvent> = Vec::new();
145    // Copilot reports per-message tokens (`outputTokens`, and — on a projected
146    // session — `inputTokens`/cache). We set them per-turn and sum for the
147    // session total; `session.shutdown` (when present) is the fallback total.
148    let mut shutdown_usage: Option<TokenUsage> = None;
149    // `seq` numbers turns (stable across re-derivation); `aux_seq` numbers
150    // fallback tool/sub-agent ids so a turn's id never depends on how many
151    // tools preceded it (which would break parent-graph idempotency).
152    let mut seq: usize = 0;
153    let mut aux_seq: usize = 0;
154
155    for (i, line) in session.lines.iter().enumerate() {
156        let ts = line.timestamp.clone().unwrap_or_default();
157        match line.event() {
158            CopilotEvent::SessionStart(_) => {}
159            CopilotEvent::SessionShutdown(s) => {
160                if s.input_tokens.is_some() || s.output_tokens.is_some() {
161                    shutdown_usage = Some(TokenUsage {
162                        input_tokens: s.input_tokens,
163                        output_tokens: s.output_tokens,
164                        cache_read_tokens: s.cache_read_tokens,
165                        cache_write_tokens: s.cache_write_tokens,
166                        breakdowns: Default::default(),
167                    });
168                }
169            }
170            CopilotEvent::UserMessage(m) => {
171                flush(&mut turns, &mut current);
172                seq += 1;
173                let mut t = empty_turn(format!("u{seq}"), Role::User, ts);
174                t.text = m.text;
175                push_linked(&mut turns, t);
176            }
177            CopilotEvent::AssistantTurnStart => {
178                flush(&mut turns, &mut current);
179                seq += 1;
180                let mut t = empty_turn(format!("a{seq}"), Role::Assistant, ts);
181                t.model = default_model.clone();
182                current = Some(t);
183            }
184            CopilotEvent::AssistantMessage(m) => {
185                let usage = m.token_usage();
186                let cur = ensure_assistant(&mut current, &mut seq, &default_model, &ts);
187                merge_turn_usage(&mut cur.token_usage, usage);
188                append_text(&mut cur.text, &m.text);
189                if let Some(r) = &m.reasoning {
190                    match &mut cur.thinking {
191                        Some(t) => {
192                            t.push_str("\n\n");
193                            t.push_str(r);
194                        }
195                        None => cur.thinking = Some(r.clone()),
196                    }
197                }
198                if cur.model.is_none() {
199                    cur.model = m.model.clone().or_else(|| default_model.clone());
200                }
201            }
202            CopilotEvent::AssistantTurnEnd => {
203                flush(&mut turns, &mut current);
204            }
205            CopilotEvent::ToolStart(te) => {
206                let cur = ensure_assistant(&mut current, &mut seq, &default_model, &ts);
207                aux_seq += 1;
208                let tool_id = te.id.clone().unwrap_or_else(|| format!("tool{aux_seq}"));
209                if let Some(fm) = file_mutation_for(&tool_id, &te.name, &te.args) {
210                    cur.file_mutations.push(fm);
211                }
212                cur.tool_uses.push(ToolInvocation {
213                    id: tool_id,
214                    name: te.name.clone(),
215                    input: te.args,
216                    result: None,
217                    category: tool_category(&te.name),
218                });
219            }
220            CopilotEvent::ToolComplete(te) => {
221                // Native edit/create completes carry the REAL file-state diff
222                // inline (`result.detailedContent`, git-style) — better
223                // fidelity than the arg-derived reconstruction from ToolStart;
224                // upgrade the matching mutation's raw perspective with it.
225                if let (Some(id), Some(det)) = (te.id.as_deref(), te.detailed.as_deref())
226                    && det.contains("@@")
227                {
228                    upgrade_mutation_diff(&mut current, &mut turns, id, det);
229                }
230                let result = ToolResult {
231                    content: te.output.clone().unwrap_or_default(),
232                    is_error: te.success == Some(false),
233                };
234                if !attach_tool_result(
235                    &mut current,
236                    &mut turns,
237                    te.id.as_deref(),
238                    &te.name,
239                    result.clone(),
240                ) {
241                    // No matching start seen — synthesize a carrier invocation.
242                    let cur = ensure_assistant(&mut current, &mut seq, &default_model, &ts);
243                    aux_seq += 1;
244                    let tool_id = te.id.clone().unwrap_or_else(|| format!("tool{aux_seq}"));
245                    if let Some(fm) = file_mutation_for(&tool_id, &te.name, &te.args) {
246                        cur.file_mutations.push(fm);
247                    }
248                    cur.tool_uses.push(ToolInvocation {
249                        id: tool_id,
250                        name: te.name.clone(),
251                        input: te.args,
252                        result: Some(result),
253                        category: tool_category(&te.name),
254                    });
255                }
256            }
257            CopilotEvent::SubagentStarted(s) => {
258                let cur = ensure_assistant(&mut current, &mut seq, &default_model, &ts);
259                aux_seq += 1;
260                cur.delegations.push(DelegatedWork {
261                    agent_id: s.id.clone().unwrap_or_else(|| format!("sub{aux_seq}")),
262                    prompt: s.prompt.unwrap_or_default(),
263                    turns: Vec::new(),
264                    result: s.result,
265                });
266            }
267            CopilotEvent::SubagentCompleted(s) => {
268                backfill_delegation_result(&mut current, &mut turns, s.id.as_deref(), s.result);
269            }
270            CopilotEvent::SkillInvoked(p) => {
271                events.push(make_event(i, "skill.invoked", &ts, p));
272            }
273            CopilotEvent::Hook { kind, payload } => {
274                events.push(make_event(i, &kind, &ts, payload));
275            }
276            CopilotEvent::Abort(p) => events.push(make_event(i, "abort", &ts, p)),
277            CopilotEvent::CompactionComplete(p) => {
278                events.push(make_event(i, "session.compaction_complete", &ts, p));
279            }
280            CopilotEvent::SessionOther { kind, payload } => {
281                events.push(make_event(i, &kind, &ts, payload));
282            }
283            CopilotEvent::Unknown { kind, payload } => {
284                events.push(make_event(i, &kind, &ts, payload));
285            }
286        }
287    }
288    flush(&mut turns, &mut current);
289
290    // Renumber turns by final position so ids don't depend on how many turns
291    // were created-then-dropped as empty (which differs across re-derivation
292    // and would break parent-graph idempotency). Turn ids are only used for the
293    // step DAG; tool/delegation pairing keys off tool/agent ids, not turn ids.
294    for (i, t) in turns.iter_mut().enumerate() {
295        t.id = format!("t{i}");
296        t.parent_id = (i > 0).then(|| format!("t{}", i - 1));
297    }
298
299    // Session total = field-wise sum of per-turn usage (Σ turns = session
300    // total). Per-turn usage only carries `output` (Copilot's
301    // `assistant.message` reports only `outputTokens`), so the session's real
302    // input/cache totals live on `session.shutdown`. When both are present,
303    // take `output` from the per-message sum and fill input/cache from the
304    // shutdown — otherwise ~all of the session's input + cache tokens (222k in
305    // the real fixture) get dropped. Fall back to whichever exists alone.
306    let mut summed: Option<TokenUsage> = None;
307    for t in &turns {
308        if let Some(u) = &t.token_usage {
309            merge_turn_usage(&mut summed, Some(u.clone()));
310        }
311    }
312    let total_usage = match (summed, shutdown_usage) {
313        (Some(mut s), Some(sd)) => {
314            s.input_tokens = s.input_tokens.or(sd.input_tokens);
315            s.cache_read_tokens = s.cache_read_tokens.or(sd.cache_read_tokens);
316            s.cache_write_tokens = s.cache_write_tokens.or(sd.cache_write_tokens);
317            s.output_tokens = s.output_tokens.or(sd.output_tokens);
318            Some(s)
319        }
320        (s, sd) => s.or(sd),
321    };
322
323    // Files changed, in first-touch order, deduped.
324    let mut files_changed: Vec<String> = Vec::new();
325    for t in &turns {
326        for fm in &t.file_mutations {
327            if !files_changed.contains(&fm.path) {
328                files_changed.push(fm.path.clone());
329            }
330        }
331    }
332
333    // Base: `session.start`'s `context` is primary; `workspace.yaml` is the
334    // fallback for anything it lacks.
335    let ws = session.workspace.as_ref();
336    let s = start.as_ref();
337    let working_dir = s
338        .and_then(|s| s.cwd.clone())
339        .or_else(|| s.and_then(|s| s.git_root.clone()))
340        .or_else(|| ws.and_then(|w| w.git_root.clone()));
341    let vcs_branch = s
342        .and_then(|s| s.branch.clone())
343        .or_else(|| ws.and_then(|w| w.branch.clone()));
344    let vcs_revision = s
345        .and_then(|s| s.revision.clone())
346        .or_else(|| ws.and_then(|w| w.revision.clone()));
347    let vcs_remote = s
348        .and_then(|s| s.repository.clone())
349        .or_else(|| ws.and_then(|w| w.repository.clone()));
350    let base = if working_dir.is_some()
351        || vcs_branch.is_some()
352        || vcs_revision.is_some()
353        || vcs_remote.is_some()
354    {
355        Some(SessionBase {
356            working_dir,
357            vcs_revision,
358            vcs_branch,
359            vcs_remote,
360        })
361    } else {
362        None
363    };
364
365    ConversationView {
366        id: session.id.clone(),
367        started_at: session.started_at(),
368        last_activity: session.last_activity(),
369        turns,
370        total_usage,
371        provider_id: Some(PROVIDER_ID.to_string()),
372        files_changed,
373        session_ids: Vec::new(),
374        events,
375        base,
376        producer: Some(ProducerInfo {
377            name: PRODUCER_NAME.to_string(),
378            version: session.version(),
379        }),
380    }
381}
382
383// ── Helpers ──────────────────────────────────────────────────────────
384
385fn empty_turn(id: String, role: Role, timestamp: String) -> Turn {
386    Turn {
387        id,
388        parent_id: None,
389        group_id: None,
390        role,
391        timestamp,
392        text: String::new(),
393        thinking: None,
394        tool_uses: Vec::new(),
395        model: None,
396        stop_reason: None,
397        token_usage: None,
398        attributed_token_usage: None,
399        environment: None,
400        delegations: Vec::new(),
401        file_mutations: Vec::new(),
402    }
403}
404
405fn ensure_assistant<'a>(
406    current: &'a mut Option<Turn>,
407    seq: &mut usize,
408    model: &Option<String>,
409    ts: &str,
410) -> &'a mut Turn {
411    if current.is_none() {
412        *seq += 1;
413        let mut t = empty_turn(format!("a{seq}"), Role::Assistant, ts.to_string());
414        t.model = model.clone();
415        *current = Some(t);
416    }
417    current.as_mut().unwrap()
418}
419
420fn append_text(buf: &mut String, more: &str) {
421    if more.is_empty() {
422        return;
423    }
424    if !buf.is_empty() {
425        buf.push_str("\n\n");
426    }
427    buf.push_str(more);
428}
429
430fn push_linked(turns: &mut Vec<Turn>, mut t: Turn) {
431    if let Some(prev) = turns.last() {
432        t.parent_id = Some(prev.id.clone());
433    }
434    turns.push(t);
435}
436
437fn turn_has_content(t: &Turn) -> bool {
438    !t.text.trim().is_empty()
439        || !t.tool_uses.is_empty()
440        || !t.delegations.is_empty()
441        || !t.file_mutations.is_empty()
442        || t.thinking.is_some()
443}
444
445fn flush(turns: &mut Vec<Turn>, current: &mut Option<Turn>) {
446    if let Some(t) = current.take()
447        && turn_has_content(&t)
448    {
449        push_linked(turns, t);
450    }
451}
452
453/// Attach a `tool.execution_complete` result to its matching invocation.
454///
455/// The Copilot `events.jsonl` schema is unverified, and the reverse-engineering
456/// sources never confirmed that tool events carry a correlation id. So:
457///
458/// - **If an `id` is present** it is treated as authoritative: match it in the
459///   open turn, then in earlier turns. A present-but-unmatched id returns
460///   `false` (the caller makes a standalone carrier) rather than guessing.
461/// - **If there is no `id`** (the likely real case), pair with the most-recent
462///   result-less invocation in the *current open turn*, preferring the same
463///   tool `name`. `start`/`complete` bracket within a turn, so this matches the
464///   sequential common case without double-counting.
465///
466/// Returns whether a match was found.
467fn attach_tool_result(
468    current: &mut Option<Turn>,
469    turns: &mut [Turn],
470    id: Option<&str>,
471    name: &str,
472    result: ToolResult,
473) -> bool {
474    if let Some(id) = id {
475        if let Some(cur) = current.as_mut()
476            && let Some(inv) = cur.tool_uses.iter_mut().rev().find(|t| t.id == id)
477        {
478            inv.result = Some(result);
479            return true;
480        }
481        for t in turns.iter_mut().rev() {
482            if let Some(inv) = t.tool_uses.iter_mut().rev().find(|t| t.id == id) {
483                inv.result = Some(result);
484                return true;
485            }
486        }
487        return false;
488    }
489    // No id: positional pairing within the current open turn.
490    if let Some(cur) = current.as_mut() {
491        if let Some(inv) = cur
492            .tool_uses
493            .iter_mut()
494            .rev()
495            .find(|t| t.result.is_none() && t.name == name)
496        {
497            inv.result = Some(result);
498            return true;
499        }
500        if let Some(inv) = cur.tool_uses.iter_mut().rev().find(|t| t.result.is_none()) {
501            inv.result = Some(result);
502            return true;
503        }
504    }
505    false
506}
507
508/// Replace the arg-derived `raw_diff` on the mutation created by tool call
509/// `id` with the native file-state diff from `result.detailedContent`.
510fn upgrade_mutation_diff(current: &mut Option<Turn>, turns: &mut [Turn], id: &str, diff: &str) {
511    let hit = |t: &mut Turn| {
512        t.file_mutations
513            .iter_mut()
514            .rev()
515            .find(|m| m.tool_id.as_deref() == Some(id))
516            .map(|m| m.raw_diff = Some(diff.to_string()))
517            .is_some()
518    };
519    if let Some(cur) = current.as_mut()
520        && hit(cur)
521    {
522        return;
523    }
524    for t in turns.iter_mut().rev() {
525        if hit(t) {
526            return;
527        }
528    }
529}
530
531fn backfill_delegation_result(
532    current: &mut Option<Turn>,
533    turns: &mut [Turn],
534    id: Option<&str>,
535    result: Option<String>,
536) {
537    let Some(id) = id else { return };
538    if let Some(cur) = current.as_mut()
539        && let Some(d) = cur.delegations.iter_mut().rev().find(|d| d.agent_id == id)
540    {
541        if d.result.is_none() {
542            d.result = result;
543        }
544        return;
545    }
546    for t in turns.iter_mut().rev() {
547        if let Some(d) = t.delegations.iter_mut().rev().find(|d| d.agent_id == id) {
548            if d.result.is_none() {
549                d.result = result;
550            }
551            return;
552        }
553    }
554}
555
556fn make_event(idx: usize, event_type: &str, ts: &str, payload: Value) -> ConversationEvent {
557    let data: HashMap<String, Value> = match payload {
558        Value::Object(map) => map.into_iter().collect(),
559        Value::Null => HashMap::new(),
560        other => {
561            let mut m = HashMap::new();
562            m.insert("value".to_string(), other);
563            m
564        }
565    };
566    ConversationEvent {
567        id: format!("evt-{idx:04}"),
568        timestamp: ts.to_string(),
569        parent_id: None,
570        event_type: event_type.to_string(),
571        data,
572    }
573}
574
575/// Best-effort file-mutation extraction for a `FileWrite`-category tool call.
576///
577/// Returns `None` for non-file-write tools or when no path is present. When
578/// the args carry full content (a create/write), a `raw` unified-diff
579/// perspective is synthesized; edit-shaped calls (old/new string) without the
580/// full file yield a structural-only mutation (no `raw_diff`). See
581/// `docs/agents/formats/copilot-cli/file-fidelity.md`.
582fn file_mutation_for(tool_id: &str, name: &str, args: &Value) -> Option<FileMutation> {
583    if tool_category(name) != Some(ToolCategory::FileWrite) {
584        return None;
585    }
586    let path = str_arg(
587        args,
588        &[
589            "path",
590            "file_path",
591            "filePath",
592            "filename",
593            "file",
594            "target_file",
595        ],
596    )?;
597
598    let n = name.to_ascii_lowercase();
599    let is_delete = n.contains("delete");
600    let looks_add = n.contains("create") || n.contains("add") || n.contains("new");
601    let content = str_arg(
602        args,
603        &[
604            "content",
605            "contents",
606            "new_content",
607            "text",
608            "file_text",
609            "new_str",
610            "newstring",
611            "newString",
612        ],
613    );
614    let old = str_arg(args, &["old_string", "old_str", "oldstring", "oldString"]);
615
616    let operation = if is_delete {
617        "delete"
618    } else if looks_add || (content.is_some() && old.is_none()) {
619        "add"
620    } else {
621        "update"
622    };
623
624    // A `raw` perspective requires a full after-content (and a known before).
625    // We only have that for full-write/add shapes.
626    let (raw_diff, after) = match (&content, old.is_some()) {
627        (Some(c), false) if !is_delete => (
628            Some(toolpath_convo::unified_diff(&path, "", c)),
629            Some(c.clone()),
630        ),
631        _ => (None, content.clone()),
632    };
633
634    Some(FileMutation {
635        path,
636        tool_id: Some(tool_id.to_string()),
637        operation: Some(operation.to_string()),
638        raw_diff,
639        before: None,
640        after: if is_delete { None } else { after },
641        rename_to: None,
642    })
643}
644
645fn str_arg(args: &Value, keys: &[&str]) -> Option<String> {
646    for k in keys {
647        if let Some(s) = args.get(*k).and_then(|v| v.as_str()) {
648            return Some(s.to_string());
649        }
650    }
651    None
652}
653
654// ── Manager facade ───────────────────────────────────────────────────
655
656/// Reads Copilot CLI sessions and converts them to [`ConversationView`]s.
657#[derive(Debug, Clone, Default)]
658pub struct CopilotConvo {
659    io: ConvoIO,
660}
661
662impl CopilotConvo {
663    pub fn new() -> Self {
664        Self { io: ConvoIO::new() }
665    }
666
667    pub fn with_resolver(resolver: PathResolver) -> Self {
668        Self {
669            io: ConvoIO::with_resolver(resolver),
670        }
671    }
672
673    pub fn io(&self) -> &ConvoIO {
674        &self.io
675    }
676
677    pub fn resolver(&self) -> &PathResolver {
678        self.io.resolver()
679    }
680
681    pub fn read_session(&self, session_id: &str) -> crate::Result<Session> {
682        self.io.read_session(session_id)
683    }
684
685    pub fn list_sessions(&self) -> crate::Result<Vec<crate::types::SessionMetadata>> {
686        self.io.list_sessions()
687    }
688
689    pub fn most_recent_session(&self) -> crate::Result<Option<Session>> {
690        let dirs = self.io.list_session_dirs()?;
691        match dirs.first() {
692            Some(dir) => Ok(Some(self.io.read_session_dir(dir)?)),
693            None => Ok(None),
694        }
695    }
696
697    pub fn read_all_sessions(&self) -> crate::Result<Vec<Session>> {
698        let dirs = self.io.list_session_dirs()?;
699        let mut out = Vec::with_capacity(dirs.len());
700        for dir in dirs {
701            match self.io.read_session_dir(&dir) {
702                Ok(s) => out.push(s),
703                Err(e) => eprintln!("Warning: failed to read {}: {}", dir.display(), e),
704            }
705        }
706        Ok(out)
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::types::EventLine;
714
715    fn parse(body: &str) -> Session {
716        let lines: Vec<EventLine> = body
717            .lines()
718            .filter(|l| !l.trim().is_empty())
719            .map(|l| serde_json::from_str(l).unwrap())
720            .collect();
721        Session {
722            id: "sess-test".to_string(),
723            dir_path: std::path::PathBuf::from("/tmp/sess-test"),
724            lines,
725            workspace: None,
726        }
727    }
728
729    // Shaped after a real session (copilotVersion 1.0.67): nested `context`,
730    // `toolName`/`toolCallId`, `result.content`, `reasoningText`, `outputTokens`.
731    fn body() -> String {
732        [
733            r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"copilotVersion":"1.0.66","producer":"copilot-agent","context":{"cwd":"/tmp/proj"}}}"#,
734            r#"{"type":"user.message","timestamp":"2026-06-30T10:00:01.000Z","data":{"content":"build a thing"}}"#,
735            r#"{"type":"assistant.turn_start","timestamp":"2026-06-30T10:00:02.000Z","data":{}}"#,
736            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"Listing files.","model":"claude-haiku-4.5","reasoningText":"Let me look at the files."}}"#,
737            r#"{"type":"tool.execution_start","timestamp":"2026-06-30T10:00:04.000Z","data":{"toolCallId":"c1","toolName":"bash","arguments":{"command":"ls"}}}"#,
738            r#"{"type":"tool.execution_complete","timestamp":"2026-06-30T10:00:05.000Z","data":{"toolCallId":"c1","success":true,"result":{"content":"a.rs","detailedContent":"a.rs"}}}"#,
739            r#"{"type":"tool.execution_start","timestamp":"2026-06-30T10:00:06.000Z","data":{"toolCallId":"c2","toolName":"create_file","arguments":{"path":"a.rs","content":"fn main() {}\n"}}}"#,
740            r#"{"type":"tool.execution_complete","timestamp":"2026-06-30T10:00:07.000Z","data":{"toolCallId":"c2","success":true}}"#,
741            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:08.000Z","data":{"content":"Done."}}"#,
742            r#"{"type":"assistant.turn_end","timestamp":"2026-06-30T10:00:09.000Z","data":{}}"#,
743            r#"{"type":"session.shutdown","timestamp":"2026-06-30T10:00:10.000Z","data":{"modelMetrics":{"model":"claude-haiku-4.5"},"usage":{"inputTokens":1200,"outputTokens":340}}}"#,
744        ]
745        .join("\n")
746    }
747
748    #[test]
749    fn builds_user_and_assistant_turns() {
750        let view = to_view(&parse(&body()));
751        assert_eq!(view.turns.len(), 2);
752        assert_eq!(view.turns[0].role, Role::User);
753        assert_eq!(view.turns[0].text, "build a thing");
754        assert_eq!(view.turns[1].role, Role::Assistant);
755        // Two assistant messages collapsed into one turn.
756        assert!(view.turns[1].text.contains("Listing files."));
757        assert!(view.turns[1].text.contains("Done."));
758    }
759
760    #[test]
761    fn assistant_turn_chains_to_user() {
762        let view = to_view(&parse(&body()));
763        assert_eq!(
764            view.turns[1].parent_id.as_deref(),
765            Some(view.turns[0].id.as_str())
766        );
767    }
768
769    #[test]
770    fn tool_calls_paired_with_results() {
771        let view = to_view(&parse(&body()));
772        let tools = &view.turns[1].tool_uses;
773        assert_eq!(tools.len(), 2);
774        let shell = tools.iter().find(|t| t.name == "bash").unwrap();
775        assert_eq!(shell.category, Some(ToolCategory::Shell));
776        // Result content comes from the nested `result.content` object.
777        assert_eq!(shell.result.as_ref().unwrap().content, "a.rs");
778        assert!(!shell.result.as_ref().unwrap().is_error);
779    }
780
781    #[test]
782    fn assistant_reasoning_becomes_thinking() {
783        let view = to_view(&parse(&body()));
784        assert_eq!(
785            view.turns[1].thinking.as_deref(),
786            Some("Let me look at the files.")
787        );
788    }
789
790    #[test]
791    fn output_tokens_summed_when_no_shutdown() {
792        // Same as body() but without session.shutdown: total falls back to the
793        // sum of per-message outputTokens (50 + 20).
794        let body = [
795            r#"{"type":"assistant.turn_start","data":{}}"#,
796            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"a","outputTokens":50}}"#,
797            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:08.000Z","data":{"content":"b","outputTokens":20}}"#,
798            r#"{"type":"assistant.turn_end","data":{}}"#,
799        ]
800        .join("\n");
801        let u = to_view(&parse(&body)).total_usage.unwrap();
802        assert_eq!(u.output_tokens, Some(70));
803        assert_eq!(u.input_tokens, None);
804    }
805
806    #[test]
807    fn session_start_context_git_is_primary_over_workspace() {
808        let body = [
809            r#"{"type":"session.start","data":{"copilotVersion":"1.0.67","context":{"cwd":"/ctx/dir","gitRoot":"/ctx/dir","repository":"acme/demo","branch":"ctx-branch","headCommit":"ctxsha"}}}"#,
810            r#"{"type":"user.message","data":{"content":"hi"}}"#,
811        ]
812        .join("\n");
813        let mut session = parse(&body);
814        // Conflicting workspace.yaml — session.start context must win.
815        session.workspace = Some(crate::types::Workspace {
816            git_root: Some("/ws/dir".into()),
817            repository: Some("other/repo".into()),
818            branch: Some("ws-branch".into()),
819            revision: Some("wssha".into()),
820        });
821        let base = to_view(&session).base.unwrap();
822        assert_eq!(base.working_dir.as_deref(), Some("/ctx/dir"));
823        assert_eq!(base.vcs_branch.as_deref(), Some("ctx-branch"));
824        assert_eq!(base.vcs_revision.as_deref(), Some("ctxsha"));
825        assert_eq!(base.vcs_remote.as_deref(), Some("acme/demo"));
826    }
827
828    #[test]
829    fn file_write_produces_mutation_with_raw_diff() {
830        let view = to_view(&parse(&body()));
831        let fm = view.turns[1]
832            .file_mutations
833            .iter()
834            .find(|f| f.path == "a.rs")
835            .expect("file mutation for a.rs");
836        assert_eq!(fm.operation.as_deref(), Some("add"));
837        assert_eq!(fm.tool_id.as_deref(), Some("c2"));
838        assert!(fm.raw_diff.as_ref().unwrap().contains("+fn main() {}"));
839        assert_eq!(view.files_changed, vec!["a.rs".to_string()]);
840    }
841
842    #[test]
843    fn total_usage_from_shutdown() {
844        let view = to_view(&parse(&body()));
845        let u = view.total_usage.unwrap();
846        assert_eq!(u.input_tokens, Some(1200));
847        assert_eq!(u.output_tokens, Some(340));
848    }
849
850    #[test]
851    fn shutdown_fills_input_cache_when_per_message_output_present() {
852        // Per-message `outputTokens` sum to a session output, but the real
853        // input/cache totals only exist on `session.shutdown.tokenDetails`.
854        // The total must keep the summed output AND pick up input/cache from
855        // the shutdown (not drop them because `summed` is Some).
856        let body = [
857            r#"{"type":"assistant.turn_start","data":{}}"#,
858            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"a","outputTokens":50}}"#,
859            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:08.000Z","data":{"content":"b","outputTokens":20}}"#,
860            r#"{"type":"assistant.turn_end","data":{}}"#,
861            r#"{"type":"session.shutdown","timestamp":"2026-06-30T10:00:10.000Z","data":{"tokenDetails":{"input":{"tokenCount":800},"cache_read":{"tokenCount":9000},"cache_write":{"tokenCount":1500},"output":{"tokenCount":70}}}}"#,
862        ]
863        .join("\n");
864        let u = to_view(&parse(&body)).total_usage.unwrap();
865        assert_eq!(u.output_tokens, Some(70), "output from per-message sum");
866        assert_eq!(u.input_tokens, Some(800), "input from shutdown");
867        assert_eq!(u.cache_read_tokens, Some(9000), "cache_read from shutdown");
868        assert_eq!(
869            u.cache_write_tokens,
870            Some(1500),
871            "cache_write from shutdown"
872        );
873    }
874
875    #[test]
876    fn view_metadata_populated() {
877        let view = to_view(&parse(&body()));
878        assert_eq!(view.provider_id.as_deref(), Some("copilot"));
879        assert_eq!(
880            view.base.as_ref().unwrap().working_dir.as_deref(),
881            Some("/tmp/proj")
882        );
883        let p = view.producer.as_ref().unwrap();
884        assert_eq!(p.name, "copilot-cli");
885        assert_eq!(p.version.as_deref(), Some("1.0.66"));
886    }
887
888    #[test]
889    fn workspace_yaml_populates_base_git_context() {
890        let mut session = parse(&body());
891        session.workspace = Some(crate::types::Workspace {
892            git_root: Some("/tmp/proj".into()),
893            repository: Some("git@github.com:o/r.git".into()),
894            branch: Some("feature/x".into()),
895            revision: Some("abc123".into()),
896        });
897        let view = to_view(&session);
898        let base = view.base.as_ref().unwrap();
899        // cwd from session.start still wins for working_dir.
900        assert_eq!(base.working_dir.as_deref(), Some("/tmp/proj"));
901        assert_eq!(base.vcs_branch.as_deref(), Some("feature/x"));
902        assert_eq!(base.vcs_remote.as_deref(), Some("git@github.com:o/r.git"));
903        assert_eq!(base.vcs_revision.as_deref(), Some("abc123"));
904    }
905
906    #[test]
907    fn base_uses_git_root_when_no_cwd() {
908        // A session with no session.start (no cwd) but a workspace git root.
909        let mut session = parse("{\"type\":\"user.message\",\"data\":{\"text\":\"hi\"}}");
910        session.workspace = Some(crate::types::Workspace {
911            git_root: Some("/repo/root".into()),
912            branch: Some("main".into()),
913            ..Default::default()
914        });
915        let view = to_view(&session);
916        let base = view.base.as_ref().unwrap();
917        assert_eq!(base.working_dir.as_deref(), Some("/repo/root"));
918        assert_eq!(base.vcs_branch.as_deref(), Some("main"));
919    }
920
921    #[test]
922    fn subagent_becomes_delegation() {
923        let body = [
924            r#"{"type":"assistant.turn_start","data":{}}"#,
925            r#"{"type":"subagent.started","timestamp":"2026-06-30T10:00:02.000Z","data":{"id":"sub-1","prompt":"do research"}}"#,
926            r#"{"type":"subagent.completed","timestamp":"2026-06-30T10:00:09.000Z","data":{"id":"sub-1","result":"found it"}}"#,
927            r#"{"type":"assistant.turn_end","data":{}}"#,
928        ]
929        .join("\n");
930        let view = to_view(&parse(&body));
931        let d = &view.turns[0].delegations[0];
932        assert_eq!(d.agent_id, "sub-1");
933        assert_eq!(d.prompt, "do research");
934        assert_eq!(d.result.as_deref(), Some("found it"));
935    }
936
937    #[test]
938    fn hooks_and_skills_become_events() {
939        let body = [
940            r#"{"type":"hook.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"name":"fmt"}}"#,
941            r#"{"type":"skill.invoked","timestamp":"2026-06-30T10:00:01.000Z","data":{"skill":"x"}}"#,
942        ]
943        .join("\n");
944        let view = to_view(&parse(&body));
945        assert_eq!(view.events.len(), 2);
946        assert_eq!(view.events[0].event_type, "hook.start");
947        assert_eq!(view.events[1].event_type, "skill.invoked");
948    }
949
950    #[test]
951    fn category_classifies_common_names() {
952        assert_eq!(tool_category("run_in_terminal"), Some(ToolCategory::Shell));
953        assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
954        assert_eq!(tool_category("create_file"), Some(ToolCategory::FileWrite));
955        assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
956        assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
957        assert_eq!(tool_category("totally_unknown_xyz"), None);
958    }
959
960    #[test]
961    fn tool_pairing_without_ids_does_not_double_count() {
962        // The reverse-engineered schema may omit a correlation id on tool
963        // events; start/complete must still collapse to ONE invocation.
964        let body = [
965            r#"{"type":"assistant.turn_start","data":{}}"#,
966            r#"{"type":"tool.execution_start","data":{"name":"shell","args":{"command":"ls"}}}"#,
967            r#"{"type":"tool.execution_complete","data":{"name":"shell","args":{"command":"ls"},"success":true,"output":"a.rs"}}"#,
968            r#"{"type":"assistant.turn_end","data":{}}"#,
969        ]
970        .join("\n");
971        let view = to_view(&parse(&body));
972        let tools = &view.turns[0].tool_uses;
973        assert_eq!(
974            tools.len(),
975            1,
976            "id-less start/complete must not double-count"
977        );
978        assert_eq!(tools[0].result.as_ref().unwrap().content, "a.rs");
979    }
980
981    #[test]
982    fn file_write_without_id_has_single_mutation() {
983        let body = [
984            r#"{"type":"assistant.turn_start","data":{}}"#,
985            r#"{"type":"tool.execution_start","data":{"name":"create_file","args":{"path":"a.rs","content":"fn main() {}\n"}}}"#,
986            r#"{"type":"tool.execution_complete","data":{"name":"create_file","args":{"path":"a.rs","content":"fn main() {}\n"},"success":true}}"#,
987            r#"{"type":"assistant.turn_end","data":{}}"#,
988        ]
989        .join("\n");
990        let view = to_view(&parse(&body));
991        assert_eq!(view.turns[0].tool_uses.len(), 1);
992        assert_eq!(
993            view.turns[0].file_mutations.len(),
994            1,
995            "id-less file write must not duplicate the mutation"
996        );
997        assert_eq!(view.files_changed, vec!["a.rs".to_string()]);
998    }
999
1000    #[test]
1001    fn tool_pairing_with_ids_still_works() {
1002        // Regression guard: explicit ids remain authoritative.
1003        let view = to_view(&parse(&body()));
1004        let shell = view.turns[1]
1005            .tool_uses
1006            .iter()
1007            .find(|t| t.name == "bash")
1008            .unwrap();
1009        assert_eq!(shell.result.as_ref().unwrap().content, "a.rs");
1010        // body() has two id-bearing tool calls: bash + create_file.
1011        assert_eq!(view.turns[1].tool_uses.len(), 2);
1012    }
1013}