use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, warn};
use super::claude_cli::{build_prompt, parse_inner_response};
use crate::ai::{AiProvider, AiRequest, AiResponse, AiUsage, ProviderCapabilities};
pub struct CopilotCliProvider {
pub model: String,
}
pub fn build_copilot_args(model: &str) -> Vec<String> {
vec![
"--output-format".to_string(),
"json".to_string(),
"-s".to_string(),
"--model".to_string(),
model.to_string(),
"--disable-builtin-mcps".to_string(),
"--no-custom-instructions".to_string(),
"--allow-all-tools".to_string(),
]
}
#[async_trait]
impl AiProvider for CopilotCliProvider {
async fn generate_content(&self, request: AiRequest) -> Result<AiResponse> {
let prompt = build_prompt(&request);
debug!("copilot-cli prompt length: {} chars", prompt.len());
let mut child = Command::new("copilot")
.args(build_copilot_args(&self.model))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn copilot CLI: {}. Is it installed?", e))?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(prompt.as_bytes())
.await
.map_err(|e| anyhow::anyhow!("copilot CLI stdin write failed: {}", e))?;
drop(stdin);
}
let output = timeout(Duration::from_secs(600), child.wait_with_output())
.await
.map_err(|_| anyhow::anyhow!("copilot CLI timed out after 10 minutes"))?
.map_err(|e| anyhow::anyhow!("copilot CLI wait error: {}", e))?;
if !output.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.trim().is_empty() {
debug!("[copilot-cli stderr] {}", line);
}
}
}
let raw = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"copilot CLI exited with {}: {}",
output.status,
stderr.trim()
);
}
parse_jsonl_events(&raw)
}
fn estimate_tokens(&self, request: &AiRequest) -> usize {
let chars: usize = request
.messages
.iter()
.filter_map(|m| m.content.as_ref())
.map(|c| c.len())
.sum();
chars / 4
}
fn get_capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
model_name: self.model.clone(),
context_window_size: 200_000,
}
}
}
pub fn parse_jsonl_events(raw: &str) -> Result<AiResponse> {
let mut content: Option<String> = None;
let mut usage: Option<AiUsage> = None;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let event: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
match event["type"].as_str() {
Some("assistant.message") => {
if let Some(text) = event["data"]["content"].as_str() {
content = Some(text.to_string());
}
if usage.is_none()
&& let Some(out_tokens) = event["data"]["outputTokens"].as_u64()
{
usage = Some(AiUsage {
prompt_tokens: 0,
completion_tokens: out_tokens as usize,
total_tokens: out_tokens as usize,
cached_tokens: None,
});
}
}
Some("result") => {
let u = &event["data"]["usage"];
if !u.is_null() {
let api_duration_ms = u["totalApiDurationMs"].as_u64().unwrap_or(0);
debug!("copilot-cli session api duration: {}ms", api_duration_ms);
}
if let Some(exit_code) = event["data"]["exitCode"].as_i64()
&& exit_code != 0
{
warn!("copilot-cli session exited with code {}", exit_code);
}
}
_ => {}
}
}
if let Some(text) = &content {
return parse_inner_response(text, usage);
}
warn!("copilot-cli: no assistant.message events in output");
Ok(AiResponse {
content: None,
thought: None,
thought_signature: None,
tool_calls: None,
usage,
truncated: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_simple_response() {
let events = [
json!({"type": "assistant.message", "data": {"content": "2 + 2 = 4", "outputTokens": 10}}),
json!({"type": "assistant.turn_end", "data": {"turnId": "0"}}),
json!({"type": "result", "data": {"exitCode": 0, "usage": {"premiumRequests": 1, "totalApiDurationMs": 3000}}}),
];
let raw: String = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let resp = parse_jsonl_events(&raw).unwrap();
assert_eq!(resp.content.as_deref(), Some("2 + 2 = 4"));
let usage = resp.usage.unwrap();
assert_eq!(usage.completion_tokens, 10);
}
#[test]
fn test_parse_tool_call_response() {
let tool_json = json!({
"tool_calls": [{
"id": "c1",
"function_name": "git_log",
"arguments": {"n": 5}
}]
});
let events = [
json!({"type": "assistant.message", "data": {"content": serde_json::to_string(&tool_json).unwrap(), "outputTokens": 25}}),
json!({"type": "result", "data": {"exitCode": 0, "usage": {}}}),
];
let raw: String = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let resp = parse_jsonl_events(&raw).unwrap();
assert!(resp.content.is_none());
let calls = resp.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function_name, "git_log");
}
#[test]
fn test_parse_empty_output() {
let resp = parse_jsonl_events("").unwrap();
assert!(resp.content.is_none());
assert!(resp.tool_calls.is_none());
}
#[test]
fn test_parse_ignores_ephemeral_events() {
let events = [
json!({"type": "session.mcp_server_status_changed", "data": {}, "ephemeral": true}),
json!({"type": "assistant.message_delta", "data": {"deltaContent": "hel"}, "ephemeral": true}),
json!({"type": "assistant.message", "data": {"content": "hello world", "outputTokens": 5}}),
json!({"type": "result", "data": {"exitCode": 0, "usage": {}}}),
];
let raw: String = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let resp = parse_jsonl_events(&raw).unwrap();
assert_eq!(resp.content.as_deref(), Some("hello world"));
}
#[test]
fn test_parse_non_zero_exit() {
let events = [json!({"type": "result", "data": {"exitCode": 1, "usage": {}}})];
let raw: String = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let resp = parse_jsonl_events(&raw).unwrap();
assert!(resp.content.is_none());
}
#[test]
fn test_parse_malformed_lines_skipped() {
let raw = "not json\n\
{\"type\": \"assistant.message\", \"data\": {\"content\": \"ok\", \"outputTokens\": 3}}\n\
also not json\n";
let resp = parse_jsonl_events(raw).unwrap();
assert_eq!(resp.content.as_deref(), Some("ok"));
}
#[test]
fn test_parse_json_content_response() {
let inner = json!({"concerns": [{"title": "Memory leak", "severity": "high"}]});
let events = [
json!({"type": "assistant.message", "data": {"content": serde_json::to_string(&inner).unwrap(), "outputTokens": 50}}),
json!({"type": "result", "data": {"exitCode": 0, "usage": {}}}),
];
let raw: String = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let resp = parse_jsonl_events(&raw).unwrap();
assert!(resp.content.is_some());
let content = resp.content.unwrap();
assert!(content.contains("Memory leak"));
}
#[test]
fn test_command_does_not_pass_prompt_via_argv() {
let args = build_copilot_args("claude-sonnet-4.5");
assert!(
!args.contains(&"-p".to_string()),
"regression: prompt should not be passed via -p argv (E2BIG risk)"
);
assert!(args.contains(&"--output-format".to_string()));
assert!(args.contains(&"--disable-builtin-mcps".to_string()));
assert!(args.contains(&"--no-custom-instructions".to_string()));
assert!(args.contains(&"--allow-all-tools".to_string()));
assert!(args.contains(&"-s".to_string()));
assert!(args.iter().any(|a| a == "claude-sonnet-4.5"));
}
#[test]
#[ignore = "requires copilot CLI installed and authenticated"]
fn test_large_prompt_via_stdin_no_e2big() {
use std::io::Write;
use std::process::{Command as StdCommand, Stdio as StdStdio};
let large_prompt = "x".repeat(200_000);
let mut child = StdCommand::new("copilot")
.args(build_copilot_args("claude-sonnet-4.5"))
.stdin(StdStdio::piped())
.stdout(StdStdio::piped())
.stderr(StdStdio::piped())
.spawn()
.expect("failed to spawn copilot — is it installed?");
child
.stdin
.take()
.unwrap()
.write_all(large_prompt.as_bytes())
.expect("stdin write failed");
let _ = child.kill();
let _ = child.wait();
}
}