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}