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