Skip to main content

zag_agent/providers/
copilot.rs

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