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}