Skip to main content

vtcode_llm/providers/
copilot.rs

1use std::borrow::Cow;
2use std::fmt::Write;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::Context;
7use async_stream::stream;
8use async_trait::async_trait;
9use tokio::sync::Mutex;
10use vtcode_config::auth::CopilotAuthConfig;
11use vtcode_config::constants::models::copilot as copilot_models;
12use vtcode_config::models::supported_models_for_provider;
13
14use crate::copilot::{
15    COPILOT_MODEL_ID, COPILOT_PROVIDER_KEY, CopilotAcpClient, CopilotPromptSessionFuture, CopilotRuntimeRequest,
16    CopilotToolCallFailure, CopilotToolCallResponse, PromptSession, PromptSessionCancelHandle, PromptUpdate,
17    probe_auth_status,
18};
19use crate::provider::{
20    LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message, MessageRole, ToolDefinition,
21};
22use crate::providers::common::validate_request_common;
23
24pub struct CopilotProvider {
25    model: String,
26    auth_config: CopilotAuthConfig,
27    workspace_root: PathBuf,
28    client: Mutex<Option<CachedCopilotClient>>,
29}
30
31struct CachedCopilotClient {
32    raw_model: Option<String>,
33    tool_signature: String,
34    client: Arc<CopilotAcpClient>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct ResolvedCopilotModel {
39    request_model: String,
40    raw_model: Option<String>,
41}
42
43impl CopilotProvider {
44    pub fn from_config(
45        model: Option<String>,
46        auth_config: Option<CopilotAuthConfig>,
47        workspace_root: Option<PathBuf>,
48    ) -> Self {
49        Self {
50            model: model.unwrap_or_else(|| COPILOT_MODEL_ID.to_string()),
51            auth_config: auth_config.unwrap_or_default(),
52            workspace_root: workspace_root
53                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
54            client: Mutex::new(None),
55        }
56    }
57
58    async fn client(
59        &self,
60        model: &ResolvedCopilotModel,
61        request: &LLMRequest,
62        tools: &[ToolDefinition],
63    ) -> Result<Arc<CopilotAcpClient>, LLMError> {
64        let tools_disabled = matches!(request.tool_choice, Some(crate::provider::ToolChoice::None));
65        let tool_signature = copilot_tool_signature(tools, tools_disabled);
66        if let Some(client) = self.cached_client(model, &tool_signature).await {
67            return Ok(client);
68        }
69
70        let auth_status = probe_auth_status(&self.auth_config, Some(&self.workspace_root)).await;
71        if !auth_status.is_authenticated() {
72            return Err(LLMError::Authentication {
73                message: auth_status
74                    .message
75                    .unwrap_or_else(|| "GitHub Copilot is not authenticated. Run `vtcode login copilot`.".to_string()),
76                metadata: None,
77            });
78        }
79
80        let llm_tools: Vec<ToolDefinition> = tools.to_vec();
81
82        let auth_config = self.request_auth_config(request);
83        let created = Arc::new(
84            CopilotAcpClient::connect(&auth_config, &self.workspace_root, model.raw_model.as_deref(), &llm_tools)
85                .await
86                .map_err(map_copilot_error)?,
87        );
88
89        let mut client = self.client.lock().await;
90        if let Some(existing) = client.as_ref()
91            && existing.raw_model.as_deref() == model.raw_model.as_deref()
92            && existing.tool_signature == tool_signature
93        {
94            return Ok(existing.client.clone());
95        }
96        *client = Some(CachedCopilotClient {
97            raw_model: model.raw_model.clone(),
98            tool_signature,
99            client: created.clone(),
100        });
101        Ok(created)
102    }
103
104    async fn cached_client(&self, model: &ResolvedCopilotModel, tool_signature: &str) -> Option<Arc<CopilotAcpClient>> {
105        let client = self.client.lock().await;
106        client
107            .as_ref()
108            .filter(|cached| {
109                cached.raw_model.as_deref() == model.raw_model.as_deref() && cached.tool_signature == tool_signature
110            })
111            .map(|cached| cached.client.clone())
112    }
113
114    fn request_auth_config(&self, request: &LLMRequest) -> Cow<'_, CopilotAuthConfig> {
115        if matches!(request.tool_choice, Some(crate::provider::ToolChoice::None)) {
116            let mut config = self.auth_config.clone();
117            config.available_tools.clear();
118            Cow::Owned(config)
119        } else {
120            Cow::Borrowed(&self.auth_config)
121        }
122    }
123
124    fn resolve_model(&self, request: &LLMRequest) -> Result<ResolvedCopilotModel, LLMError> {
125        let requested = if request.model.trim().is_empty() {
126            self.model.trim()
127        } else {
128            request.model.trim()
129        };
130
131        let raw_model = normalize_copilot_model_id(requested).ok_or_else(|| {
132            invalid_request(&format!(
133                "Unsupported GitHub Copilot model: {requested}. Choose `copilot-auto` or a live GitHub Copilot model id from the picker."
134            ))
135        })?;
136
137        Ok(ResolvedCopilotModel { request_model: requested.to_string(), raw_model })
138    }
139
140    fn build_transcript(&self, request: &LLMRequest) -> Result<String, LLMError> {
141        let mut transcript = String::new();
142
143        if let Some(system_prompt) = request.system_prompt.as_ref() {
144            append_block(&mut transcript, "System", system_prompt);
145        }
146
147        for message in request.messages.iter() {
148            let label = match message.role {
149                MessageRole::System => "System",
150                MessageRole::User => "User",
151                MessageRole::Assistant => "Assistant",
152                MessageRole::Tool => "Tool",
153            };
154            append_block(&mut transcript, label, &render_message_for_copilot(message));
155        }
156
157        Ok(transcript)
158    }
159
160    async fn stream_from_session(
161        &self,
162        model: ResolvedCopilotModel,
163        prompt_session: PromptSession,
164    ) -> Result<LLMStream, LLMError> {
165        struct PromptCancellationGuard {
166            cancel_handle: Option<PromptSessionCancelHandle>,
167        }
168
169        impl PromptCancellationGuard {
170            fn new(cancel_handle: PromptSessionCancelHandle) -> Self {
171                Self { cancel_handle: Some(cancel_handle) }
172            }
173
174            fn disarm(&mut self) {
175                self.cancel_handle = None;
176            }
177        }
178
179        impl Drop for PromptCancellationGuard {
180            fn drop(&mut self) {
181                if let Some(cancel_handle) = self.cancel_handle.take() {
182                    cancel_handle.cancel();
183                }
184            }
185        }
186
187        let (mut updates, mut runtime_requests, completion, cancel_handle) = prompt_session.into_parts();
188        let stream = stream! {
189            let mut cancellation_guard = PromptCancellationGuard::new(cancel_handle);
190            let completion = completion;
191            tokio::pin!(completion);
192
193            let mut content = String::new();
194            let mut reasoning = String::new();
195
196            loop {
197                tokio::select! {
198                    update = updates.recv() => {
199                        match update {
200                            Some(PromptUpdate::Text(delta)) => {
201                                content.push_str(&delta);
202                                yield Ok(LLMStreamEvent::Token { delta });
203                            }
204                            Some(PromptUpdate::Thought(delta)) => {
205                                let delta = if !reasoning.is_empty()
206                                    && !reasoning.ends_with('\n')
207                                    && !delta.starts_with('\n')
208                                {
209                                    format!("\n{delta}")
210                                } else {
211                                    delta
212                                };
213                                reasoning.push_str(&delta);
214                                yield Ok(LLMStreamEvent::Reasoning { delta });
215                            }
216                            None => {}
217                        }
218                    }
219                    runtime_request = runtime_requests.recv() => {
220                        if let Some(runtime_request) = runtime_request {
221                            let response = match runtime_request {
222                                CopilotRuntimeRequest::Permission(request) => {
223                                    request.respond(crate::copilot::CopilotPermissionDecision::DeniedNoApprovalRule)
224                                }
225                                CopilotRuntimeRequest::ToolCall(request) => {
226                                    let tool_name = request.request.tool_name.clone();
227                                    request.respond(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
228                                        text_result_for_llm: format!(
229                                            "GitHub Copilot tool execution is not available in this runtime. Tool `{tool_name}` was not executed."
230                                        ),
231                                        error: format!(
232                                            "tool '{tool_name}' cannot be executed outside the VT Code agent runloop session"
233                                        ),
234                                    }))
235                                }
236                                CopilotRuntimeRequest::TerminalCreate(_)
237                                | CopilotRuntimeRequest::TerminalOutput(_)
238                                | CopilotRuntimeRequest::TerminalRelease(_)
239                                | CopilotRuntimeRequest::TerminalKill(_)
240                                | CopilotRuntimeRequest::TerminalWaitForExit(_) => {
241                                    continue;
242                                }
243                                CopilotRuntimeRequest::ObservedToolCall(_) => {
244                                    continue;
245                                }
246                                CopilotRuntimeRequest::CompatibilityNotice(_) => {
247                                    continue;
248                                }
249                            };
250                            if let Err(err) = response {
251                                yield Err(map_copilot_error(err));
252                                break;
253                            }
254                        }
255                    }
256                    result = &mut completion => {
257                        let completion = match result.context("copilot acp prompt task join failed") {
258                            Ok(completion) => completion,
259                            Err(err) => {
260                                yield Err(map_copilot_error(err));
261                                break;
262                            }
263                        };
264                        let completion = match completion {
265                            Ok(completion) => completion,
266                            Err(err) => {
267                                yield Err(map_copilot_error(err));
268                                break;
269                            }
270                        };
271                        let finish_reason = map_stop_reason(&completion.stop_reason);
272                        while let Ok(update) = updates.try_recv() {
273                            match update {
274                                PromptUpdate::Text(delta) => {
275                                    content.push_str(&delta);
276                                    yield Ok(LLMStreamEvent::Token { delta });
277                                }
278                                PromptUpdate::Thought(delta) => {
279                                    let delta = if !reasoning.is_empty()
280                                        && !reasoning.ends_with('\n')
281                                        && !delta.starts_with('\n')
282                                    {
283                                        format!("\n{delta}")
284                                    } else {
285                                        delta
286                                    };
287                                    reasoning.push_str(&delta);
288                                    yield Ok(LLMStreamEvent::Reasoning { delta });
289                                }
290                            }
291                        }
292
293                        let mut response =
294                            LLMResponse::new(model.request_model.clone(), content.clone());
295                        response.finish_reason = finish_reason;
296                        if !reasoning.is_empty() {
297                            response.reasoning = Some(reasoning.clone());
298                        }
299                        cancellation_guard.disarm();
300                        yield Ok(LLMStreamEvent::Completed {
301                            response: Box::new(response),
302                        });
303                        break;
304                    }
305                }
306            }
307        };
308
309        Ok(Box::pin(stream))
310    }
311
312    async fn start_prompt_session_impl(
313        &self,
314        request: LLMRequest,
315        tools: &[ToolDefinition],
316    ) -> Result<PromptSession, LLMError> {
317        self.validate_request(&request)?;
318        let model = self.resolve_model(&request)?;
319        let transcript = self.build_transcript(&request)?;
320        let client = self.client(&model, &request, copilot_request_tools(&request, tools)).await?;
321        client.start_prompt(transcript).await.map_err(map_copilot_error)
322    }
323}
324
325#[async_trait]
326impl LLMProvider for CopilotProvider {
327    fn name(&self) -> &str {
328        COPILOT_PROVIDER_KEY
329    }
330
331    fn supports_streaming(&self) -> bool {
332        true
333    }
334
335    fn supports_non_streaming(&self, _model: &str) -> bool {
336        false
337    }
338
339    fn supports_reasoning(&self, _model: &str) -> bool {
340        true
341    }
342
343    fn supports_tools(&self, _model: &str) -> bool {
344        true
345    }
346
347    fn supports_structured_output(&self, _model: &str) -> bool {
348        false
349    }
350
351    fn supports_vision(&self, _model: &str) -> bool {
352        false
353    }
354
355    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
356        let model = self.resolve_model(&request)?;
357        let mut stream = self.stream(request).await?;
358        let mut content = String::new();
359        let mut reasoning = String::new();
360        let mut completed = None;
361
362        use futures::StreamExt;
363        while let Some(event) = stream.next().await {
364            match event? {
365                LLMStreamEvent::Token { delta } => content.push_str(&delta),
366                LLMStreamEvent::Reasoning { delta } => reasoning.push_str(&delta),
367                LLMStreamEvent::ReasoningSignature { .. } => {}
368                LLMStreamEvent::ReasoningStage { .. } => {}
369                LLMStreamEvent::Completed { response } => {
370                    completed = Some(*response);
371                    break;
372                }
373            }
374        }
375
376        Ok(completed.unwrap_or_else(|| {
377            let mut response = LLMResponse::new(model.request_model.clone(), content);
378            if !reasoning.is_empty() {
379                response.reasoning = Some(reasoning);
380            }
381            response
382        }))
383    }
384
385    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
386        self.validate_request(&request)?;
387        let model = self.resolve_model(&request)?;
388        let transcript = self.build_transcript(&request)?;
389        let client = self.client(&model, &request, &[]).await?;
390        let prompt_session = client.start_prompt(transcript).await.map_err(map_copilot_error)?;
391        self.stream_from_session(model, prompt_session).await
392    }
393
394    fn start_copilot_prompt_session<'a>(
395        &'a self,
396        request: LLMRequest,
397        tools: &'a [ToolDefinition],
398    ) -> Option<CopilotPromptSessionFuture<'a>> {
399        Some(Box::pin(async move { self.start_prompt_session_impl(request, tools).await }))
400    }
401
402    fn supported_models(&self) -> Vec<String> {
403        supported_models_for_provider(COPILOT_PROVIDER_KEY)
404            .map(|models| models.iter().map(|model| (*model).to_string()).collect())
405            .unwrap_or_else(|| {
406                copilot_models::SUPPORTED_MODELS
407                    .iter()
408                    .map(|model| (*model).to_string())
409                    .collect()
410            })
411    }
412
413    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
414        validate_request_common(request, "GitHub Copilot", COPILOT_PROVIDER_KEY, None)?;
415
416        if request.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
417            return Err(invalid_request("GitHub Copilot in VT Code v1 does not accept VT Code tool definitions."));
418        }
419
420        if request.output_format.is_some() {
421            return Err(invalid_request("GitHub Copilot in VT Code v1 does not support structured output."));
422        }
423
424        Ok(())
425    }
426}
427
428fn append_block(buffer: &mut String, label: &str, text: &str) {
429    if text.trim().is_empty() {
430        return;
431    }
432    if !buffer.is_empty() {
433        buffer.push_str("\n\n");
434    }
435    buffer.push_str(label);
436    buffer.push_str(":\n");
437    buffer.push_str(text.trim());
438}
439
440fn render_message_for_copilot(message: &Message) -> String {
441    let mut sections = Vec::new();
442    let text = message.content.as_text();
443    let trimmed = text.trim();
444    if !trimmed.is_empty() {
445        sections.push(trimmed.to_string());
446    }
447
448    if let Some(tool_calls) = message.tool_calls.as_ref().filter(|calls| !calls.is_empty()) {
449        let mut tool_history = String::from("[VT Code tool call history]");
450        for call in tool_calls {
451            let (tool_name, args) = call
452                .function
453                .as_ref()
454                .map(|function| (function.name.as_str(), function.arguments.trim()))
455                .unwrap_or((call.call_type.as_str(), ""));
456            if args.is_empty() {
457                let _ = write!(tool_history, "\n- {tool_name} id={}", call.id);
458            } else {
459                let _ = write!(tool_history, "\n- {tool_name} id={} args={args}", call.id);
460            }
461        }
462        sections.push(tool_history);
463    }
464
465    if message.role == MessageRole::Tool {
466        let mut tool_result = String::from("[VT Code tool result]");
467        if let Some(tool_call_id) = message.tool_call_id.as_deref() {
468            let _ = write!(tool_result, "\n- tool_call_id: {tool_call_id}");
469        }
470        if let Some(origin_tool) = message.origin_tool.as_deref() {
471            let _ = write!(tool_result, "\n- tool: {origin_tool}");
472        }
473        sections.insert(0, tool_result);
474    }
475
476    let (image_count, file_count) = count_non_text_parts(message);
477    if image_count > 0 {
478        sections.push(format!(
479            "[VT Code omitted {image_count} image input{} because GitHub Copilot v1 only accepts text input.]",
480            plural_suffix(image_count)
481        ));
482    }
483    if file_count > 0 {
484        sections.push(format!(
485            "[VT Code omitted {file_count} file attachment{} because GitHub Copilot v1 only accepts text input.]",
486            plural_suffix(file_count)
487        ));
488    }
489
490    sections.join("\n\n")
491}
492
493fn count_non_text_parts(message: &Message) -> (usize, usize) {
494    match &message.content {
495        crate::provider::MessageContent::Text(_) => (0, 0),
496        crate::provider::MessageContent::Parts(parts) => {
497            let image_count = parts.iter().filter(|part| part.is_image()).count();
498            let file_count = parts.iter().filter(|part| part.is_file()).count();
499            (image_count, file_count)
500        }
501    }
502}
503
504fn plural_suffix(count: usize) -> &'static str {
505    if count == 1 { "" } else { "s" }
506}
507
508fn invalid_request(message: &str) -> LLMError {
509    LLMError::InvalidRequest { message: message.to_string(), metadata: None }
510}
511
512fn map_copilot_error(error: anyhow::Error) -> LLMError {
513    let message = error.to_string();
514    if message.contains("rpc error -32001") || message.contains("Authentication required") {
515        return LLMError::Authentication {
516            message: "GitHub Copilot authentication is required. Run `vtcode login copilot`.".to_string(),
517            metadata: None,
518        };
519    }
520
521    LLMError::Provider { message, metadata: None }
522}
523
524fn map_stop_reason(stop_reason: &str) -> crate::provider::FinishReason {
525    match stop_reason {
526        "end_turn" => crate::provider::FinishReason::Stop,
527        "max_tokens" => crate::provider::FinishReason::Length,
528        "refusal" => crate::provider::FinishReason::Refusal,
529        "cancelled" => crate::provider::FinishReason::Error("cancelled".to_string()),
530        other => crate::provider::FinishReason::Error(other.to_string()),
531    }
532}
533
534fn copilot_request_tools<'a>(request: &LLMRequest, tools: &'a [ToolDefinition]) -> &'a [ToolDefinition] {
535    if matches!(request.tool_choice, Some(crate::provider::ToolChoice::None)) {
536        &[]
537    } else {
538        tools
539    }
540}
541
542fn copilot_tool_signature(tools: &[ToolDefinition], tools_disabled: bool) -> String {
543    let mut signature_parts = tools
544        .iter()
545        .filter_map(|tool| {
546            let function = tool.function.as_ref()?;
547            Some(format!("{}:{}", function.name, serde_json::to_string(&function.parameters).ok()?))
548        })
549        .collect::<Vec<_>>();
550    signature_parts.sort_unstable();
551    format!("{}:{}", if tools_disabled { "none" } else { "enabled" }, signature_parts.join("|"))
552}
553
554fn normalize_copilot_model_id(model: &str) -> Option<Option<String>> {
555    let trimmed = model.trim();
556    if trimmed.is_empty() {
557        return None;
558    }
559
560    match trimmed {
561        copilot_models::AUTO => Some(None),
562        copilot_models::GPT_5_CODEX => Some(Some("gpt-5.2-codex".to_string())),
563        copilot_models::GPT_5_1_CODEX_MAX => Some(Some("gpt-5.1-codex-max".to_string())),
564        copilot_models::GPT_5_6_SOL => Some(Some("gpt-5.6-sol".to_string())),
565        copilot_models::GPT_5_6_LUNA => Some(Some("gpt-5.6-luna".to_string())),
566        copilot_models::CLAUDE_SONNET_5 => Some(Some("claude-sonnet-4.6".to_string())),
567        _ if trimmed.contains(char::is_whitespace) => None,
568        _ => Some(Some(trimmed.to_string())),
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::CopilotProvider;
575    use super::{copilot_request_tools, copilot_tool_signature, normalize_copilot_model_id};
576    use crate::provider::{ContentPart, LLMProvider, LLMRequest, Message, ToolCall, ToolDefinition};
577    use std::path::PathBuf;
578    use std::sync::Arc;
579    use vtcode_config::constants::models::copilot as copilot_models;
580
581    fn provider() -> CopilotProvider {
582        CopilotProvider::from_config(None, None, Some(PathBuf::from("/tmp")))
583    }
584
585    #[test]
586    fn tool_free_prompt_session_omits_separately_supplied_tools() {
587        let tools = vec![ToolDefinition::function(
588            "lookup".to_owned(),
589            "Look up saved data".to_owned(),
590            serde_json::json!({"type": "object"}),
591        )];
592        let mut request = LLMRequest {
593            tool_choice: Some(crate::provider::ToolChoice::None),
594            ..Default::default()
595        };
596        assert!(copilot_request_tools(&request, &tools).is_empty());
597        assert!(provider().request_auth_config(&request).available_tools.is_empty());
598        assert_ne!(
599            copilot_tool_signature(&tools, false),
600            copilot_tool_signature(copilot_request_tools(&request, &tools), true)
601        );
602        assert_ne!(
603            copilot_tool_signature(&[], false),
604            copilot_tool_signature(&[], true),
605            "built-in tool policy must distinguish cached clients even without custom tools"
606        );
607        request.tool_choice = Some(crate::provider::ToolChoice::Auto);
608        assert_eq!(copilot_request_tools(&request, &tools).len(), 1);
609        assert_eq!(provider().request_auth_config(&request).available_tools, ["view", "glob", "grep"]);
610    }
611
612    #[test]
613    fn transcript_flattens_system_user_and_assistant_messages() {
614        let provider = provider();
615        let request = LLMRequest {
616            system_prompt: Some(Arc::from("Follow repository conventions.")),
617            messages: Arc::new(vec![
618                Message::user("Inspect the diff.".to_string()),
619                Message::assistant("The diff looks safe.".to_string()),
620            ]),
621            ..Default::default()
622        };
623
624        let transcript = provider.build_transcript(&request).expect("transcript should build");
625
626        assert_eq!(
627            transcript,
628            "System:\nFollow repository conventions.\n\nUser:\nInspect the diff.\n\nAssistant:\nThe diff looks safe."
629        );
630    }
631
632    #[test]
633    fn curated_model_mapping_uses_auto_as_empty_override() {
634        assert_eq!(normalize_copilot_model_id(copilot_models::AUTO), Some(None));
635        assert_eq!(normalize_copilot_model_id(copilot_models::GPT_5_6_SOL), Some(Some("gpt-5.6-sol".to_string())));
636    }
637
638    #[test]
639    fn normalize_copilot_model_id_accepts_raw_model_ids() {
640        assert_eq!(normalize_copilot_model_id("gpt-5-codex"), Some(Some("gpt-5-codex".to_string())));
641        assert_eq!(normalize_copilot_model_id("gpt 5.3"), None);
642    }
643
644    #[test]
645    fn validate_request_allows_tool_history_followups() {
646        let provider = provider();
647        let request = LLMRequest {
648            messages: Arc::new(vec![Message::tool_response("call-1".to_string(), "tool output".to_string())]),
649            ..Default::default()
650        };
651
652        provider
653            .validate_request(&request)
654            .expect("tool history should be flattened for Copilot");
655    }
656
657    #[test]
658    fn transcript_flattens_tool_history_and_image_inputs() {
659        let provider = provider();
660        let request = LLMRequest {
661            messages: Arc::new(vec![
662                Message::assistant_with_tools(
663                    "Running checks.".to_string(),
664                    vec![ToolCall::function(
665                        "call-1".to_string(),
666                        "exec_command".to_string(),
667                        r#"{"cmd":"cargo check"}"#.to_string(),
668                    )],
669                ),
670                Message::tool_response_with_origin(
671                    "call-1".to_string(),
672                    "cargo check completed successfully.".to_string(),
673                    "exec_command".to_string(),
674                ),
675                Message::user_with_parts(vec![
676                    ContentPart::text("Tell me more.".to_string()),
677                    ContentPart::image("AAAA".to_string(), "image/png".to_string()),
678                ]),
679            ]),
680            ..Default::default()
681        };
682
683        let transcript = provider
684            .build_transcript(&request)
685            .expect("transcript should flatten Copilot-incompatible history");
686
687        assert!(transcript.contains("Assistant:\nRunning checks."));
688        assert!(transcript.contains("[VT Code tool call history]"));
689        assert!(transcript.contains("- exec_command id=call-1 args={\"cmd\":\"cargo check\"}"));
690        assert!(transcript.contains("Tool:\n[VT Code tool result]"));
691        assert!(transcript.contains("- tool_call_id: call-1"));
692        assert!(transcript.contains("- tool: exec_command"));
693        assert!(transcript.contains("cargo check completed successfully."));
694        assert!(transcript.contains("User:\nTell me more."));
695        assert!(transcript.contains("omitted 1 image input"));
696    }
697
698    #[test]
699    fn supported_models_include_copilot_auto() {
700        let provider = provider();
701
702        assert!(provider.supported_models().iter().any(|model| model == copilot_models::AUTO));
703    }
704
705    #[test]
706    fn supports_reasoning_for_alias_and_live_raw_models() {
707        let provider = provider();
708
709        assert!(provider.supports_reasoning(copilot_models::AUTO));
710        assert!(provider.supports_reasoning("gpt-5-codex"));
711    }
712}