1use 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
18pub 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
62pub(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 let mut pending_tools: FastHashMap<String, PendingTool> = FastHashMap::with_capacity(32);
77
78 let mut current_model = String::new();
83 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 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 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 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 !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 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
460struct PendingTool {
463 tool_name: String,
465 arguments: Value,
467 timestamp: i64,
469 tracked: bool,
471 arguments_supported: bool,
473}
474
475fn 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 "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 "rg" | "grep" | "glob" | "web_search" | "web_fetch" => state.tool_counts.read += 1,
504 "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 "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 _ => {}
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
570fn 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
588fn 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
657fn 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 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
694fn 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
709fn 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 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 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 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 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}