1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::sync::Mutex as StdMutex;
4
5use anyhow::{Context, Result, anyhow};
6use serde_json::{Value, json};
7use tokio::time::timeout;
8use vtcode_commons::sanitizer::sanitize_provider_diagnostic;
9use vtcode_commons::serde_helpers::json_to_string_pretty;
10use vtcode_config::auth::CopilotAuthConfig;
11
12use super::command::{CopilotModelSelectionMode, resolve_copilot_command, spawn_copilot_acp_process};
13use super::transport::StdioTransport;
14use super::types::{
15 CopilotAcpCompatibilityState, CopilotObservedToolCall, CopilotObservedToolCallStatus, CopilotPermissionDecision,
16 CopilotPermissionRequest, CopilotShellCommandSummary, CopilotTerminalCreateRequest, CopilotTerminalCreateResponse,
17 CopilotTerminalEnvVar, CopilotTerminalExitStatus, CopilotTerminalKillRequest, CopilotTerminalOutputRequest,
18 CopilotTerminalOutputResponse, CopilotTerminalReleaseRequest, CopilotTerminalWaitForExitRequest,
19 CopilotToolCallFailure, CopilotToolCallRequest, CopilotToolCallResponse,
20};
21use crate::provider::ToolDefinition;
22use vtcode_config::constants::tools;
23
24type RpcId = i64;
25
26const ACP_METHOD_NOT_FOUND_CODE: i32 = -32601;
27const ACP_RUNTIME_UNAVAILABLE_CODE: i32 = -32000;
28const MAX_TERMINAL_OUTPUT_BYTE_LIMIT: usize = 1_048_576;
29const MAX_TERMINAL_ARG_COUNT: usize = 256;
30const MAX_TERMINAL_ENV_VAR_COUNT: usize = 128;
31
32#[derive(Debug)]
33pub enum PromptUpdate {
34 Text(String),
35 Thought(String),
36}
37
38#[derive(Debug)]
39pub struct PromptCompletion {
40 pub stop_reason: String,
41}
42
43pub struct PromptSession {
44 updates: tokio::sync::mpsc::UnboundedReceiver<PromptUpdate>,
45 runtime_requests: tokio::sync::mpsc::UnboundedReceiver<CopilotRuntimeRequest>,
46 completion: tokio::task::JoinHandle<Result<PromptCompletion>>,
47 cancel_handle: PromptSessionCancelHandle,
48}
49
50#[derive(Clone)]
51pub struct PromptSessionCancelHandle {
52 client: CopilotAcpClient,
53 completion_abort: tokio::task::AbortHandle,
54}
55
56impl PromptSessionCancelHandle {
57 pub fn cancel(&self) {
58 let _ = self.client.cancel();
59 self.client.clear_active_prompt();
60 self.completion_abort.abort();
61 }
62}
63
64impl PromptSession {
65 pub fn into_parts(
66 self,
67 ) -> (
68 tokio::sync::mpsc::UnboundedReceiver<PromptUpdate>,
69 tokio::sync::mpsc::UnboundedReceiver<CopilotRuntimeRequest>,
70 tokio::task::JoinHandle<Result<PromptCompletion>>,
71 PromptSessionCancelHandle,
72 ) {
73 (self.updates, self.runtime_requests, self.completion, self.cancel_handle)
74 }
75}
76
77#[derive(Debug)]
78pub enum CopilotRuntimeRequest {
79 Permission(PendingPermissionRequest),
80 ToolCall(PendingToolCallRequest),
81 TerminalCreate(PendingTerminalCreateRequest),
82 TerminalOutput(PendingTerminalOutputRequest),
83 TerminalRelease(PendingTerminalReleaseRequest),
84 TerminalKill(PendingTerminalKillRequest),
85 TerminalWaitForExit(PendingTerminalWaitForExitRequest),
86 ObservedToolCall(CopilotObservedToolCall),
87 CompatibilityNotice(CopilotCompatibilityNotice),
88}
89
90#[derive(Debug)]
91pub struct PendingPermissionRequest {
92 pub request: CopilotPermissionRequest,
93 response_tx: tokio::sync::oneshot::Sender<Value>,
94 response_format: PermissionResponseFormat,
95}
96
97impl PendingPermissionRequest {
98 pub fn respond(self, decision: CopilotPermissionDecision) -> Result<()> {
99 self.response_tx
100 .send(self.response_format.render(decision))
101 .map_err(|_e| anyhow!("copilot permission response channel closed"))
102 }
103}
104
105macro_rules! define_pending_request {
106 ($name:ident, $request_ty:ty, $response_ty:ty, $error_message:literal) => {
107 #[derive(Debug)]
108 pub struct $name {
109 pub request: $request_ty,
110 response_tx: tokio::sync::oneshot::Sender<$response_ty>,
111 }
112
113 impl $name {
114 pub fn respond(self, response: $response_ty) -> Result<()> {
115 self.response_tx.send(response).map_err(|_e| anyhow!($error_message))
116 }
117 }
118 };
119}
120
121macro_rules! define_pending_signal_request {
122 ($name:ident, $request_ty:ty, $error_message:literal) => {
123 #[derive(Debug)]
124 pub struct $name {
125 pub request: $request_ty,
126 response_tx: tokio::sync::oneshot::Sender<()>,
127 }
128
129 impl $name {
130 pub fn respond(self) -> Result<()> {
131 self.response_tx.send(()).map_err(|_e| anyhow!($error_message))
132 }
133 }
134 };
135}
136
137define_pending_request!(
138 PendingToolCallRequest,
139 CopilotToolCallRequest,
140 CopilotToolCallResponse,
141 "copilot tool response channel closed"
142);
143define_pending_request!(
144 PendingTerminalCreateRequest,
145 CopilotTerminalCreateRequest,
146 CopilotTerminalCreateResponse,
147 "copilot terminal create response channel closed"
148);
149define_pending_request!(
150 PendingTerminalOutputRequest,
151 CopilotTerminalOutputRequest,
152 CopilotTerminalOutputResponse,
153 "copilot terminal output response channel closed"
154);
155define_pending_signal_request!(
156 PendingTerminalReleaseRequest,
157 CopilotTerminalReleaseRequest,
158 "copilot terminal release response channel closed"
159);
160define_pending_signal_request!(
161 PendingTerminalKillRequest,
162 CopilotTerminalKillRequest,
163 "copilot terminal kill response channel closed"
164);
165define_pending_request!(
166 PendingTerminalWaitForExitRequest,
167 CopilotTerminalWaitForExitRequest,
168 CopilotTerminalExitStatus,
169 "copilot terminal wait response channel closed"
170);
171
172#[derive(Clone)]
173pub struct CopilotAcpClient {
174 inner: Arc<CopilotAcpClientInner>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct CopilotCompatibilityNotice {
179 pub state: CopilotAcpCompatibilityState,
180 pub message: String,
181}
182
183struct CopilotAcpClientInner {
190 transport: StdioTransport,
192 active_prompt: StdMutex<Option<ActivePrompt>>,
194 session_id: StdMutex<Option<String>>,
196 compatibility_state: StdMutex<CopilotAcpCompatibilityState>,
198}
199
200struct ActivePrompt {
201 updates: tokio::sync::mpsc::UnboundedSender<PromptUpdate>,
202 runtime_requests: tokio::sync::mpsc::UnboundedSender<CopilotRuntimeRequest>,
203}
204
205#[derive(Debug, Clone)]
206enum PermissionResponseFormat {
207 CopilotCli,
208 AcpLegacy { options: Vec<AcpPermissionOption> },
209}
210
211impl PermissionResponseFormat {
212 fn render(self, decision: CopilotPermissionDecision) -> Value {
213 match self {
214 Self::CopilotCli => json!({
215 "result": decision.to_rpc_result(),
216 }),
217 Self::AcpLegacy { options } => json!({
218 "outcome": legacy_permission_outcome(&options, &decision),
219 }),
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
225struct AcpPermissionOption {
226 option_id: String,
227 kind: AcpPermissionOptionKind,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231enum AcpPermissionOptionKind {
232 AllowOnce,
233 AllowAlways,
234 RejectOnce,
235 RejectAlways,
236 Other,
237}
238
239#[derive(Clone)]
240enum RpcReply {
241 Result(Value),
242 Error { code: i32, message: &'static str },
243}
244
245impl RpcReply {
246 fn result(value: Value) -> Self {
247 Self::Result(value)
248 }
249
250 fn runtime_error(message: &'static str) -> Self {
251 Self::Error { code: ACP_RUNTIME_UNAVAILABLE_CODE, message }
252 }
253}
254
255impl CopilotAcpClient {
260 pub(crate) async fn connect(
261 config: &CopilotAuthConfig,
262 workspace_root: &Path,
263 raw_model: Option<&str>,
264 custom_tools: &[ToolDefinition],
265 ) -> Result<Self> {
266 match Self::connect_once(
267 config,
268 workspace_root,
269 raw_model,
270 custom_tools,
271 CopilotModelSelectionMode::CliArgument,
272 )
273 .await
274 {
275 Ok(client) => Ok(client),
276 Err(primary_error) if raw_model.is_some() => Self::connect_once(
277 config,
278 workspace_root,
279 raw_model,
280 custom_tools,
281 CopilotModelSelectionMode::EnvironmentVariable,
282 )
283 .await
284 .with_context(|| format!("copilot acp startup with --model failed first: {primary_error}")),
285 Err(error) => Err(error),
286 }
287 }
288
289 async fn connect_once(
290 config: &CopilotAuthConfig,
291 workspace_root: &Path,
292 raw_model: Option<&str>,
293 custom_tools: &[ToolDefinition],
294 model_selection_mode: CopilotModelSelectionMode,
295 ) -> Result<Self> {
296 let resolved = resolve_copilot_command(config)?;
297 let mut child = spawn_copilot_acp_process(&resolved, config, workspace_root, raw_model, model_selection_mode)?;
298 let stdin = child
299 .stdin
300 .take()
301 .ok_or_else(|| anyhow!("copilot acp child stdin unavailable"))?;
302 let stdout = child
303 .stdout
304 .take()
305 .ok_or_else(|| anyhow!("copilot acp child stdout unavailable"))?;
306 let stderr = child
307 .stderr
308 .take()
309 .ok_or_else(|| anyhow!("copilot acp child stderr unavailable"))?;
310
311 let transport = StdioTransport::from_child(child, stdin, stdout, stderr, resolved.auth_timeout);
312
313 let inner = Arc::new(CopilotAcpClientInner {
314 transport,
315 active_prompt: StdMutex::new(None),
316 session_id: StdMutex::new(None),
317 compatibility_state: StdMutex::new(CopilotAcpCompatibilityState::Unavailable),
318 });
319
320 let inner_weak = Arc::downgrade(&inner);
324 inner.transport.set_notification_handler(Arc::new(move |message| {
325 if let Some(inner_strong) = inner_weak.upgrade() {
326 handle_acp_message(&inner_strong, message)?;
327 }
328 Ok(())
329 }));
330
331 let client = Self { inner };
332 timeout(resolved.startup_timeout, async {
333 client.initialize().await?;
334 let session_id = client
335 .create_session(config, workspace_root.to_path_buf(), raw_model, custom_tools)
336 .await?;
337 *client
338 .inner
339 .session_id
340 .lock()
341 .map_err(|_e| anyhow!("copilot acp session mutex poisoned"))? = Some(session_id);
342 *client
343 .inner
344 .compatibility_state
345 .lock()
346 .map_err(|_e| anyhow!("copilot acp compatibility mutex poisoned"))? =
347 CopilotAcpCompatibilityState::FullTools;
348 Ok::<(), anyhow::Error>(())
349 })
350 .await
351 .context("copilot acp startup timeout")??;
352 Ok(client)
353 }
354
355 fn session_id(&self) -> Result<String> {
356 self.inner
357 .session_id
358 .lock()
359 .map_err(|_e| anyhow!("copilot acp session mutex poisoned"))?
360 .clone()
361 .ok_or_else(|| anyhow!("copilot acp session not initialized"))
362 }
363
364 pub(crate) async fn start_prompt(&self, prompt_text: String) -> Result<PromptSession> {
365 let (updates_tx, updates_rx) = tokio::sync::mpsc::unbounded_channel();
366 let (runtime_tx, runtime_rx) = tokio::sync::mpsc::unbounded_channel();
367 {
368 let mut active_prompt = self
369 .inner
370 .active_prompt
371 .lock()
372 .map_err(|_e| anyhow!("copilot acp active prompt mutex poisoned"))?;
373 if active_prompt.is_some() {
374 return Err(anyhow!("copilot acp only supports one active prompt"));
375 }
376 *active_prompt = Some(ActivePrompt { updates: updates_tx, runtime_requests: runtime_tx });
377 }
378
379 if self.compatibility_state()? == CopilotAcpCompatibilityState::PromptOnly {
380 enqueue_runtime_request(
381 &self.inner,
382 CopilotRuntimeRequest::CompatibilityNotice(CopilotCompatibilityNotice {
383 state: CopilotAcpCompatibilityState::PromptOnly,
384 message: "GitHub Copilot ACP is running in prompt-only degraded mode. VT Code will keep the session alive, but Copilot-native runtime hooks are partially incompatible.".to_string(),
385 }),
386 )?;
387 }
388
389 let client = self.clone();
390 let session_id = self.session_id()?;
391 let completion = tokio::spawn(async move {
392 let result = client
393 .call(
394 "session/prompt",
395 json!({
396 "sessionId": session_id,
397 "prompt": [
398 {
399 "type": "text",
400 "text": prompt_text,
401 }
402 ]
403 }),
404 )
405 .await
406 .context("copilot acp session/prompt");
407
408 client.clear_active_prompt();
409 let result = result?;
410
411 let stop_reason = result
412 .get("stopReason")
413 .and_then(Value::as_str)
414 .unwrap_or("end_turn")
415 .to_string();
416 Ok(PromptCompletion { stop_reason })
417 });
418 let cancel_handle = PromptSessionCancelHandle {
419 client: self.clone(),
420 completion_abort: completion.abort_handle(),
421 };
422
423 Ok(PromptSession {
424 updates: updates_rx,
425 runtime_requests: runtime_rx,
426 completion,
427 cancel_handle,
428 })
429 }
430
431 fn cancel(&self) -> Result<()> {
432 self.inner
433 .transport
434 .notify(
435 "session/cancel",
436 json!({
437 "sessionId": self.session_id()?,
438 }),
439 )
440 .map_err(anyhow::Error::from)
441 }
442
443 async fn initialize(&self) -> Result<()> {
444 let response = self
445 .call(
446 "initialize",
447 json!({
448 "protocolVersion": 1,
449 "clientCapabilities": {
450 "fs": {
451 "readTextFile": false,
452 "writeTextFile": false,
453 },
454 "terminal": true,
455 },
456 "clientInfo": {
457 "name": "vtcode",
458 "title": "VT Code",
459 "version": env!("CARGO_PKG_VERSION"),
460 }
461 }),
462 )
463 .await
464 .context("copilot acp initialize")?;
465
466 let protocol_version = response.get("protocolVersion").and_then(Value::as_i64).unwrap_or(1);
467 if protocol_version != 1 {
468 return Err(anyhow!("unsupported copilot acp protocol version {protocol_version}"));
469 }
470
471 Ok(())
472 }
473
474 async fn create_session(
475 &self,
476 config: &CopilotAuthConfig,
477 workspace_root: PathBuf,
478 raw_model: Option<&str>,
479 custom_tools: &[ToolDefinition],
480 ) -> Result<String> {
481 match self
482 .create_session_v2(config, workspace_root.clone(), raw_model, custom_tools)
483 .await
484 {
485 Ok(session_id) => Ok(session_id),
486 Err(v2_error) => self
487 .create_session_v1(workspace_root)
488 .await
489 .with_context(|| format!("copilot acp session.create failed first: {v2_error}")),
490 }
491 }
492
493 async fn create_session_v2(
494 &self,
495 config: &CopilotAuthConfig,
496 workspace_root: PathBuf,
497 raw_model: Option<&str>,
498 custom_tools: &[ToolDefinition],
499 ) -> Result<String> {
500 let mut params = serde_json::Map::from_iter([
501 ("clientName".to_string(), Value::String("VT Code".to_string())),
502 ("workingDirectory".to_string(), json!(workspace_root)),
503 ("requestPermission".to_string(), Value::Bool(true)),
504 ("streaming".to_string(), Value::Bool(true)),
505 ("mcpServers".to_string(), Value::Array(Vec::new())),
506 ]);
507
508 if let Some(raw_model) = raw_model.filter(|value| !value.trim().is_empty()) {
509 params.insert("model".to_string(), Value::String(raw_model.to_string()));
510 }
511 let custom_tools = custom_tools_payload(custom_tools);
512 if !custom_tools.is_empty() {
513 params.insert("tools".to_string(), Value::Array(custom_tools));
514 }
515 if !config.available_tools.is_empty() {
516 params.insert("availableTools".to_string(), json!(config.available_tools));
517 }
518 if !config.excluded_tools.is_empty() {
519 params.insert("excludedTools".to_string(), json!(config.excluded_tools));
520 }
521
522 let response = self
523 .call("session.create", Value::Object(params))
524 .await
525 .context("copilot acp session.create")?;
526
527 response
528 .get("sessionId")
529 .and_then(Value::as_str)
530 .map(ToString::to_string)
531 .ok_or_else(|| anyhow!("copilot acp session.create missing sessionId"))
532 }
533
534 async fn create_session_v1(&self, workspace_root: PathBuf) -> Result<String> {
535 let response = self
536 .call(
537 "session/new",
538 json!({
539 "cwd": workspace_root,
540 "mcpServers": [],
541 }),
542 )
543 .await
544 .context("copilot acp session/new")?;
545
546 response
547 .get("sessionId")
548 .and_then(Value::as_str)
549 .map(ToString::to_string)
550 .ok_or_else(|| anyhow!("copilot acp session/new missing sessionId"))
551 }
552
553 async fn call(&self, method: &str, params: Value) -> Result<Value> {
554 self.inner.transport.call(method, params).await.map_err(anyhow::Error::from)
555 }
556
557 fn clear_active_prompt(&self) {
558 if let Ok(mut active_prompt) = self.inner.active_prompt.lock() {
559 *active_prompt = None;
560 }
561 }
562
563 fn compatibility_state(&self) -> Result<CopilotAcpCompatibilityState> {
564 self.inner
565 .compatibility_state
566 .lock()
567 .map(|state| *state)
568 .map_err(|_e| anyhow!("copilot acp compatibility mutex poisoned"))
569 }
570}
571
572fn handle_acp_message(inner: &Arc<CopilotAcpClientInner>, message: Value) -> Result<()> {
579 let Some(method) = message.get("method").and_then(Value::as_str) else {
580 return Ok(());
581 };
582
583 match method {
584 "session/update" => handle_session_update(inner, message.get("params"))?,
585 "permission.request" => handle_permission_request(inner, &message)?,
586 "session/request_permission" => handle_legacy_permission_request(inner, &message)?,
587 "tool.call" => handle_tool_call_request(inner, &message)?,
588 "terminal/create" => handle_terminal_create_request(inner, &message)?,
589 "terminal/output" => handle_terminal_output_request(inner, &message)?,
590 "terminal/release" => handle_terminal_release_request(inner, &message)?,
591 "terminal/kill" => handle_terminal_kill_request(inner, &message)?,
592 "terminal/wait_for_exit" => handle_terminal_wait_for_exit_request(inner, &message)?,
593 client_method => {
594 if let Some(id) = request_id(&message) {
595 let error_message = unsupported_client_capability_message(client_method);
596 mark_prompt_degraded(inner, error_message.clone())?;
597 inner
598 .transport
599 .respond_error(id, ACP_METHOD_NOT_FOUND_CODE, error_message)
600 .map_err(anyhow::Error::from)?;
601 }
602 }
603 }
604
605 Ok(())
606}
607
608fn handle_session_update(inner: &Arc<CopilotAcpClientInner>, params: Option<&Value>) -> Result<()> {
609 let Some(update) = params.and_then(|params| params.get("update")) else {
610 return Ok(());
611 };
612 let Some(kind) = update.get("sessionUpdate").and_then(Value::as_str) else {
613 return Ok(());
614 };
615
616 match kind {
617 "agent_message_chunk" => {
618 if let Some(text) = extract_text(update.get("content")) {
619 send_prompt_update(inner, PromptUpdate::Text(text))?;
620 }
621 }
622 "agent_thought_chunk" => {
623 if let Some(text) = extract_text(update.get("content")) {
624 send_prompt_update(inner, PromptUpdate::Thought(text))?;
625 }
626 }
627 "tool_call" | "tool_call_update" => {
628 if let Some(tool_call) = parse_observed_tool_call(update) {
629 match enqueue_runtime_request(inner, CopilotRuntimeRequest::ObservedToolCall(tool_call)) {
630 Ok(_) => {}
631 Err(err) if is_runtime_request_channel_closed_error(&err) => {}
632 Err(err) => return Err(err),
633 }
634 } else {
635 mark_prompt_degraded(
636 inner,
637 "GitHub Copilot ACP sent an unparseable tool call update; VT Code is continuing in prompt-only degraded mode.".to_string(),
638 )?;
639 }
640 }
641 "plan" | "available_commands_update" | "mode_update" => {}
642 _ => {}
643 }
644
645 Ok(())
646}
647
648fn send_rpc_reply(inner: &CopilotAcpClientInner, id: RpcId, reply: RpcReply) -> Result<()> {
649 match reply {
650 RpcReply::Result(value) => inner.transport.respond(id, value).map_err(anyhow::Error::from),
651 RpcReply::Error { code, message } => {
652 inner.transport.respond_error(id, code, message).map_err(anyhow::Error::from)
653 }
654 }
655}
656
657fn spawn_runtime_response_task<TResponse, F>(
658 inner: Arc<CopilotAcpClientInner>,
659 id: RpcId,
660 response_rx: tokio::sync::oneshot::Receiver<TResponse>,
661 build_success_reply: F,
662 closed_reply: RpcReply,
663 warn_context: &'static str,
664) where
665 TResponse: Send + 'static,
666 F: FnOnce(TResponse) -> RpcReply + Send + 'static,
667{
668 tokio::spawn(async move {
669 let reply = match response_rx.await {
670 Ok(response) => build_success_reply(response),
671 Err(_) => closed_reply,
672 };
673
674 if let Err(err) = send_rpc_reply(inner.as_ref(), id, reply) {
675 let safe_error = sanitize_provider_diagnostic(err.to_string().as_bytes());
676 tracing::warn!(target: "copilot.acp", context = warn_context, error = %safe_error, "copilot acp response failed");
677 }
678 });
679}
680
681fn dispatch_runtime_request<TResponse, F>(
682 inner: &Arc<CopilotAcpClientInner>,
683 request: CopilotRuntimeRequest,
684 response_rx: tokio::sync::oneshot::Receiver<TResponse>,
685 id: RpcId,
686 build_success_reply: F,
687 closed_reply: RpcReply,
688 unavailable_reply: RpcReply,
689 warn_context: &'static str,
690) -> Result<()>
691where
692 TResponse: Send + 'static,
693 F: FnOnce(TResponse) -> RpcReply + Send + 'static,
694{
695 let dispatched = match enqueue_runtime_request(inner, request) {
696 Ok(dispatched) => dispatched,
697 Err(err) if is_runtime_request_channel_closed_error(&err) => false,
698 Err(err) => return Err(err),
699 };
700
701 if !dispatched {
702 return send_rpc_reply(inner.as_ref(), id, unavailable_reply);
703 }
704
705 spawn_runtime_response_task(inner.clone(), id, response_rx, build_success_reply, closed_reply, warn_context);
706 Ok(())
707}
708
709fn handle_runtime_request_message<TRequest, TResponse, Parse, Wrap, Build>(
710 inner: &Arc<CopilotAcpClientInner>,
711 message: &Value,
712 parse_request: Parse,
713 wrap_request: Wrap,
714 build_success_reply: Build,
715 closed_reply: RpcReply,
716 unavailable_reply: RpcReply,
717 warn_context: &'static str,
718) -> Result<()>
719where
720 TResponse: Send + 'static,
721 Parse: FnOnce(&Value) -> Result<TRequest>,
722 Wrap: FnOnce(TRequest, tokio::sync::oneshot::Sender<TResponse>) -> CopilotRuntimeRequest,
723 Build: FnOnce(TResponse) -> RpcReply + Send + 'static,
724{
725 let Some(id) = request_id(message) else {
726 return Ok(());
727 };
728
729 let params = message.get("params").cloned().unwrap_or(Value::Null);
730 let request = parse_request(¶ms)?;
731 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
732
733 dispatch_runtime_request(
734 inner,
735 wrap_request(request, response_tx),
736 response_rx,
737 id,
738 build_success_reply,
739 closed_reply,
740 unavailable_reply,
741 warn_context,
742 )
743}
744
745fn handle_permission_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
746 handle_runtime_request_message(
747 inner,
748 message,
749 |params| {
750 Ok(params
751 .get("permissionRequest")
752 .cloned()
753 .map(parse_permission_request)
754 .transpose()?
755 .unwrap_or(CopilotPermissionRequest::Unknown { kind: None, raw: Value::Null }))
756 },
757 |request, response_tx| {
758 CopilotRuntimeRequest::Permission(PendingPermissionRequest {
759 request,
760 response_tx,
761 response_format: PermissionResponseFormat::CopilotCli,
762 })
763 },
764 RpcReply::result,
765 RpcReply::result(PermissionResponseFormat::CopilotCli.render(CopilotPermissionDecision::DeniedNoApprovalRule)),
766 RpcReply::result(PermissionResponseFormat::CopilotCli.render(CopilotPermissionDecision::DeniedNoApprovalRule)),
767 "permission.respond",
768 )
769}
770
771fn handle_legacy_permission_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
772 handle_runtime_request_message(
773 inner,
774 message,
775 |params| {
776 let request = params
777 .get("toolCall")
778 .cloned()
779 .map(parse_legacy_permission_request)
780 .transpose()?
781 .unwrap_or(CopilotPermissionRequest::Unknown {
782 kind: Some("session/request_permission".to_string()),
783 raw: Value::Null,
784 });
785 Ok((request, parse_permission_options(params.get("options"))))
786 },
787 |(request, options), response_tx| {
788 CopilotRuntimeRequest::Permission(PendingPermissionRequest {
789 request,
790 response_tx,
791 response_format: PermissionResponseFormat::AcpLegacy { options },
792 })
793 },
794 RpcReply::result,
795 RpcReply::result(json!({ "outcome": { "outcome": "cancelled" } })),
796 RpcReply::result(json!({ "outcome": { "outcome": "cancelled" } })),
797 "legacy_permission.respond",
798 )
799}
800
801fn handle_tool_call_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
802 let Some(id) = request_id(message) else {
803 return Ok(());
804 };
805
806 let params = message.get("params").cloned().unwrap_or(Value::Null);
807 let request = CopilotToolCallRequest {
808 tool_call_id: params.get("toolCallId").and_then(Value::as_str).unwrap_or_default().to_string(),
809 tool_name: params.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(),
810 arguments: params.get("arguments").cloned().unwrap_or(Value::Null),
811 };
812 let tool_name = request.tool_name.clone();
813 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
814
815 dispatch_runtime_request(
816 inner,
817 CopilotRuntimeRequest::ToolCall(PendingToolCallRequest { request, response_tx }),
818 response_rx,
819 id,
820 |response| RpcReply::result(build_tool_call_result(response)),
821 tool_call_closed_reply(&tool_name),
822 tool_call_unavailable_reply(&tool_name),
823 "tool_call.respond",
824 )
825}
826
827fn handle_terminal_create_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
828 handle_runtime_request_message(
829 inner,
830 message,
831 parse_terminal_create_request,
832 |request, response_tx| {
833 CopilotRuntimeRequest::TerminalCreate(PendingTerminalCreateRequest { request, response_tx })
834 },
835 |response| RpcReply::result(build_terminal_create_result(response)),
836 RpcReply::runtime_error("VT Code could not create the requested terminal."),
837 RpcReply::runtime_error(
838 "VT Code could not create the requested terminal because the Copilot runtime is unavailable.",
839 ),
840 "terminal_create.respond",
841 )
842}
843
844fn handle_terminal_output_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
845 handle_runtime_request_message(
846 inner,
847 message,
848 |params| {
849 parse_terminal_request(params, |session_id, terminal_id| CopilotTerminalOutputRequest {
850 session_id,
851 terminal_id,
852 })
853 },
854 |request, response_tx| {
855 CopilotRuntimeRequest::TerminalOutput(PendingTerminalOutputRequest { request, response_tx })
856 },
857 |response| RpcReply::result(build_terminal_output_result(response)),
858 RpcReply::runtime_error("VT Code could not read the requested terminal output."),
859 RpcReply::runtime_error(
860 "VT Code could not read the requested terminal output because the Copilot runtime is unavailable.",
861 ),
862 "terminal_output.respond",
863 )
864}
865
866fn handle_terminal_release_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
867 handle_runtime_request_message(
868 inner,
869 message,
870 |params| {
871 parse_terminal_request(params, |session_id, terminal_id| CopilotTerminalReleaseRequest {
872 session_id,
873 terminal_id,
874 })
875 },
876 |request, response_tx| {
877 CopilotRuntimeRequest::TerminalRelease(PendingTerminalReleaseRequest { request, response_tx })
878 },
879 |_| RpcReply::result(json!({})),
880 RpcReply::runtime_error("VT Code could not release the requested terminal."),
881 RpcReply::runtime_error(
882 "VT Code could not release the requested terminal because the Copilot runtime is unavailable.",
883 ),
884 "terminal_release.respond",
885 )
886}
887
888fn handle_terminal_kill_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
889 handle_runtime_request_message(
890 inner,
891 message,
892 |params| {
893 parse_terminal_request(params, |session_id, terminal_id| CopilotTerminalKillRequest {
894 session_id,
895 terminal_id,
896 })
897 },
898 |request, response_tx| CopilotRuntimeRequest::TerminalKill(PendingTerminalKillRequest { request, response_tx }),
899 |_| RpcReply::result(json!({})),
900 RpcReply::runtime_error("VT Code could not kill the requested terminal command."),
901 RpcReply::runtime_error(
902 "VT Code could not kill the requested terminal command because the Copilot runtime is unavailable.",
903 ),
904 "terminal_kill.respond",
905 )
906}
907
908fn handle_terminal_wait_for_exit_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
909 handle_runtime_request_message(
910 inner,
911 message,
912 |params| {
913 parse_terminal_request(params, |session_id, terminal_id| CopilotTerminalWaitForExitRequest {
914 session_id,
915 terminal_id,
916 })
917 },
918 |request, response_tx| {
919 CopilotRuntimeRequest::TerminalWaitForExit(PendingTerminalWaitForExitRequest { request, response_tx })
920 },
921 |response| RpcReply::result(build_terminal_wait_for_exit_result(response)),
922 RpcReply::runtime_error("VT Code could not wait for the requested terminal."),
923 RpcReply::runtime_error(
924 "VT Code could not wait for the requested terminal because the Copilot runtime is unavailable.",
925 ),
926 "terminal_wait_for_exit.respond",
927 )
928}
929
930fn parse_terminal_create_request(params: &Value) -> Result<CopilotTerminalCreateRequest> {
931 let session_id = optional_session_id(params);
932 let command = required_non_empty_string(params, "command", "copilot terminal/create missing command")?;
933 let args = parse_string_array(
934 params.get("args"),
935 MAX_TERMINAL_ARG_COUNT,
936 "copilot terminal args must be strings",
937 "copilot terminal/create has too many args",
938 )?;
939 let env = parse_terminal_env_vars(params.get("env"))?;
940 let cwd = params.get("cwd").and_then(Value::as_str).map(PathBuf::from);
941 let output_byte_limit = params.get("outputByteLimit").and_then(Value::as_u64).map(|value| {
942 usize::try_from(value)
943 .unwrap_or(MAX_TERMINAL_OUTPUT_BYTE_LIMIT)
944 .min(MAX_TERMINAL_OUTPUT_BYTE_LIMIT)
945 });
946
947 Ok(CopilotTerminalCreateRequest {
948 session_id,
949 command,
950 args,
951 env,
952 cwd,
953 output_byte_limit,
954 })
955}
956
957fn parse_terminal_request<T>(params: &Value, build: impl FnOnce(String, String) -> T) -> Result<T> {
958 let session_id = optional_session_id(params);
959 let terminal_id = required_non_empty_string(params, "terminalId", "copilot terminal request missing terminalId")?;
960 Ok(build(session_id, terminal_id))
961}
962
963fn optional_session_id(params: &Value) -> String {
964 params.get("sessionId").and_then(Value::as_str).unwrap_or_default().to_string()
965}
966
967fn required_non_empty_string(params: &Value, key: &str, error_message: &'static str) -> Result<String> {
968 params
969 .get(key)
970 .and_then(Value::as_str)
971 .map(str::trim)
972 .filter(|value| !value.is_empty())
973 .map(str::to_string)
974 .ok_or_else(|| anyhow!(error_message))
975}
976
977fn parse_string_array(
978 value: Option<&Value>,
979 max_items: usize,
980 item_error: &'static str,
981 limit_error: &'static str,
982) -> Result<Vec<String>> {
983 let Some(values) = value.and_then(Value::as_array) else {
984 return Ok(Vec::new());
985 };
986
987 if values.len() > max_items {
988 anyhow::bail!(limit_error);
989 }
990
991 values
992 .iter()
993 .map(|value| value.as_str().map(str::to_string).ok_or_else(|| anyhow!(item_error)))
994 .collect()
995}
996
997fn parse_terminal_env_vars(value: Option<&Value>) -> Result<Vec<CopilotTerminalEnvVar>> {
998 let Some(values) = value.and_then(Value::as_array) else {
999 return Ok(Vec::new());
1000 };
1001
1002 if values.len() > MAX_TERMINAL_ENV_VAR_COUNT {
1003 anyhow::bail!("copilot terminal/create has too many env entries");
1004 }
1005
1006 values
1007 .iter()
1008 .map(|value| {
1009 let object = value
1010 .as_object()
1011 .ok_or_else(|| anyhow!("copilot terminal env entries must be objects"))?;
1012 let name = object
1013 .get("name")
1014 .and_then(Value::as_str)
1015 .map(str::trim)
1016 .filter(|value| !value.is_empty())
1017 .map(str::to_string)
1018 .ok_or_else(|| anyhow!("copilot terminal env entries require a name"))?;
1019 let value = object
1020 .get("value")
1021 .and_then(Value::as_str)
1022 .map(str::to_string)
1023 .ok_or_else(|| anyhow!("copilot terminal env entries require a value"))?;
1024 Ok(CopilotTerminalEnvVar { name, value })
1025 })
1026 .collect()
1027}
1028
1029fn build_terminal_create_result(response: CopilotTerminalCreateResponse) -> Value {
1030 json!({
1031 "terminalId": response.terminal_id,
1032 })
1033}
1034
1035fn build_terminal_output_result(response: CopilotTerminalOutputResponse) -> Value {
1036 let exit_status = response.exit_status.map(build_terminal_exit_status_json);
1037 let mut result = serde_json::Map::from_iter([
1038 ("output".to_string(), Value::String(response.output)),
1039 ("truncated".to_string(), Value::Bool(response.truncated)),
1040 ]);
1041 if let Some(exit_status) = exit_status {
1042 result.insert("exitStatus".to_string(), exit_status);
1043 }
1044 Value::Object(result)
1045}
1046
1047fn build_terminal_wait_for_exit_result(response: CopilotTerminalExitStatus) -> Value {
1048 build_terminal_exit_status_json(response)
1049}
1050
1051fn build_terminal_exit_status_json(status: CopilotTerminalExitStatus) -> Value {
1052 let mut result = serde_json::Map::new();
1053 result.insert("exitCode".to_string(), status.exit_code.map_or(Value::Null, |value| Value::from(u64::from(value))));
1054 result.insert("signal".to_string(), status.signal.map_or(Value::Null, Value::String));
1055 Value::Object(result)
1056}
1057
1058fn send_prompt_update(inner: &Arc<CopilotAcpClientInner>, update: PromptUpdate) -> Result<()> {
1063 let sender = inner
1068 .active_prompt
1069 .lock()
1070 .map_err(|_e| anyhow!("copilot acp active prompt mutex poisoned"))?
1071 .as_ref()
1072 .map(|active_prompt| active_prompt.updates.clone());
1073 let Some(sender) = sender else {
1074 return Ok(());
1075 };
1076
1077 if sender.send(update).is_err() {
1078 clear_active_prompt_state(inner);
1079 }
1080 Ok(())
1081}
1082
1083fn mark_prompt_degraded(inner: &Arc<CopilotAcpClientInner>, message: String) -> Result<()> {
1084 {
1085 let mut compatibility_state = inner
1086 .compatibility_state
1087 .lock()
1088 .map_err(|_e| anyhow!("copilot acp compatibility mutex poisoned"))?;
1089 if *compatibility_state == CopilotAcpCompatibilityState::PromptOnly {
1090 return Ok(());
1091 }
1092 *compatibility_state = CopilotAcpCompatibilityState::PromptOnly;
1093 }
1094 tracing::warn!(
1095 target: "copilot.acp",
1096 message = %message,
1097 "GitHub Copilot ACP switched to prompt-only degraded mode"
1098 );
1099 match enqueue_runtime_request(
1100 inner,
1101 CopilotRuntimeRequest::CompatibilityNotice(CopilotCompatibilityNotice {
1102 state: CopilotAcpCompatibilityState::PromptOnly,
1103 message,
1104 }),
1105 ) {
1106 Ok(_) => {}
1107 Err(err) if is_runtime_request_channel_closed_error(&err) => {}
1108 Err(err) => return Err(err),
1109 }
1110 Ok(())
1111}
1112
1113fn enqueue_runtime_request(inner: &Arc<CopilotAcpClientInner>, request: CopilotRuntimeRequest) -> Result<bool> {
1114 let sender = inner
1115 .active_prompt
1116 .lock()
1117 .map_err(|_e| anyhow!("copilot acp active prompt mutex poisoned"))?
1118 .as_ref()
1119 .map(|active_prompt| active_prompt.runtime_requests.clone());
1120 let Some(sender) = sender else {
1121 return Ok(false);
1122 };
1123
1124 if sender.send(request).is_err() {
1125 clear_active_prompt_state(inner);
1126 return Err(anyhow!("copilot runtime request channel closed"));
1127 }
1128 Ok(true)
1129}
1130
1131fn is_runtime_request_channel_closed_error(err: &anyhow::Error) -> bool {
1132 err.to_string().contains("copilot runtime request channel closed")
1133}
1134
1135fn clear_active_prompt_state(inner: &Arc<CopilotAcpClientInner>) {
1136 if let Ok(mut active_prompt) = inner.active_prompt.lock() {
1137 *active_prompt = None;
1138 }
1139}
1140
1141fn build_tool_call_result(response: CopilotToolCallResponse) -> Value {
1147 let inner = match response {
1148 CopilotToolCallResponse::Success(success) => json!({
1149 "textResultForLlm": success.text_result_for_llm,
1150 "resultType": "success",
1151 "toolTelemetry": {},
1152 }),
1153 CopilotToolCallResponse::Failure(failure) => json!({
1154 "textResultForLlm": failure.text_result_for_llm,
1155 "resultType": "failure",
1156 "error": failure.error,
1157 "toolTelemetry": {},
1158 }),
1159 };
1160 json!({ "result": inner })
1161}
1162
1163fn unsupported_client_capability_message(method: &str) -> String {
1164 format!("VT Code's builtin Copilot client does not implement `{method}`.")
1165}
1166
1167fn tool_call_closed_reply(tool_name: &str) -> RpcReply {
1168 RpcReply::result(build_tool_call_result(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
1169 text_result_for_llm: format!("VT Code could not complete the client tool `{tool_name}`."),
1170 error: format!("tool '{tool_name}' response channel closed"),
1171 })))
1172}
1173
1174fn tool_call_unavailable_reply(tool_name: &str) -> RpcReply {
1175 RpcReply::result(build_tool_call_result(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
1176 text_result_for_llm: format!("VT Code does not expose the client tool `{tool_name}` to GitHub Copilot."),
1177 error: format!("tool '{tool_name}' not supported by VT Code"),
1178 })))
1179}
1180
1181fn derived_copilot_tool_name(title: Option<&str>, kind: Option<&str>) -> String {
1182 title
1183 .filter(|value| !value.trim().is_empty())
1184 .map(ToString::to_string)
1185 .or_else(|| {
1186 kind.filter(|value| !value.trim().is_empty())
1187 .map(|kind| format!("copilot_{kind}"))
1188 })
1189 .unwrap_or_else(|| "copilot_tool".to_string())
1190}
1191
1192fn parse_observed_tool_call(update: &Value) -> Option<CopilotObservedToolCall> {
1193 let tool_call_id = update.get("toolCallId")?.as_str()?.to_string();
1194 let tool_name = derived_copilot_tool_name(
1195 update.get("title").and_then(Value::as_str),
1196 update.get("kind").and_then(Value::as_str),
1197 );
1198 let status = parse_observed_tool_status(
1199 update.get("status").and_then(Value::as_str),
1200 update.get("sessionUpdate").and_then(Value::as_str),
1201 );
1202 let arguments = update.get("rawInput").cloned();
1203 let output = extract_observed_tool_output(update);
1204 let terminal_id = extract_tool_call_terminal_id(update.get("content"));
1205
1206 Some(CopilotObservedToolCall {
1207 tool_call_id,
1208 tool_name,
1209 status,
1210 arguments,
1211 output,
1212 terminal_id,
1213 })
1214}
1215
1216fn parse_observed_tool_status(status: Option<&str>, session_update: Option<&str>) -> CopilotObservedToolCallStatus {
1217 match status.unwrap_or_else(|| {
1218 if session_update == Some("tool_call") {
1219 "pending"
1220 } else {
1221 "in_progress"
1222 }
1223 }) {
1224 "pending" => CopilotObservedToolCallStatus::Pending,
1225 "in_progress" => CopilotObservedToolCallStatus::InProgress,
1226 "completed" => CopilotObservedToolCallStatus::Completed,
1227 "failed" => CopilotObservedToolCallStatus::Failed,
1228 _ => CopilotObservedToolCallStatus::InProgress,
1229 }
1230}
1231
1232fn extract_observed_tool_output(update: &Value) -> Option<String> {
1233 update
1234 .get("rawOutput")
1235 .and_then(extract_observed_tool_raw_output)
1236 .or_else(|| extract_tool_call_content_text(update.get("content")))
1237}
1238
1239fn extract_observed_tool_raw_output(raw_output: &Value) -> Option<String> {
1240 match raw_output {
1241 Value::String(text) => Some(text.clone()).filter(|text| !text.trim().is_empty()),
1242 Value::Object(object) => object
1243 .get("content")
1244 .and_then(Value::as_str)
1245 .filter(|text| !text.trim().is_empty())
1246 .map(ToString::to_string)
1247 .or_else(|| {
1248 object
1249 .get("detailedContent")
1250 .and_then(Value::as_str)
1251 .filter(|text| !text.trim().is_empty())
1252 .map(ToString::to_string)
1253 })
1254 .or_else(|| Some(json_to_string_pretty(raw_output))),
1255 _ => Some(json_to_string_pretty(raw_output)),
1256 }
1257}
1258
1259fn extract_tool_call_content_text(content: Option<&Value>) -> Option<String> {
1260 content.and_then(Value::as_array).into_iter().flatten().find_map(|item| {
1261 item.get("content").and_then(|content| {
1262 content
1263 .get("type")
1264 .and_then(Value::as_str)
1265 .filter(|value| *value == "text")
1266 .and_then(|_| content.get("text"))
1267 .and_then(Value::as_str)
1268 .map(ToString::to_string)
1269 })
1270 })
1271}
1272
1273fn extract_tool_call_terminal_id(content: Option<&Value>) -> Option<String> {
1274 content.and_then(Value::as_array).into_iter().flatten().find_map(|item| {
1275 item.get("content").and_then(|content| {
1276 content
1277 .get("type")
1278 .and_then(Value::as_str)
1279 .filter(|value| *value == "terminal")
1280 .and_then(|_| content.get("terminalId"))
1281 .and_then(Value::as_str)
1282 .map(ToString::to_string)
1283 })
1284 })
1285}
1286
1287fn parse_legacy_permission_request(value: Value) -> Result<CopilotPermissionRequest> {
1288 let Some(object) = value.as_object() else {
1289 return Ok(CopilotPermissionRequest::Unknown {
1290 kind: Some("session/request_permission".to_string()),
1291 raw: value,
1292 });
1293 };
1294
1295 let tool_call_id = object.get("toolCallId").and_then(Value::as_str).map(ToString::to_string);
1296 let tool_name = derived_copilot_tool_name(
1297 object.get("title").and_then(Value::as_str),
1298 object.get("kind").and_then(Value::as_str),
1299 );
1300
1301 Ok(CopilotPermissionRequest::CustomTool {
1302 tool_call_id,
1303 tool_name: tool_name.clone(),
1304 tool_description: object
1305 .get("title")
1306 .and_then(Value::as_str)
1307 .unwrap_or("GitHub Copilot ACP permission request")
1308 .to_string(),
1309 args: object.get("rawInput").cloned(),
1310 })
1311}
1312
1313fn parse_permission_options(value: Option<&Value>) -> Vec<AcpPermissionOption> {
1314 value
1315 .and_then(Value::as_array)
1316 .map(|items| {
1317 items
1318 .iter()
1319 .filter_map(|item| {
1320 Some(AcpPermissionOption {
1321 option_id: item.get("optionId")?.as_str()?.to_string(),
1322 kind: parse_permission_option_kind(item.get("kind").and_then(Value::as_str)),
1323 })
1324 })
1325 .collect()
1326 })
1327 .unwrap_or_default()
1328}
1329
1330fn parse_permission_option_kind(kind: Option<&str>) -> AcpPermissionOptionKind {
1331 match kind {
1332 Some("allow_once") => AcpPermissionOptionKind::AllowOnce,
1333 Some("allow_always") => AcpPermissionOptionKind::AllowAlways,
1334 Some("reject_once") => AcpPermissionOptionKind::RejectOnce,
1335 Some("reject_always") => AcpPermissionOptionKind::RejectAlways,
1336 _ => AcpPermissionOptionKind::Other,
1337 }
1338}
1339
1340fn legacy_permission_outcome(options: &[AcpPermissionOption], decision: &CopilotPermissionDecision) -> Value {
1341 let selected = match decision {
1342 CopilotPermissionDecision::Approved => {
1343 pick_permission_option(options, &[AcpPermissionOptionKind::AllowOnce, AcpPermissionOptionKind::AllowAlways])
1344 }
1345 CopilotPermissionDecision::ApprovedAlways => {
1346 pick_permission_option(options, &[AcpPermissionOptionKind::AllowAlways, AcpPermissionOptionKind::AllowOnce])
1347 }
1348 CopilotPermissionDecision::DeniedByRules | CopilotPermissionDecision::DeniedByContentExclusionPolicy { .. } => {
1349 pick_permission_option(
1350 options,
1351 &[
1352 AcpPermissionOptionKind::RejectAlways,
1353 AcpPermissionOptionKind::RejectOnce,
1354 ],
1355 )
1356 }
1357 CopilotPermissionDecision::DeniedNoApprovalRule
1358 | CopilotPermissionDecision::DeniedInteractivelyByUser { .. } => pick_permission_option(
1359 options,
1360 &[
1361 AcpPermissionOptionKind::RejectOnce,
1362 AcpPermissionOptionKind::RejectAlways,
1363 ],
1364 ),
1365 };
1366
1367 if let Some(option_id) = selected {
1368 json!({
1369 "outcome": "selected",
1370 "optionId": option_id,
1371 })
1372 } else {
1373 json!({
1374 "outcome": "cancelled",
1375 })
1376 }
1377}
1378
1379fn pick_permission_option(
1380 options: &[AcpPermissionOption],
1381 preferred_kinds: &[AcpPermissionOptionKind],
1382) -> Option<String> {
1383 preferred_kinds.iter().find_map(|preferred| {
1384 options
1385 .iter()
1386 .find(|option| option.kind == *preferred)
1387 .map(|option| option.option_id.clone())
1388 })
1389}
1390
1391fn extract_text(content: Option<&Value>) -> Option<String> {
1392 match content {
1393 Some(Value::Object(map)) => {
1394 if map.get("type").and_then(Value::as_str) == Some("text") {
1395 map.get("text").and_then(Value::as_str).map(ToString::to_string)
1396 } else {
1397 None
1398 }
1399 }
1400 Some(Value::String(text)) => Some(text.clone()),
1401 _ => None,
1402 }
1403}
1404
1405fn custom_tools_payload(custom_tools: &[ToolDefinition]) -> Vec<Value> {
1406 custom_tools
1407 .iter()
1408 .filter_map(|tool| {
1409 let function = tool.function.as_ref()?;
1410 Some(json!({
1411 "name": function.name,
1412 "description": function.description,
1413 "parameters": function.parameters,
1414 "skipPermission": true,
1415 }))
1416 })
1417 .collect()
1418}
1419
1420fn parse_permission_request(value: Value) -> Result<CopilotPermissionRequest> {
1421 let Some(object) = value.as_object() else {
1422 return Ok(CopilotPermissionRequest::Unknown { kind: None, raw: value });
1423 };
1424
1425 let kind = object.get("kind").and_then(Value::as_str).map(ToString::to_string);
1426 let tool_call_id = object.get("toolCallId").and_then(Value::as_str).map(ToString::to_string);
1427
1428 Ok(match kind.as_deref() {
1429 Some(tools::SHELL) => CopilotPermissionRequest::Shell {
1430 tool_call_id,
1431 full_command_text: object
1432 .get("fullCommandText")
1433 .and_then(Value::as_str)
1434 .unwrap_or_default()
1435 .to_string(),
1436 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
1437 commands: object
1438 .get("commands")
1439 .and_then(Value::as_array)
1440 .map(|commands| {
1441 commands
1442 .iter()
1443 .filter_map(|command| {
1444 Some(CopilotShellCommandSummary {
1445 identifier: command.get("identifier").and_then(Value::as_str)?.to_string(),
1446 read_only: command.get("readOnly").and_then(Value::as_bool).unwrap_or(false),
1447 })
1448 })
1449 .collect::<Vec<_>>()
1450 })
1451 .unwrap_or_default(),
1452 possible_paths: string_array(object.get("possiblePaths")),
1453 possible_urls: object
1454 .get("possibleUrls")
1455 .and_then(Value::as_array)
1456 .map(|urls| {
1457 urls.iter()
1458 .filter_map(|entry| entry.get("url").and_then(Value::as_str).map(ToString::to_string))
1459 .collect::<Vec<_>>()
1460 })
1461 .unwrap_or_default(),
1462 has_write_file_redirection: object.get("hasWriteFileRedirection").and_then(Value::as_bool).unwrap_or(false),
1463 can_offer_session_approval: object.get("canOfferSessionApproval").and_then(Value::as_bool).unwrap_or(false),
1464 warning: object.get("warning").and_then(Value::as_str).map(ToString::to_string),
1465 },
1466 Some("write") => CopilotPermissionRequest::Write {
1467 tool_call_id,
1468 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
1469 file_name: object.get("fileName").and_then(Value::as_str).unwrap_or_default().to_string(),
1470 diff: object.get("diff").and_then(Value::as_str).unwrap_or_default().to_string(),
1471 new_file_contents: object.get("newFileContents").and_then(Value::as_str).map(ToString::to_string),
1472 },
1473 Some("read") => CopilotPermissionRequest::Read {
1474 tool_call_id,
1475 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
1476 path: object.get("path").and_then(Value::as_str).unwrap_or_default().to_string(),
1477 },
1478 Some("mcp") => CopilotPermissionRequest::Mcp {
1479 tool_call_id,
1480 server_name: object.get("serverName").and_then(Value::as_str).unwrap_or_default().to_string(),
1481 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
1482 tool_title: object.get("toolTitle").and_then(Value::as_str).unwrap_or_default().to_string(),
1483 args: object.get("args").cloned(),
1484 read_only: object.get("readOnly").and_then(Value::as_bool).unwrap_or(false),
1485 },
1486 Some("url") => CopilotPermissionRequest::Url {
1487 tool_call_id,
1488 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
1489 url: object.get("url").and_then(Value::as_str).unwrap_or_default().to_string(),
1490 },
1491 Some("memory") => CopilotPermissionRequest::Memory {
1492 tool_call_id,
1493 subject: object.get("subject").and_then(Value::as_str).unwrap_or_default().to_string(),
1494 fact: object.get("fact").and_then(Value::as_str).unwrap_or_default().to_string(),
1495 citations: object.get("citations").and_then(Value::as_str).unwrap_or_default().to_string(),
1496 },
1497 Some("custom-tool") => CopilotPermissionRequest::CustomTool {
1498 tool_call_id,
1499 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
1500 tool_description: object
1501 .get("toolDescription")
1502 .and_then(Value::as_str)
1503 .unwrap_or_default()
1504 .to_string(),
1505 args: object.get("args").cloned(),
1506 },
1507 Some("hook") => CopilotPermissionRequest::Hook {
1508 tool_call_id,
1509 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
1510 tool_args: object.get("toolArgs").cloned(),
1511 hook_message: object.get("hookMessage").and_then(Value::as_str).map(ToString::to_string),
1512 },
1513 _ => CopilotPermissionRequest::Unknown { kind, raw: value },
1514 })
1515}
1516
1517fn string_array(value: Option<&Value>) -> Vec<String> {
1518 value
1519 .and_then(Value::as_array)
1520 .map(|items| {
1521 items
1522 .iter()
1523 .filter_map(|item| item.as_str().map(ToString::to_string))
1524 .collect::<Vec<_>>()
1525 })
1526 .unwrap_or_default()
1527}
1528
1529fn request_id(message: &Value) -> Option<i64> {
1530 message.get("id").and_then(Value::as_i64)
1531}
1532
1533#[cfg(test)]
1534mod tests;