1use crate::agent::{Agent, ModelSize};
3use crate::output::AgentOutput;
4use crate::providers::common::CommonAgentState;
5use crate::session_log::{
6 BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
7 LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
8};
9use anyhow::{Context, Result};
10
11fn tool_kind_from_name(name: &str) -> ToolKind {
13 match name {
14 "bash" | "shell" => ToolKind::Shell,
15 "view" | "read" | "cat" => ToolKind::FileRead,
16 "write" => ToolKind::FileWrite,
17 "edit" | "insert" | "replace" => ToolKind::FileEdit,
18 "grep" | "glob" | "find" | "search" => ToolKind::Search,
19 _ => ToolKind::Other,
20 }
21}
22use async_trait::async_trait;
23use log::info;
24use std::collections::HashSet;
25use std::io::{BufRead, BufReader, Seek, SeekFrom};
26use std::path::{Path, PathBuf};
27use tokio::fs;
28use tokio::process::Command;
29
30pub fn session_state_dir() -> std::path::PathBuf {
32 dirs::home_dir()
33 .unwrap_or_else(|| std::path::PathBuf::from("."))
34 .join(".copilot/session-state")
35}
36
37pub const DEFAULT_MODEL: &str = "claude-sonnet-4.6";
38
39pub const AVAILABLE_MODELS: &[&str] = &[
40 "claude-sonnet-4.6",
41 "claude-haiku-4.5",
42 "claude-opus-4.6",
43 "claude-sonnet-4.5",
44 "claude-opus-4.5",
45 "gpt-5.4",
46 "gpt-5.4-mini",
47 "gpt-5.3-codex",
48 "gpt-5.2-codex",
49 "gpt-5.2",
50 "gpt-5.1-codex-max",
51 "gpt-5.1-codex",
52 "gpt-5.1",
53 "gpt-5",
54 "gpt-5.1-codex-mini",
55 "gpt-5-mini",
56 "gpt-4.1",
57 "gemini-3.1-pro-preview",
58 "gemini-3-pro-preview",
59];
60
61pub struct Copilot {
62 pub common: CommonAgentState,
63}
64
65pub struct CopilotLiveLogAdapter {
66 ctx: LiveLogContext,
67 session_path: Option<PathBuf>,
68 offset: u64,
69 seen_event_ids: HashSet<String>,
70}
71
72pub struct CopilotHistoricalLogAdapter;
73
74impl Copilot {
75 pub fn new() -> Self {
76 Self {
77 common: CommonAgentState::new(DEFAULT_MODEL),
78 }
79 }
80
81 async fn write_instructions_file(&self) -> Result<()> {
82 let base = self.common.get_base_path();
83 log::debug!("Writing Copilot instructions file to {}", base.display());
84 let instructions_dir = base.join(".github/instructions/agent");
85 fs::create_dir_all(&instructions_dir).await?;
86 fs::write(
87 instructions_dir.join("agent.instructions.md"),
88 &self.common.system_prompt,
89 )
90 .await?;
91 Ok(())
92 }
93
94 fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
96 let mut args = Vec::new();
97
98 if !interactive || self.common.skip_permissions {
100 args.push("--allow-all".to_string());
101 }
102
103 if !self.common.model.is_empty() {
104 args.extend(["--model".to_string(), self.common.model.clone()]);
105 }
106
107 for dir in &self.common.add_dirs {
108 args.extend(["--add-dir".to_string(), dir.clone()]);
109 }
110
111 if let Some(turns) = self.common.max_turns {
112 args.extend(["--max-turns".to_string(), turns.to_string()]);
113 }
114
115 match (interactive, prompt) {
116 (true, Some(p)) => args.extend(["-i".to_string(), p.to_string()]),
117 (false, Some(p)) => args.extend(["-p".to_string(), p.to_string()]),
118 _ => {}
119 }
120
121 args
122 }
123
124 fn make_command(&self, agent_args: Vec<String>) -> Command {
126 self.common.make_command("copilot", agent_args)
127 }
128
129 async fn execute(
130 &self,
131 interactive: bool,
132 prompt: Option<&str>,
133 ) -> Result<Option<AgentOutput>> {
134 if self.common.output_format.is_some() {
136 anyhow::bail!(
137 "Copilot does not support the --output flag. Remove the flag and try again."
138 );
139 }
140
141 if !self.common.system_prompt.is_empty() {
142 log::debug!(
143 "Copilot system prompt (written to instructions): {}",
144 self.common.system_prompt
145 );
146 self.write_instructions_file().await?;
147 }
148
149 let agent_args = self.build_run_args(interactive, prompt);
150 log::debug!("Copilot command: copilot {}", agent_args.join(" "));
151 if let Some(p) = prompt {
152 log::debug!("Copilot user prompt: {p}");
153 }
154 let mut cmd = self.make_command(agent_args);
155
156 if interactive {
157 CommonAgentState::run_interactive_command_with_hook(
158 &mut cmd,
159 "Copilot",
160 self.common.on_spawn_hook.as_ref(),
161 )
162 .await?;
163 Ok(None)
164 } else {
165 self.common
166 .run_non_interactive_simple(&mut cmd, "Copilot")
167 .await
168 }
169 }
170}
171
172#[cfg(test)]
173#[path = "copilot_tests.rs"]
174mod tests;
175
176impl Default for Copilot {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl CopilotLiveLogAdapter {
183 pub fn new(ctx: LiveLogContext) -> Self {
184 Self {
185 ctx,
186 session_path: None,
187 offset: 0,
188 seen_event_ids: HashSet::new(),
189 }
190 }
191
192 fn discover_session_path(&self) -> Option<PathBuf> {
193 let base = copilot_session_state_dir();
194 if let Some(session_id) = &self.ctx.provider_session_id {
195 let candidate = base.join(session_id).join("events.jsonl");
196 if candidate.exists() {
197 return Some(candidate);
198 }
199 }
200
201 let started_at = system_time_from_utc(self.ctx.started_at);
202 let workspace = self
203 .ctx
204 .workspace_path
205 .as_deref()
206 .or(self.ctx.root.as_deref());
207 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
208 let entries = std::fs::read_dir(base).ok()?;
209 for entry in entries.flatten() {
210 let path = entry.path().join("events.jsonl");
211 if !path.exists() {
212 continue;
213 }
214 let modified = entry
215 .metadata()
216 .ok()
217 .and_then(|metadata| metadata.modified().ok())
218 .or_else(|| {
219 std::fs::metadata(&path)
220 .ok()
221 .and_then(|metadata| metadata.modified().ok())
222 })?;
223 if modified < started_at {
224 continue;
225 }
226 if let Some(workspace) = workspace
227 && !copilot_session_matches_workspace(&entry.path(), workspace)
228 {
229 continue;
230 }
231 if best
232 .as_ref()
233 .map(|(current, _)| modified > *current)
234 .unwrap_or(true)
235 {
236 best = Some((modified, path));
237 }
238 }
239 best.map(|(_, path)| path)
240 }
241}
242
243#[async_trait]
244impl LiveLogAdapter for CopilotLiveLogAdapter {
245 async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
246 if self.session_path.is_none() {
247 self.session_path = self.discover_session_path();
248 if let Some(path) = &self.session_path {
249 writer.add_source_path(path.to_string_lossy().to_string())?;
250 let metadata_path = path.with_file_name("vscode.metadata.json");
251 if metadata_path.exists() {
252 writer.add_source_path(metadata_path.to_string_lossy().to_string())?;
253 }
254 let workspace_path = path.with_file_name("workspace.yaml");
255 if workspace_path.exists() {
256 writer.add_source_path(workspace_path.to_string_lossy().to_string())?;
257 }
258 }
259 }
260
261 let Some(path) = self.session_path.as_ref() else {
262 return Ok(());
263 };
264
265 let mut file = std::fs::File::open(path)
266 .with_context(|| format!("Failed to open {}", path.display()))?;
267 file.seek(SeekFrom::Start(self.offset))?;
268 let mut reader = BufReader::new(file);
269 let mut line = String::new();
270
271 while reader.read_line(&mut line)? > 0 {
272 self.offset += line.len() as u64;
273 let trimmed = line.trim();
274 if trimmed.is_empty() {
275 line.clear();
276 continue;
277 }
278 let Some(parsed) = parse_copilot_event_line(trimmed, &mut self.seen_event_ids) else {
279 line.clear();
280 continue;
281 };
282 if parsed.parse_failed {
283 writer.emit(
284 LogSourceKind::ProviderFile,
285 LogEventKind::ParseWarning {
286 message: "Failed to parse Copilot event line".to_string(),
287 raw: Some(trimmed.to_string()),
288 },
289 )?;
290 line.clear();
291 continue;
292 }
293 if let Some(session_id) = parsed.provider_session_id {
294 writer.set_provider_session_id(Some(session_id))?;
295 }
296 for event in parsed.events {
297 writer.emit(LogSourceKind::ProviderFile, event)?;
298 }
299 line.clear();
300 }
301
302 Ok(())
303 }
304}
305
306impl HistoricalLogAdapter for CopilotHistoricalLogAdapter {
307 fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
308 let base = copilot_session_state_dir();
309 let entries = match std::fs::read_dir(&base) {
310 Ok(entries) => entries,
311 Err(_) => return Ok(Vec::new()),
312 };
313 let mut sessions = Vec::new();
314 for entry in entries.flatten() {
315 let session_dir = entry.path();
316 if !session_dir.is_dir() {
317 continue;
318 }
319 let events_path = session_dir.join("events.jsonl");
320 if !events_path.exists() {
321 continue;
322 }
323 info!("Scanning Copilot history: {}", events_path.display());
324 let file = std::fs::File::open(&events_path)
325 .with_context(|| format!("Failed to open {}", events_path.display()))?;
326 let reader = BufReader::new(file);
327 let mut seen_event_ids = HashSet::new();
328 let mut events = Vec::new();
329 let mut provider_session_id = None;
330 let mut model = None;
331 let mut workspace_path = read_copilot_workspace_path(&session_dir);
332
333 for line in reader.lines() {
334 let line = line?;
335 let trimmed = line.trim();
336 if trimmed.is_empty() {
337 continue;
338 }
339 let Some(parsed) = parse_copilot_event_line(trimmed, &mut seen_event_ids) else {
340 continue;
341 };
342 if parsed.parse_failed {
343 events.push((
344 LogSourceKind::Backfill,
345 LogEventKind::ParseWarning {
346 message: "Failed to parse Copilot event line".to_string(),
347 raw: Some(trimmed.to_string()),
348 },
349 ));
350 continue;
351 }
352 if provider_session_id.is_none() {
353 provider_session_id = parsed.provider_session_id;
354 }
355 if model.is_none() {
356 model = parsed.model;
357 }
358 if workspace_path.is_none() {
359 workspace_path = parsed.workspace_path;
360 }
361 for event in parsed.events {
362 events.push((LogSourceKind::Backfill, event));
363 }
364 }
365
366 let session_id = provider_session_id
367 .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
368 let mut source_paths = vec![events_path.to_string_lossy().to_string()];
369 let metadata_path = session_dir.join("vscode.metadata.json");
370 if metadata_path.exists() {
371 source_paths.push(metadata_path.to_string_lossy().to_string());
372 }
373 let workspace_yaml = session_dir.join("workspace.yaml");
374 if workspace_yaml.exists() {
375 source_paths.push(workspace_yaml.to_string_lossy().to_string());
376 }
377 sessions.push(BackfilledSession {
378 metadata: SessionLogMetadata {
379 provider: "copilot".to_string(),
380 wrapper_session_id: session_id.clone(),
381 provider_session_id: Some(session_id),
382 workspace_path,
383 command: "backfill".to_string(),
384 model,
385 resumed: false,
386 backfilled: true,
387 },
388 completeness: LogCompleteness::Full,
389 source_paths,
390 events,
391 });
392 }
393 Ok(sessions)
394 }
395}
396
397pub(crate) struct ParsedCopilotEvent {
398 pub(crate) provider_session_id: Option<String>,
399 pub(crate) model: Option<String>,
400 pub(crate) workspace_path: Option<String>,
401 pub(crate) events: Vec<LogEventKind>,
402 pub(crate) parse_failed: bool,
403}
404
405pub(crate) fn parse_copilot_event_line(
406 line: &str,
407 seen_event_ids: &mut HashSet<String>,
408) -> Option<ParsedCopilotEvent> {
409 let value: serde_json::Value = match serde_json::from_str(line) {
410 Ok(value) => value,
411 Err(_) => {
412 return Some(ParsedCopilotEvent {
413 provider_session_id: None,
414 model: None,
415 workspace_path: None,
416 events: Vec::new(),
417 parse_failed: true,
418 });
419 }
420 };
421
422 let event_id = value
423 .get("id")
424 .and_then(|value| value.as_str())
425 .unwrap_or_default();
426 if !event_id.is_empty() && !seen_event_ids.insert(event_id.to_string()) {
427 return None;
428 }
429
430 let event_type = value
431 .get("type")
432 .and_then(|value| value.as_str())
433 .unwrap_or_default();
434 let data = value
435 .get("data")
436 .cloned()
437 .unwrap_or(serde_json::Value::Null);
438 let provider_session_id = value
439 .get("data")
440 .and_then(|value| value.get("sessionId"))
441 .and_then(|value| value.as_str())
442 .map(str::to_string);
443 let model = value
444 .get("data")
445 .and_then(|value| value.get("selectedModel"))
446 .and_then(|value| value.as_str())
447 .map(str::to_string);
448 let workspace_path = value
449 .get("data")
450 .and_then(|value| value.get("context"))
451 .and_then(|value| value.get("cwd").or_else(|| value.get("gitRoot")))
452 .and_then(|value| value.as_str())
453 .map(str::to_string);
454 let mut events = Vec::new();
455
456 match event_type {
457 "session.start" => events.push(LogEventKind::ProviderStatus {
458 message: "Copilot session started".to_string(),
459 data: Some(data),
460 }),
461 "session.model_change" => events.push(LogEventKind::ProviderStatus {
462 message: "Copilot model changed".to_string(),
463 data: Some(data),
464 }),
465 "session.info" => events.push(LogEventKind::ProviderStatus {
466 message: data
467 .get("message")
468 .and_then(|value| value.as_str())
469 .unwrap_or("Copilot session info")
470 .to_string(),
471 data: Some(data),
472 }),
473 "session.truncation" => events.push(LogEventKind::ProviderStatus {
474 message: "Copilot session truncation".to_string(),
475 data: Some(data),
476 }),
477 "user.message" => events.push(LogEventKind::UserMessage {
478 role: "user".to_string(),
479 content: data
480 .get("content")
481 .or_else(|| data.get("transformedContent"))
482 .and_then(|value| value.as_str())
483 .unwrap_or_default()
484 .to_string(),
485 message_id: value
486 .get("id")
487 .and_then(|value| value.as_str())
488 .map(str::to_string),
489 }),
490 "assistant.turn_start" => events.push(LogEventKind::ProviderStatus {
491 message: "Copilot assistant turn started".to_string(),
492 data: Some(data),
493 }),
494 "assistant.turn_end" => events.push(LogEventKind::ProviderStatus {
495 message: "Copilot assistant turn ended".to_string(),
496 data: Some(data),
497 }),
498 "assistant.message" => {
499 let message_id = data
500 .get("messageId")
501 .and_then(|value| value.as_str())
502 .map(str::to_string);
503 let content = data
504 .get("content")
505 .and_then(|value| value.as_str())
506 .unwrap_or_default()
507 .to_string();
508 if !content.is_empty() {
509 events.push(LogEventKind::AssistantMessage {
510 content,
511 message_id: message_id.clone(),
512 });
513 }
514 if let Some(tool_requests) = data.get("toolRequests").and_then(|value| value.as_array())
515 {
516 for request in tool_requests {
517 let name = request
518 .get("name")
519 .and_then(|value| value.as_str())
520 .unwrap_or_default();
521 events.push(LogEventKind::ToolCall {
522 tool_kind: Some(tool_kind_from_name(name)),
523 tool_name: name.to_string(),
524 tool_id: request
525 .get("toolCallId")
526 .and_then(|value| value.as_str())
527 .map(str::to_string),
528 input: request.get("arguments").cloned(),
529 });
530 }
531 }
532 }
533 "assistant.reasoning" => {
534 let content = data
535 .get("content")
536 .and_then(|value| value.as_str())
537 .unwrap_or_default()
538 .to_string();
539 if !content.is_empty() {
540 events.push(LogEventKind::Reasoning {
541 content,
542 message_id: data
543 .get("reasoningId")
544 .and_then(|value| value.as_str())
545 .map(str::to_string),
546 });
547 }
548 }
549 "tool.execution_start" => {
550 let name = data
551 .get("toolName")
552 .and_then(|value| value.as_str())
553 .unwrap_or_default();
554 events.push(LogEventKind::ToolCall {
555 tool_kind: Some(tool_kind_from_name(name)),
556 tool_name: name.to_string(),
557 tool_id: data
558 .get("toolCallId")
559 .and_then(|value| value.as_str())
560 .map(str::to_string),
561 input: data.get("arguments").cloned(),
562 });
563 }
564 "tool.execution_complete" => {
565 let name = data.get("toolName").and_then(|value| value.as_str());
566 events.push(LogEventKind::ToolResult {
567 tool_kind: name.map(tool_kind_from_name),
568 tool_name: name.map(str::to_string),
569 tool_id: data
570 .get("toolCallId")
571 .and_then(|value| value.as_str())
572 .map(str::to_string),
573 success: data.get("success").and_then(|value| value.as_bool()),
574 output: data
575 .get("result")
576 .and_then(|value| value.get("content"))
577 .and_then(|value| value.as_str())
578 .map(str::to_string),
579 error: data
580 .get("result")
581 .and_then(|value| value.get("error"))
582 .and_then(|value| value.as_str())
583 .map(str::to_string),
584 data: Some(data),
585 });
586 }
587 _ => events.push(LogEventKind::ProviderStatus {
588 message: format!("Copilot event: {event_type}"),
589 data: Some(data),
590 }),
591 }
592
593 Some(ParsedCopilotEvent {
594 provider_session_id,
595 model,
596 workspace_path,
597 events,
598 parse_failed: false,
599 })
600}
601
602fn copilot_session_state_dir() -> PathBuf {
603 session_state_dir()
604}
605
606fn read_copilot_workspace_path(session_dir: &Path) -> Option<String> {
607 let metadata_path = session_dir.join("vscode.metadata.json");
608 if let Ok(content) = std::fs::read_to_string(&metadata_path)
609 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
610 {
611 if let Some(path) = value
612 .get("cwd")
613 .or_else(|| value.get("workspacePath"))
614 .or_else(|| value.get("gitRoot"))
615 .and_then(|value| value.as_str())
616 {
617 return Some(path.to_string());
618 }
619 }
620 let workspace_yaml = session_dir.join("workspace.yaml");
621 if let Ok(content) = std::fs::read_to_string(workspace_yaml) {
622 for line in content.lines() {
623 let trimmed = line.trim();
624 if let Some(rest) = trimmed
625 .strip_prefix("cwd:")
626 .or_else(|| trimmed.strip_prefix("workspace:"))
627 .or_else(|| trimmed.strip_prefix("path:"))
628 {
629 return Some(rest.trim().trim_matches('"').to_string());
630 }
631 }
632 }
633 None
634}
635
636fn copilot_session_matches_workspace(session_dir: &Path, workspace: &str) -> bool {
637 if let Some(candidate) = read_copilot_workspace_path(session_dir) {
638 return candidate == workspace;
639 }
640
641 let events_path = session_dir.join("events.jsonl");
642 let file = match std::fs::File::open(events_path) {
643 Ok(file) => file,
644 Err(_) => return false,
645 };
646 let reader = BufReader::new(file);
647 for line in reader.lines().map_while(Result::ok).take(8) {
648 let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
649 continue;
650 };
651 let Some(data) = value.get("data") else {
652 continue;
653 };
654 let candidate = data
655 .get("context")
656 .and_then(|context| context.get("cwd").or_else(|| context.get("gitRoot")))
657 .and_then(|value| value.as_str());
658 if candidate == Some(workspace) {
659 return true;
660 }
661 }
662 false
663}
664
665fn system_time_from_utc(value: chrono::DateTime<chrono::Utc>) -> std::time::SystemTime {
666 std::time::SystemTime::UNIX_EPOCH
667 + std::time::Duration::from_secs(value.timestamp().max(0) as u64)
668}
669
670#[async_trait]
671impl Agent for Copilot {
672 fn name(&self) -> &str {
673 "copilot"
674 }
675
676 fn default_model() -> &'static str {
677 DEFAULT_MODEL
678 }
679
680 fn model_for_size(size: ModelSize) -> &'static str {
681 match size {
682 ModelSize::Small => "claude-haiku-4.5",
683 ModelSize::Medium => "claude-sonnet-4.6",
684 ModelSize::Large => "claude-opus-4.6",
685 }
686 }
687
688 fn available_models() -> &'static [&'static str] {
689 AVAILABLE_MODELS
690 }
691
692 crate::providers::common::impl_common_agent_setters!();
693
694 fn set_skip_permissions(&mut self, skip: bool) {
695 self.common.skip_permissions = skip;
696 }
697
698 crate::providers::common::impl_as_any!();
699
700 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
701 self.execute(false, prompt).await
702 }
703
704 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
705 self.execute(true, prompt).await?;
706 Ok(())
707 }
708
709 async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
710 let mut args = if let Some(session_id) = session_id {
711 vec!["--resume".to_string(), session_id.to_string()]
712 } else if last {
713 vec!["--continue".to_string()]
714 } else {
715 vec!["--resume".to_string()]
716 };
717
718 if self.common.skip_permissions {
719 args.push("--allow-all".to_string());
720 }
721
722 if !self.common.model.is_empty() {
723 args.extend(["--model".to_string(), self.common.model.clone()]);
724 }
725
726 for dir in &self.common.add_dirs {
727 args.extend(["--add-dir".to_string(), dir.clone()]);
728 }
729
730 let mut cmd = self.make_command(args);
731 CommonAgentState::run_interactive_command_with_hook(
732 &mut cmd,
733 "Copilot",
734 self.common.on_spawn_hook.as_ref(),
735 )
736 .await
737 }
738
739 async fn cleanup(&self) -> Result<()> {
740 log::debug!("Cleaning up Copilot agent resources");
741 let base = self.common.get_base_path();
742 let instructions_file = base.join(".github/instructions/agent/agent.instructions.md");
743
744 if instructions_file.exists() {
745 fs::remove_file(&instructions_file).await?;
746 }
747
748 let agent_dir = base.join(".github/instructions/agent");
750 if agent_dir.exists()
751 && fs::read_dir(&agent_dir)
752 .await?
753 .next_entry()
754 .await?
755 .is_none()
756 {
757 fs::remove_dir(&agent_dir).await?;
758 }
759
760 let instructions_dir = base.join(".github/instructions");
761 if instructions_dir.exists()
762 && fs::read_dir(&instructions_dir)
763 .await?
764 .next_entry()
765 .await?
766 .is_none()
767 {
768 fs::remove_dir(&instructions_dir).await?;
769 }
770
771 let github_dir = base.join(".github");
772 if github_dir.exists()
773 && fs::read_dir(&github_dir)
774 .await?
775 .next_entry()
776 .await?
777 .is_none()
778 {
779 fs::remove_dir(&github_dir).await?;
780 }
781
782 Ok(())
783 }
784}