Skip to main content

sr_ai/ai/
copilot.rs

1use super::{AiBackend, AiEvent, AiRequest, AiResponse};
2use anyhow::{Context, Result};
3use async_trait::async_trait;
4use tokio::process::Command;
5use tokio::sync::mpsc;
6
7const DEFAULT_MODEL: &str = "gpt-4.1";
8
9/// Read-only tools the Copilot agent is allowed to use.
10/// Uses gh copilot's `--allow-tool` syntax: `shell(cmd:subcommand)`.
11/// No mutating git commands (add, commit, push, reset, clean, rm, etc.).
12const ALLOWED_TOOLS: &[&str] = &[
13    "shell(git:diff)",
14    "shell(git:log)",
15    "shell(git:show)",
16    "shell(git:status)",
17    "shell(git:ls-files)",
18    "shell(git:rev-parse)",
19    "shell(git:branch)",
20    "shell(git:cat-file)",
21    "shell(git:rev-list)",
22    "shell(git:shortlog)",
23    "shell(git:blame)",
24];
25
26pub struct CopilotBackend {
27    model: Option<String>,
28    debug: bool,
29}
30
31impl CopilotBackend {
32    pub fn new(model: Option<String>, debug: bool) -> Self {
33        Self { model, debug }
34    }
35}
36
37/// Build the system prompt, embedding the JSON schema when present.
38fn build_system_prompt(base: &str, json_schema: Option<&str>) -> String {
39    match json_schema {
40        Some(schema) => format!(
41            "{base}\n\n\
42             You MUST respond with valid JSON matching this schema:\n\
43             ```json\n{schema}\n```\n\n\
44             Respond ONLY with the JSON object, no markdown fences, no explanation."
45        ),
46        None => base.to_string(),
47    }
48}
49
50#[async_trait]
51impl AiBackend for CopilotBackend {
52    fn name(&self) -> &str {
53        "copilot"
54    }
55
56    async fn is_available(&self) -> bool {
57        Command::new("gh")
58            .args(["copilot", "--version"])
59            .output()
60            .await
61            .is_ok_and(|o| o.status.success())
62    }
63
64    async fn request(
65        &self,
66        req: &AiRequest,
67        _events: Option<mpsc::UnboundedSender<AiEvent>>,
68    ) -> Result<AiResponse> {
69        let model = self.model.as_deref().unwrap_or(DEFAULT_MODEL);
70        let system = build_system_prompt(&req.system_prompt, req.json_schema.as_deref());
71
72        // Copilot CLI has no --system-prompt flag; embed system prompt in the user prompt.
73        let combined_prompt = format!("{system}\n\n{}", req.user_prompt);
74
75        let mut cmd = Command::new("gh");
76        cmd.current_dir(&req.working_dir)
77            .arg("copilot")
78            .arg("-p")
79            .arg(&combined_prompt)
80            .arg("-s")
81            .arg("--model")
82            .arg(model);
83
84        // Sandbox: only allow read-only git subcommands.
85        for tool in ALLOWED_TOOLS {
86            cmd.arg("--allow-tool").arg(tool);
87        }
88
89        cmd.arg("--no-custom-instructions").arg("--autopilot");
90
91        if self.debug {
92            eprintln!("[DEBUG] Calling gh copilot (model={model})");
93        }
94
95        let output = cmd.output().await.context("failed to run gh copilot")?;
96
97        let raw = String::from_utf8_lossy(&output.stdout).to_string();
98        let stderr = String::from_utf8_lossy(&output.stderr);
99
100        if self.debug {
101            eprintln!("[DEBUG] gh copilot exit code: {}", output.status);
102            eprintln!(
103                "[DEBUG] Raw response (first 500 chars): {}",
104                &raw[..raw.len().min(500)]
105            );
106            if !stderr.is_empty() {
107                eprintln!("[DEBUG] Stderr: {stderr}");
108            }
109        }
110
111        if !output.status.success() {
112            anyhow::bail!(crate::error::SrAiError::AiBackend(format!(
113                "gh copilot failed (exit {}): {}",
114                output.status,
115                stderr.trim()
116            )));
117        }
118
119        // Extract JSON from response (may be wrapped in markdown fences)
120        let text = extract_json(&raw).unwrap_or(raw);
121
122        Ok(AiResponse { text, usage: None })
123    }
124}
125
126/// Extract JSON from a response that may contain markdown code fences.
127pub(crate) fn extract_json(raw: &str) -> Option<String> {
128    let trimmed = raw.trim();
129
130    // Try direct parse first
131    if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
132        return Some(trimmed.to_string());
133    }
134
135    // Try extracting from ```json ... ``` fences
136    if let Some(start) = trimmed.find("```json") {
137        let after = &trimmed[start + 7..];
138        if let Some(end) = after.find("```") {
139            let json_str = after[..end].trim();
140            if serde_json::from_str::<serde_json::Value>(json_str).is_ok() {
141                return Some(json_str.to_string());
142            }
143        }
144    }
145
146    // Try extracting from ``` ... ``` fences
147    if let Some(start) = trimmed.find("```") {
148        let after = &trimmed[start + 3..];
149        let after = if let Some(nl) = after.find('\n') {
150            &after[nl + 1..]
151        } else {
152            after
153        };
154        if let Some(end) = after.find("```") {
155            let json_str = after[..end].trim();
156            if serde_json::from_str::<serde_json::Value>(json_str).is_ok() {
157                return Some(json_str.to_string());
158            }
159        }
160    }
161
162    // Try finding first { ... } or [ ... ]
163    for (open, close) in [("{", "}"), ("[", "]")] {
164        if let Some(start) = trimmed.find(open)
165            && let Some(end) = trimmed.rfind(close)
166            && end > start
167        {
168            let candidate = &trimmed[start..=end];
169            if serde_json::from_str::<serde_json::Value>(candidate).is_ok() {
170                return Some(candidate.to_string());
171            }
172        }
173    }
174
175    None
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    // --- extract_json tests ---
183
184    #[test]
185    fn extract_direct_json() {
186        let input = r#"{"commits": []}"#;
187        assert_eq!(extract_json(input), Some(input.to_string()));
188    }
189
190    #[test]
191    fn extract_from_json_fences() {
192        let input = "Here is the plan:\n```json\n{\"commits\": []}\n```\nDone.";
193        assert_eq!(extract_json(input), Some(r#"{"commits": []}"#.to_string()));
194    }
195
196    #[test]
197    fn extract_from_plain_fences() {
198        let input = "Result:\n```\n{\"commits\": [{\"order\": 1}]}\n```";
199        assert_eq!(
200            extract_json(input),
201            Some(r#"{"commits": [{"order": 1}]}"#.to_string())
202        );
203    }
204
205    #[test]
206    fn extract_from_surrounding_text() {
207        let input = "The result is {\"commits\": []} and that's it.";
208        assert_eq!(extract_json(input), Some(r#"{"commits": []}"#.to_string()));
209    }
210
211    #[test]
212    fn extract_array_json() {
213        let input = "Here: [1, 2, 3] done";
214        assert_eq!(extract_json(input), Some("[1, 2, 3]".to_string()));
215    }
216
217    #[test]
218    fn extract_returns_none_for_invalid() {
219        assert_eq!(extract_json("no json here"), None);
220        assert_eq!(extract_json(""), None);
221        assert_eq!(extract_json("{not valid json}"), None);
222    }
223
224    #[test]
225    fn extract_with_whitespace() {
226        let input = "  \n  {\"key\": \"value\"}  \n  ";
227        assert_eq!(extract_json(input), Some(r#"{"key": "value"}"#.to_string()));
228    }
229
230    // --- build_system_prompt tests ---
231
232    #[test]
233    fn system_prompt_without_schema() {
234        let result = build_system_prompt("You are a commit assistant.", None);
235        assert_eq!(result, "You are a commit assistant.");
236    }
237
238    #[test]
239    fn system_prompt_with_schema() {
240        let schema = r#"{"type": "object"}"#;
241        let result = build_system_prompt("Base prompt.", Some(schema));
242        assert!(result.starts_with("Base prompt."));
243        assert!(result.contains("You MUST respond with valid JSON"));
244        assert!(result.contains(schema));
245        assert!(result.contains("no markdown fences"));
246    }
247
248    // --- backend metadata tests ---
249
250    #[test]
251    fn backend_name() {
252        let backend = CopilotBackend::new(None, false);
253        assert_eq!(backend.name(), "copilot");
254    }
255
256    #[test]
257    fn default_model_constant() {
258        assert_eq!(DEFAULT_MODEL, "gpt-4.1");
259    }
260
261    // --- build_system_prompt preserves base content ---
262
263    #[test]
264    fn system_prompt_preserves_multiline_base() {
265        let base = "Line one.\nLine two.\nLine three.";
266        let result = build_system_prompt(base, None);
267        assert_eq!(result, base);
268
269        let with_schema = build_system_prompt(base, Some("{}"));
270        assert!(with_schema.starts_with(base));
271    }
272}