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 pub(super) inner: Arc<CopilotAcpClientInner>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct CopilotCompatibilityNotice {
179 pub state: CopilotAcpCompatibilityState,
180 pub message: String,
181}
182
183pub(super) struct CopilotAcpClientInner {
190 pub(super) transport: StdioTransport,
192 pub(super) active_prompt: StdMutex<Option<ActivePrompt>>,
194 pub(super) session_id: StdMutex<Option<String>>,
196 pub(super) compatibility_state: StdMutex<CopilotAcpCompatibilityState>,
198}
199
200pub(super) struct ActivePrompt {
201 pub(super) updates: tokio::sync::mpsc::UnboundedSender<PromptUpdate>,
202 pub(super) 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
255mod client;
256mod terminal;
257
258use terminal::{
259 handle_terminal_create_request, handle_terminal_kill_request, handle_terminal_output_request,
260 handle_terminal_release_request, handle_terminal_wait_for_exit_request,
261};
262
263pub(super) fn handle_acp_message(inner: &Arc<CopilotAcpClientInner>, message: Value) -> Result<()> {
274 let Some(method) = message.get("method").and_then(Value::as_str) else {
275 return Ok(());
276 };
277
278 match method {
279 "session/update" => handle_session_update(inner, message.get("params"))?,
280 "permission.request" => handle_permission_request(inner, &message)?,
281 "session/request_permission" => handle_legacy_permission_request(inner, &message)?,
282 "tool.call" => handle_tool_call_request(inner, &message)?,
283 "terminal/create" => handle_terminal_create_request(inner, &message)?,
284 "terminal/output" => handle_terminal_output_request(inner, &message)?,
285 "terminal/release" => handle_terminal_release_request(inner, &message)?,
286 "terminal/kill" => handle_terminal_kill_request(inner, &message)?,
287 "terminal/wait_for_exit" => handle_terminal_wait_for_exit_request(inner, &message)?,
288 client_method => {
289 if let Some(id) = request_id(&message) {
290 let error_message = unsupported_client_capability_message(client_method);
291 mark_prompt_degraded(inner, error_message.clone())?;
292 inner
293 .transport
294 .respond_error(id, ACP_METHOD_NOT_FOUND_CODE, error_message)
295 .map_err(anyhow::Error::from)?;
296 }
297 }
298 }
299
300 Ok(())
301}
302
303fn handle_session_update(inner: &Arc<CopilotAcpClientInner>, params: Option<&Value>) -> Result<()> {
304 let Some(update) = params.and_then(|params| params.get("update")) else {
305 return Ok(());
306 };
307 let Some(kind) = update.get("sessionUpdate").and_then(Value::as_str) else {
308 return Ok(());
309 };
310
311 match kind {
312 "agent_message_chunk" => {
313 if let Some(text) = extract_text(update.get("content")) {
314 send_prompt_update(inner, PromptUpdate::Text(text))?;
315 }
316 }
317 "agent_thought_chunk" => {
318 if let Some(text) = extract_text(update.get("content")) {
319 send_prompt_update(inner, PromptUpdate::Thought(text))?;
320 }
321 }
322 "tool_call" | "tool_call_update" => {
323 if let Some(tool_call) = parse_observed_tool_call(update) {
324 match enqueue_runtime_request(inner, CopilotRuntimeRequest::ObservedToolCall(tool_call)) {
325 Ok(_) => {}
326 Err(err) if is_runtime_request_channel_closed_error(&err) => {}
327 Err(err) => return Err(err),
328 }
329 } else {
330 mark_prompt_degraded(
331 inner,
332 "GitHub Copilot ACP sent an unparseable tool call update; VT Code is continuing in prompt-only degraded mode.".to_string(),
333 )?;
334 }
335 }
336 "plan" | "available_commands_update" | "mode_update" => {}
337 _ => {}
338 }
339
340 Ok(())
341}
342
343fn send_rpc_reply(inner: &CopilotAcpClientInner, id: RpcId, reply: RpcReply) -> Result<()> {
344 match reply {
345 RpcReply::Result(value) => inner.transport.respond(id, value).map_err(anyhow::Error::from),
346 RpcReply::Error { code, message } => {
347 inner.transport.respond_error(id, code, message).map_err(anyhow::Error::from)
348 }
349 }
350}
351
352fn spawn_runtime_response_task<TResponse, F>(
353 inner: Arc<CopilotAcpClientInner>,
354 id: RpcId,
355 response_rx: tokio::sync::oneshot::Receiver<TResponse>,
356 build_success_reply: F,
357 closed_reply: RpcReply,
358 warn_context: &'static str,
359) where
360 TResponse: Send + 'static,
361 F: FnOnce(TResponse) -> RpcReply + Send + 'static,
362{
363 tokio::spawn(async move {
364 let reply = match response_rx.await {
365 Ok(response) => build_success_reply(response),
366 Err(_) => closed_reply,
367 };
368
369 if let Err(err) = send_rpc_reply(inner.as_ref(), id, reply) {
370 let safe_error = sanitize_provider_diagnostic(err.to_string().as_bytes());
371 tracing::warn!(target: "copilot.acp", context = warn_context, error = %safe_error, "copilot acp response failed");
372 }
373 });
374}
375
376fn dispatch_runtime_request<TResponse, F>(
377 inner: &Arc<CopilotAcpClientInner>,
378 request: CopilotRuntimeRequest,
379 response_rx: tokio::sync::oneshot::Receiver<TResponse>,
380 id: RpcId,
381 build_success_reply: F,
382 closed_reply: RpcReply,
383 unavailable_reply: RpcReply,
384 warn_context: &'static str,
385) -> Result<()>
386where
387 TResponse: Send + 'static,
388 F: FnOnce(TResponse) -> RpcReply + Send + 'static,
389{
390 let dispatched = match enqueue_runtime_request(inner, request) {
391 Ok(dispatched) => dispatched,
392 Err(err) if is_runtime_request_channel_closed_error(&err) => false,
393 Err(err) => return Err(err),
394 };
395
396 if !dispatched {
397 return send_rpc_reply(inner.as_ref(), id, unavailable_reply);
398 }
399
400 spawn_runtime_response_task(inner.clone(), id, response_rx, build_success_reply, closed_reply, warn_context);
401 Ok(())
402}
403
404fn handle_runtime_request_message<TRequest, TResponse, Parse, Wrap, Build>(
405 inner: &Arc<CopilotAcpClientInner>,
406 message: &Value,
407 parse_request: Parse,
408 wrap_request: Wrap,
409 build_success_reply: Build,
410 closed_reply: RpcReply,
411 unavailable_reply: RpcReply,
412 warn_context: &'static str,
413) -> Result<()>
414where
415 TResponse: Send + 'static,
416 Parse: FnOnce(&Value) -> Result<TRequest>,
417 Wrap: FnOnce(TRequest, tokio::sync::oneshot::Sender<TResponse>) -> CopilotRuntimeRequest,
418 Build: FnOnce(TResponse) -> RpcReply + Send + 'static,
419{
420 let Some(id) = request_id(message) else {
421 return Ok(());
422 };
423
424 let params = message.get("params").cloned().unwrap_or(Value::Null);
425 let request = parse_request(¶ms)?;
426 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
427
428 dispatch_runtime_request(
429 inner,
430 wrap_request(request, response_tx),
431 response_rx,
432 id,
433 build_success_reply,
434 closed_reply,
435 unavailable_reply,
436 warn_context,
437 )
438}
439
440fn handle_permission_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
441 handle_runtime_request_message(
442 inner,
443 message,
444 |params| {
445 Ok(params
446 .get("permissionRequest")
447 .cloned()
448 .map(parse_permission_request)
449 .transpose()?
450 .unwrap_or(CopilotPermissionRequest::Unknown { kind: None, raw: Value::Null }))
451 },
452 |request, response_tx| {
453 CopilotRuntimeRequest::Permission(PendingPermissionRequest {
454 request,
455 response_tx,
456 response_format: PermissionResponseFormat::CopilotCli,
457 })
458 },
459 RpcReply::result,
460 RpcReply::result(PermissionResponseFormat::CopilotCli.render(CopilotPermissionDecision::DeniedNoApprovalRule)),
461 RpcReply::result(PermissionResponseFormat::CopilotCli.render(CopilotPermissionDecision::DeniedNoApprovalRule)),
462 "permission.respond",
463 )
464}
465
466fn handle_legacy_permission_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
467 handle_runtime_request_message(
468 inner,
469 message,
470 |params| {
471 let request = params
472 .get("toolCall")
473 .cloned()
474 .map(parse_legacy_permission_request)
475 .transpose()?
476 .unwrap_or(CopilotPermissionRequest::Unknown {
477 kind: Some("session/request_permission".to_string()),
478 raw: Value::Null,
479 });
480 Ok((request, parse_permission_options(params.get("options"))))
481 },
482 |(request, options), response_tx| {
483 CopilotRuntimeRequest::Permission(PendingPermissionRequest {
484 request,
485 response_tx,
486 response_format: PermissionResponseFormat::AcpLegacy { options },
487 })
488 },
489 RpcReply::result,
490 RpcReply::result(json!({ "outcome": { "outcome": "cancelled" } })),
491 RpcReply::result(json!({ "outcome": { "outcome": "cancelled" } })),
492 "legacy_permission.respond",
493 )
494}
495
496fn handle_tool_call_request(inner: &Arc<CopilotAcpClientInner>, message: &Value) -> Result<()> {
497 let Some(id) = request_id(message) else {
498 return Ok(());
499 };
500
501 let params = message.get("params").cloned().unwrap_or(Value::Null);
502 let request = CopilotToolCallRequest {
503 tool_call_id: params.get("toolCallId").and_then(Value::as_str).unwrap_or_default().to_string(),
504 tool_name: params.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(),
505 arguments: params.get("arguments").cloned().unwrap_or(Value::Null),
506 };
507 let tool_name = request.tool_name.clone();
508 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
509
510 dispatch_runtime_request(
511 inner,
512 CopilotRuntimeRequest::ToolCall(PendingToolCallRequest { request, response_tx }),
513 response_rx,
514 id,
515 |response| RpcReply::result(build_tool_call_result(response)),
516 tool_call_closed_reply(&tool_name),
517 tool_call_unavailable_reply(&tool_name),
518 "tool_call.respond",
519 )
520}
521
522fn send_prompt_update(inner: &Arc<CopilotAcpClientInner>, update: PromptUpdate) -> Result<()> {
527 let sender = inner
532 .active_prompt
533 .lock()
534 .map_err(|_e| anyhow!("copilot acp active prompt mutex poisoned"))?
535 .as_ref()
536 .map(|active_prompt| active_prompt.updates.clone());
537 let Some(sender) = sender else {
538 return Ok(());
539 };
540
541 if sender.send(update).is_err() {
542 clear_active_prompt_state(inner);
543 }
544 Ok(())
545}
546
547fn mark_prompt_degraded(inner: &Arc<CopilotAcpClientInner>, message: String) -> Result<()> {
548 {
549 let mut compatibility_state = inner
550 .compatibility_state
551 .lock()
552 .map_err(|_e| anyhow!("copilot acp compatibility mutex poisoned"))?;
553 if *compatibility_state == CopilotAcpCompatibilityState::PromptOnly {
554 return Ok(());
555 }
556 *compatibility_state = CopilotAcpCompatibilityState::PromptOnly;
557 }
558 tracing::warn!(
559 target: "copilot.acp",
560 message = %message,
561 "GitHub Copilot ACP switched to prompt-only degraded mode"
562 );
563 match enqueue_runtime_request(
564 inner,
565 CopilotRuntimeRequest::CompatibilityNotice(CopilotCompatibilityNotice {
566 state: CopilotAcpCompatibilityState::PromptOnly,
567 message,
568 }),
569 ) {
570 Ok(_) => {}
571 Err(err) if is_runtime_request_channel_closed_error(&err) => {}
572 Err(err) => return Err(err),
573 }
574 Ok(())
575}
576
577pub(super) fn enqueue_runtime_request(
578 inner: &Arc<CopilotAcpClientInner>,
579 request: CopilotRuntimeRequest,
580) -> Result<bool> {
581 let sender = inner
582 .active_prompt
583 .lock()
584 .map_err(|_e| anyhow!("copilot acp active prompt mutex poisoned"))?
585 .as_ref()
586 .map(|active_prompt| active_prompt.runtime_requests.clone());
587 let Some(sender) = sender else {
588 return Ok(false);
589 };
590
591 if sender.send(request).is_err() {
592 clear_active_prompt_state(inner);
593 return Err(anyhow!("copilot runtime request channel closed"));
594 }
595 Ok(true)
596}
597
598fn is_runtime_request_channel_closed_error(err: &anyhow::Error) -> bool {
599 err.to_string().contains("copilot runtime request channel closed")
600}
601
602fn clear_active_prompt_state(inner: &Arc<CopilotAcpClientInner>) {
603 if let Ok(mut active_prompt) = inner.active_prompt.lock() {
604 *active_prompt = None;
605 }
606}
607
608fn build_tool_call_result(response: CopilotToolCallResponse) -> Value {
614 let inner = match response {
615 CopilotToolCallResponse::Success(success) => json!({
616 "textResultForLlm": success.text_result_for_llm,
617 "resultType": "success",
618 "toolTelemetry": {},
619 }),
620 CopilotToolCallResponse::Failure(failure) => json!({
621 "textResultForLlm": failure.text_result_for_llm,
622 "resultType": "failure",
623 "error": failure.error,
624 "toolTelemetry": {},
625 }),
626 };
627 json!({ "result": inner })
628}
629
630fn unsupported_client_capability_message(method: &str) -> String {
631 format!("VT Code's builtin Copilot client does not implement `{method}`.")
632}
633
634fn tool_call_closed_reply(tool_name: &str) -> RpcReply {
635 RpcReply::result(build_tool_call_result(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
636 text_result_for_llm: format!("VT Code could not complete the client tool `{tool_name}`."),
637 error: format!("tool '{tool_name}' response channel closed"),
638 })))
639}
640
641fn tool_call_unavailable_reply(tool_name: &str) -> RpcReply {
642 RpcReply::result(build_tool_call_result(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
643 text_result_for_llm: format!("VT Code does not expose the client tool `{tool_name}` to GitHub Copilot."),
644 error: format!("tool '{tool_name}' not supported by VT Code"),
645 })))
646}
647
648fn derived_copilot_tool_name(title: Option<&str>, kind: Option<&str>) -> String {
649 title
650 .filter(|value| !value.trim().is_empty())
651 .map(ToString::to_string)
652 .or_else(|| {
653 kind.filter(|value| !value.trim().is_empty())
654 .map(|kind| format!("copilot_{kind}"))
655 })
656 .unwrap_or_else(|| "copilot_tool".to_string())
657}
658
659mod observed;
660
661use observed::parse_observed_tool_call;
662
663fn parse_legacy_permission_request(value: Value) -> Result<CopilotPermissionRequest> {
664 let Some(object) = value.as_object() else {
665 return Ok(CopilotPermissionRequest::Unknown {
666 kind: Some("session/request_permission".to_string()),
667 raw: value,
668 });
669 };
670
671 let tool_call_id = object.get("toolCallId").and_then(Value::as_str).map(ToString::to_string);
672 let tool_name = derived_copilot_tool_name(
673 object.get("title").and_then(Value::as_str),
674 object.get("kind").and_then(Value::as_str),
675 );
676
677 Ok(CopilotPermissionRequest::CustomTool {
678 tool_call_id,
679 tool_name: tool_name.clone(),
680 tool_description: object
681 .get("title")
682 .and_then(Value::as_str)
683 .unwrap_or("GitHub Copilot ACP permission request")
684 .to_string(),
685 args: object.get("rawInput").cloned(),
686 })
687}
688
689fn parse_permission_options(value: Option<&Value>) -> Vec<AcpPermissionOption> {
690 value
691 .and_then(Value::as_array)
692 .map(|items| {
693 items
694 .iter()
695 .filter_map(|item| {
696 Some(AcpPermissionOption {
697 option_id: item.get("optionId")?.as_str()?.to_string(),
698 kind: parse_permission_option_kind(item.get("kind").and_then(Value::as_str)),
699 })
700 })
701 .collect()
702 })
703 .unwrap_or_default()
704}
705
706fn parse_permission_option_kind(kind: Option<&str>) -> AcpPermissionOptionKind {
707 match kind {
708 Some("allow_once") => AcpPermissionOptionKind::AllowOnce,
709 Some("allow_always") => AcpPermissionOptionKind::AllowAlways,
710 Some("reject_once") => AcpPermissionOptionKind::RejectOnce,
711 Some("reject_always") => AcpPermissionOptionKind::RejectAlways,
712 _ => AcpPermissionOptionKind::Other,
713 }
714}
715
716fn legacy_permission_outcome(options: &[AcpPermissionOption], decision: &CopilotPermissionDecision) -> Value {
717 let selected = match decision {
718 CopilotPermissionDecision::Approved => {
719 pick_permission_option(options, &[AcpPermissionOptionKind::AllowOnce, AcpPermissionOptionKind::AllowAlways])
720 }
721 CopilotPermissionDecision::ApprovedAlways => {
722 pick_permission_option(options, &[AcpPermissionOptionKind::AllowAlways, AcpPermissionOptionKind::AllowOnce])
723 }
724 CopilotPermissionDecision::DeniedByRules | CopilotPermissionDecision::DeniedByContentExclusionPolicy { .. } => {
725 pick_permission_option(
726 options,
727 &[
728 AcpPermissionOptionKind::RejectAlways,
729 AcpPermissionOptionKind::RejectOnce,
730 ],
731 )
732 }
733 CopilotPermissionDecision::DeniedNoApprovalRule
734 | CopilotPermissionDecision::DeniedInteractivelyByUser { .. } => pick_permission_option(
735 options,
736 &[
737 AcpPermissionOptionKind::RejectOnce,
738 AcpPermissionOptionKind::RejectAlways,
739 ],
740 ),
741 };
742
743 if let Some(option_id) = selected {
744 json!({
745 "outcome": "selected",
746 "optionId": option_id,
747 })
748 } else {
749 json!({
750 "outcome": "cancelled",
751 })
752 }
753}
754
755fn pick_permission_option(
756 options: &[AcpPermissionOption],
757 preferred_kinds: &[AcpPermissionOptionKind],
758) -> Option<String> {
759 preferred_kinds.iter().find_map(|preferred| {
760 options
761 .iter()
762 .find(|option| option.kind == *preferred)
763 .map(|option| option.option_id.clone())
764 })
765}
766
767fn extract_text(content: Option<&Value>) -> Option<String> {
768 match content {
769 Some(Value::Object(map)) => {
770 if map.get("type").and_then(Value::as_str) == Some("text") {
771 map.get("text").and_then(Value::as_str).map(ToString::to_string)
772 } else {
773 None
774 }
775 }
776 Some(Value::String(text)) => Some(text.clone()),
777 _ => None,
778 }
779}
780
781fn custom_tools_payload(custom_tools: &[ToolDefinition]) -> Vec<Value> {
782 custom_tools
783 .iter()
784 .filter_map(|tool| {
785 let function = tool.function.as_ref()?;
786 Some(json!({
787 "name": function.name,
788 "description": function.description,
789 "parameters": function.parameters,
790 "skipPermission": true,
791 }))
792 })
793 .collect()
794}
795
796fn parse_permission_request(value: Value) -> Result<CopilotPermissionRequest> {
797 let Some(object) = value.as_object() else {
798 return Ok(CopilotPermissionRequest::Unknown { kind: None, raw: value });
799 };
800
801 let kind = object.get("kind").and_then(Value::as_str).map(ToString::to_string);
802 let tool_call_id = object.get("toolCallId").and_then(Value::as_str).map(ToString::to_string);
803
804 Ok(match kind.as_deref() {
805 Some(tools::SHELL) => CopilotPermissionRequest::Shell {
806 tool_call_id,
807 full_command_text: object
808 .get("fullCommandText")
809 .and_then(Value::as_str)
810 .unwrap_or_default()
811 .to_string(),
812 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
813 commands: object
814 .get("commands")
815 .and_then(Value::as_array)
816 .map(|commands| {
817 commands
818 .iter()
819 .filter_map(|command| {
820 Some(CopilotShellCommandSummary {
821 identifier: command.get("identifier").and_then(Value::as_str)?.to_string(),
822 read_only: command.get("readOnly").and_then(Value::as_bool).unwrap_or(false),
823 })
824 })
825 .collect::<Vec<_>>()
826 })
827 .unwrap_or_default(),
828 possible_paths: string_array(object.get("possiblePaths")),
829 possible_urls: object
830 .get("possibleUrls")
831 .and_then(Value::as_array)
832 .map(|urls| {
833 urls.iter()
834 .filter_map(|entry| entry.get("url").and_then(Value::as_str).map(ToString::to_string))
835 .collect::<Vec<_>>()
836 })
837 .unwrap_or_default(),
838 has_write_file_redirection: object.get("hasWriteFileRedirection").and_then(Value::as_bool).unwrap_or(false),
839 can_offer_session_approval: object.get("canOfferSessionApproval").and_then(Value::as_bool).unwrap_or(false),
840 warning: object.get("warning").and_then(Value::as_str).map(ToString::to_string),
841 },
842 Some("write") => CopilotPermissionRequest::Write {
843 tool_call_id,
844 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
845 file_name: object.get("fileName").and_then(Value::as_str).unwrap_or_default().to_string(),
846 diff: object.get("diff").and_then(Value::as_str).unwrap_or_default().to_string(),
847 new_file_contents: object.get("newFileContents").and_then(Value::as_str).map(ToString::to_string),
848 },
849 Some("read") => CopilotPermissionRequest::Read {
850 tool_call_id,
851 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
852 path: object.get("path").and_then(Value::as_str).unwrap_or_default().to_string(),
853 },
854 Some("mcp") => CopilotPermissionRequest::Mcp {
855 tool_call_id,
856 server_name: object.get("serverName").and_then(Value::as_str).unwrap_or_default().to_string(),
857 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
858 tool_title: object.get("toolTitle").and_then(Value::as_str).unwrap_or_default().to_string(),
859 args: object.get("args").cloned(),
860 read_only: object.get("readOnly").and_then(Value::as_bool).unwrap_or(false),
861 },
862 Some("url") => CopilotPermissionRequest::Url {
863 tool_call_id,
864 intention: object.get("intention").and_then(Value::as_str).unwrap_or_default().to_string(),
865 url: object.get("url").and_then(Value::as_str).unwrap_or_default().to_string(),
866 },
867 Some("memory") => CopilotPermissionRequest::Memory {
868 tool_call_id,
869 subject: object.get("subject").and_then(Value::as_str).unwrap_or_default().to_string(),
870 fact: object.get("fact").and_then(Value::as_str).unwrap_or_default().to_string(),
871 citations: object.get("citations").and_then(Value::as_str).unwrap_or_default().to_string(),
872 },
873 Some("custom-tool") => CopilotPermissionRequest::CustomTool {
874 tool_call_id,
875 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
876 tool_description: object
877 .get("toolDescription")
878 .and_then(Value::as_str)
879 .unwrap_or_default()
880 .to_string(),
881 args: object.get("args").cloned(),
882 },
883 Some("hook") => CopilotPermissionRequest::Hook {
884 tool_call_id,
885 tool_name: object.get("toolName").and_then(Value::as_str).unwrap_or_default().to_string(),
886 tool_args: object.get("toolArgs").cloned(),
887 hook_message: object.get("hookMessage").and_then(Value::as_str).map(ToString::to_string),
888 },
889 _ => CopilotPermissionRequest::Unknown { kind, raw: value },
890 })
891}
892
893fn string_array(value: Option<&Value>) -> Vec<String> {
894 value
895 .and_then(Value::as_array)
896 .map(|items| {
897 items
898 .iter()
899 .filter_map(|item| item.as_str().map(ToString::to_string))
900 .collect::<Vec<_>>()
901 })
902 .unwrap_or_default()
903}
904
905fn request_id(message: &Value) -> Option<i64> {
906 message.get("id").and_then(Value::as_i64)
907}
908
909#[cfg(test)]
910mod tests;