1use 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
18pub const PROVIDER_ID: &str = "copilot";
20pub const PRODUCER_NAME: &str = "copilot-cli";
22
23pub fn tool_category(name: &str) -> Option<ToolCategory> {
30 let n = name.to_ascii_lowercase();
31 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 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
85pub 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 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
121pub(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
137pub 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 let mut shutdown_usage: Option<TokenUsage> = None;
149 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 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 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 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 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 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 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
383fn 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
453fn 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 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
508fn 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
575fn 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 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#[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 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 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 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 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 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 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 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 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 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 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 assert_eq!(view.turns[1].tool_uses.len(), 2);
1012 }
1013}