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