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. Per RFC 9110's
90        /// `Retry-After` `delta-seconds` form, this is an integer count of
91        /// seconds. Handlers can use it to render a humanized reset time
92        /// alongside the prompt.
93        retry_after_seconds: Option<u64>,
94    },
95}
96
97/// Response from the handler back to the SDK, used to construct the
98/// JSON-RPC reply sent to the CLI.
99#[non_exhaustive]
100#[derive(Debug)]
101pub enum HandlerResponse {
102    /// No response needed (used for fire-and-forget `SessionEvent`s).
103    Ok,
104    /// Permission decision.
105    Permission(PermissionResult),
106    /// User input response (or `None` to signal no input available).
107    UserInput(Option<UserInputResponse>),
108    /// Result of a tool execution.
109    ToolResult(ToolResult),
110    /// Elicitation result (accept/decline/cancel with optional form data).
111    Elicitation(ElicitationResult),
112    /// Exit plan mode decision.
113    ExitPlanMode(ExitPlanModeResult),
114    /// Auto-mode-switch decision.
115    AutoModeSwitch(AutoModeSwitchResponse),
116}
117
118/// Result of a permission request.
119///
120/// `#[non_exhaustive]` so future variants can be added without a major
121/// version bump. Match arms must include a `_` fallback.
122#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum PermissionResult {
125    /// Permission granted.
126    Approved,
127    /// Permission denied.
128    Denied,
129    /// Defer the response. The handler will resolve this request itself
130    /// later — typically after a UI prompt — by calling
131    /// `session.permissions.handlePendingPermissionRequest` directly. The
132    /// SDK will not send a response for this request.
133    ///
134    /// **Notification path only** (`permission.requested`). On the direct
135    /// RPC path (`permission.request`), `Deferred` falls back to
136    /// [`Approved`](Self::Approved) because that path must return a value
137    /// to satisfy the JSON-RPC reply contract.
138    Deferred,
139    /// Provide the full response payload. The SDK passes the value as-is
140    /// in the `result` field of `handlePendingPermissionRequest`
141    /// (notification path) or as the JSON-RPC `result` directly (direct
142    /// RPC path).
143    ///
144    /// Use this for response shapes beyond `{ "kind": "approve-once" }`
145    /// or `{ "kind": "reject" }` — for example, "approve and remember"
146    /// with allowlist data.
147    Custom(serde_json::Value),
148    /// No user is available to respond — for example, headless agents
149    /// without an interactive session. Sent as
150    /// `{ "kind": "user-not-available" }`.
151    UserNotAvailable,
152    /// The handler has no result to provide and the CLI should fall back
153    /// to its default policy. Sent as `{ "kind": "no-result" }`. Distinct
154    /// from [`Deferred`](Self::Deferred), which suppresses the reply
155    /// entirely so the handler can resolve later out-of-band.
156    NoResult,
157}
158
159/// Response to a user input request.
160#[derive(Debug, Clone)]
161pub struct UserInputResponse {
162    /// The user's answer text.
163    pub answer: String,
164    /// Whether the answer was free-form (not a preset choice).
165    pub was_freeform: bool,
166}
167
168/// Result of an exit-plan-mode request.
169#[derive(Debug, Clone)]
170pub struct ExitPlanModeResult {
171    /// Whether the user approved exiting plan mode.
172    pub approved: bool,
173    /// The action the user selected (if any).
174    pub selected_action: Option<String>,
175    /// Optional feedback text from the user.
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            session_log: None,
389            error: Some(msg),
390        })
391    }
392
393    /// The CLI is requesting an elicitation (structured form / URL prompt).
394    ///
395    /// Default: cancel.
396    async fn on_elicitation(
397        &self,
398        _session_id: SessionId,
399        _request_id: RequestId,
400        _request: ElicitationRequest,
401    ) -> ElicitationResult {
402        ElicitationResult {
403            action: "cancel".to_string(),
404            content: None,
405        }
406    }
407
408    /// The CLI is asking the user whether to exit plan mode.
409    ///
410    /// Default: [`ExitPlanModeResult::default`] (approved with no action).
411    async fn on_exit_plan_mode(
412        &self,
413        _session_id: SessionId,
414        _data: ExitPlanModeData,
415    ) -> ExitPlanModeResult {
416        ExitPlanModeResult::default()
417    }
418
419    /// The CLI is asking whether to switch to auto model after an eligible
420    /// rate limit.
421    ///
422    /// `retry_after_seconds`, when present, is the number of seconds until the
423    /// rate limit resets (RFC 9110 `Retry-After` `delta-seconds`). Handlers
424    /// can use it to render a humanized reset time alongside the prompt.
425    ///
426    /// Default: [`AutoModeSwitchResponse::No`] — decline. Override only if
427    /// your application surfaces a UX for the rate-limit-recovery prompt.
428    async fn on_auto_mode_switch(
429        &self,
430        _session_id: SessionId,
431        _error_code: Option<String>,
432        _retry_after_seconds: Option<u64>,
433    ) -> AutoModeSwitchResponse {
434        AutoModeSwitchResponse::No
435    }
436}
437
438/// A [`SessionHandler`] that auto-approves all permissions and ignores all events.
439///
440/// Useful for CLI tools, scripts, and tests that don't need interactive
441/// permission prompts or custom tool handling.
442#[derive(Debug, Clone)]
443pub struct ApproveAllHandler;
444
445#[async_trait]
446impl SessionHandler for ApproveAllHandler {
447    async fn on_permission_request(
448        &self,
449        _session_id: SessionId,
450        _request_id: RequestId,
451        _data: PermissionRequestData,
452    ) -> PermissionResult {
453        PermissionResult::Approved
454    }
455}
456
457/// A [`SessionHandler`] that denies all permission requests and otherwise
458/// relies on the trait's default fallback responses for every other event
459/// (e.g. tool invocations return "unhandled", elicitations cancel, plan-mode
460/// prompts decline). This is the safe default used when no handler is set on
461/// [`SessionConfig::handler`](crate::types::SessionConfig::handler) — sessions
462/// will not stall on permission prompts (they're denied immediately) but no
463/// privileged actions will be taken without an explicit opt-in.
464#[derive(Debug, Clone)]
465pub struct DenyAllHandler;
466
467#[async_trait]
468impl SessionHandler for DenyAllHandler {
469    // All defaults are already safe: permissions deny, everything else is a
470    // sensible fallback. We just reuse them here for clarity.
471}
472
473#[cfg(test)]
474mod tests {
475    use serde_json::Value;
476
477    use super::*;
478    use crate::types::{PermissionRequestData, RequestId, SessionId};
479
480    fn perm_data() -> PermissionRequestData {
481        PermissionRequestData::default()
482    }
483
484    // A handler that overrides only `on_permission_request` (per-method style).
485    struct ApproveViaPerMethod;
486
487    #[async_trait]
488    impl SessionHandler for ApproveViaPerMethod {
489        async fn on_permission_request(
490            &self,
491            _: SessionId,
492            _: RequestId,
493            _: PermissionRequestData,
494        ) -> PermissionResult {
495            PermissionResult::Approved
496        }
497    }
498
499    // A handler that overrides `on_event` directly (legacy / routing style).
500    struct ApproveViaOnEvent;
501
502    #[async_trait]
503    impl SessionHandler for ApproveViaOnEvent {
504        async fn on_event(&self, event: HandlerEvent) -> HandlerResponse {
505            match event {
506                HandlerEvent::PermissionRequest { .. } => {
507                    HandlerResponse::Permission(PermissionResult::Approved)
508                }
509                _ => HandlerResponse::Ok,
510            }
511        }
512    }
513
514    #[tokio::test]
515    async fn per_method_override_dispatches_via_default_on_event() {
516        let h = ApproveViaPerMethod;
517        let resp = h
518            .on_event(HandlerEvent::PermissionRequest {
519                session_id: SessionId::from("s1".to_string()),
520                request_id: RequestId::new("r1"),
521                data: perm_data(),
522            })
523            .await;
524        assert!(matches!(
525            resp,
526            HandlerResponse::Permission(PermissionResult::Approved)
527        ));
528    }
529
530    #[tokio::test]
531    async fn on_event_override_short_circuits_per_method_defaults() {
532        let h = ApproveViaOnEvent;
533        let resp = h
534            .on_event(HandlerEvent::PermissionRequest {
535                session_id: SessionId::from("s1".to_string()),
536                request_id: RequestId::new("r1"),
537                data: perm_data(),
538            })
539            .await;
540        assert!(matches!(
541            resp,
542            HandlerResponse::Permission(PermissionResult::Approved)
543        ));
544    }
545
546    #[tokio::test]
547    async fn deny_all_handler_uses_default_permission_deny() {
548        let h = DenyAllHandler;
549        let resp = h
550            .on_event(HandlerEvent::PermissionRequest {
551                session_id: SessionId::from("s1".to_string()),
552                request_id: RequestId::new("r1"),
553                data: perm_data(),
554            })
555            .await;
556        assert!(matches!(
557            resp,
558            HandlerResponse::Permission(PermissionResult::Denied)
559        ));
560    }
561
562    #[tokio::test]
563    async fn default_on_external_tool_returns_failure() {
564        let h = DenyAllHandler;
565        let resp = h
566            .on_event(HandlerEvent::ExternalTool {
567                invocation: crate::types::ToolInvocation {
568                    session_id: SessionId::from("s1".to_string()),
569                    tool_call_id: "tc1".to_string(),
570                    tool_name: "missing".to_string(),
571                    arguments: Value::Null,
572                    traceparent: None,
573                    tracestate: None,
574                },
575            })
576            .await;
577        match resp {
578            HandlerResponse::ToolResult(crate::types::ToolResult::Expanded(exp)) => {
579                assert_eq!(exp.result_type, "failure");
580                assert!(exp.text_result_for_llm.contains("missing"));
581                assert_eq!(exp.error.as_deref(), Some(exp.text_result_for_llm.as_str()));
582            }
583            other => panic!("unexpected response: {other:?}"),
584        }
585    }
586
587    #[tokio::test]
588    async fn default_on_elicitation_returns_cancel() {
589        let h = DenyAllHandler;
590        let resp = h
591            .on_event(HandlerEvent::ElicitationRequest {
592                session_id: SessionId::from("s1".to_string()),
593                request_id: RequestId::new("r1"),
594                request: crate::types::ElicitationRequest {
595                    message: "test".to_string(),
596                    requested_schema: None,
597                    mode: Some(crate::types::ElicitationMode::Form),
598                    elicitation_source: None,
599                    url: None,
600                },
601            })
602            .await;
603        match resp {
604            HandlerResponse::Elicitation(r) => assert_eq!(r.action, "cancel"),
605            other => panic!("unexpected response: {other:?}"),
606        }
607    }
608}