Skip to main content

zag_agent/providers/
copilot.rs

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