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