Skip to main content

vct_core/session/
copilot.rs

1//! Parser for GitHub Copilot CLI session events
2//! (`~/.copilot/session-state/<sessionId>/events.jsonl`).
3//!
4//! One [`CopilotEvent`] per line, dispatched on `event_type`. Token usage is
5//! taken from the authoritative `session.shutdown` record when present, with
6//! streamed `assistant.message.outputTokens` as a partial fallback for
7//! sessions that never shut down cleanly. File operations are paired across
8//! `tool.execution_start` / `tool.execution_complete` by `toolCallId` and
9//! only counted on success. See the table below for the full event map.
10use crate::constants::{FastHashMap, capacity};
11use crate::models::*;
12use crate::session::diagnostics::{ParseDiagnostics, ParsedAnalysis};
13use crate::session::state::{ParseMode, SessionParseState};
14use crate::utils::{get_git_remote_url, parse_iso_timestamp};
15use anyhow::Result;
16use serde_json::{Value, json};
17
18// =============================================================================
19// Copilot CLI `events.jsonl` streaming parser
20// =============================================================================
21//
22// Copilot CLI stores every session as
23// `~/.copilot/session-state/<sessionId>/events.jsonl`. Each line is a single
24// `CopilotEvent` whose `event_type` decides how to interpret `data`:
25//
26//   session.start            → session-scoped context (sessionId, cwd, …)
27//   session.model_change     → tracks the currently active model
28//   session.task_complete    → task summary, informational only
29//   session.shutdown         → authoritative per-model token usage
30//   system.message           → system prompt; ignored
31//   user.message             → user-turn content; ignored
32//   assistant.message        → streaming output; only outputTokens is reliable
33//   assistant.turn_start/end → turn bookkeeping; ignored
34//   tool.execution_start     → paired with the matching complete event
35//   tool.execution_complete  → fires the analyzer's file-op handlers
36//
37// Legacy single-object dumps under `~/.copilot/history-session-state/` are
38// not supported — users with old dumps will see them fall through to the
39// Codex default in `detect_extension_type` and fail cleanly rather than
40// being mis-parsed.
41
42/// Parse Copilot CLI session events from the JSONL event stream.
43///
44/// Returns a single-record [`CodeAnalysis`] stamped with the
45/// `"Copilot-CLI"` extension name. When the stream lacks a
46/// `session.shutdown` record, the per-model usage map is grafted from the
47/// streamed output-token fallback and will report `input_tokens: 0` so
48/// callers can detect the partial accounting.
49///
50/// # Errors
51///
52/// Returns `anyhow::Result` for parity with the other provider parsers, but
53/// has no fallible step — events that fail to deserialise into their typed
54/// payload are skipped — so it returns `Ok` for any iterator.
55pub fn parse_copilot_events<I>(events: I, mode: ParseMode) -> Result<CodeAnalysis>
56where
57    I: IntoIterator<Item = CopilotEvent>,
58{
59    Ok(parse_copilot_events_with_diagnostics(events, mode)?.analysis)
60}
61
62/// Streaming Copilot parser with event-payload schema diagnostics.
63pub(crate) fn parse_copilot_events_with_diagnostics<I>(
64    events: I,
65    mode: ParseMode,
66) -> Result<ParsedAnalysis>
67where
68    I: IntoIterator<Item = CopilotEvent>,
69{
70    let mut state = SessionParseState::with_mode(mode);
71    let mut conversation_usage: FastHashMap<String, Value> =
72        FastHashMap::with_capacity(capacity::MODELS_PER_SESSION);
73    // Pending tool calls indexed by `toolCallId` — each `tool.execution_start`
74    // stashes its arguments here until the matching `tool.execution_complete`
75    // arrives with the result payload.
76    let mut pending_tools: FastHashMap<String, PendingTool> = FastHashMap::with_capacity(32);
77
78    // Fallback accounting used when the session does not reach
79    // `session.shutdown` (e.g. crash, SIGKILL, ongoing session). We still
80    // want to attribute `assistant.message.outputTokens` to *some* model,
81    // so we track the active model switches.
82    let mut current_model = String::new();
83    // Set to `true` once we consume a `session.shutdown` event. If so, the
84    // shutdown record is authoritative and we discard the fallback output
85    // tallies built from streamed `assistant.message` events — those would
86    // otherwise double-count.
87    let mut shutdown_seen = false;
88    let mut pending_output_tokens: FastHashMap<String, i64> = FastHashMap::with_capacity(3);
89    let mut diagnostics = ParseDiagnostics::default();
90
91    for event in events {
92        let recognized = matches!(
93            event.event_type.as_str(),
94            "session.start"
95                | "session.model_change"
96                | "session.task_complete"
97                | "session.shutdown"
98                | "session.info"
99                | "session.mode_changed"
100                | "system.message"
101                | "user.message"
102                | "assistant.message"
103                | "assistant.turn_start"
104                | "assistant.turn_end"
105                | "tool.execution_start"
106                | "tool.execution_complete"
107                | "hook.start"
108                | "hook.end"
109                | "abort"
110                | "subagent.started"
111                | "subagent.completed"
112                | "system.notification"
113                | "session.resume"
114        );
115        if recognized {
116            diagnostics.record_recognized_source();
117        } else {
118            diagnostics.record_unrecognized();
119        }
120        let ts = parse_iso_timestamp(&event.timestamp);
121        if ts > state.last_ts {
122            state.last_ts = ts;
123        }
124
125        match event.event_type.as_str() {
126            "session.start" => {
127                if let Ok(data) =
128                    serde_json::from_value::<CopilotSessionStartData>(event.data.clone())
129                {
130                    if state.task_id.is_empty() && !data.session_id.is_empty() {
131                        state.task_id = data.session_id;
132                    }
133                    if let Some(ctx) = data.context {
134                        if state.folder_path.is_empty() {
135                            if !ctx.cwd.is_empty() {
136                                state.folder_path = ctx.cwd;
137                            } else if !ctx.git_root.is_empty() {
138                                state.folder_path = ctx.git_root;
139                            }
140                        }
141                        if state.git_remote.is_empty() {
142                            state.git_remote =
143                                build_remote_url(&ctx.repository_host, &ctx.repository);
144                        }
145                    }
146                }
147            }
148            "session.model_change" => {
149                let new_model = event
150                    .data
151                    .get("newModel")
152                    .and_then(|v| v.as_str())
153                    .filter(|s| !s.is_empty());
154                if let Some(new_model) = new_model {
155                    current_model = canonicalize_model_name(new_model);
156                }
157            }
158            "session.shutdown" => {
159                let payload_supported = shutdown_payload_supported(&event.data);
160                if payload_supported
161                    && let Ok(data) =
162                        serde_json::from_value::<CopilotShutdownData>(event.data.clone())
163                {
164                    diagnostics.record_relevant(true);
165                    for (model, metric) in data.model_metrics {
166                        if model.is_empty() {
167                            continue;
168                        }
169                        let Some(usage) = metric.usage else {
170                            continue;
171                        };
172                        // Copilot's `outputTokens` follows OpenAI's convention
173                        // and already includes `reasoningTokens`, so subtract it
174                        // back out to keep each token billed once (the flat token
175                        // shape treats output and reasoning as disjoint buckets).
176                        let output_tokens =
177                            usage.output_tokens.saturating_sub(usage.reasoning_tokens);
178                        let usage_json = json!({
179                            "input_tokens": usage.input_tokens,
180                            "output_tokens": output_tokens,
181                            "reasoning_output_tokens": usage.reasoning_tokens,
182                            "cache_read_input_tokens": usage.cache_read_tokens,
183                            "cache_creation_input_tokens": usage.cache_write_tokens,
184                        });
185                        conversation_usage.insert(canonicalize_model_name(&model), usage_json);
186                    }
187                    shutdown_seen = true;
188                } else {
189                    diagnostics.record_relevant(false);
190                }
191            }
192            "assistant.message" => {
193                // Only used as a fallback when no `session.shutdown` arrives.
194                if let Some(output_tokens) = event.data.get("outputTokens") {
195                    let output_tokens = output_tokens.as_i64();
196                    diagnostics
197                        .record_relevant(output_tokens.is_some() && !current_model.is_empty());
198                    if let Some(output_tokens) = output_tokens.filter(|&t| t > 0)
199                        && !current_model.is_empty()
200                    {
201                        *pending_output_tokens
202                            .entry(current_model.clone())
203                            .or_insert(0) += output_tokens;
204                    }
205                }
206            }
207            "tool.execution_start" => {
208                match serde_json::from_value::<CopilotToolStartData>(event.data.clone()) {
209                    Ok(data) if !data.tool_call_id.is_empty() && !data.tool_name.is_empty() => {
210                        let tracked = is_tracked_tool(&data.tool_name);
211                        let arguments_supported =
212                            tracked_tool_arguments_supported(&data.tool_name, &data.arguments);
213                        pending_tools.insert(
214                            data.tool_call_id,
215                            PendingTool {
216                                tool_name: data.tool_name,
217                                arguments: data.arguments,
218                                timestamp: ts,
219                                tracked,
220                                arguments_supported,
221                            },
222                        );
223                    }
224                    Ok(_) | Err(_) => diagnostics.record_relevant(false),
225                }
226            }
227            "tool.execution_complete" => {
228                let Some(tool_call_id) = event
229                    .data
230                    .get("toolCallId")
231                    .and_then(Value::as_str)
232                    .filter(|id| !id.is_empty())
233                else {
234                    diagnostics.record_relevant(false);
235                    continue;
236                };
237                let Some(pending) = pending_tools.remove(tool_call_id) else {
238                    continue;
239                };
240                let Some(success) = event.data.get("success").and_then(Value::as_bool) else {
241                    if pending.tracked {
242                        diagnostics.record_relevant(false);
243                    }
244                    continue;
245                };
246                // Only dispatch successful tool calls — failures rarely
247                // produce meaningful arguments (e.g. path validation errors)
248                // and would skew line-count totals.
249                if !success {
250                    if pending.tracked {
251                        diagnostics.record_relevant(true);
252                    }
253                    continue;
254                }
255                let data =
256                    match serde_json::from_value::<CopilotToolCompleteData>(event.data.clone()) {
257                        Ok(data) => data,
258                        Err(_) => {
259                            if pending.tracked {
260                                diagnostics.record_relevant(false);
261                            }
262                            continue;
263                        }
264                    };
265                if pending.tracked {
266                    let result_supported = tracked_tool_result_supported(
267                        &pending.tool_name,
268                        &pending.arguments,
269                        &data.result,
270                    );
271                    diagnostics.record_relevant(pending.arguments_supported && result_supported);
272                    if !pending.arguments_supported {
273                        continue;
274                    }
275                }
276                dispatch_tool(&mut state, &pending, &data);
277            }
278            _ => {}
279        }
280    }
281
282    // If `session.shutdown` never arrived, graft the fallback streamed
283    // output-token counters into `conversation_usage` so the row still has
284    // a non-zero number (callers can tell it's partial by the missing
285    // `input_tokens`).
286    if !shutdown_seen {
287        for (model, output_tokens) in pending_output_tokens {
288            conversation_usage.insert(
289                model,
290                json!({
291                    "input_tokens": 0,
292                    "output_tokens": output_tokens,
293                    "cache_read_input_tokens": 0,
294                    "cache_creation_input_tokens": 0,
295                }),
296            );
297        }
298    }
299
300    // Fallback git remote lookup when `session.start.context` did not carry
301    // a repository string (e.g. running outside a git tree or pre-1.0 CLI).
302    if state.git_remote.is_empty() {
303        state.git_remote = get_git_remote_url(&state.folder_path);
304    }
305
306    let record = state.into_record(conversation_usage);
307
308    let analysis = CodeAnalysis {
309        user: String::new(),
310        extension_name: String::from("Copilot-CLI"),
311        insights_version: String::new(),
312        machine_id: String::new(),
313        records: vec![record],
314    };
315    Ok(ParsedAnalysis::new(analysis, diagnostics))
316}
317
318fn shutdown_payload_supported(data: &Value) -> bool {
319    let Some(metrics) = data.get("modelMetrics").and_then(Value::as_object) else {
320        return false;
321    };
322    metrics.values().all(|metric| {
323        let Some(metric) = metric.as_object() else {
324            return false;
325        };
326        match metric.get("usage") {
327            None | Some(Value::Null) => true,
328            Some(usage) => copilot_usage_supported(usage),
329        }
330    })
331}
332
333fn copilot_usage_supported(usage: &Value) -> bool {
334    const TOKEN_FIELDS: &[&str] = &[
335        "inputTokens",
336        "outputTokens",
337        "cacheReadTokens",
338        "cacheWriteTokens",
339        "reasoningTokens",
340    ];
341    let Some(usage) = usage.as_object() else {
342        return false;
343    };
344    if usage.is_empty() {
345        return true;
346    }
347
348    let mut recognized = false;
349    for field in TOKEN_FIELDS {
350        if let Some(value) = usage.get(*field) {
351            recognized = true;
352            if value.as_i64().is_none() {
353                return false;
354            }
355        }
356    }
357    recognized
358}
359
360fn is_tracked_tool(name: &str) -> bool {
361    matches!(
362        name,
363        "view"
364            | "show_file"
365            | "read_file"
366            | "rg"
367            | "grep"
368            | "glob"
369            | "web_search"
370            | "web_fetch"
371            | "create"
372            | "write_file"
373            | "write"
374            | "str_replace"
375            | "edit"
376            | "replace"
377            | "edit_file"
378            | "apply_patch"
379            | "bash"
380            | "shell"
381            | "execute"
382            | "write_bash"
383    )
384}
385
386fn tracked_tool_arguments_supported(name: &str, args: &Value) -> bool {
387    match name {
388        "view" | "show_file" | "read_file" => args
389            .get("path")
390            .and_then(Value::as_str)
391            .is_some_and(|path| !path.is_empty()),
392        "rg" | "grep" | "glob" | "web_search" | "web_fetch" => true,
393        "create" | "write_file" | "write" => {
394            args.get("path")
395                .and_then(Value::as_str)
396                .is_some_and(|path| !path.is_empty())
397                && args
398                    .get("file_text")
399                    .or_else(|| args.get("content"))
400                    .is_some_and(Value::is_string)
401        }
402        "str_replace" | "edit" | "replace" | "edit_file" => {
403            args.get("path")
404                .and_then(Value::as_str)
405                .is_some_and(|path| !path.is_empty())
406                && args
407                    .get("old_string")
408                    .or_else(|| args.get("old_str"))
409                    .or_else(|| args.get("old_text"))
410                    .is_some_and(Value::is_string)
411                && args
412                    .get("new_string")
413                    .or_else(|| args.get("new_str"))
414                    .or_else(|| args.get("new_text"))
415                    .is_some_and(Value::is_string)
416        }
417        "apply_patch" => extract_apply_patch_text(args).is_some_and(|patch| {
418            parse_apply_patch_text(patch)
419                .iter()
420                .any(|patch| !patch.file_path.is_empty())
421        }),
422        "bash" | "shell" | "execute" => args
423            .get("command")
424            .or_else(|| args.get("cmd"))
425            .and_then(Value::as_str)
426            .is_some_and(|command| !command.trim().is_empty()),
427        "write_bash" => args
428            .get("input")
429            .and_then(Value::as_str)
430            .is_some_and(|command| !command.trim().is_empty()),
431        _ => false,
432    }
433}
434
435fn tracked_tool_result_supported(name: &str, args: &Value, result: &Value) -> bool {
436    match name {
437        "view" | "show_file" | "read_file" => {
438            if let Some(range) = args
439                .get("view_range")
440                .and_then(Value::as_array)
441                .filter(|range| range.len() >= 2)
442            {
443                return range.first().and_then(Value::as_i64).is_some()
444                    && range.get(1).and_then(Value::as_i64).is_some();
445            }
446            result.get("content").is_some_and(Value::is_string)
447        }
448        _ => true,
449    }
450}
451
452fn extract_apply_patch_text(args: &Value) -> Option<&str> {
453    args.as_str()
454        .or_else(|| args.get("input").and_then(Value::as_str))
455        .or_else(|| args.get("patch").and_then(Value::as_str))
456        .or_else(|| args.get("patchText").and_then(Value::as_str))
457        .or_else(|| args.get("string").and_then(Value::as_str))
458}
459
460/// A `tool.execution_start` event held until its matching
461/// `tool.execution_complete` arrives, keyed by `toolCallId`.
462struct PendingTool {
463    /// Tool name (e.g. `view`, `create`, `str_replace`, `bash`).
464    tool_name: String,
465    /// Raw tool arguments object, interpreted lazily by [`dispatch_tool`].
466    arguments: Value,
467    /// Start-event timestamp in epoch milliseconds, used for the detail record.
468    timestamp: i64,
469    /// Whether this tool contributes to the analysis projection.
470    tracked: bool,
471    /// Whether the tracked tool's arguments use a supported schema.
472    arguments_supported: bool,
473}
474
475/// Routes a completed Copilot tool call to the matching file-operation tally.
476///
477/// Branches on `pending.tool_name`; unrecognised tools (e.g. `report_intent`,
478/// `task_complete`) are silently ignored. Argument field names are probed
479/// with historical aliases for forward compatibility across CLI releases.
480fn dispatch_tool(
481    state: &mut SessionParseState,
482    pending: &PendingTool,
483    complete: &CopilotToolCompleteData,
484) {
485    let ts = pending.timestamp;
486    let args = &pending.arguments;
487
488    match pending.tool_name.as_str() {
489        // Current Copilot CLI exposes `view` for reads. Historical versions
490        // used `str_replace_editor` with `command == "view"`, which we no
491        // longer attempt to parse.
492        "view" | "show_file" | "read_file" => {
493            let Some(path) = args.get("path").and_then(|p| p.as_str()) else {
494                return;
495            };
496
497            state.tool_counts.read += 1;
498            let content = extract_view_content(args, &complete.result);
499            attach_read_detail(state, path, &content, ts);
500        }
501        // Search and web tools surface content but do not identify one complete
502        // file body, so retain the invocation without inventing line totals.
503        "rg" | "grep" | "glob" | "web_search" | "web_fetch" => state.tool_counts.read += 1,
504        // `create` is the primary write tool. Historical names are kept for
505        // robustness; a future release that renames the tool will still get
506        // counted if the argument shape stays similar.
507        "create" | "write_file" | "write" => {
508            let Some(path) = args.get("path").and_then(|p| p.as_str()) else {
509                return;
510            };
511            let content = args
512                .get("file_text")
513                .or_else(|| args.get("content"))
514                .and_then(|c| c.as_str())
515                .unwrap_or("");
516            state.add_write_detail(path, content, ts);
517        }
518        // Edit-style tool names the CLI is known or likely to emit. Field
519        // shape is assumed to stay `{path, old_string|old_str, new_string|new_str}`.
520        "str_replace" | "edit" | "replace" | "edit_file" => {
521            let Some(path) = args.get("path").and_then(|p| p.as_str()) else {
522                return;
523            };
524            let old_str = args
525                .get("old_string")
526                .or_else(|| args.get("old_str"))
527                .or_else(|| args.get("old_text"))
528                .and_then(|s| s.as_str())
529                .unwrap_or("");
530            let new_str = args
531                .get("new_string")
532                .or_else(|| args.get("new_str"))
533                .or_else(|| args.get("new_text"))
534                .and_then(|s| s.as_str())
535                .unwrap_or("");
536            state.add_edit_detail(path, old_str, new_str, ts);
537        }
538        "apply_patch" => {
539            let patch = extract_apply_patch_text(args).unwrap_or("");
540            apply_patch_text(state, patch, ts);
541        }
542        "bash" | "shell" | "execute" => {
543            let command = args
544                .get("command")
545                .or_else(|| args.get("cmd"))
546                .and_then(|c| c.as_str())
547                .unwrap_or("");
548            let description = args
549                .get("description")
550                .and_then(|d| d.as_str())
551                .unwrap_or("");
552            state.add_run_command(command, description, ts);
553        }
554        "write_bash" => {
555            let input = args.get("input").and_then(Value::as_str).unwrap_or("");
556            state.add_run_command(input, "", ts);
557        }
558        // `report_intent`, `task_complete`, `update_topic`, … have no
559        // file-operation semantics we care about. Silently ignore.
560        _ => {}
561    }
562}
563
564fn attach_read_detail(state: &mut SessionParseState, path: &str, content: &str, ts: i64) {
565    let invocation_count = state.tool_counts.read;
566    state.add_read_detail(path, content, ts);
567    state.tool_counts.read = invocation_count;
568}
569
570/// Folds a successful `apply_patch` call into per-file details.
571fn apply_patch_text(state: &mut SessionParseState, patch_text: &str, ts: i64) {
572    for patch in parse_apply_patch_text(patch_text) {
573        let (old_string, new_string) = extract_patch_strings(&patch.lines);
574        match patch.action.as_str() {
575            "add" => state.add_write_detail(&patch.file_path, &new_string, ts),
576            "delete" => state.add_edit_detail_raw(&patch.file_path, &old_string, "", ts),
577            _ => state.add_edit_detail_raw(&patch.file_path, &old_string, &new_string, ts),
578        }
579    }
580}
581
582struct CopilotPatch {
583    action: String,
584    file_path: String,
585    lines: Vec<String>,
586}
587
588/// Parses the patch envelope used by the current Copilot CLI.
589fn parse_apply_patch_text(patch_text: &str) -> Vec<CopilotPatch> {
590    let Some(start) = patch_text.find("*** Begin Patch") else {
591        return Vec::new();
592    };
593
594    let mut patches = Vec::with_capacity(3);
595    let mut current: Option<CopilotPatch> = None;
596    for line in patch_text[start..].lines() {
597        let line = line.trim_end_matches('\r');
598        let header = [
599            ("*** Update File:", "update"),
600            ("*** Add File:", "add"),
601            ("*** Delete File:", "delete"),
602        ]
603        .into_iter()
604        .find_map(|(prefix, action)| line.strip_prefix(prefix).map(|path| (action, path)));
605
606        if line.starts_with("*** End Patch") {
607            if let Some(patch) = current.take() {
608                patches.push(patch);
609            }
610            break;
611        } else if line.starts_with("*** Begin Patch") {
612            continue;
613        } else if let Some((action, path)) = header {
614            if let Some(patch) = current.take() {
615                patches.push(patch);
616            }
617            current = Some(CopilotPatch {
618                action: action.to_string(),
619                file_path: path.trim().to_string(),
620                lines: Vec::with_capacity(20),
621            });
622        } else if let Some(path) = line.strip_prefix("*** Move to:") {
623            if let Some(patch) = &mut current {
624                patch.file_path = path.trim().to_string();
625            }
626        } else if let Some(patch) = &mut current {
627            patch.lines.push(line.to_string());
628        }
629    }
630
631    if let Some(patch) = current {
632        patches.push(patch);
633    }
634    patches
635}
636
637fn extract_patch_strings(lines: &[String]) -> (String, String) {
638    let mut old_string = String::new();
639    let mut new_string = String::new();
640    for line in lines {
641        if line.starts_with("@@") || line.starts_with('\\') {
642            continue;
643        }
644        if let Some(line) = line.strip_prefix('+') {
645            new_string.push_str(line);
646            new_string.push('\n');
647        } else if let Some(line) = line.strip_prefix('-') {
648            old_string.push_str(line);
649            old_string.push('\n');
650        }
651    }
652    old_string.truncate(old_string.trim_end_matches('\n').len());
653    new_string.truncate(new_string.trim_end_matches('\n').len());
654    (old_string, new_string)
655}
656
657/// Resolve the content a Copilot `view` tool saw.
658///
659/// Callers can pass us two sources:
660///
661/// 1. `arguments.view_range` — inclusive `[start, end]` line numbers. When
662///    present we synthesise a `line_count`-line placeholder using a
663///    non-newline character so `add_read_detail`'s `trim_end_matches('\n')`
664///    cannot collapse it back to zero. The actual content is not needed
665///    because we only care about the line count.
666/// 2. `complete.result.content` — the string the model actually received.
667///    Preferred when available and when no `view_range` was supplied.
668fn extract_view_content(arguments: &Value, result: &Value) -> String {
669    if let Some(range) = arguments.get("view_range").and_then(|v| v.as_array())
670        && range.len() >= 2
671    {
672        let start = range.first().and_then(|v| v.as_i64()).unwrap_or(0);
673        let end = range.get(1).and_then(|v| v.as_i64()).unwrap_or(0);
674        let line_count = (end - start + 1).max(0) as usize;
675        if line_count == 0 {
676            return String::new();
677        }
678        // A pure-newline placeholder ("\n".repeat(N - 1)) would survive
679        // `count_lines` on its own, but `add_read_detail` first trims
680        // trailing newlines and then the whole thing collapses to an
681        // empty string — so the line tally would silently come back as
682        // zero. Use single-char "lines" joined by '\n' so the trim is a
683        // no-op and `count_lines` recovers exactly `line_count`.
684        return vec!["-"; line_count].join("\n");
685    }
686
687    result
688        .get("content")
689        .and_then(|c| c.as_str())
690        .unwrap_or("")
691        .to_string()
692}
693
694/// Best-effort reconstruction of a repository's git remote URL from the
695/// `session.start.context` fields.
696///
697/// Copilot writes `{ repository: "owner/repo", repositoryHost: "github.com" }`
698/// but does *not* include the full clone URL. We prefix with `https://`
699/// because that's the canonical web-facing form; the value is only used for
700/// display in the usage report, not for actual git operations, so the
701/// SSH-vs-HTTPS distinction does not matter.
702fn build_remote_url(host: &str, repository: &str) -> String {
703    if host.is_empty() || repository.is_empty() {
704        return String::new();
705    }
706    format!("https://{}/{}", host.trim(), repository.trim())
707}
708
709/// Canonicalise a Copilot-supplied model name.
710///
711/// Copilot CLI writes Anthropic model names with **dot-separated** minor
712/// versions (e.g. `claude-sonnet-4.6`, `claude-opus-4.7`), while the
713/// LiteLLM pricing table and every other CLI in this tool (Claude Code,
714/// Codex) use the **dash-separated** form (`claude-sonnet-4-6`,
715/// `claude-opus-4-7`).
716///
717/// If we leave the Copilot names as-is, two things go wrong:
718///
719/// 1. `merge_usage_values` keeps Copilot's `claude-sonnet-4.6` separate
720///    from Claude Code's `claude-sonnet-4-6`, splitting a single model's
721///    usage across two rows.
722/// 2. The pricing matcher's substring/fuzzy tier finds no exact key for
723///    `claude-sonnet-4.6` and picks the *only* dot-named variant it has
724///    — `openrouter/anthropic/claude-sonnet-4.6` — which is an OpenRouter
725///    proxy entry with different per-token rates, not the Anthropic
726///    native rate the Copilot caller is actually being billed against.
727///
728/// We limit the rewrite to names starting with `claude-` so OpenAI /
729/// Google models whose native form legitimately contains dots (e.g.
730/// `gpt-5.1`, `gemini-1.5-pro`) are left untouched.
731fn canonicalize_model_name(name: &str) -> String {
732    if name.starts_with("claude-") {
733        name.replace('.', "-")
734    } else {
735        name.to_string()
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::{
742        CopilotToolCompleteData, PendingTool, canonicalize_model_name, dispatch_tool,
743        extract_view_content, parse_copilot_events_with_diagnostics,
744        tracked_tool_arguments_supported, tracked_tool_result_supported,
745    };
746    use crate::models::CopilotEvent;
747    use crate::session::state::ParseMode;
748    use crate::session::state::SessionParseState;
749    use serde_json::{Value, json};
750
751    fn event(event_type: &str, data: Value) -> CopilotEvent {
752        CopilotEvent {
753            event_type: event_type.to_string(),
754            data,
755            id: String::new(),
756            timestamp: "2026-07-12T00:00:00Z".to_string(),
757            parent_id: None,
758        }
759    }
760
761    fn count_lines_after_trim(s: &str) -> usize {
762        // Mirror src/session/state.rs count_lines + add_read_detail's
763        // trim_end_matches('\n') so the test reflects the actual line
764        // tally the analyzer would record.
765        let trimmed = s.trim_end_matches('\n');
766        if trimmed.is_empty() {
767            0
768        } else {
769            trimmed.chars().filter(|c| *c == '\n').count() + 1
770        }
771    }
772
773    #[test]
774    fn view_range_placeholder_survives_trim_end() {
775        // view_range [1, 5] → 5 logical lines. The synthesised
776        // placeholder must yield 5 from `count_lines` even after
777        // `trim_end_matches('\n')` runs in `add_read_detail`.
778        let args = json!({ "view_range": [1, 5], "path": "/tmp/foo" });
779        let result = json!({});
780        let placeholder = extract_view_content(&args, &result);
781        assert_eq!(
782            count_lines_after_trim(&placeholder),
783            5,
784            "view_range [1,5] must count as 5 lines after add_read_detail's trim"
785        );
786    }
787
788    #[test]
789    fn view_range_with_zero_span_returns_empty() {
790        // Edge case: empty range produces an empty placeholder so the
791        // upstream early-return in `add_read_detail` skips it cleanly.
792        let args = json!({ "view_range": [5, 4], "path": "/tmp/foo" });
793        let result = json!({});
794        assert_eq!(extract_view_content(&args, &result), "");
795    }
796
797    #[test]
798    fn view_without_range_uses_result_content() {
799        let args = json!({ "path": "/tmp/foo" });
800        let result = json!({ "content": "alpha\nbeta\ngamma" });
801        assert_eq!(extract_view_content(&args, &result), "alpha\nbeta\ngamma");
802    }
803
804    fn completed() -> CopilotToolCompleteData {
805        CopilotToolCompleteData {
806            tool_call_id: "call-1".to_string(),
807            success: true,
808            result: json!({ "content": "alpha\nbeta" }),
809            model: "test".to_string(),
810        }
811    }
812
813    #[test]
814    fn current_show_file_maps_to_read() {
815        let pending = PendingTool {
816            tool_name: "show_file".to_string(),
817            arguments: json!({ "path": "/tmp/a.txt", "view_range": [1, 2] }),
818            timestamp: 1,
819            tracked: true,
820            arguments_supported: true,
821        };
822        let mut state = SessionParseState::new();
823        dispatch_tool(&mut state, &pending, &completed());
824        assert_eq!(state.tool_counts.read, 1);
825        assert_eq!(state.total_read_lines, 2);
826    }
827
828    #[test]
829    fn show_file_empty_and_drifted_results_keep_the_known_invocation() {
830        let pending = PendingTool {
831            tool_name: "show_file".to_string(),
832            arguments: json!({ "path": "/tmp/empty.txt" }),
833            timestamp: 1,
834            tracked: true,
835            arguments_supported: true,
836        };
837
838        let mut empty = completed();
839        empty.result = json!({ "content": "" });
840        assert!(tracked_tool_result_supported(
841            &pending.tool_name,
842            &pending.arguments,
843            &empty.result
844        ));
845        let mut state = SessionParseState::new();
846        dispatch_tool(&mut state, &pending, &empty);
847        assert_eq!(state.tool_counts.read, 1);
848        assert_eq!(state.total_read_lines, 0);
849
850        let mut drifted = completed();
851        drifted.result = json!({ "futureContent": "" });
852        assert!(!tracked_tool_result_supported(
853            &pending.tool_name,
854            &pending.arguments,
855            &drifted.result
856        ));
857        let mut state = SessionParseState::new();
858        dispatch_tool(&mut state, &pending, &drifted);
859        assert_eq!(state.tool_counts.read, 1);
860        assert_eq!(state.total_read_lines, 0);
861    }
862
863    #[test]
864    fn current_search_tools_count_read_invocations_without_fake_lines() {
865        let mut state = SessionParseState::new();
866        for tool_name in ["rg", "grep", "glob", "web_search", "web_fetch"] {
867            let pending = PendingTool {
868                tool_name: tool_name.to_string(),
869                arguments: json!({ "pattern": "needle", "paths": ["src"] }),
870                timestamp: 1,
871                tracked: true,
872                arguments_supported: true,
873            };
874            dispatch_tool(&mut state, &pending, &completed());
875        }
876        assert_eq!(state.tool_counts.read, 5);
877        assert_eq!(state.total_read_lines, 0);
878    }
879
880    #[test]
881    fn apply_patch_arguments_require_a_supported_nonempty_file_header() {
882        let drifted = json!("*** Begin Patch\n*** Future File: src/lib.rs\n+new\n*** End Patch");
883        assert!(!tracked_tool_arguments_supported("apply_patch", &drifted));
884
885        let empty_body = json!("*** Begin Patch\n*** Add File: empty.txt\n*** End Patch");
886        assert!(tracked_tool_arguments_supported("apply_patch", &empty_body));
887
888        let empty_path = json!("*** Begin Patch\n*** Add File:\n+new\n*** End Patch");
889        assert!(!tracked_tool_arguments_supported(
890            "apply_patch",
891            &empty_path
892        ));
893    }
894
895    #[test]
896    fn current_apply_patch_string_maps_to_file_operations() {
897        let pending = PendingTool {
898            tool_name: "apply_patch".to_string(),
899            arguments: json!(
900                "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** Add File: notes.txt\n+hello\n*** End Patch"
901            ),
902            timestamp: 1,
903            tracked: true,
904            arguments_supported: true,
905        };
906        let mut state = SessionParseState::new();
907        dispatch_tool(&mut state, &pending, &completed());
908        assert_eq!(state.tool_counts.edit, 1);
909        assert_eq!(state.tool_counts.write, 1);
910        assert_eq!(state.edit_details.len(), 1);
911        assert_eq!(state.write_details.len(), 1);
912    }
913
914    #[test]
915    fn current_apply_patch_string_field_maps_to_edit() {
916        let pending = PendingTool {
917            tool_name: "apply_patch".to_string(),
918            arguments: json!({
919                "string": "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch"
920            }),
921            timestamp: 1,
922            tracked: true,
923            arguments_supported: true,
924        };
925        let mut state = SessionParseState::new();
926        dispatch_tool(&mut state, &pending, &completed());
927        assert_eq!(state.tool_counts.edit, 1);
928        assert_eq!(state.edit_details.len(), 1);
929    }
930
931    #[test]
932    fn current_apply_patch_input_field_maps_to_edit() {
933        let pending = PendingTool {
934            tool_name: "apply_patch".to_string(),
935            arguments: json!({
936                "input": "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch"
937            }),
938            timestamp: 1,
939            tracked: true,
940            arguments_supported: true,
941        };
942        let mut state = SessionParseState::new();
943        dispatch_tool(&mut state, &pending, &completed());
944        assert_eq!(state.tool_counts.edit, 1);
945        assert_eq!(state.edit_details.len(), 1);
946    }
947
948    #[test]
949    fn current_write_bash_counts_nonempty_input() {
950        let pending = PendingTool {
951            tool_name: "write_bash".to_string(),
952            arguments: json!({ "input": "yes", "shellId": "shell-1" }),
953            timestamp: 1,
954            tracked: true,
955            arguments_supported: true,
956        };
957        let mut state = SessionParseState::new();
958        dispatch_tool(&mut state, &pending, &completed());
959        assert_eq!(state.tool_counts.bash, 1);
960        assert_eq!(state.run_details.len(), 1);
961    }
962
963    #[test]
964    fn shutdown_rejects_unknown_only_usage_keys_without_inventing_zero_usage() {
965        let parsed = parse_copilot_events_with_diagnostics(
966            vec![event(
967                "session.shutdown",
968                json!({
969                    "modelMetrics": {
970                        "future-model": {
971                            "usage": {
972                                "promptTokens": 123,
973                                "completionTokens": 45
974                            }
975                        }
976                    }
977                }),
978            )],
979            ParseMode::Full,
980        )
981        .unwrap();
982
983        assert!(parsed.diagnostics.is_complete_failure());
984        assert!(
985            parsed.analysis.records[0].conversation_usage.is_empty(),
986            "unknown token keys must not become a successful all-zero usage row"
987        );
988    }
989
990    #[test]
991    fn shutdown_accepts_partial_current_usage_even_when_the_value_is_zero() {
992        let parsed = parse_copilot_events_with_diagnostics(
993            vec![event(
994                "session.shutdown",
995                json!({
996                    "modelMetrics": {
997                        "current-model": {
998                            "usage": { "inputTokens": 0 }
999                        }
1000                    }
1001                }),
1002            )],
1003            ParseMode::Full,
1004        )
1005        .unwrap();
1006
1007        assert!(!parsed.diagnostics.is_complete_failure());
1008        assert_eq!(
1009            parsed.analysis.records[0].conversation_usage["current-model"]["input_tokens"],
1010            0
1011        );
1012    }
1013
1014    #[test]
1015    fn missing_tool_success_is_schema_drift_but_explicit_false_is_a_known_failure() {
1016        let start = || {
1017            event(
1018                "tool.execution_start",
1019                json!({
1020                    "toolCallId": "call-1",
1021                    "toolName": "show_file",
1022                    "arguments": { "path": "/tmp/a.txt" }
1023                }),
1024            )
1025        };
1026
1027        let missing = parse_copilot_events_with_diagnostics(
1028            vec![
1029                start(),
1030                event(
1031                    "tool.execution_complete",
1032                    json!({
1033                        "toolCallId": "call-1",
1034                        "status": "success",
1035                        "result": { "content": "one\ntwo" }
1036                    }),
1037                ),
1038            ],
1039            ParseMode::Full,
1040        )
1041        .unwrap();
1042        assert!(missing.diagnostics.is_complete_failure());
1043        assert_eq!(missing.analysis.records[0].tool_call_counts.read, 0);
1044
1045        let failed = parse_copilot_events_with_diagnostics(
1046            vec![
1047                start(),
1048                event(
1049                    "tool.execution_complete",
1050                    json!({
1051                        "toolCallId": "call-1",
1052                        "success": false,
1053                        "result": { "error": "invalid path" }
1054                    }),
1055                ),
1056            ],
1057            ParseMode::Full,
1058        )
1059        .unwrap();
1060        assert!(!failed.diagnostics.is_complete_failure());
1061        assert_eq!(failed.diagnostics.partial_failure_count(), 0);
1062        assert_eq!(failed.analysis.records[0].tool_call_counts.read, 0);
1063    }
1064
1065    #[test]
1066    fn claude_dot_version_rewrites_to_dash() {
1067        assert_eq!(
1068            canonicalize_model_name("claude-sonnet-4.6"),
1069            "claude-sonnet-4-6"
1070        );
1071        assert_eq!(
1072            canonicalize_model_name("claude-opus-4.7"),
1073            "claude-opus-4-7"
1074        );
1075    }
1076
1077    #[test]
1078    fn claude_dash_version_is_unchanged() {
1079        assert_eq!(
1080            canonicalize_model_name("claude-sonnet-4-6"),
1081            "claude-sonnet-4-6"
1082        );
1083    }
1084
1085    #[test]
1086    fn non_claude_models_keep_dots() {
1087        // OpenAI / Azure model names use dots natively; do not touch them.
1088        assert_eq!(canonicalize_model_name("gpt-5.1"), "gpt-5.1");
1089        assert_eq!(canonicalize_model_name("gpt-4.1-mini"), "gpt-4.1-mini");
1090        assert_eq!(canonicalize_model_name("gemini-1.5-pro"), "gemini-1.5-pro");
1091    }
1092}