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 `ask_user` tool.
298///
299/// When unset, `requestUserInput: false` goes on the wire and the
300/// `ask_user` tool is disabled for the session.
301#[async_trait]
302pub trait UserInputHandler: Send + Sync + 'static {
303    /// Answer a question on behalf of the user. Return `None` to signal
304    /// "no answer available".
305    async fn handle(
306        &self,
307        session_id: SessionId,
308        question: String,
309        choices: Option<Vec<String>>,
310        allow_freeform: Option<bool>,
311    ) -> Option<UserInputResponse>;
312}
313
314/// Handler for `exit_plan_mode.requested` events. When unset,
315/// `requestExitPlanMode: false` goes on the wire.
316#[async_trait]
317pub trait ExitPlanModeHandler: Send + Sync + 'static {
318    /// Decide whether to leave plan mode.
319    async fn handle(&self, session_id: SessionId, data: ExitPlanModeData) -> ExitPlanModeResult;
320}
321
322/// Handler for `auto_mode_switch.requested` events. When unset,
323/// `requestAutoModeSwitch: false` goes on the wire.
324#[async_trait]
325pub trait AutoModeSwitchHandler: Send + Sync + 'static {
326    /// Decide whether to fall back to the auto model after an eligible
327    /// rate-limit error. `retry_after_seconds`, when present, is the
328    /// number of seconds until the rate limit resets.
329    async fn handle(
330        &self,
331        session_id: SessionId,
332        error_code: Option<String>,
333        retry_after_seconds: Option<f64>,
334    ) -> AutoModeSwitchResponse;
335}
336
337/// A [`PermissionHandler`] that approves ordinary requests when managed settings are disabled.
338///
339/// When managed settings are enabled, the handler logs an error and returns a
340/// user-not-available decision. As a defense-in-depth fallback, a request marked
341/// as requiring managed approval is left unanswered even if the session flag is
342/// absent.
343#[derive(Debug, Clone)]
344pub struct ApproveAllHandler;
345
346#[async_trait]
347impl PermissionHandler for ApproveAllHandler {
348    async fn handle(
349        &self,
350        _session_id: SessionId,
351        _request_id: RequestId,
352        data: PermissionRequestData,
353    ) -> PermissionResult {
354        if data.managed_settings_enabled {
355            permission_handler_failure(
356                "ApproveAllHandler cannot be used when managed settings are enabled",
357            )
358        } else if data.managed_approval_required == Some(true) {
359            PermissionResult::no_result()
360        } else {
361            PermissionResult::approve_once()
362        }
363    }
364}
365
366/// A [`PermissionHandler`] that denies every request.
367#[derive(Debug, Clone)]
368pub struct DenyAllHandler;
369
370#[async_trait]
371impl PermissionHandler for DenyAllHandler {
372    async fn handle(
373        &self,
374        _session_id: SessionId,
375        _request_id: RequestId,
376        _data: PermissionRequestData,
377    ) -> PermissionResult {
378        PermissionResult::reject(None)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[tokio::test]
387    async fn approve_all_handler_returns_approved() {
388        let result = ApproveAllHandler
389            .handle(
390                SessionId::from("s1"),
391                RequestId::new("1"),
392                PermissionRequestData::default(),
393            )
394            .await;
395        assert!(matches!(
396            result,
397            PermissionResult::Decision {
398                decision: PermissionDecision::ApproveOnce(_),
399                ..
400            }
401        ));
402    }
403
404    #[tokio::test]
405    async fn approve_all_handler_fails_when_managed_settings_enabled() {
406        let result = ApproveAllHandler
407            .handle(
408                SessionId::from("s1"),
409                RequestId::new("1"),
410                PermissionRequestData {
411                    managed_settings_enabled: true,
412                    ..Default::default()
413                },
414            )
415            .await;
416        assert!(matches!(
417            result,
418            PermissionResult::Decision {
419                decision: PermissionDecision::UserNotAvailable(_),
420                ..
421            }
422        ));
423    }
424
425    #[tokio::test]
426    async fn approve_all_handler_leaves_managed_approval_pending() {
427        let result = ApproveAllHandler
428            .handle(
429                SessionId::from("s1"),
430                RequestId::new("1"),
431                PermissionRequestData {
432                    managed_approval_required: Some(true),
433                    ..Default::default()
434                },
435            )
436            .await;
437        assert!(matches!(result, PermissionResult::NoResult));
438    }
439
440    #[tokio::test]
441    async fn deny_all_handler_returns_denied() {
442        let result = DenyAllHandler
443            .handle(
444                SessionId::from("s1"),
445                RequestId::new("1"),
446                PermissionRequestData::default(),
447            )
448            .await;
449        assert!(matches!(
450            result,
451            PermissionResult::Decision {
452                decision: PermissionDecision::Reject(_),
453                ..
454            }
455        ));
456    }
457
458    #[test]
459    fn mcp_auth_result_token_converts_to_wire_response() {
460        let wire = McpAuthResult::Token {
461            access_token: "host-token".to_string(),
462            token_type: Some("Bearer".to_string()),
463            expires_in: Some(3600),
464        }
465        .into_wire();
466
467        match wire {
468            McpOauthPendingRequestResponse::Token(token) => {
469                assert_eq!(token.access_token, "host-token");
470                assert_eq!(token.token_type.as_deref(), Some("Bearer"));
471                assert_eq!(token.expires_in, Some(3600));
472            }
473            McpOauthPendingRequestResponse::Cancelled(_) => panic!("expected token response"),
474        }
475    }
476}