Skip to main content

github_copilot_sdk/
handler.rs

1//! Optional session-callback traits.
2//!
3//! Each callback the CLI may dispatch (permission requests, elicitation
4//! prompts, user-input questions, exit-plan-mode prompts,
5//! auto-mode-switch prompts) has its own focused trait with a single
6//! `handle` method.
7//!
8//! Handlers are **optional**: install only the ones the application cares
9//! about. The SDK derives the corresponding wire flag on
10//! `session.create` / `session.resume` from the presence of each handler,
11//! so the runtime does not emit broadcasts this client would never
12//! respond to.
13//!
14//! Tool dispatch uses its own per-tool registry built from
15//! [`Tool::with_handler`](crate::types::Tool::with_handler) on entries passed to
16//! [`SessionConfig::with_tools`](crate::types::SessionConfig::with_tools).
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21use crate::generated::api_types::{
22    McpOauthPendingRequestResponse, McpOauthPendingRequestResponseCancelled,
23    McpOauthPendingRequestResponseCancelledKind, McpOauthPendingRequestResponseToken,
24    McpOauthPendingRequestResponseTokenKind, PermissionDecision, PermissionDecisionApproveOnce,
25    PermissionDecisionContext, PermissionDecisionReject, PermissionDecisionUserNotAvailable,
26};
27use crate::session_events::{
28    McpOauthRequestReason, McpOauthRequiredStaticClientConfig, McpOauthWWWAuthenticateParams,
29};
30use crate::types::{
31    ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId,
32    SessionId,
33};
34
35/// Decision returned by a [`PermissionHandler`].
36///
37/// Either a concrete wire-level [`PermissionDecision`] (approve, reject,
38/// approve-for-session, approve-permanently, user-not-available, …) with
39/// optional telemetry context, or [`PermissionResult::NoResult`], which tells
40/// the SDK to suppress its response so another connected client can answer
41/// instead.
42///
43/// ```
44/// use github_copilot_sdk::handler::PermissionResult;
45///
46/// fn is_decision(result: PermissionResult) -> bool {
47///     match result {
48///         PermissionResult::Decision { .. } => true,
49///         PermissionResult::NoResult => false,
50///     }
51/// }
52/// ```
53#[derive(Debug, Clone)]
54pub enum PermissionResult {
55    /// Send a permission decision on the wire.
56    Decision {
57        /// The decision to send.
58        decision: PermissionDecision,
59        /// Optional context describing how and where the decision was reached.
60        context: Option<PermissionDecisionContext>,
61    },
62    /// Decline to respond to this request, allowing another connected
63    /// client to answer instead. The SDK suppresses the response.
64    NoResult,
65}
66
67impl PermissionResult {
68    /// Approve this single request.
69    pub fn approve_once() -> Self {
70        Self::Decision {
71            decision: PermissionDecision::ApproveOnce(PermissionDecisionApproveOnce::default()),
72            context: None,
73        }
74    }
75
76    /// Reject the request, optionally forwarding feedback to the LLM.
77    pub fn reject(feedback: impl Into<Option<String>>) -> Self {
78        Self::Decision {
79            decision: PermissionDecision::Reject(PermissionDecisionReject {
80                feedback: feedback.into(),
81                ..Default::default()
82            }),
83            context: None,
84        }
85    }
86
87    /// Deny because no user is available to confirm.
88    pub fn user_not_available() -> Self {
89        Self::Decision {
90            decision: PermissionDecision::UserNotAvailable(
91                PermissionDecisionUserNotAvailable::default(),
92            ),
93            context: None,
94        }
95    }
96
97    /// Decline to respond, allowing another connected client to answer
98    /// instead.
99    pub fn no_result() -> Self {
100        Self::NoResult
101    }
102
103    /// Attach provenance describing how and where this decision was made,
104    /// so the runtime can attribute auto-approval telemetry.
105    ///
106    /// It is a no-op on [`PermissionResult::NoResult`].
107    ///
108    /// ```rust,no_run
109    /// # use github_copilot_sdk::handler::PermissionResult;
110    /// # use github_copilot_sdk::{
111    /// #     PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource,
112    /// #     PermissionDecisionSurface,
113    /// # };
114    ///
115    /// let result = PermissionResult::approve_once().with_context(PermissionDecisionContext {
116    ///     outcome: PermissionDecisionOutcome::AutoApproved,
117    ///     response_capability: None,
118    ///     source: PermissionDecisionSource::HostPolicy,
119    ///     surface: PermissionDecisionSurface::Sdk,
120    /// });
121    /// ```
122    pub fn with_context(self, context: PermissionDecisionContext) -> Self {
123        match self {
124            Self::Decision { decision, .. } => Self::Decision {
125                decision,
126                context: Some(context),
127            },
128            Self::NoResult => Self::NoResult,
129        }
130    }
131}
132
133impl From<PermissionDecision> for PermissionResult {
134    fn from(value: PermissionDecision) -> Self {
135        Self::Decision {
136            decision: value,
137            context: None,
138        }
139    }
140}
141
142pub(crate) fn permission_handler_failure(message: &str) -> PermissionResult {
143    tracing::error!(error = message, "permission handler failed");
144    PermissionResult::user_not_available()
145}
146
147/// Response to a user input request.
148#[derive(Debug, Clone)]
149pub struct UserInputResponse {
150    /// The user's answer text.
151    pub answer: String,
152    /// Whether the answer was free-form (not a preset choice).
153    pub was_freeform: bool,
154}
155
156/// Result of an exit-plan-mode request.
157#[derive(Debug, Clone, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct ExitPlanModeResult {
160    /// Whether the user approved exiting plan mode.
161    pub approved: bool,
162    /// The action the user selected (if any).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub selected_action: Option<String>,
165    /// Optional feedback text from the user.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub feedback: Option<String>,
168}
169
170impl Default for ExitPlanModeResult {
171    fn default() -> Self {
172        Self {
173            approved: true,
174            selected_action: None,
175            feedback: None,
176        }
177    }
178}
179
180/// Response to an auto-mode-switch request.
181#[non_exhaustive]
182#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum AutoModeSwitchResponse {
185    /// Approve the auto-mode switch for this rate-limit cycle only.
186    Yes,
187    /// Approve and remember -- auto-accept future auto-mode switches in
188    /// this session without prompting.
189    YesAlways,
190    /// Decline the auto-mode switch. The session stays on the current
191    /// model and surfaces the rate-limit error.
192    No,
193}
194
195/// Handler for `permission.requested` broadcasts.
196///
197/// Install via
198/// [`SessionConfig::with_permission_handler`](crate::types::SessionConfig::with_permission_handler)
199/// (or the matching method on [`ResumeSessionConfig`](crate::types::ResumeSessionConfig)).
200/// When no permission handler is supplied, the SDK sends
201/// `requestPermission: false` on the wire and the runtime short-circuits
202/// permission prompts for this client.
203#[async_trait]
204pub trait PermissionHandler: Send + Sync + 'static {
205    /// Resolve a permission request.
206    async fn handle(
207        &self,
208        session_id: SessionId,
209        request_id: RequestId,
210        data: PermissionRequestData,
211    ) -> PermissionResult;
212}
213
214/// Handler for `elicitation.requested` broadcasts.
215///
216/// When unset, `requestElicitation: false` goes on the wire.
217#[async_trait]
218pub trait ElicitationHandler: Send + Sync + 'static {
219    /// Respond to an elicitation prompt (form, URL confirm, etc.).
220    async fn handle(
221        &self,
222        session_id: SessionId,
223        request_id: RequestId,
224        request: ElicitationRequest,
225    ) -> ElicitationResult;
226}
227
228/// MCP OAuth request that the SDK host can satisfy with a host-acquired token.
229#[derive(Debug, Clone)]
230pub struct McpAuthRequest {
231    /// Identifier for the pending MCP OAuth request.
232    pub request_id: RequestId,
233    /// Display name of the MCP server that requires OAuth.
234    pub server_name: String,
235    /// URL of the MCP server that requires OAuth.
236    pub server_url: String,
237    /// Why the runtime is requesting host-provided OAuth credentials.
238    pub reason: McpOauthRequestReason,
239    /// Parsed WWW-Authenticate parameters from the MCP server, if available.
240    pub www_authenticate_params: Option<McpOauthWWWAuthenticateParams>,
241    /// Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available.
242    pub resource_metadata: Option<String>,
243    /// Static OAuth client configuration, if the server specifies one.
244    pub static_client_config: Option<McpOauthRequiredStaticClientConfig>,
245}
246
247/// Result returned by an MCP auth request handler.
248#[derive(Debug, Clone)]
249pub enum McpAuthResult {
250    /// Supplies host-acquired OAuth token data.
251    Token {
252        /// Access token acquired by the SDK host.
253        access_token: String,
254        /// OAuth token type. Defaults to Bearer when omitted.
255        token_type: Option<String>,
256        /// Token lifetime in seconds, if known.
257        expires_in: Option<i64>,
258    },
259    /// Declines or cancels the pending OAuth request.
260    Cancelled,
261}
262
263impl McpAuthResult {
264    pub(crate) fn into_wire(self) -> McpOauthPendingRequestResponse {
265        match self {
266            Self::Token {
267                access_token,
268                token_type,
269                expires_in,
270            } => McpOauthPendingRequestResponse::Token(McpOauthPendingRequestResponseToken {
271                access_token,
272                token_type,
273                expires_in,
274                kind: McpOauthPendingRequestResponseTokenKind::Token,
275            }),
276            Self::Cancelled => {
277                McpOauthPendingRequestResponse::Cancelled(McpOauthPendingRequestResponseCancelled {
278                    kind: McpOauthPendingRequestResponseCancelledKind::Cancelled,
279                })
280            }
281        }
282    }
283}
284
285/// Handler for MCP server OAuth requests.
286#[async_trait]
287pub trait McpAuthHandler: Send + Sync + 'static {
288    /// Resolve an MCP OAuth request with host token data or cancellation.
289    async fn handle(
290        &self,
291        session_id: SessionId,
292        request_id: RequestId,
293        request: McpAuthRequest,
294    ) -> McpAuthResult;
295}
296
297/// Handler for `user_input.requested` events from the legacy question-and-answer
298/// `ask_user` variant.
299///
300/// When unset, `requestUserInput: false` goes on the wire, so this client
301/// cannot handle legacy user-input requests.
302#[async_trait]
303pub trait UserInputHandler: Send + Sync + 'static {
304    /// Answer a question on behalf of the user. Return `None` to signal
305    /// "no answer available".
306    async fn handle(
307        &self,
308        session_id: SessionId,
309        question: String,
310        choices: Option<Vec<String>>,
311        allow_freeform: Option<bool>,
312    ) -> Option<UserInputResponse>;
313}
314
315/// Handler for `exit_plan_mode.requested` events. When unset,
316/// `requestExitPlanMode: false` goes on the wire.
317#[async_trait]
318pub trait ExitPlanModeHandler: Send + Sync + 'static {
319    /// Decide whether to leave plan mode.
320    async fn handle(&self, session_id: SessionId, data: ExitPlanModeData) -> ExitPlanModeResult;
321}
322
323/// Handler for `auto_mode_switch.requested` events. When unset,
324/// `requestAutoModeSwitch: false` goes on the wire.
325#[async_trait]
326pub trait AutoModeSwitchHandler: Send + Sync + 'static {
327    /// Decide whether to fall back to the auto model after an eligible
328    /// rate-limit error. `retry_after_seconds`, when present, is the
329    /// number of seconds until the rate limit resets.
330    async fn handle(
331        &self,
332        session_id: SessionId,
333        error_code: Option<String>,
334        retry_after_seconds: Option<f64>,
335    ) -> AutoModeSwitchResponse;
336}
337
338/// A [`PermissionHandler`] that approves ordinary requests when managed settings are disabled.
339///
340/// When managed settings are enabled, the handler logs an error and returns a
341/// user-not-available decision. As a defense-in-depth fallback, a request marked
342/// as requiring managed approval is left unanswered even if the session flag is
343/// absent.
344#[derive(Debug, Clone)]
345pub struct ApproveAllHandler;
346
347#[async_trait]
348impl PermissionHandler for ApproveAllHandler {
349    async fn handle(
350        &self,
351        _session_id: SessionId,
352        _request_id: RequestId,
353        data: PermissionRequestData,
354    ) -> PermissionResult {
355        if data.managed_settings_enabled {
356            permission_handler_failure(
357                "ApproveAllHandler cannot be used when managed settings are enabled",
358            )
359        } else if data.managed_approval_required == Some(true) {
360            PermissionResult::no_result()
361        } else {
362            PermissionResult::approve_once()
363        }
364    }
365}
366
367/// A [`PermissionHandler`] that denies every request.
368#[derive(Debug, Clone)]
369pub struct DenyAllHandler;
370
371#[async_trait]
372impl PermissionHandler for DenyAllHandler {
373    async fn handle(
374        &self,
375        _session_id: SessionId,
376        _request_id: RequestId,
377        _data: PermissionRequestData,
378    ) -> PermissionResult {
379        PermissionResult::reject(None)
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[tokio::test]
388    async fn approve_all_handler_returns_approved() {
389        let result = ApproveAllHandler
390            .handle(
391                SessionId::from("s1"),
392                RequestId::new("1"),
393                PermissionRequestData::default(),
394            )
395            .await;
396        assert!(matches!(
397            result,
398            PermissionResult::Decision {
399                decision: PermissionDecision::ApproveOnce(_),
400                ..
401            }
402        ));
403    }
404
405    #[tokio::test]
406    async fn approve_all_handler_fails_when_managed_settings_enabled() {
407        let result = ApproveAllHandler
408            .handle(
409                SessionId::from("s1"),
410                RequestId::new("1"),
411                PermissionRequestData {
412                    managed_settings_enabled: true,
413                    ..Default::default()
414                },
415            )
416            .await;
417        assert!(matches!(
418            result,
419            PermissionResult::Decision {
420                decision: PermissionDecision::UserNotAvailable(_),
421                ..
422            }
423        ));
424    }
425
426    #[tokio::test]
427    async fn approve_all_handler_leaves_managed_approval_pending() {
428        let result = ApproveAllHandler
429            .handle(
430                SessionId::from("s1"),
431                RequestId::new("1"),
432                PermissionRequestData {
433                    managed_approval_required: Some(true),
434                    ..Default::default()
435                },
436            )
437            .await;
438        assert!(matches!(result, PermissionResult::NoResult));
439    }
440
441    #[tokio::test]
442    async fn deny_all_handler_returns_denied() {
443        let result = DenyAllHandler
444            .handle(
445                SessionId::from("s1"),
446                RequestId::new("1"),
447                PermissionRequestData::default(),
448            )
449            .await;
450        assert!(matches!(
451            result,
452            PermissionResult::Decision {
453                decision: PermissionDecision::Reject(_),
454                ..
455            }
456        ));
457    }
458
459    #[test]
460    fn mcp_auth_result_token_converts_to_wire_response() {
461        let wire = McpAuthResult::Token {
462            access_token: "host-token".to_string(),
463            token_type: Some("Bearer".to_string()),
464            expires_in: Some(3600),
465        }
466        .into_wire();
467
468        match wire {
469            McpOauthPendingRequestResponse::Token(token) => {
470                assert_eq!(token.access_token, "host-token");
471                assert_eq!(token.token_type.as_deref(), Some("Bearer"));
472                assert_eq!(token.expires_in, Some(3600));
473            }
474            McpOauthPendingRequestResponse::Cancelled(_) => panic!("expected token response"),
475        }
476    }
477}