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