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