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}