1use crate::provider::native_name;
14use crate::types::{EventLine, Session, Workspace};
15use serde_json::{Map, Value, json};
16use std::collections::HashMap;
17use toolpath_convo::{
18 ConversationProjector, ConversationView, Result, Role, TokenUsage, ToolInvocation, Turn,
19};
20
21pub const DEFAULT_COPILOT_VERSION: &str = "1.0.67";
23
24#[derive(Debug, Clone)]
26pub struct CopilotProjector {
27 pub copilot_version: String,
28}
29
30impl Default for CopilotProjector {
31 fn default() -> Self {
32 Self {
33 copilot_version: DEFAULT_COPILOT_VERSION.to_string(),
34 }
35 }
36}
37
38impl CopilotProjector {
39 pub fn new() -> Self {
40 Self::default()
41 }
42}
43
44impl ConversationProjector for CopilotProjector {
45 type Output = Session;
46
47 fn project(&self, view: &ConversationView) -> Result<Session> {
48 Ok(self.build(view))
49 }
50}
51
52struct LineBuilder {
55 lines: Vec<EventLine>,
56 seq: usize,
57 last_id: Option<String>,
58}
59
60impl LineBuilder {
61 fn new() -> Self {
62 Self {
63 lines: Vec::new(),
64 seq: 0,
65 last_id: None,
66 }
67 }
68
69 fn push(&mut self, kind: &str, ts: &str, data: Value) {
70 self.seq += 1;
71 let id = event_uuid(self.seq);
76 let mut extra: HashMap<String, Value> = HashMap::new();
77 extra.insert("id".to_string(), Value::String(id.clone()));
78 extra.insert(
81 "parentId".to_string(),
82 match &self.last_id {
83 Some(parent) => Value::String(parent.clone()),
84 None => Value::Null,
85 },
86 );
87 self.lines.push(EventLine {
88 kind: kind.to_string(),
89 timestamp: (!ts.is_empty()).then(|| ts.to_string()),
90 data: Some(data),
91 payload: None,
92 extra,
93 });
94 self.last_id = Some(id);
95 }
96}
97
98impl CopilotProjector {
99 fn build(&self, view: &ConversationView) -> Session {
100 let mut b = LineBuilder::new();
101
102 let base_ts = view
106 .turns
107 .iter()
108 .map(|t| t.timestamp.as_str())
109 .find(|s| is_iso_offset(s))
110 .map(str::to_string)
111 .or_else(|| view.started_at.map(|dt| dt.to_rfc3339()))
112 .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string());
113
114 b.push(
116 "session.start",
117 &base_ts,
118 self.session_start_data(view, &base_ts),
119 );
120
121 let mut assistant_turn: usize = 0;
122 for turn in &view.turns {
123 let ts = iso_or(&turn.timestamp, &base_ts);
124 match &turn.role {
125 Role::User => b.push("user.message", &ts, json!({ "content": turn.text })),
126 Role::System => b.push(
127 "system.message",
128 &ts,
129 json!({ "role": "system", "content": turn.text }),
130 ),
131 Role::Assistant => {
132 let turn_id = assistant_turn.to_string();
133 let message_id = message_uuid(assistant_turn);
134 assistant_turn += 1;
135 self.push_assistant(&mut b, turn, &ts, &turn_id, &message_id);
136 }
137 Role::Other(_) => b.push("user.message", &ts, json!({ "content": turn.text })),
140 }
141 }
142
143 Session {
144 id: view.id.clone(),
145 dir_path: std::path::PathBuf::from(&view.id),
146 lines: b.lines,
147 workspace: self.workspace(view),
148 }
149 }
150
151 fn session_start_data(&self, view: &ConversationView, start_time: &str) -> Value {
152 let mut ctx = Map::new();
153 if let Some(base) = &view.base {
154 if let Some(wd) = &base.working_dir {
155 let wd = strip_file_uri(wd);
156 ctx.insert("cwd".into(), json!(wd));
157 ctx.insert("gitRoot".into(), json!(wd));
158 }
159 if let Some(r) = &base.vcs_remote {
160 ctx.insert("repository".into(), json!(r));
161 ctx.insert("hostType".into(), json!("github"));
162 ctx.insert("repositoryHost".into(), json!("github.com"));
163 }
164 if let Some(br) = &base.vcs_branch {
165 ctx.insert("branch".into(), json!(br));
166 }
167 if let Some(rev) = &base.vcs_revision {
168 ctx.insert("headCommit".into(), json!(rev));
169 ctx.insert("baseCommit".into(), json!(rev));
170 }
171 }
172 let producer = view
173 .producer
174 .as_ref()
175 .map(|p| p.name.clone())
176 .unwrap_or_else(|| "copilot-agent".to_string());
177 json!({
181 "sessionId": view.id,
182 "version": 1,
183 "producer": producer,
184 "copilotVersion": self.copilot_version,
185 "startTime": start_time,
186 "contextTier": Value::Null,
187 "context": Value::Object(ctx),
188 "alreadyInUse": false,
189 "remoteSteerable": false,
190 })
191 }
192
193 fn push_assistant(
194 &self,
195 b: &mut LineBuilder,
196 turn: &Turn,
197 ts: &str,
198 turn_id: &str,
199 message_id: &str,
200 ) {
201 b.push("assistant.turn_start", ts, json!({ "turnId": turn_id }));
204
205 let mut data = Map::new();
208 data.insert("content".into(), json!(turn.text));
209 data.insert("turnId".into(), json!(turn_id));
210 data.insert("messageId".into(), json!(message_id));
211 if let Some(m) = &turn.model {
212 data.insert("model".into(), json!(m));
213 }
214 if let Some(th) = &turn.thinking {
215 data.insert("reasoningText".into(), json!(th));
216 }
217 if let Some(u) = &turn.token_usage {
218 insert_token_fields(&mut data, u);
219 }
220 if !turn.tool_uses.is_empty() {
221 let reqs: Vec<Value> = turn
227 .tool_uses
228 .iter()
229 .enumerate()
230 .map(|(i, tu)| {
231 let (name, args) = projected_tool(tu);
232 json!({
233 "toolCallId": call_id(tu, turn_id, i),
234 "name": name,
235 "arguments": args,
236 })
237 })
238 .collect();
239 data.insert("toolRequests".into(), Value::Array(reqs));
240 }
241 b.push("assistant.message", ts, Value::Object(data));
242
243 for (i, tu) in turn.tool_uses.iter().enumerate() {
247 let cid = call_id(tu, turn_id, i);
248 if let Some(fw) = file_write_projection(tu) {
249 let success = tu.result.as_ref().map(|r| !r.is_error).unwrap_or(true);
253 b.push(
254 "tool.execution_start",
255 ts,
256 json!({
257 "toolCallId": cid,
258 "toolName": fw.tool_name,
259 "arguments": fw.arguments,
260 "turnId": turn_id,
261 }),
262 );
263 b.push(
264 "tool.execution_complete",
265 ts,
266 json!({
267 "toolCallId": cid,
268 "success": success,
269 "result": match &fw.detailed {
270 Some(d) => json!({ "content": fw.content, "detailedContent": d }),
271 None => json!({ "content": fw.content }),
272 },
273 "toolTelemetry": fw.telemetry,
274 "turnId": turn_id,
275 }),
276 );
277 continue;
278 }
279 let (g_name, g_args) = projected_tool(tu);
280 b.push(
281 "tool.execution_start",
282 ts,
283 json!({
284 "toolCallId": cid,
285 "toolName": g_name,
286 "arguments": g_args,
287 "turnId": turn_id,
288 }),
289 );
290 if let Some(res) = &tu.result {
291 b.push(
292 "tool.execution_complete",
293 ts,
294 json!({
295 "toolCallId": cid,
296 "success": !res.is_error,
297 "result": { "content": res.content },
298 "turnId": turn_id,
299 }),
300 );
301 }
302 }
303
304 for (i, d) in turn.delegations.iter().enumerate() {
308 let sub_call = if d.agent_id.trim().is_empty() {
313 format!("toolcall-sub-{turn_id}-{i}")
314 } else {
315 d.agent_id.clone()
316 };
317 let agent_name = if d.agent_id.trim().is_empty() {
318 "subagent"
319 } else {
320 d.agent_id.as_str()
321 };
322 b.push(
323 "subagent.started",
324 ts,
325 json!({
326 "id": d.agent_id,
327 "agentName": agent_name,
328 "agentDisplayName": agent_name,
329 "agentDescription": "Delegated sub-agent task",
330 "prompt": d.prompt,
331 "turnId": turn_id,
332 "toolCallId": sub_call,
333 }),
334 );
335 if let Some(result) = &d.result {
336 b.push(
337 "subagent.completed",
338 ts,
339 json!({
340 "id": d.agent_id,
341 "agentName": agent_name,
342 "agentDisplayName": agent_name,
343 "agentDescription": "Delegated sub-agent task",
344 "result": result,
345 "turnId": turn_id,
346 "toolCallId": sub_call,
347 }),
348 );
349 }
350 }
351
352 b.push(
353 "assistant.turn_end",
354 ts,
355 json!({ "turnId": turn_id, "messageId": message_id }),
356 );
357 }
358
359 fn workspace(&self, view: &ConversationView) -> Option<Workspace> {
360 let base = view.base.as_ref()?;
361 let ws = Workspace {
362 git_root: base.working_dir.as_deref().map(strip_file_uri),
363 repository: base.vcs_remote.clone(),
364 branch: base.vcs_branch.clone(),
365 revision: base.vcs_revision.clone(),
366 };
367 (!ws.is_empty()).then_some(ws)
368 }
369}
370
371fn call_id(tu: &ToolInvocation, turn_id: &str, i: usize) -> String {
375 if tu.id.trim().is_empty() {
376 format!("toolcall-{turn_id}-{i}")
377 } else {
378 tu.id.clone()
379 }
380}
381
382fn tool_name(tu: &ToolInvocation) -> String {
383 match tu.category {
384 Some(cat) => native_name(cat, &tu.input).to_string(),
385 None => tu.name.clone(),
386 }
387}
388
389fn projected_tool(tu: &ToolInvocation) -> (String, Value) {
395 if let Some(fw) = file_write_projection(tu) {
396 return (fw.tool_name.to_string(), fw.arguments);
397 }
398 if tu.category == Some(toolpath_convo::ToolCategory::FileRead)
399 && let Some(path) = str_in(&tu.input, &["path", "file_path", "filePath", "file"])
400 {
401 let mut args = Map::new();
402 args.insert("path".into(), json!(path));
403 let off = tu.input.get("offset").and_then(|v| v.as_i64());
405 let lim = tu.input.get("limit").and_then(|v| v.as_i64());
406 if let (Some(o), Some(l)) = (off, lim) {
407 args.insert("view_range".into(), json!([o, o + l - 1]));
408 }
409 return ("view".to_string(), Value::Object(args));
410 }
411 (tool_name(tu), tu.input.clone())
412}
413
414struct FileWrite {
419 tool_name: &'static str,
420 arguments: Value,
421 content: String,
422 detailed: Option<String>,
426 telemetry: Value,
430}
431
432fn str_in(v: &Value, keys: &[&str]) -> Option<String> {
433 for k in keys {
434 if let Some(s) = v.get(*k).and_then(|x| x.as_str()) {
435 return Some(s.to_string());
436 }
437 }
438 None
439}
440
441fn file_write_projection(tu: &ToolInvocation) -> Option<FileWrite> {
442 if tu.category != Some(toolpath_convo::ToolCategory::FileWrite) {
443 return None;
444 }
445 let input = &tu.input;
446 let path = str_in(
447 input,
448 &[
449 "path",
450 "file_path",
451 "filePath",
452 "filename",
453 "file",
454 "target_file",
455 ],
456 )?;
457 if input.get("old_string").is_some() || input.get("old_str").is_some() {
458 let old = str_in(input, &["old_str", "old_string", "oldString"]).unwrap_or_default();
459 let new = str_in(input, &["new_str", "new_string", "newString"]).unwrap_or_default();
460 let detailed = hunked(git_diff(&path, &old, &new, false));
461 let telemetry = file_telemetry("edit", &path, detailed.as_deref().unwrap_or(""));
462 return Some(FileWrite {
463 tool_name: "edit",
464 arguments: json!({ "path": path, "old_str": old, "new_str": new }),
465 content: format!("File {path} updated with changes."),
466 detailed,
467 telemetry,
468 });
469 }
470 let content = str_in(
471 input,
472 &[
473 "file_text",
474 "content",
475 "contents",
476 "text",
477 "new_str",
478 "new_string",
479 ],
480 )?;
481 let detailed = hunked(git_diff(&path, "", &content, true));
482 let telemetry = file_telemetry("create", &path, detailed.as_deref().unwrap_or(""));
483 Some(FileWrite {
484 tool_name: "create",
485 arguments: json!({ "path": path, "file_text": content }),
486 content: format!(
487 "Created file {path} with {} characters",
488 content.chars().count()
489 ),
490 detailed,
491 telemetry,
492 })
493}
494
495fn file_telemetry(command: &str, path: &str, diff: &str) -> Value {
499 let ext = path
500 .rsplit_once('.')
501 .filter(|(_, e)| !e.contains('/'))
502 .map(|(_, e)| format!(".{e}"))
503 .unwrap_or_default();
504 let lang = language_id(&ext);
505 let (added, removed) = diff_line_counts(diff);
506 let s = |v: Value| serde_json::to_string(&v).unwrap_or_default();
507 json!({
508 "properties": {
509 "command": command,
510 "resolvedPathAgainstCwd": "false",
511 "fileExtension": s(json!([ext])),
512 "codeBlocks": s(json!([{
513 "fileExt": ext, "languageId": lang,
514 "linesAdded": added, "linesRemoved": removed
515 }])),
516 "languageId": s(json!([lang])),
517 },
518 "metrics": { "linesAdded": added, "linesRemoved": removed },
519 "restrictedProperties": { "filePaths": s(json!([path])) },
520 })
521}
522
523fn diff_line_counts(diff: &str) -> (usize, usize) {
525 let mut added = 0;
526 let mut removed = 0;
527 for l in diff.lines() {
528 if l.starts_with("+++") || l.starts_with("---") {
529 continue;
530 }
531 if l.starts_with('+') {
532 added += 1;
533 } else if l.starts_with('-') {
534 removed += 1;
535 }
536 }
537 (added, removed)
538}
539
540fn language_id(ext: &str) -> &'static str {
541 match ext {
542 ".rs" => "rust",
543 ".md" | ".markdown" => "markdown",
544 ".txt" => "text",
545 ".py" => "python",
546 ".js" | ".mjs" | ".cjs" => "javascript",
547 ".ts" => "typescript",
548 ".tsx" => "typescriptreact",
549 ".jsx" => "javascriptreact",
550 ".json" => "json",
551 ".jsonl" => "jsonl",
552 ".toml" => "toml",
553 ".yaml" | ".yml" => "yaml",
554 ".sh" | ".bash" => "shellscript",
555 ".go" => "go",
556 ".c" => "c",
557 ".cpp" | ".cc" | ".cxx" | ".h" | ".hpp" => "cpp",
558 ".java" => "java",
559 ".rb" => "ruby",
560 ".php" => "php",
561 ".html" | ".htm" => "html",
562 ".css" => "css",
563 ".scss" => "scss",
564 ".sql" => "sql",
565 ".xml" => "xml",
566 _ => "plaintext",
567 }
568}
569
570fn git_diff(path: &str, before: &str, after: &str, create: bool) -> String {
577 let p = path.strip_prefix('/').unwrap_or(path);
578 let from = if create {
579 "a/dev/null".to_string()
580 } else {
581 format!("a/{p}")
582 };
583 let to = format!("b/{p}");
584 let after_eff = if create && after.is_empty() && before.is_empty() {
589 "\n"
590 } else {
591 after
592 };
593 let diff = similar::TextDiff::from_lines(before, after_eff);
594 let body = diff
595 .unified_diff()
596 .context_radius(3)
597 .header(&from, &to)
598 .to_string();
599 if create {
600 format!("\ndiff --git a/{p} b/{p}\ncreate file mode 100644\nindex 0000000..0000000\n{body}")
601 } else {
602 format!("\ndiff --git a/{p} b/{p}\nindex 0000000..0000000 100644\n{body}")
603 }
604}
605
606fn hunked(diff: String) -> Option<String> {
608 diff.lines().any(|l| l.starts_with("@@")).then_some(diff)
609}
610
611fn insert_token_fields(data: &mut Map<String, Value>, u: &TokenUsage) {
612 if let Some(o) = u.output_tokens {
613 data.insert("outputTokens".into(), json!(o));
614 }
615 if let Some(i) = u.input_tokens {
616 data.insert("inputTokens".into(), json!(i));
617 }
618 if let Some(c) = u.cache_read_tokens {
619 data.insert("cacheReadTokens".into(), json!(c));
620 }
621 if let Some(c) = u.cache_write_tokens {
622 data.insert("cacheWriteTokens".into(), json!(c));
623 }
624}
625
626fn strip_file_uri(s: &str) -> String {
627 s.strip_prefix("file://").unwrap_or(s).to_string()
628}
629
630fn event_uuid(n: usize) -> String {
635 format!("00000000-0000-4000-8000-{:012x}", n)
636}
637
638fn message_uuid(n: usize) -> String {
641 format!("00000000-0000-4000-8001-{:012x}", n)
642}
643
644fn is_iso_offset(s: &str) -> bool {
647 chrono::DateTime::parse_from_rfc3339(s).is_ok()
648}
649
650fn iso_or(s: &str, fallback: &str) -> String {
652 if is_iso_offset(s) {
653 s.to_string()
654 } else {
655 fallback.to_string()
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::provider::to_view;
663
664 #[test]
665 fn round_trips_a_view() {
666 let body = [
669 r#"{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{"copilotVersion":"1.0.67","context":{"cwd":"/tmp/proj","gitRoot":"/tmp/proj","repository":"o/r","branch":"main","headCommit":"abc"}}}"#,
670 r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"build it"}}"#,
671 r#"{"type":"assistant.turn_start","timestamp":"2026-07-01T00:00:02Z","data":{}}"#,
672 r#"{"type":"assistant.message","timestamp":"2026-07-01T00:00:03Z","data":{"content":"listing","model":"claude-haiku-4.5","reasoningText":"think","outputTokens":42}}"#,
673 r#"{"type":"tool.execution_start","timestamp":"2026-07-01T00:00:04Z","data":{"toolCallId":"c1","toolName":"bash","arguments":{"command":"ls"}}}"#,
674 r#"{"type":"tool.execution_complete","timestamp":"2026-07-01T00:00:05Z","data":{"toolCallId":"c1","success":true,"result":{"content":"a.rs"}}}"#,
675 r#"{"type":"assistant.turn_end","timestamp":"2026-07-01T00:00:06Z","data":{}}"#,
676 ]
677 .join("\n");
678 let session = crate::Session {
679 id: "s1".into(),
680 dir_path: "/tmp/s1".into(),
681 lines: body
682 .lines()
683 .map(|l| serde_json::from_str(l).unwrap())
684 .collect(),
685 workspace: None,
686 };
687 let view1 = to_view(&session);
688
689 let projected = CopilotProjector::new().project(&view1).unwrap();
690 let view2 = to_view(&projected);
691
692 assert_eq!(view1.turns.len(), view2.turns.len());
694 assert_eq!(view2.turns[0].role, Role::User);
695 assert_eq!(view2.turns[0].text, "build it");
696 assert_eq!(view2.turns[1].role, Role::Assistant);
697 assert_eq!(view2.turns[1].text, "listing");
698 assert_eq!(view2.turns[1].thinking.as_deref(), Some("think"));
700 assert_eq!(view2.turns[1].model.as_deref(), Some("claude-haiku-4.5"));
701 assert_eq!(
702 view2.turns[1].token_usage.as_ref().unwrap().output_tokens,
703 Some(42)
704 );
705 let tu = &view2.turns[1].tool_uses[0];
707 assert_eq!(tu.id, "c1");
708 assert_eq!(tu.name, "bash");
709 assert_eq!(tu.result.as_ref().unwrap().content, "a.rs");
710 let base = view2.base.as_ref().unwrap();
712 assert_eq!(base.working_dir.as_deref(), Some("/tmp/proj"));
713 assert_eq!(base.vcs_branch.as_deref(), Some("main"));
714 assert_eq!(base.vcs_revision.as_deref(), Some("abc"));
715 assert_eq!(view2.total_usage.as_ref().unwrap().output_tokens, Some(42));
717 }
718
719 #[test]
720 fn event_ids_are_uuid_shaped() {
721 fn uuid_shaped(s: &str) -> bool {
724 let b = s.as_bytes();
725 s.len() == 36
726 && b[8] == b'-'
727 && b[13] == b'-'
728 && b[18] == b'-'
729 && b[23] == b'-'
730 && s.chars().all(|c| c == '-' || c.is_ascii_hexdigit())
731 }
732 let session = crate::Session {
733 id: "s".into(),
734 dir_path: "/tmp/s".into(),
735 lines: [
736 r#"{"type":"user.message","data":{"content":"hi"}}"#,
737 r#"{"type":"assistant.turn_start","data":{}}"#,
738 r#"{"type":"assistant.message","data":{"content":"ok"}}"#,
739 r#"{"type":"assistant.turn_end","data":{}}"#,
740 ]
741 .iter()
742 .map(|l| serde_json::from_str(l).unwrap())
743 .collect(),
744 workspace: None,
745 };
746 let view = to_view(&session);
747 let projected = CopilotProjector::new().project(&view).unwrap();
748 assert!(projected.lines.len() >= 2);
749 for line in &projected.lines {
750 let id = line.extra.get("id").and_then(|v| v.as_str()).unwrap();
751 assert!(uuid_shaped(id), "event id not UUID-shaped: {id:?}");
752 if let Some(p) = line.extra.get("parentId").and_then(|v| v.as_str()) {
753 assert!(uuid_shaped(p), "parentId not UUID-shaped: {p:?}");
754 }
755 }
756 }
757
758 #[test]
759 fn empty_tool_id_gets_a_synthesized_call_id() {
760 use serde_json::json;
763 use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult};
764 let mut view = ConversationView {
765 id: "x".into(),
766 ..Default::default()
767 };
768 view.turns.push(Turn {
769 id: "a1".into(),
770 parent_id: None,
771 group_id: None,
772 role: Role::Assistant,
773 timestamp: "2026-07-01T00:00:00Z".into(),
774 text: String::new(),
775 thinking: None,
776 tool_uses: vec![ToolInvocation {
777 id: String::new(), name: "bash".into(),
779 input: json!({"command": "ls"}),
780 result: Some(ToolResult {
781 content: "out".into(),
782 is_error: false,
783 }),
784 category: Some(ToolCategory::Shell),
785 }],
786 model: None,
787 stop_reason: None,
788 token_usage: None,
789 attributed_token_usage: None,
790 environment: None,
791 delegations: Vec::new(),
792 file_mutations: Vec::new(),
793 });
794 let session = CopilotProjector::new().project(&view).unwrap();
795 let mut ids: Vec<String> = Vec::new();
797 for line in &session.lines {
798 let d = line.data.as_ref().unwrap();
799 if line.kind.starts_with("tool.execution") {
800 ids.push(d["toolCallId"].as_str().unwrap().to_string());
801 }
802 if line.kind == "assistant.message"
803 && let Some(reqs) = d.get("toolRequests").and_then(|v| v.as_array())
804 {
805 for r in reqs {
806 ids.push(r["toolCallId"].as_str().unwrap().to_string());
807 }
808 }
809 }
810 assert!(!ids.is_empty());
811 assert!(ids.iter().all(|s| !s.is_empty()), "no empty toolCallId");
812 assert!(
814 ids.windows(2).all(|w| w[0] == w[1]),
815 "call id must be stable: {ids:?}"
816 );
817 }
818
819 #[test]
820 fn file_edits_project_to_copilot_edit_shape_with_diff() {
821 use serde_json::json;
825 use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult};
826 fn assistant_with(tool: ToolInvocation) -> ConversationView {
827 let mut v = ConversationView {
828 id: "x".into(),
829 ..Default::default()
830 };
831 v.turns.push(Turn {
832 id: "a1".into(),
833 parent_id: None,
834 group_id: None,
835 role: Role::Assistant,
836 timestamp: "2026-07-01T00:00:00Z".into(),
837 text: String::new(),
838 thinking: None,
839 tool_uses: vec![tool],
840 model: None,
841 stop_reason: None,
842 token_usage: None,
843 attributed_token_usage: None,
844 environment: None,
845 delegations: Vec::new(),
846 file_mutations: Vec::new(),
847 });
848 v
849 }
850 let base = |id: &str, name: &str, input| ToolInvocation {
851 id: id.into(),
852 name: name.into(),
853 input,
854 result: Some(ToolResult {
855 content: "done".into(),
856 is_error: false,
857 }),
858 category: Some(ToolCategory::FileWrite),
859 };
860 let find = |lines: &[EventLine], kind: &str, kv: &str| -> serde_json::Value {
861 lines
862 .iter()
863 .find(|l| {
864 l.kind == kind
865 && l.data
866 .as_ref()
867 .unwrap()
868 .get("toolName")
869 .and_then(|v| v.as_str())
870 == Some(kv)
871 })
872 .map(|l| l.data.clone().unwrap())
873 .unwrap_or(json!(null))
874 };
875
876 let edit = base(
878 "c1",
879 "Edit",
880 json!({"file_path": "/p/a.rs", "old_string": "old", "new_string": "new"}),
881 );
882 let s = CopilotProjector::new()
883 .project(&assistant_with(edit))
884 .unwrap();
885 let start = find(&s.lines, "tool.execution_start", "edit");
886 assert_eq!(start["arguments"]["path"], "/p/a.rs");
887 assert_eq!(start["arguments"]["old_str"], "old");
888 assert_eq!(start["arguments"]["new_str"], "new");
889 let done = s
890 .lines
891 .iter()
892 .find(|l| l.kind == "tool.execution_complete")
893 .unwrap();
894 let detailed = done.data.as_ref().unwrap()["result"]["detailedContent"]
895 .as_str()
896 .unwrap();
897 assert!(
898 detailed.contains("diff --git a/p/a.rs b/p/a.rs"),
899 "got: {detailed}"
900 );
901 assert!(detailed.contains("-old") && detailed.contains("+new"));
902 assert!(
905 !detailed.contains("\n--- \n") && !detailed.contains("\n+++ \n"),
906 "duplicate/empty diff header: {detailed:?}"
907 );
908 assert_eq!(
909 detailed.matches("\n--- a/").count(),
910 1,
911 "one --- header: {detailed:?}"
912 );
913 let tele = &done.data.as_ref().unwrap()["toolTelemetry"];
915 assert_eq!(tele["metrics"]["linesAdded"], 1);
916 assert_eq!(tele["metrics"]["linesRemoved"], 1);
917 assert!(
918 tele["properties"]["codeBlocks"]
919 .as_str()
920 .unwrap()
921 .contains("\"languageId\":\"rust\""),
922 "codeBlocks: {tele:?}"
923 );
924
925 let create = base(
927 "c2",
928 "Write",
929 json!({"file_path": "/p/b.rs", "content": "hello"}),
930 );
931 let s = CopilotProjector::new()
932 .project(&assistant_with(create))
933 .unwrap();
934 let start = find(&s.lines, "tool.execution_start", "create");
935 assert_eq!(start["arguments"]["file_text"], "hello");
936 let done = s
937 .lines
938 .iter()
939 .find(|l| l.kind == "tool.execution_complete")
940 .unwrap();
941 let detailed = done.data.as_ref().unwrap()["result"]["detailedContent"]
942 .as_str()
943 .unwrap();
944 assert!(detailed.contains("create file mode"), "got: {detailed}");
945 assert!(detailed.contains("--- a/dev/null") && detailed.contains("+hello"));
946 }
947
948 #[test]
949 fn tool_requests_mirror_carries_remapped_args() {
950 use serde_json::json;
956 use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult};
957 let mk = |name: &str, cat, input| ToolInvocation {
958 id: format!("c-{name}"),
959 name: name.into(),
960 input,
961 result: Some(ToolResult {
962 content: "ok".into(),
963 is_error: false,
964 }),
965 category: Some(cat),
966 };
967 let mut view = ConversationView {
968 id: "x".into(),
969 ..Default::default()
970 };
971 view.turns.push(Turn {
972 id: "a1".into(),
973 parent_id: None,
974 group_id: None,
975 role: Role::Assistant,
976 timestamp: "2026-07-01T00:00:00Z".into(),
977 text: "t".into(),
978 thinking: None,
979 tool_uses: vec![
980 mk(
981 "Edit",
982 ToolCategory::FileWrite,
983 json!({"file_path": "/p/a.md", "old_string": "x", "new_string": "y"}),
984 ),
985 mk(
986 "Read",
987 ToolCategory::FileRead,
988 json!({"file_path": "/p/b.rs", "offset": 10, "limit": 5}),
989 ),
990 ],
991 model: None,
992 stop_reason: None,
993 token_usage: None,
994 attributed_token_usage: None,
995 environment: None,
996 delegations: Vec::new(),
997 file_mutations: Vec::new(),
998 });
999 let session = CopilotProjector::new().project(&view).unwrap();
1000 let msg = session
1001 .lines
1002 .iter()
1003 .find(|l| l.kind == "assistant.message")
1004 .unwrap();
1005 let reqs = msg.data.as_ref().unwrap()["toolRequests"]
1006 .as_array()
1007 .unwrap();
1008 assert_eq!(reqs[0]["name"], "edit");
1010 assert_eq!(reqs[0]["arguments"]["path"], "/p/a.md");
1011 assert!(reqs[0]["arguments"].get("file_path").is_none());
1012 assert_eq!(reqs[0]["arguments"]["old_str"], "x");
1013 assert_eq!(reqs[1]["name"], "view");
1015 assert_eq!(reqs[1]["arguments"]["path"], "/p/b.rs");
1016 assert_eq!(reqs[1]["arguments"]["view_range"], json!([10, 14]));
1017 let start = session
1019 .lines
1020 .iter()
1021 .find(|l| {
1022 l.kind == "tool.execution_start" && l.data.as_ref().unwrap()["toolName"] == "view"
1023 })
1024 .unwrap();
1025 assert_eq!(start.data.as_ref().unwrap()["arguments"]["path"], "/p/b.rs");
1026 }
1027
1028 #[test]
1029 fn remaps_foreign_tool_names() {
1030 use serde_json::json;
1031 use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult};
1032 let mut view = ConversationView {
1034 id: "x".into(),
1035 provider_id: Some("codex".into()),
1036 ..Default::default()
1037 };
1038 view.turns.push(Turn {
1039 id: "a1".into(),
1040 parent_id: None,
1041 group_id: None,
1042 role: Role::Assistant,
1043 timestamp: "2026-07-01T00:00:00Z".into(),
1044 text: String::new(),
1045 thinking: None,
1046 tool_uses: vec![ToolInvocation {
1047 id: "c1".into(),
1048 name: "shell".into(),
1049 input: json!({"command": "ls"}),
1050 result: Some(ToolResult {
1051 content: "out".into(),
1052 is_error: false,
1053 }),
1054 category: Some(ToolCategory::Shell),
1055 }],
1056 model: None,
1057 stop_reason: None,
1058 token_usage: None,
1059 attributed_token_usage: None,
1060 environment: None,
1061 delegations: Vec::new(),
1062 file_mutations: Vec::new(),
1063 });
1064 let projected = CopilotProjector::new().project(&view).unwrap();
1065 let back = to_view(&projected);
1066 assert_eq!(back.turns[0].tool_uses[0].name, "bash");
1067 }
1068}