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