Skip to main content

vtcode_llm/copilot/acp_client/
mod.rs

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
183// ---------------------------------------------------------------------------
184// Inner state — Copilot-specific only.
185// The generic transport machinery (request correlation, child I/O) lives in
186// StdioTransport.
187// ---------------------------------------------------------------------------
188
189pub(super) struct CopilotAcpClientInner {
190    /// Generic JSON-RPC-over-stdio transport.
191    pub(super) transport: StdioTransport,
192    /// State for the currently active prompt session (if any).
193    pub(super) active_prompt: StdMutex<Option<ActivePrompt>>,
194    /// Copilot session identifier (set after session.create succeeds).
195    pub(super) session_id: StdMutex<Option<String>>,
196    /// Copilot ACP compatibility state (updated as messages arrive).
197    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
263// ---------------------------------------------------------------------------
264// CopilotAcpClient implementation
265// ---------------------------------------------------------------------------
266
267// ---------------------------------------------------------------------------
268// ACP message dispatch (Copilot-specific protocol)
269// ---------------------------------------------------------------------------
270// StdioTransport already handles JSON-RPC response routing (id → pending).
271// This function receives only server-initiated requests and notifications.
272
273pub(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(&params)?;
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
522// ---------------------------------------------------------------------------
523// Active prompt helpers
524// ---------------------------------------------------------------------------
525
526fn send_prompt_update(inner: &Arc<CopilotAcpClientInner>, update: PromptUpdate) -> Result<()> {
527    // Clone the sender under the lock, then drop the guard before sending or
528    // clearing. `clear_active_prompt_state` re-locks the same `std::sync::Mutex`,
529    // and the let-chain guard stays alive through the whole block — calling
530    // into it while the guard is held self-deadlocks on a `StdMutex`.
531    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
608// ---------------------------------------------------------------------------
609// Payload builders
610// ---------------------------------------------------------------------------
611
612/// Build the JSON-RPC `result` value for a `tool.call` response.
613fn 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;