Skip to main content

github_copilot_sdk/
handler.rs

1//! Event handler traits for session lifecycle.
2//!
3//! The [`SessionHandler`](crate::handler::SessionHandler) trait is the primary extension point — implement
4//! [`on_event`](crate::handler::SessionHandler::on_event) to control how sessions respond to
5//! CLI events, permission requests, tool calls, and user input prompts.
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10use crate::types::{
11    ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId,
12    SessionEvent, SessionId, ToolInvocation, ToolResult,
13};
14
15/// Events dispatched by the SDK session event loop to the handler.
16///
17/// The handler returns a [`HandlerResponse`] indicating how the SDK should
18/// respond to the CLI. For fire-and-forget events (`SessionEvent`), the
19/// response is ignored.
20#[non_exhaustive]
21#[derive(Debug)]
22pub enum HandlerEvent {
23    /// Informational session event from the timeline (e.g. assistant.message_delta,
24    /// session.idle, tool.execution_start). Fire-and-forget — return `HandlerResponse::Ok`.
25    SessionEvent {
26        /// The session that emitted this event.
27        session_id: SessionId,
28        /// The event payload.
29        event: SessionEvent,
30    },
31
32    /// The CLI requests permission for an action. Return `HandlerResponse::Permission(..)`.
33    PermissionRequest {
34        /// The requesting session.
35        session_id: SessionId,
36        /// Unique ID to correlate the response.
37        request_id: RequestId,
38        /// Permission request payload.
39        data: PermissionRequestData,
40    },
41
42    /// The CLI requests user input. Return `HandlerResponse::UserInput(..)`.
43    /// The handler may block (e.g. awaiting a UI dialog) — this is expected.
44    UserInput {
45        /// The requesting session.
46        session_id: SessionId,
47        /// The question text to present.
48        question: String,
49        /// Optional multiple-choice options.
50        choices: Option<Vec<String>>,
51        /// Whether free-form text input is allowed.
52        allow_freeform: Option<bool>,
53    },
54
55    /// The CLI requests execution of a client-defined tool.
56    /// Return `HandlerResponse::ToolResult(..)`.
57    ExternalTool {
58        /// The tool call to execute.
59        invocation: ToolInvocation,
60    },
61
62    /// The CLI broadcasts an elicitation request for the provider to handle.
63    /// Return `HandlerResponse::Elicitation(..)`.
64    ElicitationRequest {
65        /// The requesting session.
66        session_id: SessionId,
67        /// Unique ID to correlate the response.
68        request_id: RequestId,
69        /// The elicitation request payload.
70        request: ElicitationRequest,
71    },
72
73    /// The CLI requests exiting plan mode. Return `HandlerResponse::ExitPlanMode(..)`.
74    ExitPlanMode {
75        /// The requesting session.
76        session_id: SessionId,
77        /// Plan mode exit payload.
78        data: ExitPlanModeData,
79    },
80
81    /// The CLI asks whether to switch to auto model when an eligible rate
82    /// limit is hit. Return [`HandlerResponse::AutoModeSwitch`].
83    AutoModeSwitch {
84        /// The requesting session.
85        session_id: SessionId,
86        /// The specific rate-limit error code that triggered the request,
87        /// if known (e.g. `user_weekly_rate_limited`, `user_global_rate_limited`).
88        error_code: Option<String>,
89        /// Seconds until the rate limit resets, when known.
90        retry_after_seconds: Option<f64>,
91    },
92}
93
94/// Response from the handler back to the SDK, used to construct the
95/// JSON-RPC reply sent to the CLI.
96#[non_exhaustive]
97#[derive(Debug)]
98pub enum HandlerResponse {
99    /// No response needed (used for fire-and-forget `SessionEvent`s).
100    Ok,
101    /// Permission decision.
102    Permission(PermissionResult),
103    /// User input response (or `None` to signal no input available).
104    UserInput(Option<UserInputResponse>),
105    /// Result of a tool execution.
106    ToolResult(ToolResult),
107    /// Elicitation result (accept/decline/cancel with optional form data).
108    Elicitation(ElicitationResult),
109    /// Exit plan mode decision.
110    ExitPlanMode(ExitPlanModeResult),
111    /// Auto-mode-switch decision.
112    AutoModeSwitch(AutoModeSwitchResponse),
113}
114
115/// Result of a permission request.
116///
117/// `#[non_exhaustive]` so future variants can be added without a major
118/// version bump. Match arms must include a `_` fallback.
119#[derive(Debug, Clone)]
120#[non_exhaustive]
121pub enum PermissionResult {
122    /// Permission granted.
123    Approved,
124    /// Permission denied.
125    Denied,
126    /// Defer the response. The handler will resolve this request itself
127    /// later — typically after a UI prompt — by calling
128    /// `session.permissions.handlePendingPermissionRequest` directly. The
129    /// SDK will not send a response for this request.
130    ///
131    /// **Notification path only** (`permission.requested`). On the direct
132    /// RPC path (`permission.request`), `Deferred` falls back to
133    /// [`Approved`](Self::Approved) because that path must return a value
134    /// to satisfy the JSON-RPC reply contract.
135    Deferred,
136    /// Provide the full response payload. The SDK passes the value as-is
137    /// in the `result` field of `handlePendingPermissionRequest`
138    /// (notification path) or as the JSON-RPC `result` directly (direct
139    /// RPC path).
140    ///
141    /// Use this for response shapes beyond `{ "kind": "approve-once" }`
142    /// or `{ "kind": "reject" }` — for example, "approve and remember"
143    /// with allowlist data.
144    Custom(serde_json::Value),
145    /// No user is available to respond — for example, headless agents
146    /// without an interactive session. Sent as
147    /// `{ "kind": "user-not-available" }`.
148    UserNotAvailable,
149    /// The handler has no result to provide and the CLI should fall back
150    /// to its default policy. Sent as `{ "kind": "no-result" }`. Distinct
151    /// from [`Deferred`](Self::Deferred), which suppresses the reply
152    /// entirely so the handler can resolve later out-of-band.
153    NoResult,
154}
155
156/// Response to a user input request.
157#[derive(Debug, Clone)]
158pub struct UserInputResponse {
159    /// The user's answer text.
160    pub answer: String,
161    /// Whether the answer was free-form (not a preset choice).
162    pub was_freeform: bool,
163}
164
165/// Result of an exit-plan-mode request.
166#[derive(Debug, Clone, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct ExitPlanModeResult {
169    /// Whether the user approved exiting plan mode.
170    pub approved: bool,
171    /// The action the user selected (if any).
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub selected_action: Option<String>,
174    /// Optional feedback text from the user.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub feedback: Option<String>,
177}
178
179impl Default for ExitPlanModeResult {
180    fn default() -> Self {
181        Self {
182            approved: true,
183            selected_action: None,
184            feedback: None,
185        }
186    }
187}
188
189/// Response to a [`HandlerEvent::AutoModeSwitch`] request.
190///
191/// Wire serialization matches the CLI's `autoModeSwitch.request` response
192/// schema: `"yes"`, `"yes_always"`, or `"no"`.
193#[non_exhaustive]
194#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum AutoModeSwitchResponse {
197    /// Approve the auto-mode switch for this rate-limit cycle only.
198    Yes,
199    /// Approve and remember — auto-accept future auto-mode switches in this
200    /// session without prompting.
201    YesAlways,
202    /// Decline the auto-mode switch. The session stays on the current model
203    /// and surfaces the rate-limit error.
204    No,
205}
206
207/// Callback trait for session events.
208///
209/// Implement this trait to control how a session responds to CLI events,
210/// permission requests, tool calls, user input prompts, elicitations, and
211/// plan-mode exits. There are two styles of implementation — pick whichever
212/// fits your use case:
213///
214/// 1. **Per-event methods (recommended for most handlers).** Override the
215///    specific `on_*` methods you care about; every method has a safe
216///    default so you only write what you need. This is the pattern used by
217///    [`serenity::EventHandler`][serenity], `lapin`, and most Rust SDKs
218///    that dispatch broker/client callbacks.
219/// 2. **Single [`on_event`](Self::on_event) method.** Override this one
220///    method and `match` on [`HandlerEvent`] yourself. Useful for logging
221///    middleware, custom routing, or when you want an exhaustiveness check
222///    across all variants.
223///
224/// When you override [`on_event`](Self::on_event) directly, the per-event methods are not
225/// called — your implementation is entirely responsible for dispatch. The
226/// default [`on_event`](Self::on_event) fans out to the per-event methods.
227///
228/// [serenity]: https://docs.rs/serenity/latest/serenity/client/trait.EventHandler.html
229///
230/// # Default behavior
231///
232/// - Permission requests → **denied** (safe default).
233/// - User input → `None` (no answer available).
234/// - External tool calls → failure result with "no handler registered".
235/// - Elicitation → `"cancel"`.
236/// - Exit plan mode → [`ExitPlanModeResult::default`].
237/// - Auto-mode-switch → [`AutoModeSwitchResponse::No`] (decline by default; the
238///   session stays on its current model and surfaces the rate-limit error).
239/// - Session events → ignored (fire-and-forget).
240///
241/// # Concurrency
242///
243/// **Request-triggered events** (`UserInput`, `ExternalTool` via `tool.call`,
244/// `ExitPlanMode`, `PermissionRequest` via `permission.request`) are awaited
245/// inline in the event loop and therefore processed **serially** per session.
246/// Blocking here pauses that session's event loop — which is correct, since
247/// the CLI is also blocked waiting for the response.
248///
249/// **Notification-triggered events** (`PermissionRequest` via
250/// `permission.requested`, `ExternalTool` via `external_tool.requested`) are
251/// dispatched on spawned tasks and may run **concurrently** with each other
252/// and with the serial event loop. Implementations must be safe for
253/// concurrent invocation.
254///
255/// # Example
256///
257/// ```no_run
258/// use async_trait::async_trait;
259/// use github_copilot_sdk::handler::{PermissionResult, SessionHandler};
260/// use github_copilot_sdk::types::{PermissionRequestData, RequestId, SessionId};
261///
262/// struct ApproveReadsOnly;
263///
264/// #[async_trait]
265/// impl SessionHandler for ApproveReadsOnly {
266///     async fn on_permission_request(
267///         &self,
268///         _sid: SessionId,
269///         _rid: RequestId,
270///         data: PermissionRequestData,
271///     ) -> PermissionResult {
272///         match data.extra.get("tool").and_then(|v| v.as_str()) {
273///             Some("view") | Some("ls") | Some("grep") => PermissionResult::Approved,
274///             _ => PermissionResult::Denied,
275///         }
276///     }
277/// }
278/// ```
279#[async_trait]
280pub trait SessionHandler: Send + Sync + 'static {
281    /// Handle an event from the session.
282    ///
283    /// The default implementation destructures `event` and calls the
284    /// matching per-event method (e.g. [`on_permission_request`](Self::on_permission_request)
285    /// for [`HandlerEvent::PermissionRequest`]). Override this method only
286    /// if you want a single dispatch point with exhaustive matching — most
287    /// handlers should override the per-event methods instead.
288    ///
289    /// See the [trait-level docs](SessionHandler#concurrency) for details on
290    /// which events may be dispatched concurrently.
291    async fn on_event(&self, event: HandlerEvent) -> HandlerResponse {
292        match event {
293            HandlerEvent::SessionEvent { session_id, event } => {
294                self.on_session_event(session_id, event).await;
295                HandlerResponse::Ok
296            }
297            HandlerEvent::PermissionRequest {
298                session_id,
299                request_id,
300                data,
301            } => HandlerResponse::Permission(
302                self.on_permission_request(session_id, request_id, data)
303                    .await,
304            ),
305            HandlerEvent::UserInput {
306                session_id,
307                question,
308                choices,
309                allow_freeform,
310            } => HandlerResponse::UserInput(
311                self.on_user_input(session_id, question, choices, allow_freeform)
312                    .await,
313            ),
314            HandlerEvent::ExternalTool { invocation } => {
315                HandlerResponse::ToolResult(self.on_external_tool(invocation).await)
316            }
317            HandlerEvent::ElicitationRequest {
318                session_id,
319                request_id,
320                request,
321            } => HandlerResponse::Elicitation(
322                self.on_elicitation(session_id, request_id, request).await,
323            ),
324            HandlerEvent::ExitPlanMode { session_id, data } => {
325                HandlerResponse::ExitPlanMode(self.on_exit_plan_mode(session_id, data).await)
326            }
327            HandlerEvent::AutoModeSwitch {
328                session_id,
329                error_code,
330                retry_after_seconds,
331            } => HandlerResponse::AutoModeSwitch(
332                self.on_auto_mode_switch(session_id, error_code, retry_after_seconds)
333                    .await,
334            ),
335        }
336    }
337
338    /// Informational timeline event (assistant messages, tool execution
339    /// markers, session idle, etc.). Fire-and-forget — the return value is
340    /// ignored.
341    ///
342    /// Default: do nothing.
343    async fn on_session_event(&self, _session_id: SessionId, _event: SessionEvent) {}
344
345    /// The CLI is asking whether the agent may perform a privileged action.
346    ///
347    /// Default: [`PermissionResult::Denied`]. The default-deny posture
348    /// matches the CLI's safety model; override to implement your own
349    /// policy (see the [`permission`](crate::permission) module for common
350    /// wrappers like `approve_all` / `approve_if`).
351    async fn on_permission_request(
352        &self,
353        _session_id: SessionId,
354        _request_id: RequestId,
355        _data: PermissionRequestData,
356    ) -> PermissionResult {
357        PermissionResult::Denied
358    }
359
360    /// The CLI is asking the user a question (optionally with a list of
361    /// choices).
362    ///
363    /// Default: `None` — the CLI interprets this as "no answer available"
364    /// and falls back to its own prompt behavior.
365    async fn on_user_input(
366        &self,
367        _session_id: SessionId,
368        _question: String,
369        _choices: Option<Vec<String>>,
370        _allow_freeform: Option<bool>,
371    ) -> Option<UserInputResponse> {
372        None
373    }
374
375    /// The CLI wants to invoke a client-defined ("external") tool.
376    ///
377    /// Default: a failure [`ToolResult`] indicating no tool handler is
378    /// registered. Typical implementations route to a
379    /// [`ToolHandlerRouter`](crate::tool::ToolHandlerRouter) which
380    /// dispatches to tools registered via
381    /// [`define_tool`](crate::tool::define_tool) or custom
382    /// [`ToolHandler`](crate::tool::ToolHandler) impls.
383    async fn on_external_tool(&self, invocation: ToolInvocation) -> ToolResult {
384        let msg = format!("No handler registered for tool '{}'", invocation.tool_name);
385        ToolResult::Expanded(crate::types::ToolResultExpanded {
386            text_result_for_llm: msg.clone(),
387            result_type: "failure".to_string(),
388            binary_results_for_llm: None,
389            session_log: None,
390            error: Some(msg),
391            tool_telemetry: None,
392        })
393    }
394
395    /// The CLI is requesting an elicitation (structured form / URL prompt).
396    ///
397    /// Default: cancel.
398    async fn on_elicitation(
399        &self,
400        _session_id: SessionId,
401        _request_id: RequestId,
402        _request: ElicitationRequest,
403    ) -> ElicitationResult {
404        ElicitationResult {
405            action: "cancel".to_string(),
406            content: None,
407        }
408    }
409
410    /// The CLI is asking the user whether to exit plan mode.
411    ///
412    /// Default: [`ExitPlanModeResult::default`] (approved with no action).
413    async fn on_exit_plan_mode(
414        &self,
415        _session_id: SessionId,
416        _data: ExitPlanModeData,
417    ) -> ExitPlanModeResult {
418        ExitPlanModeResult::default()
419    }
420
421    /// The CLI is asking whether to switch to auto model after an eligible
422    /// rate limit.
423    ///
424    /// `retry_after_seconds`, when present, is the number of seconds until the
425    /// rate limit resets. Handlers can use it to render a humanized reset time
426    /// alongside the prompt.
427    ///
428    /// Default: [`AutoModeSwitchResponse::No`] — decline. Override only if
429    /// your application surfaces a UX for the rate-limit-recovery prompt.
430    async fn on_auto_mode_switch(
431        &self,
432        _session_id: SessionId,
433        _error_code: Option<String>,
434        _retry_after_seconds: Option<f64>,
435    ) -> AutoModeSwitchResponse {
436        AutoModeSwitchResponse::No
437    }
438}
439
440/// A [`SessionHandler`] that auto-approves all permissions and ignores all events.
441///
442/// Useful for CLI tools, scripts, and tests that don't need interactive
443/// permission prompts or custom tool handling.
444#[derive(Debug, Clone)]
445pub struct ApproveAllHandler;
446
447#[async_trait]
448impl SessionHandler for ApproveAllHandler {
449    async fn on_permission_request(
450        &self,
451        _session_id: SessionId,
452        _request_id: RequestId,
453        _data: PermissionRequestData,
454    ) -> PermissionResult {
455        PermissionResult::Approved
456    }
457}
458
459/// A [`SessionHandler`] that denies all permission requests and otherwise
460/// relies on the trait's default fallback responses for every other event
461/// (e.g. tool invocations return "unhandled", elicitations cancel, plan-mode
462/// prompts decline). This is the safe default used when no handler is set on
463/// [`SessionConfig::handler`](crate::types::SessionConfig::handler) — sessions
464/// will not stall on permission prompts (they're denied immediately) but no
465/// privileged actions will be taken without an explicit opt-in.
466#[derive(Debug, Clone)]
467pub struct DenyAllHandler;
468
469#[async_trait]
470impl SessionHandler for DenyAllHandler {
471    // All defaults are already safe: permissions deny, everything else is a
472    // sensible fallback. We just reuse them here for clarity.
473}
474
475#[cfg(test)]
476mod tests {
477    use serde_json::Value;
478
479    use super::*;
480    use crate::types::{PermissionRequestData, RequestId, SessionId};
481
482    fn perm_data() -> PermissionRequestData {
483        PermissionRequestData::default()
484    }
485
486    // A handler that overrides only `on_permission_request` (per-method style).
487    struct ApproveViaPerMethod;
488
489    #[async_trait]
490    impl SessionHandler for ApproveViaPerMethod {
491        async fn on_permission_request(
492            &self,
493            _: SessionId,
494            _: RequestId,
495            _: PermissionRequestData,
496        ) -> PermissionResult {
497            PermissionResult::Approved
498        }
499    }
500
501    // A handler that overrides `on_event` directly (legacy / routing style).
502    struct ApproveViaOnEvent;
503
504    #[async_trait]
505    impl SessionHandler for ApproveViaOnEvent {
506        async fn on_event(&self, event: HandlerEvent) -> HandlerResponse {
507            match event {
508                HandlerEvent::PermissionRequest { .. } => {
509                    HandlerResponse::Permission(PermissionResult::Approved)
510                }
511                _ => HandlerResponse::Ok,
512            }
513        }
514    }
515
516    #[tokio::test]
517    async fn per_method_override_dispatches_via_default_on_event() {
518        let h = ApproveViaPerMethod;
519        let resp = h
520            .on_event(HandlerEvent::PermissionRequest {
521                session_id: SessionId::from("s1".to_string()),
522                request_id: RequestId::new("r1"),
523                data: perm_data(),
524            })
525            .await;
526        assert!(matches!(
527            resp,
528            HandlerResponse::Permission(PermissionResult::Approved)
529        ));
530    }
531
532    #[tokio::test]
533    async fn on_event_override_short_circuits_per_method_defaults() {
534        let h = ApproveViaOnEvent;
535        let resp = h
536            .on_event(HandlerEvent::PermissionRequest {
537                session_id: SessionId::from("s1".to_string()),
538                request_id: RequestId::new("r1"),
539                data: perm_data(),
540            })
541            .await;
542        assert!(matches!(
543            resp,
544            HandlerResponse::Permission(PermissionResult::Approved)
545        ));
546    }
547
548    #[tokio::test]
549    async fn deny_all_handler_uses_default_permission_deny() {
550        let h = DenyAllHandler;
551        let resp = h
552            .on_event(HandlerEvent::PermissionRequest {
553                session_id: SessionId::from("s1".to_string()),
554                request_id: RequestId::new("r1"),
555                data: perm_data(),
556            })
557            .await;
558        assert!(matches!(
559            resp,
560            HandlerResponse::Permission(PermissionResult::Denied)
561        ));
562    }
563
564    #[tokio::test]
565    async fn default_on_external_tool_returns_failure() {
566        let h = DenyAllHandler;
567        let resp = h
568            .on_event(HandlerEvent::ExternalTool {
569                invocation: crate::types::ToolInvocation {
570                    session_id: SessionId::from("s1".to_string()),
571                    tool_call_id: "tc1".to_string(),
572                    tool_name: "missing".to_string(),
573                    arguments: Value::Null,
574                    traceparent: None,
575                    tracestate: None,
576                },
577            })
578            .await;
579        match resp {
580            HandlerResponse::ToolResult(crate::types::ToolResult::Expanded(exp)) => {
581                assert_eq!(exp.result_type, "failure");
582                assert!(exp.text_result_for_llm.contains("missing"));
583                assert_eq!(exp.error.as_deref(), Some(exp.text_result_for_llm.as_str()));
584            }
585            other => panic!("unexpected response: {other:?}"),
586        }
587    }
588
589    #[tokio::test]
590    async fn default_on_elicitation_returns_cancel() {
591        let h = DenyAllHandler;
592        let resp = h
593            .on_event(HandlerEvent::ElicitationRequest {
594                session_id: SessionId::from("s1".to_string()),
595                request_id: RequestId::new("r1"),
596                request: crate::types::ElicitationRequest {
597                    message: "test".to_string(),
598                    requested_schema: None,
599                    mode: Some(crate::types::ElicitationMode::Form),
600                    elicitation_source: None,
601                    url: None,
602                },
603            })
604            .await;
605        match resp {
606            HandlerResponse::Elicitation(r) => assert_eq!(r.action, "cancel"),
607            other => panic!("unexpected response: {other:?}"),
608        }
609    }
610}