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