Skip to main content

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