Skip to main content

zag_agent/providers/
copilot.rs

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