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    ///     source: PermissionDecisionSource::HostPolicy,
118    ///     surface: PermissionDecisionSurface::Sdk,
119    /// });
120    /// ```
121    pub fn with_context(self, context: PermissionDecisionContext) -> Self {
122        match self {
123            Self::Decision { decision, .. } => Self::Decision {
124                decision,
125                context: Some(context),
126            },
127            Self::NoResult => Self::NoResult,
128        }
129    }
130}
131
132impl From<PermissionDecision> for PermissionResult {
133    fn from(value: PermissionDecision) -> Self {
134        Self::Decision {
135            decision: value,
136            context: None,
137        }
138    }
139}
140
141pub(crate) fn permission_handler_failure(message: &str) -> PermissionResult {
142    tracing::error!(error = message, "permission handler failed");
143    PermissionResult::user_not_available()
144}
145
146/// Response to a user input request.
147#[derive(Debug, Clone)]
148pub struct UserInputResponse {
149    /// The user's answer text.
150    pub answer: String,
151    /// Whether the answer was free-form (not a preset choice).
152    pub was_freeform: bool,
153}
154
155/// Result of an exit-plan-mode request.
156#[derive(Debug, Clone, Serialize)]
157#[serde(rename_all = "camelCase")]
158pub struct ExitPlanModeResult {
159    /// Whether the user approved exiting plan mode.
160    pub approved: bool,
161    /// The action the user selected (if any).
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub selected_action: Option<String>,
164    /// Optional feedback text from the user.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub feedback: Option<String>,
167}
168
169impl Default for ExitPlanModeResult {
170    fn default() -> Self {
171        Self {
172            approved: true,
173            selected_action: None,
174            feedback: None,
175        }
176    }
177}
178
179/// Response to an auto-mode-switch request.
180#[non_exhaustive]
181#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum AutoModeSwitchResponse {
184    /// Approve the auto-mode switch for this rate-limit cycle only.
185    Yes,
186    /// Approve and remember -- auto-accept future auto-mode switches in
187    /// this session without prompting.
188    YesAlways,
189    /// Decline the auto-mode switch. The session stays on the current
190    /// model and surfaces the rate-limit error.
191    No,
192}
193
194/// Handler for `permission.requested` broadcasts.
195///
196/// Install via
197/// [`SessionConfig::with_permission_handler`](crate::types::SessionConfig::with_permission_handler)
198/// (or the matching method on [`ResumeSessionConfig`](crate::types::ResumeSessionConfig)).
199/// When no permission handler is supplied, the SDK sends
200/// `requestPermission: false` on the wire and the runtime short-circuits
201/// permission prompts for this client.
202#[async_trait]
203pub trait PermissionHandler: Send + Sync + 'static {
204    /// Resolve a permission request.
205    async fn handle(
206        &self,
207        session_id: SessionId,
208        request_id: RequestId,
209        data: PermissionRequestData,
210    ) -> PermissionResult;
211}
212
213/// Handler for `elicitation.requested` broadcasts.
214///
215/// When unset, `requestElicitation: false` goes on the wire.
216#[async_trait]
217pub trait ElicitationHandler: Send + Sync + 'static {
218    /// Respond to an elicitation prompt (form, URL confirm, etc.).
219    async fn handle(
220        &self,
221        session_id: SessionId,
222        request_id: RequestId,
223        request: ElicitationRequest,
224    ) -> ElicitationResult;
225}
226
227/// MCP OAuth request that the SDK host can satisfy with a host-acquired token.
228#[derive(Debug, Clone)]
229pub struct McpAuthRequest {
230    /// Identifier for the pending MCP OAuth request.
231    pub request_id: RequestId,
232    /// Display name of the MCP server that requires OAuth.
233    pub server_name: String,
234    /// URL of the MCP server that requires OAuth.
235    pub server_url: String,
236    /// Why the runtime is requesting host-provided OAuth credentials.
237    pub reason: McpOauthRequestReason,
238    /// Parsed WWW-Authenticate parameters from the MCP server, if available.
239    pub www_authenticate_params: Option<McpOauthWWWAuthenticateParams>,
240    /// Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available.
241    pub resource_metadata: Option<String>,
242    /// Static OAuth client configuration, if the server specifies one.
243    pub static_client_config: Option<McpOauthRequiredStaticClientConfig>,
244}
245
246/// Result returned by an MCP auth request handler.
247#[derive(Debug, Clone)]
248pub enum McpAuthResult {
249    /// Supplies host-acquired OAuth token data.
250    Token {
251        /// Access token acquired by the SDK host.
252        access_token: String,
253        /// OAuth token type. Defaults to Bearer when omitted.
254        token_type: Option<String>,
255        /// Token lifetime in seconds, if known.
256        expires_in: Option<i64>,
257    },
258    /// Declines or cancels the pending OAuth request.
259    Cancelled,
260}
261
262impl McpAuthResult {
263    pub(crate) fn into_wire(self) -> McpOauthPendingRequestResponse {
264        match self {
265            Self::Token {
266                access_token,
267                token_type,
268                expires_in,
269            } => McpOauthPendingRequestResponse::Token(McpOauthPendingRequestResponseToken {
270                access_token,
271                token_type,
272                expires_in,
273                kind: McpOauthPendingRequestResponseTokenKind::Token,
274            }),
275            Self::Cancelled => {
276                McpOauthPendingRequestResponse::Cancelled(McpOauthPendingRequestResponseCancelled {
277                    kind: McpOauthPendingRequestResponseCancelledKind::Cancelled,
278                })
279            }
280        }
281    }
282}
283
284/// Handler for MCP server OAuth requests.
285#[async_trait]
286pub trait McpAuthHandler: Send + Sync + 'static {
287    /// Resolve an MCP OAuth request with host token data or cancellation.
288    async fn handle(
289        &self,
290        session_id: SessionId,
291        request_id: RequestId,
292        request: McpAuthRequest,
293    ) -> McpAuthResult;
294}
295
296/// Handler for `user_input.requested` events from the `ask_user` tool.
297///
298/// When unset, `requestUserInput: false` goes on the wire and the
299/// `ask_user` tool is disabled for the session.
300#[async_trait]
301pub trait UserInputHandler: Send + Sync + 'static {
302    /// Answer a question on behalf of the user. Return `None` to signal
303    /// "no answer available".
304    async fn handle(
305        &self,
306        session_id: SessionId,
307        question: String,
308        choices: Option<Vec<String>>,
309        allow_freeform: Option<bool>,
310    ) -> Option<UserInputResponse>;
311}
312
313/// Handler for `exit_plan_mode.requested` events. When unset,
314/// `requestExitPlanMode: false` goes on the wire.
315#[async_trait]
316pub trait ExitPlanModeHandler: Send + Sync + 'static {
317    /// Decide whether to leave plan mode.
318    async fn handle(&self, session_id: SessionId, data: ExitPlanModeData) -> ExitPlanModeResult;
319}
320
321/// Handler for `auto_mode_switch.requested` events. When unset,
322/// `requestAutoModeSwitch: false` goes on the wire.
323#[async_trait]
324pub trait AutoModeSwitchHandler: Send + Sync + 'static {
325    /// Decide whether to fall back to the auto model after an eligible
326    /// rate-limit error. `retry_after_seconds`, when present, is the
327    /// number of seconds until the rate limit resets.
328    async fn handle(
329        &self,
330        session_id: SessionId,
331        error_code: Option<String>,
332        retry_after_seconds: Option<f64>,
333    ) -> AutoModeSwitchResponse;
334}
335
336/// A [`PermissionHandler`] that approves ordinary requests when managed settings are disabled.
337///
338/// When managed settings are enabled, the handler logs an error and returns a
339/// user-not-available decision. As a defense-in-depth fallback, a request marked
340/// as requiring managed approval is left unanswered even if the session flag is
341/// absent.
342#[derive(Debug, Clone)]
343pub struct ApproveAllHandler;
344
345#[async_trait]
346impl PermissionHandler for ApproveAllHandler {
347    async fn handle(
348        &self,
349        _session_id: SessionId,
350        _request_id: RequestId,
351        data: PermissionRequestData,
352    ) -> PermissionResult {
353        if data.managed_settings_enabled {
354            permission_handler_failure(
355                "ApproveAllHandler cannot be used when managed settings are enabled",
356            )
357        } else if data.managed_approval_required == Some(true) {
358            PermissionResult::no_result()
359        } else {
360            PermissionResult::approve_once()
361        }
362    }
363}
364
365/// A [`PermissionHandler`] that denies every request.
366#[derive(Debug, Clone)]
367pub struct DenyAllHandler;
368
369#[async_trait]
370impl PermissionHandler for DenyAllHandler {
371    async fn handle(
372        &self,
373        _session_id: SessionId,
374        _request_id: RequestId,
375        _data: PermissionRequestData,
376    ) -> PermissionResult {
377        PermissionResult::reject(None)
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[tokio::test]
386    async fn approve_all_handler_returns_approved() {
387        let result = ApproveAllHandler
388            .handle(
389                SessionId::from("s1"),
390                RequestId::new("1"),
391                PermissionRequestData::default(),
392            )
393            .await;
394        assert!(matches!(
395            result,
396            PermissionResult::Decision {
397                decision: PermissionDecision::ApproveOnce(_),
398                ..
399            }
400        ));
401    }
402
403    #[tokio::test]
404    async fn approve_all_handler_fails_when_managed_settings_enabled() {
405        let result = ApproveAllHandler
406            .handle(
407                SessionId::from("s1"),
408                RequestId::new("1"),
409                PermissionRequestData {
410                    managed_settings_enabled: true,
411                    ..Default::default()
412                },
413            )
414            .await;
415        assert!(matches!(
416            result,
417            PermissionResult::Decision {
418                decision: PermissionDecision::UserNotAvailable(_),
419                ..
420            }
421        ));
422    }
423
424    #[tokio::test]
425    async fn approve_all_handler_leaves_managed_approval_pending() {
426        let result = ApproveAllHandler
427            .handle(
428                SessionId::from("s1"),
429                RequestId::new("1"),
430                PermissionRequestData {
431                    managed_approval_required: Some(true),
432                    ..Default::default()
433                },
434            )
435            .await;
436        assert!(matches!(result, PermissionResult::NoResult));
437    }
438
439    #[tokio::test]
440    async fn deny_all_handler_returns_denied() {
441        let result = DenyAllHandler
442            .handle(
443                SessionId::from("s1"),
444                RequestId::new("1"),
445                PermissionRequestData::default(),
446            )
447            .await;
448        assert!(matches!(
449            result,
450            PermissionResult::Decision {
451                decision: PermissionDecision::Reject(_),
452                ..
453            }
454        ));
455    }
456
457    #[test]
458    fn mcp_auth_result_token_converts_to_wire_response() {
459        let wire = McpAuthResult::Token {
460            access_token: "host-token".to_string(),
461            token_type: Some("Bearer".to_string()),
462            expires_in: Some(3600),
463        }
464        .into_wire();
465
466        match wire {
467            McpOauthPendingRequestResponse::Token(token) => {
468                assert_eq!(token.access_token, "host-token");
469                assert_eq!(token.token_type.as_deref(), Some("Bearer"));
470                assert_eq!(token.expires_in, Some(3600));
471            }
472            McpOauthPendingRequestResponse::Cancelled(_) => panic!("expected token response"),
473        }
474    }
475}