Skip to main content

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