Skip to main content

github_copilot_sdk/
session.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use parking_lot::Mutex as ParkingLotMutex;
7use serde_json::Value;
8use tokio::sync::oneshot;
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11use tracing::{Instrument, warn};
12
13use crate::canvas::CanvasHandler;
14use crate::generated::api_types::{
15    LogRequest, ModelSwitchToRequest, OpenCanvasInstance, RegisterEventInterestParams,
16    ToolsGetCurrentMetadataResult, rpc_methods,
17};
18use crate::generated::session_events::{
19    CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData,
20    SessionCanvasClosedData, SessionErrorData, SessionEventType,
21};
22use crate::handler::{
23    AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler,
24    McpAuthHandler, McpAuthRequest, McpAuthResult, PermissionHandler, PermissionResult,
25    UserInputHandler, UserInputResponse,
26};
27use crate::hooks::SessionHooks;
28use crate::provider_token::BearerTokenProvider;
29use crate::session_fs::SessionFsProvider;
30use crate::trace_context::inject_trace_context;
31use crate::transforms::SystemMessageTransform;
32use crate::types::{
33    CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest,
34    ElicitationResult, ExitPlanModeData, GetMessagesResponse, MessageOptions,
35    PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride,
36    SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions,
37    SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, TraceContext,
38    UiInputOptions, ensure_attachment_display_names,
39};
40use crate::{
41    Client, Error, ErrorKind, JsonRpcResponse, SessionErrorKind, SessionEventNotification,
42    error_codes,
43};
44
45/// Fixed name of the runtime's built-in tool-search tool. A client can replace
46/// its behavior by registering a tool with this exact name and
47/// `overrides_built_in_tool` set to `true`.
48const TOOL_SEARCH_TOOL_NAME: &str = "tool_search_tool";
49
50/// Bundle of the per-session callbacks the SDK dispatches to. Built from a
51/// [`SessionConfig`] / [`ResumeSessionConfig`] at
52/// [`Client::create_session`] / [`Client::resume_session`] time. Each
53/// field is `None` (or an empty map for tools) when the caller didn't
54/// install a handler -- in that case the SDK skips dispatch for that
55/// event type. The wire flags on `session.create` / `session.resume`
56/// are derived from these fields.
57#[derive(Clone)]
58pub(crate) struct SessionHandlers {
59    pub permission: Option<Arc<dyn PermissionHandler>>,
60    pub elicitation: Option<Arc<dyn ElicitationHandler>>,
61    pub mcp_auth: Option<Arc<dyn McpAuthHandler>>,
62    pub user_input: Option<Arc<dyn UserInputHandler>>,
63    pub exit_plan_mode: Option<Arc<dyn ExitPlanModeHandler>>,
64    pub auto_mode_switch: Option<Arc<dyn AutoModeSwitchHandler>>,
65    pub tools: Arc<HashMap<String, Arc<dyn crate::tool::ToolHandler>>>,
66}
67
68/// Shared state between a [`Session`] and its event loop, used by [`Session::send_and_wait`].
69struct IdleWaiter {
70    tx: oneshot::Sender<Result<Option<SessionEvent>, Error>>,
71    last_assistant_message: Option<SessionEvent>,
72    started_at: Instant,
73    first_assistant_message_seen: bool,
74}
75
76/// RAII guard that clears the [`Session::idle_waiter`] slot on drop. Used
77/// by [`Session::send_and_wait`] to ensure the slot doesn't leak if the
78/// caller's future is cancelled (outer `tokio::time::timeout` / `select!`
79/// / dropped JoinHandle). Synchronous clear via `parking_lot::Mutex` —
80/// no async drop needed.
81///
82/// Without this, an outer cancellation between "install waiter" and
83/// "drain channel" would leave the slot occupied, causing all subsequent
84/// `send` and `send_and_wait` calls on the session to return
85/// [`SendWhileWaiting`](SessionErrorKind::SendWhileWaiting). Closes RFD-400
86/// review finding #2.
87struct WaiterGuard {
88    slot: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
89}
90
91impl Drop for WaiterGuard {
92    fn drop(&mut self) {
93        self.slot.lock().take();
94    }
95}
96
97struct PendingSessionRegistration {
98    client: Client,
99    session_id: SessionId,
100    shutdown: CancellationToken,
101    disarmed: bool,
102}
103
104impl PendingSessionRegistration {
105    fn new(client: Client, session_id: SessionId, shutdown: CancellationToken) -> Self {
106        Self {
107            client,
108            session_id,
109            shutdown,
110            disarmed: false,
111        }
112    }
113
114    async fn cleanup(mut self, event_loop: JoinHandle<()>) {
115        self.shutdown.cancel();
116        let _ = event_loop.await;
117        self.client.unregister_session(&self.session_id);
118        self.disarmed = true;
119    }
120
121    fn disarm(&mut self) {
122        self.disarmed = true;
123    }
124}
125
126impl Drop for PendingSessionRegistration {
127    fn drop(&mut self) {
128        if !self.disarmed {
129            self.shutdown.cancel();
130            self.client.unregister_session(&self.session_id);
131        }
132    }
133}
134
135/// A session on a GitHub Copilot CLI server.
136///
137/// Created via [`Client::create_session`] or [`Client::resume_session`].
138/// Owns an internal event loop that dispatches events to the per-callback
139/// handlers installed on the session config.
140///
141/// Protocol methods (`send`, `get_events`, `abort`, etc.) automatically
142/// inject the session ID into RPC params.
143///
144/// Call [`destroy`](Self::destroy) for graceful cleanup (RPC + local). If dropped
145/// without calling `destroy`, the `Drop` impl aborts the event loop and
146/// unregisters from the router as a best-effort safety net.
147pub struct Session {
148    id: SessionId,
149    cwd: PathBuf,
150    workspace_path: Option<PathBuf>,
151    remote_url: Option<String>,
152    client: Client,
153    /// Handle to the spawned event-loop task. Sync `parking_lot::Mutex`
154    /// because the lock is never held across an `.await` and the `Drop`
155    /// impl needs to take the handle synchronously without `try_lock`
156    /// fallibility.
157    event_loop: ParkingLotMutex<Option<JoinHandle<()>>>,
158    /// Cooperative shutdown signal for the event loop. The loop selects
159    /// on [`shutdown.cancelled()`](CancellationToken::cancelled) alongside
160    /// its inbound channels; [`Session::stop_event_loop`] and [`Drop`]
161    /// both call [`cancel()`](CancellationToken::cancel) to ask the loop
162    /// to exit between iterations rather than aborting the task (which
163    /// can land at any await point and leave the session mid-protocol).
164    /// See RFD-400 review finding #3.
165    ///
166    /// `CancellationToken` is the canonical signalling primitive in
167    /// `tokio_util`; it is what `tonic` uses for the equivalent task-
168    /// coordination case. Advanced consumers can obtain a child token
169    /// via [`Session::cancellation_token`] to bind their own work to
170    /// the session lifetime.
171    shutdown: CancellationToken,
172    /// Only populated while a `send_and_wait` call is in flight.
173    ///
174    /// Sync `parking_lot::Mutex` because the lock is never held across an
175    /// `.await`, and synchronous access lets the `WaiterGuard` RAII helper
176    /// in `send_and_wait` clear the slot from a `Drop` impl on caller-side
177    /// cancellation. See RFD-400 review (cancel-safety hardening).
178    idle_waiter: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
179    /// Capabilities negotiated with the CLI, updated on `capabilities.changed` events.
180    capabilities: Arc<parking_lot::RwLock<SessionCapabilities>>,
181    /// Canvas instances currently known to be open for this session.
182    open_canvases: Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
183    /// Broadcast channel for runtime event subscribers — see [`Session::subscribe`].
184    event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
185}
186
187impl Session {
188    /// Session ID assigned by the CLI.
189    pub fn id(&self) -> &SessionId {
190        &self.id
191    }
192
193    /// Working directory of the CLI process.
194    pub fn cwd(&self) -> &PathBuf {
195        &self.cwd
196    }
197
198    /// Workspace directory for the session (if using infinite sessions).
199    pub fn workspace_path(&self) -> Option<&Path> {
200        self.workspace_path.as_deref()
201    }
202
203    /// Remote session URL, if the session is running remotely.
204    pub fn remote_url(&self) -> Option<&str> {
205        self.remote_url.as_deref()
206    }
207
208    /// Session capabilities negotiated with the CLI.
209    ///
210    /// Capabilities are set during session creation and updated at runtime
211    /// via `capabilities.changed` events.
212    pub fn capabilities(&self) -> SessionCapabilities {
213        self.capabilities.read().clone()
214    }
215
216    /// Open canvas instances reported by the most recent `session.resume`
217    /// response or surfaced by inbound `canvas.opened` events.
218    pub fn open_canvases(&self) -> Vec<OpenCanvasInstance> {
219        self.open_canvases.read().clone()
220    }
221
222    /// Returns a [`CancellationToken`] that fires when this session shuts
223    /// down (via [`Session::stop_event_loop`], [`Session::destroy`], or
224    /// [`Drop`]).
225    ///
226    /// Use this to bind an external task's lifetime to the session — when
227    /// the session shuts down, awaiting [`cancelled()`](CancellationToken::cancelled)
228    /// resolves so cooperative consumers can stop cleanly.
229    ///
230    /// The returned handle is a *child* token: calling
231    /// [`cancel()`](CancellationToken::cancel) on it cancels only the
232    /// caller's child, not the session itself. To cancel the session, call
233    /// [`Session::stop_event_loop`].
234    ///
235    /// # Example
236    ///
237    /// ```no_run
238    /// # async fn example(session: github_copilot_sdk::session::Session) {
239    /// let token = session.cancellation_token();
240    /// tokio::select! {
241    ///     _ = token.cancelled() => println!("session shut down"),
242    ///     _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
243    ///         println!("60s elapsed, session still alive");
244    ///     }
245    /// }
246    /// # }
247    /// ```
248    pub fn cancellation_token(&self) -> CancellationToken {
249        self.shutdown.child_token()
250    }
251
252    /// Subscribe to events for this session.
253    ///
254    /// Returns an [`EventSubscription`](crate::subscription::EventSubscription)
255    /// that yields every [`SessionEvent`] dispatched on this session's
256    /// event loop. Drop the value to unsubscribe; there is no separate
257    /// cancel handle.
258    ///
259    /// **Observe-only.** Subscribers receive a clone of every
260    /// [`SessionEvent`] but cannot influence permission decisions, tool
261    /// results, or anything else that requires returning a value. Those
262    /// remain the responsibility of the per-callback handlers passed via
263    /// [`SessionConfig`]'s `with_*_handler`
264    /// builder methods.
265    ///
266    /// The returned handle implements both an inherent
267    /// [`recv`](crate::subscription::EventSubscription::recv) method and
268    /// [`Stream`](tokio_stream::Stream), so callers can use a `while let`
269    /// loop or any combinator from `tokio_stream::StreamExt` /
270    /// `futures::StreamExt`.
271    ///
272    /// Each subscriber maintains its own queue. If a consumer cannot keep
273    /// up, the oldest events are dropped and `recv` returns
274    /// [`RecvErrorKind::Lagged`](crate::subscription::RecvErrorKind::Lagged)
275    /// reporting the count of skipped events. Slow consumers do not block
276    /// the session's event loop.
277    ///
278    /// # Example
279    ///
280    /// ```no_run
281    /// # async fn example(session: github_copilot_sdk::session::Session) {
282    /// let mut events = session.subscribe();
283    /// tokio::spawn(async move {
284    ///     while let Ok(event) = events.recv().await {
285    ///         println!("[{}] event {}", event.id, event.event_type);
286    ///     }
287    /// });
288    /// # }
289    /// ```
290    pub fn subscribe(&self) -> crate::subscription::EventSubscription {
291        crate::subscription::EventSubscription::new(self.event_tx.subscribe())
292    }
293
294    /// The underlying Client (for advanced use cases).
295    pub fn client(&self) -> &Client {
296        &self.client
297    }
298
299    /// Typed RPC namespace for this session.
300    ///
301    /// Every protocol method lives here under its schema-aligned path —
302    /// e.g. `session.rpc().workspaces().list_files()`. Wire method names
303    /// and request/response types are generated from the protocol schema,
304    /// so the typed namespace can't drift from the wire contract.
305    ///
306    /// The hand-authored helpers on [`Session`] delegate to this namespace
307    /// and remain the recommended entry point for everyday use; reach for
308    /// `rpc()` when you want a method without a hand-written wrapper.
309    pub fn rpc(&self) -> crate::generated::rpc::SessionRpc<'_> {
310        crate::generated::rpc::SessionRpc { session: self }
311    }
312
313    /// Stop the internal event loop. Called automatically on [`destroy`](Self::destroy).
314    ///
315    /// Cooperative: signals shutdown via the session's [`CancellationToken`]
316    /// and awaits the loop's natural exit rather than aborting the task.
317    /// Any in-flight handler (permission callback, tool call, elicitation
318    /// response) completes before the loop exits, so the CLI never sees a
319    /// half-handled request. See RFD-400 review finding #3.
320    pub async fn stop_event_loop(&self) {
321        self.shutdown.cancel();
322        let handle = self.event_loop.lock().take();
323        if let Some(handle) = handle {
324            let _ = handle.await;
325        }
326        // Fail any pending send_and_wait so it returns immediately.
327        if let Some(waiter) = self.idle_waiter.lock().take() {
328            let _ = waiter.tx.send(Err(
329                ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()
330            ));
331        }
332    }
333
334    /// Send a user message to the agent.
335    ///
336    /// Accepts anything convertible to [`MessageOptions`] — pass a `&str` for the
337    /// trivial case, or build a `MessageOptions` for mode/attachments. The
338    /// `wait_timeout` field on `MessageOptions` is ignored here (use
339    /// [`send_and_wait`](Self::send_and_wait) if you need to wait).
340    ///
341    /// Returns the assigned message ID, which can be used to correlate the
342    /// send with later [`SessionEvent`]s emitted in
343    /// response (assistant messages, tool requests, etc.).
344    ///
345    /// Returns an error if a [`send_and_wait`](Self::send_and_wait) call is
346    /// currently in flight, since the plain send would race with the waiter.
347    ///
348    /// # Cancel safety
349    ///
350    /// **Cancel-safe.** The underlying `session.send` RPC is dispatched
351    /// through the writer-actor (see [`Client::call`](crate::Client::call)),
352    /// so dropping this future after the actor has committed to writing
353    /// will not produce a partial frame on the wire. If the caller's
354    /// future is dropped between "frame enqueued" and "response received",
355    /// the message has already landed on the wire — the agent will process
356    /// it and emit events normally; the caller just won't see the returned
357    /// message ID.
358    pub async fn send(&self, opts: impl Into<MessageOptions>) -> Result<String, Error> {
359        if self.idle_waiter.lock().is_some() {
360            return Err(ErrorKind::Session(SessionErrorKind::SendWhileWaiting).into());
361        }
362        self.send_inner(opts.into()).await
363    }
364
365    async fn send_inner(&self, opts: MessageOptions) -> Result<String, Error> {
366        let mut params = serde_json::json!({
367            "sessionId": self.id,
368            "prompt": opts.prompt,
369        });
370        if let Some(m) = opts.mode {
371            params["mode"] = serde_json::to_value(m)?;
372        }
373        if let Some(am) = opts.agent_mode {
374            params["agentMode"] = serde_json::to_value(am)?;
375        }
376        if let Some(mut a) = opts.attachments {
377            ensure_attachment_display_names(&mut a);
378            params["attachments"] = serde_json::to_value(a)?;
379        }
380        if let Some(headers) = opts.request_headers
381            && !headers.is_empty()
382        {
383            params["requestHeaders"] = serde_json::to_value(headers)?;
384        }
385        if let Some(display_prompt) = opts.display_prompt {
386            params["displayPrompt"] = serde_json::to_value(display_prompt)?;
387        }
388        let trace_ctx = if opts.traceparent.is_some() || opts.tracestate.is_some() {
389            TraceContext {
390                traceparent: opts.traceparent,
391                tracestate: opts.tracestate,
392            }
393        } else {
394            self.client.resolve_trace_context().await
395        };
396        inject_trace_context(&mut params, &trace_ctx);
397        let rpc_start = Instant::now();
398        let result = self.client.call("session.send", Some(params)).await?;
399        let message_id = result
400            .get("messageId")
401            .and_then(|v| v.as_str())
402            .map(|s| s.to_string())
403            .unwrap_or_default();
404        tracing::debug!(
405            elapsed_ms = rpc_start.elapsed().as_millis(),
406            session_id = %self.id,
407            message_id = %message_id,
408            "Session::send completed successfully"
409        );
410        Ok(message_id)
411    }
412
413    /// Send a user message and wait for the agent to finish processing.
414    ///
415    /// Accepts anything convertible to [`MessageOptions`] — pass a `&str` for the
416    /// trivial case, or build a `MessageOptions` for mode/attachments/timeout.
417    /// Blocks until `session.idle` (success) or `session.error` (failure),
418    /// returning the last `assistant.message` event captured during streaming.
419    /// Times out after `MessageOptions::wait_timeout` (default 60 seconds).
420    ///
421    /// Only one `send_and_wait` call may be active per session at a time.
422    /// Calling [`send`](Self::send) while a `send_and_wait`
423    /// is in flight will also return an error.
424    ///
425    /// # Cancel safety
426    ///
427    /// **Cancel-safe.** A `WaiterGuard` clears the in-flight slot on every
428    /// exit path (success, internal failure, internal timeout, *and*
429    /// external cancellation via `tokio::time::timeout` / `select!` /
430    /// dropped JoinHandle). Subsequent `send` and `send_and_wait` calls on
431    /// this session will succeed normally — the slot is never leaked.
432    pub async fn send_and_wait(
433        &self,
434        opts: impl Into<MessageOptions>,
435    ) -> Result<Option<SessionEvent>, Error> {
436        let total_start = Instant::now();
437        let opts = opts.into();
438        let timeout_duration = opts.wait_timeout.unwrap_or(Duration::from_secs(60));
439        let (tx, rx) = oneshot::channel();
440
441        {
442            let mut guard = self.idle_waiter.lock();
443            if guard.is_some() {
444                return Err(ErrorKind::Session(SessionErrorKind::SendWhileWaiting).into());
445            }
446            *guard = Some(IdleWaiter {
447                tx,
448                last_assistant_message: None,
449                started_at: total_start,
450                first_assistant_message_seen: false,
451            });
452        }
453
454        // RAII: clears the idle_waiter slot on every exit path, including
455        // external cancellation (caller's outer `select!` / `timeout` /
456        // dropped future). Without this, an outer cancellation would leak
457        // the slot and brick subsequent `send`/`send_and_wait` calls.
458        let _waiter_guard = WaiterGuard {
459            slot: self.idle_waiter.clone(),
460        };
461
462        let result = tokio::time::timeout(timeout_duration, async {
463            self.send_inner(opts).await?;
464            match rx.await {
465                Ok(result) => result,
466                Err(_) => Err(ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()),
467            }
468        })
469        .await;
470
471        match result {
472            Ok(inner) => {
473                tracing::debug!(
474                    elapsed_ms = total_start.elapsed().as_millis(),
475                    session_id = %self.id,
476                    completed_by = if inner.is_ok() { "idle" } else { "error" },
477                    "Session::send_and_wait complete"
478                );
479                inner
480            }
481            Err(_) => {
482                tracing::warn!(
483                    elapsed_ms = total_start.elapsed().as_millis(),
484                    session_id = %self.id,
485                    completed_by = "timeout",
486                    "Session::send_and_wait failed"
487                );
488                Err(ErrorKind::Session(SessionErrorKind::Timeout(timeout_duration)).into())
489            }
490        }
491    }
492
493    /// Retrieve the session's timeline events.
494    pub async fn get_events(&self) -> Result<Vec<SessionEvent>, Error> {
495        let result = self
496            .client
497            .call(
498                "session.getMessages",
499                Some(serde_json::json!({ "sessionId": self.id })),
500            )
501            .await?;
502        let response: GetMessagesResponse = serde_json::from_value(result)?;
503        Ok(response.events)
504    }
505
506    /// Deprecated alias for [`get_events`](Self::get_events).
507    #[deprecated(since = "0.1.0", note = "Use `get_events()` instead")]
508    pub async fn get_messages(&self) -> Result<Vec<SessionEvent>, Error> {
509        self.get_events().await
510    }
511
512    /// Abort the current agent turn.
513    ///
514    /// # Cancel safety
515    ///
516    /// **Cancel-safe.** Single `session.abort` RPC; the underlying
517    /// [`Client::call`](crate::Client::call) is cancel-safe via the
518    /// writer-actor.
519    pub async fn abort(&self) -> Result<(), Error> {
520        self.client
521            .call(
522                "session.abort",
523                Some(serde_json::json!({ "sessionId": self.id })),
524            )
525            .await?;
526        Ok(())
527    }
528
529    /// Switch to a different model.
530    ///
531    /// Pass `None` for `opts` if no extra configuration is needed.
532    pub async fn set_model(&self, model: &str, opts: Option<SetModelOptions>) -> Result<(), Error> {
533        let opts = opts.unwrap_or_default();
534        let request = ModelSwitchToRequest {
535            model_id: model.to_string(),
536            reasoning_effort: opts.reasoning_effort,
537            reasoning_summary: opts.reasoning_summary,
538            verbosity: None,
539            context_tier: opts.context_tier,
540            model_capabilities: opts.model_capabilities,
541            defer_if_model_change_queued: None,
542        };
543        self.rpc().model().switch_to(request).await?;
544        Ok(())
545    }
546
547    /// Disconnect this session from the CLI.
548    ///
549    /// Sends the `session.destroy` RPC, stops the event loop, and unregisters
550    /// the session from the client. **Session state on disk** (conversation
551    /// history, planning state, artifacts) is **preserved**, so the
552    /// conversation can be resumed later via [`Client::resume_session`]
553    /// using this session's ID. To permanently remove all on-disk session
554    /// data, use [`Client::delete_session`] instead.
555    ///
556    /// The caller should ensure the session is idle (e.g. [`send_and_wait`]
557    /// has returned) before disconnecting; in-flight tool or event handlers
558    /// may otherwise observe failures.
559    ///
560    /// [`Client::resume_session`]: crate::Client::resume_session
561    /// [`Client::delete_session`]: crate::Client::delete_session
562    /// [`send_and_wait`]: Self::send_and_wait
563    pub async fn disconnect(&self) -> Result<(), Error> {
564        self.client
565            .call(
566                "session.destroy",
567                Some(serde_json::json!({ "sessionId": self.id })),
568            )
569            .await?;
570        self.stop_event_loop().await;
571        self.client.unregister_session(&self.id);
572        Ok(())
573    }
574
575    /// Deprecated alias for [`disconnect`](Self::disconnect). The
576    /// underlying wire RPC happens to be named `session.destroy`, but it
577    /// only severs the connection — on-disk session state is preserved.
578    /// Prefer `disconnect` in new code.
579    #[deprecated(since = "0.1.0", note = "Use `disconnect()` instead")]
580    pub async fn destroy(&self) -> Result<(), Error> {
581        self.disconnect().await
582    }
583
584    /// Write a log message to the session.
585    ///
586    /// Pass `None` for `opts` to use defaults (info level, persisted).
587    pub async fn log(
588        &self,
589        message: &str,
590        opts: Option<crate::types::LogOptions>,
591    ) -> Result<(), Error> {
592        let opts = opts.unwrap_or_default();
593        let level = match opts.level {
594            Some(level) => Some(serde_json::from_value(serde_json::to_value(level)?)?),
595            None => None,
596        };
597        let request = LogRequest {
598            message: message.to_string(),
599            level,
600            ephemeral: opts.ephemeral,
601            r#type: None,
602            tip: None,
603            url: None,
604        };
605        self.rpc().log(request).await?;
606        Ok(())
607    }
608
609    /// Returns the UI sub-API for elicitation, confirmation, selection, and
610    /// free-form input.
611    ///
612    /// All UI methods route through `session.ui.*` RPCs and require host
613    /// support — check `session.capabilities().ui.elicitation` before use.
614    pub fn ui(&self) -> SessionUi<'_> {
615        SessionUi { session: self }
616    }
617
618    /// Returns an error if the host doesn't support elicitation.
619    fn assert_elicitation(&self) -> Result<(), Error> {
620        if self
621            .capabilities
622            .read()
623            .ui
624            .as_ref()
625            .and_then(|u| u.elicitation)
626            != Some(true)
627        {
628            return Err(ErrorKind::Session(SessionErrorKind::ElicitationNotSupported).into());
629        }
630        Ok(())
631    }
632}
633
634impl Drop for Session {
635    fn drop(&mut self) {
636        // Cooperative shutdown: cancel the event loop's token to signal
637        // exit between iterations. The loop will see the cancellation on
638        // its next select poll and break cleanly without interrupting an
639        // in-flight handler. We do NOT abort the JoinHandle — that would
640        // land at any await point in the loop body, potentially leaving
641        // the CLI with an unanswered request id. RFD-400 review finding
642        // #3.
643        //
644        // The handle itself is left in `event_loop` to be reaped by the
645        // tokio runtime when it next polls; we intentionally don't await
646        // it here because Drop is sync.
647        self.shutdown.cancel();
648        self.client.unregister_session(&self.id);
649    }
650}
651
652/// UI sub-API for a [`Session`] — elicitation, confirmation, selection,
653/// and free-form input.
654///
655/// Acquired via [`Session::ui`]. Methods route to `session.ui.*` RPCs and
656/// require host elicitation support — check
657/// `session.capabilities().ui.elicitation` before use.
658pub struct SessionUi<'a> {
659    session: &'a Session,
660}
661
662impl<'a> SessionUi<'a> {
663    /// Request user input via an interactive UI form (elicitation).
664    ///
665    /// Sends a JSON Schema describing form fields to the CLI host. The host
666    /// renders a form dialog and returns the user's response.
667    ///
668    /// Prefer the typed convenience methods [`confirm`](Self::confirm),
669    /// [`select`](Self::select), and [`input`](Self::input) for common cases.
670    pub async fn elicitation(
671        &self,
672        message: &str,
673        schema: Value,
674    ) -> Result<ElicitationResult, Error> {
675        self.session.assert_elicitation()?;
676        let result = self
677            .session
678            .client
679            .call(
680                "session.ui.elicitation",
681                Some(serde_json::json!({
682                    "sessionId": self.session.id,
683                    "message": message,
684                    "requestedSchema": schema,
685                })),
686            )
687            .await?;
688        let elicitation: ElicitationResult = serde_json::from_value(result)?;
689        Ok(elicitation)
690    }
691
692    /// Ask the user a yes/no confirmation question.
693    ///
694    /// Returns `true` if the user accepted and confirmed, `false` otherwise.
695    pub async fn confirm(&self, message: &str) -> Result<bool, Error> {
696        self.session.assert_elicitation()?;
697        let schema = serde_json::json!({
698            "type": "object",
699            "properties": {
700                "confirmed": {
701                    "type": "boolean",
702                    "default": true,
703                }
704            },
705            "required": ["confirmed"]
706        });
707        let result = self.elicitation(message, schema).await?;
708        Ok(result.action == "accept"
709            && result
710                .content
711                .and_then(|c| c.get("confirmed").and_then(|v| v.as_bool()))
712                == Some(true))
713    }
714
715    /// Ask the user to select from a list of options.
716    ///
717    /// Returns the selected option string on accept, or `None` on decline/cancel.
718    pub async fn select(&self, message: &str, options: &[&str]) -> Result<Option<String>, Error> {
719        self.session.assert_elicitation()?;
720        let schema = serde_json::json!({
721            "type": "object",
722            "properties": {
723                "selection": {
724                    "type": "string",
725                    "enum": options,
726                }
727            },
728            "required": ["selection"]
729        });
730        let result = self.elicitation(message, schema).await?;
731        if result.action != "accept" {
732            return Ok(None);
733        }
734        let selection = result.content.and_then(|c| {
735            c.get("selection")
736                .and_then(|v| v.as_str())
737                .map(String::from)
738        });
739        Ok(selection)
740    }
741
742    /// Ask the user for free-form text input.
743    ///
744    /// Returns the input string on accept, or `None` on decline/cancel.
745    /// Use [`UiInputOptions`] to set validation constraints and field metadata.
746    pub async fn input(
747        &self,
748        message: &str,
749        options: Option<&UiInputOptions<'_>>,
750    ) -> Result<Option<String>, Error> {
751        self.session.assert_elicitation()?;
752        let mut field = serde_json::json!({ "type": "string" });
753        if let Some(opts) = options {
754            if let Some(title) = opts.title {
755                field["title"] = Value::String(title.to_string());
756            }
757            if let Some(desc) = opts.description {
758                field["description"] = Value::String(desc.to_string());
759            }
760            if let Some(min) = opts.min_length {
761                field["minLength"] = Value::Number(min.into());
762            }
763            if let Some(max) = opts.max_length {
764                field["maxLength"] = Value::Number(max.into());
765            }
766            if let Some(fmt) = &opts.format {
767                field["format"] = Value::String(fmt.as_str().to_string());
768            }
769            if let Some(default) = opts.default {
770                field["default"] = Value::String(default.to_string());
771            }
772        }
773        let schema = serde_json::json!({
774            "type": "object",
775            "properties": { "value": field },
776            "required": ["value"]
777        });
778        let result = self.elicitation(message, schema).await?;
779        if result.action != "accept" {
780            return Ok(None);
781        }
782        let value = result
783            .content
784            .and_then(|c| c.get("value").and_then(|v| v.as_str()).map(String::from));
785        Ok(value)
786    }
787}
788
789impl Client {
790    /// Create a new session on the CLI.
791    ///
792    /// Sends `session.create`, registers the session on the router,
793    /// and spawns an internal event loop that dispatches to the handler.
794    ///
795    /// All callbacks (per-event handlers, tool handlers, hooks, transform)
796    /// are configured via [`SessionConfig`] using its `with_*_handler` /
797    /// `with_tools` / `with_hooks` / `with_system_message_transform` builder
798    /// methods.
799    ///
800    /// If [`hooks_handler`](SessionConfig::hooks_handler) is set, the
801    /// wire-level `hooks` flag is automatically enabled.
802    ///
803    /// If [`system_message_transform`](SessionConfig::system_message_transform) is set, the SDK injects
804    /// `action: "transform"` sections into the [`SystemMessageConfig`] wire
805    /// format and handles `systemMessage.transform` RPC callbacks during
806    /// the session.
807    ///
808    /// Each per-event handler is independently optional. If a handler is
809    /// not installed, the SDK signals the runtime not to emit the matching
810    /// broadcast (and silently skips dispatch if one arrives anyway).
811    pub async fn create_session(&self, mut config: SessionConfig) -> Result<Session, Error> {
812        let total_start = Instant::now();
813        // For cloud sessions, let the CLI/server assign the session id and
814        // register the session lazily once the response arrives. For non-cloud
815        // sessions we generate the id client-side (when the caller didn't
816        // supply one) so the session can be registered BEFORE the RPC — the
817        // CLI may issue session-scoped requests (e.g. sessionFs.writeFile for
818        // workspace metadata) during session.create processing, before it has
819        // sent the response.
820        let caller_session_id = config.session_id.clone();
821        let use_server_generated_id = config.cloud.is_some() && caller_session_id.is_none();
822        let local_session_id: Option<SessionId> = if use_server_generated_id {
823            None
824        } else {
825            Some(
826                caller_session_id
827                    .clone()
828                    .unwrap_or_else(|| SessionId::new(uuid::Uuid::new_v4().to_string())),
829            )
830        };
831        if config.hooks_handler.is_some() && config.hooks.is_none() {
832            config.hooks = Some(true);
833        }
834        if let Some(transforms) = config.system_message_transform.clone() {
835            inject_transform_sections(&mut config, transforms.as_ref());
836        }
837        let mode = self.inner.mode;
838        if mode == crate::ClientMode::Empty && config.available_tools.is_none() {
839            return Err(Error::with_message(
840                ErrorKind::InvalidConfig,
841                "ClientMode::Empty requires available_tools to be set on the session config. \
842                 Use ToolSet to specify which tools the session may use (e.g. \
843                 ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED)).",
844            ));
845        }
846        crate::mode::validate_tool_filter_list(
847            "available_tools",
848            config.available_tools.as_deref(),
849        )?;
850        crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?;
851        config.system_message =
852            crate::mode::system_message_for_mode(mode, config.system_message.take());
853        config.memory = crate::mode::memory_for_mode(mode, config.memory.take());
854        if mode == crate::ClientMode::Empty {
855            if config.enable_session_telemetry.is_none() {
856                config.enable_session_telemetry = Some(false);
857            }
858            if config.skip_embedding_retrieval.is_none() {
859                config.skip_embedding_retrieval = Some(true);
860            }
861            if config.enable_on_demand_instruction_discovery.is_none() {
862                config.enable_on_demand_instruction_discovery = Some(false);
863            }
864            if config.enable_file_hooks.is_none() {
865                config.enable_file_hooks = Some(false);
866            }
867            if config.enable_host_git_operations.is_none() {
868                config.enable_host_git_operations = Some(false);
869            }
870            if config.enable_session_store.is_none() {
871                config.enable_session_store = Some(false);
872            }
873            if config.enable_skills.is_none() {
874                config.enable_skills = Some(false);
875            }
876        }
877        if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() {
878            config.mcp_oauth_token_storage = Some("in-memory".into());
879        }
880        if mode == crate::ClientMode::Empty && config.embedding_cache_storage.is_none() {
881            config.embedding_cache_storage = Some("in-memory".into());
882        }
883        let opt_skip_custom_instructions = config.skip_custom_instructions;
884        let opt_custom_agents_local_only = config.custom_agents_local_only;
885        let opt_coauthor_enabled = config.coauthor_enabled;
886        let opt_manage_schedule_enabled = config.manage_schedule_enabled;
887        let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?;
888        wire.enable_github_telemetry_forwarding =
889            self.inner.on_github_telemetry.is_some().then_some(true);
890
891        let permission_handler = crate::permission::resolve_handler(
892            runtime.permission_handler.take(),
893            runtime.permission_policy.take(),
894        );
895        let handlers = SessionHandlers {
896            permission: permission_handler,
897            elicitation: runtime.elicitation_handler.take(),
898            mcp_auth: runtime.mcp_auth_handler.take(),
899            user_input: runtime.user_input_handler.take(),
900            exit_plan_mode: runtime.exit_plan_mode_handler.take(),
901            auto_mode_switch: runtime.auto_mode_switch_handler.take(),
902            tools: Arc::new(std::mem::take(&mut runtime.tool_handlers)),
903        };
904        let hooks = runtime.hooks_handler.take();
905        let transforms = runtime.system_message_transform.take();
906        let tools_count = wire.tools.as_ref().map_or(0, Vec::len);
907        let commands_count = runtime.commands.as_ref().map_or(0, Vec::len);
908        let has_hooks = hooks.is_some();
909        let command_handlers = build_command_handler_map(runtime.commands.as_deref());
910        let canvas_handler = runtime.canvas_handler.take();
911        let session_fs_provider = runtime.session_fs_provider.take();
912        let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers);
913        let has_mcp_auth_handler = handlers.mcp_auth.is_some();
914        if self.inner.session_fs_configured && session_fs_provider.is_none() {
915            return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into());
916        }
917        if self.inner.session_fs_sqlite_declared
918            && let Some(ref provider) = session_fs_provider
919            && provider.sqlite().is_none()
920        {
921            return Err(Error::with_message(
922                ErrorKind::InvalidConfig,
923                "SessionFs capabilities declare SQLite support but the provider \
924                 does not implement SessionFsSqliteProvider",
925            ));
926        }
927
928        let mut params = serde_json::to_value(&wire)?;
929        let trace_ctx = self.resolve_trace_context().await;
930        inject_trace_context(&mut params, &trace_ctx);
931
932        let setup_start = Instant::now();
933        let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default()));
934        let idle_waiter = Arc::new(ParkingLotMutex::new(None));
935        let open_canvases = Arc::new(parking_lot::RwLock::new(Vec::new()));
936        let shutdown = CancellationToken::new();
937        let (event_tx, _) = tokio::sync::broadcast::channel(512);
938
939        // For cloud sessions (use_server_generated_id), defer session
940        // registration to the inline callback so the read task registers
941        // the session synchronously the instant the response arrives.
942        // For non-cloud sessions, register up-front so the CLI can issue
943        // session-scoped requests during session.create processing.
944        let inline_stash: Arc<
945            ParkingLotMutex<Option<(SessionId, crate::router::SessionChannels)>>,
946        > = Arc::new(ParkingLotMutex::new(None));
947
948        let inline_callback: Option<crate::jsonrpc::InlineResponseCallback> = if let Some(ref sid) =
949            local_session_id
950        {
951            let channels = self.register_session(sid);
952            *inline_stash.lock() = Some((sid.clone(), channels));
953            None
954        } else {
955            let client = self.clone();
956            let stash = inline_stash.clone();
957            let expected = caller_session_id.clone();
958            Some(Box::new(move |response| {
959                let result = response.result.as_ref().ok_or_else(|| {
960                    Error::with_message(ErrorKind::Json, "session.create response had no result")
961                })?;
962                let parsed: CreateSessionResult =
963                    serde_json::from_value(result.clone()).map_err(Error::from)?;
964                if let Some(requested) = expected.as_ref()
965                    && parsed.session_id != *requested
966                {
967                    return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
968                        requested: requested.clone(),
969                        returned: parsed.session_id,
970                    })
971                    .into());
972                }
973                let channels = client.register_session(&parsed.session_id);
974                *stash.lock() = Some((parsed.session_id, channels));
975                Ok(())
976            }))
977        };
978
979        let rpc_start = Instant::now();
980        let result = match self
981            .call_with_inline_callback("session.create", Some(params), inline_callback)
982            .await
983        {
984            Ok(result) => result,
985            Err(error) => {
986                if let Some((id, _channels)) = inline_stash.lock().take() {
987                    self.unregister_session(&id);
988                }
989                return Err(error);
990            }
991        };
992        tracing::debug!(
993            elapsed_ms = rpc_start.elapsed().as_millis(),
994            "Client::create_session session creation request completed successfully"
995        );
996        let create_result: CreateSessionResult = match serde_json::from_value(result) {
997            Ok(result) => result,
998            Err(error) => {
999                if let Some((id, _channels)) = inline_stash.lock().take() {
1000                    self.unregister_session(&id);
1001                }
1002                return Err(error.into());
1003            }
1004        };
1005
1006        if let Some(ref requested) = local_session_id
1007            && create_result.session_id != *requested
1008        {
1009            if let Some((id, _channels)) = inline_stash.lock().take() {
1010                self.unregister_session(&id);
1011            }
1012            return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
1013                requested: requested.clone(),
1014                returned: create_result.session_id.clone(),
1015            })
1016            .into());
1017        }
1018
1019        let (session_id, channels) = inline_stash
1020            .lock()
1021            .take()
1022            .expect("session registration must have populated stash on success");
1023        let event_loop = spawn_event_loop(
1024            session_id.clone(),
1025            self.clone(),
1026            handlers,
1027            hooks,
1028            transforms,
1029            command_handlers,
1030            canvas_handler,
1031            session_fs_provider,
1032            bearer_token_providers,
1033            channels,
1034            idle_waiter.clone(),
1035            capabilities.clone(),
1036            open_canvases.clone(),
1037            event_tx.clone(),
1038            shutdown.clone(),
1039        );
1040        tracing::debug!(
1041            elapsed_ms = setup_start.elapsed().as_millis(),
1042            session_id = %session_id,
1043            tools_count,
1044            commands_count,
1045            has_hooks,
1046            "Client::create_session local setup complete"
1047        );
1048        *capabilities.write() = create_result.capabilities.unwrap_or_default();
1049        if has_mcp_auth_handler {
1050            register_mcp_auth_interest(self, &session_id).await?;
1051        }
1052
1053        tracing::debug!(
1054            elapsed_ms = total_start.elapsed().as_millis(),
1055            session_id = %session_id,
1056            "Client::create_session complete"
1057        );
1058        let session = Session {
1059            id: session_id,
1060            cwd: self.cwd().clone(),
1061            workspace_path: create_result.workspace_path,
1062            remote_url: create_result.remote_url,
1063            client: self.clone(),
1064            event_loop: ParkingLotMutex::new(Some(event_loop)),
1065            shutdown,
1066            idle_waiter,
1067            capabilities,
1068            open_canvases,
1069            event_tx,
1070        };
1071        apply_mode_post_create_patch(
1072            &session,
1073            mode,
1074            opt_skip_custom_instructions,
1075            opt_custom_agents_local_only,
1076            opt_coauthor_enabled,
1077            opt_manage_schedule_enabled,
1078        )
1079        .await?;
1080        Ok(session)
1081    }
1082
1083    /// Resume an existing session on the CLI.
1084    ///
1085    /// Sends `session.resume` and `session.skills.reload`, registers the
1086    /// session on the router, and spawns the event loop.
1087    ///
1088    /// All callbacks (event handler, hooks, transform) are configured
1089    /// via [`ResumeSessionConfig`] using its `with_*` builder methods.
1090    ///
1091    /// See [`Self::create_session`] for the defaults applied when callback
1092    /// fields are unset.
1093    pub async fn resume_session(&self, mut config: ResumeSessionConfig) -> Result<Session, Error> {
1094        let total_start = Instant::now();
1095        let session_id = config.session_id.clone();
1096        if config.hooks_handler.is_some() && config.hooks.is_none() {
1097            config.hooks = Some(true);
1098        }
1099        if let Some(transforms) = config.system_message_transform.clone() {
1100            inject_transform_sections_resume(&mut config, transforms.as_ref());
1101        }
1102        let mode = self.inner.mode;
1103        if mode == crate::ClientMode::Empty && config.available_tools.is_none() {
1104            return Err(Error::with_message(
1105                ErrorKind::InvalidConfig,
1106                "ClientMode::Empty requires available_tools to be set on the session config. \
1107                 Use ToolSet to specify which tools the session may use (e.g. \
1108                 ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED)).",
1109            ));
1110        }
1111        crate::mode::validate_tool_filter_list(
1112            "available_tools",
1113            config.available_tools.as_deref(),
1114        )?;
1115        crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?;
1116        config.system_message =
1117            crate::mode::system_message_for_mode(mode, config.system_message.take());
1118        config.memory = crate::mode::memory_for_mode(mode, config.memory.take());
1119        if mode == crate::ClientMode::Empty {
1120            if config.enable_session_telemetry.is_none() {
1121                config.enable_session_telemetry = Some(false);
1122            }
1123            if config.skip_embedding_retrieval.is_none() {
1124                config.skip_embedding_retrieval = Some(true);
1125            }
1126            if config.enable_on_demand_instruction_discovery.is_none() {
1127                config.enable_on_demand_instruction_discovery = Some(false);
1128            }
1129            if config.enable_file_hooks.is_none() {
1130                config.enable_file_hooks = Some(false);
1131            }
1132            if config.enable_host_git_operations.is_none() {
1133                config.enable_host_git_operations = Some(false);
1134            }
1135            if config.enable_session_store.is_none() {
1136                config.enable_session_store = Some(false);
1137            }
1138            if config.enable_skills.is_none() {
1139                config.enable_skills = Some(false);
1140            }
1141        }
1142        if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() {
1143            config.mcp_oauth_token_storage = Some("in-memory".into());
1144        }
1145        if mode == crate::ClientMode::Empty && config.embedding_cache_storage.is_none() {
1146            config.embedding_cache_storage = Some("in-memory".into());
1147        }
1148        let opt_skip_custom_instructions = config.skip_custom_instructions;
1149        let opt_custom_agents_local_only = config.custom_agents_local_only;
1150        let opt_coauthor_enabled = config.coauthor_enabled;
1151        let opt_manage_schedule_enabled = config.manage_schedule_enabled;
1152        let (mut wire, mut runtime) = config.into_wire()?;
1153        wire.enable_github_telemetry_forwarding =
1154            self.inner.on_github_telemetry.is_some().then_some(true);
1155
1156        let permission_handler = crate::permission::resolve_handler(
1157            runtime.permission_handler.take(),
1158            runtime.permission_policy.take(),
1159        );
1160        let handlers = SessionHandlers {
1161            permission: permission_handler,
1162            elicitation: runtime.elicitation_handler.take(),
1163            mcp_auth: runtime.mcp_auth_handler.take(),
1164            user_input: runtime.user_input_handler.take(),
1165            exit_plan_mode: runtime.exit_plan_mode_handler.take(),
1166            auto_mode_switch: runtime.auto_mode_switch_handler.take(),
1167            tools: Arc::new(std::mem::take(&mut runtime.tool_handlers)),
1168        };
1169        let hooks = runtime.hooks_handler.take();
1170        let transforms = runtime.system_message_transform.take();
1171        let tools_count = wire.tools.as_ref().map_or(0, Vec::len);
1172        let commands_count = runtime.commands.as_ref().map_or(0, Vec::len);
1173        let has_hooks = hooks.is_some();
1174        let command_handlers = build_command_handler_map(runtime.commands.as_deref());
1175        let canvas_handler = runtime.canvas_handler.take();
1176        let session_fs_provider = runtime.session_fs_provider.take();
1177        let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers);
1178        let has_mcp_auth_handler = handlers.mcp_auth.is_some();
1179        if self.inner.session_fs_configured && session_fs_provider.is_none() {
1180            return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into());
1181        }
1182        if self.inner.session_fs_sqlite_declared
1183            && let Some(ref provider) = session_fs_provider
1184            && provider.sqlite().is_none()
1185        {
1186            return Err(Error::with_message(
1187                ErrorKind::InvalidConfig,
1188                "SessionFs capabilities declare SQLite support but the provider \
1189                 does not implement SessionFsSqliteProvider",
1190            ));
1191        }
1192
1193        let mut params = serde_json::to_value(&wire)?;
1194        let trace_ctx = self.resolve_trace_context().await;
1195        inject_trace_context(&mut params, &trace_ctx);
1196
1197        let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default()));
1198        let setup_start = Instant::now();
1199        let channels = self.register_session(&session_id);
1200        let idle_waiter = Arc::new(ParkingLotMutex::new(None));
1201        let open_canvases = Arc::new(parking_lot::RwLock::new(Vec::new()));
1202        let shutdown = CancellationToken::new();
1203        let (event_tx, _) = tokio::sync::broadcast::channel(512);
1204        let event_loop = spawn_event_loop(
1205            session_id.clone(),
1206            self.clone(),
1207            handlers,
1208            hooks,
1209            transforms,
1210            command_handlers,
1211            canvas_handler,
1212            session_fs_provider,
1213            bearer_token_providers,
1214            channels,
1215            idle_waiter.clone(),
1216            capabilities.clone(),
1217            open_canvases.clone(),
1218            event_tx.clone(),
1219            shutdown.clone(),
1220        );
1221        let mut registration =
1222            PendingSessionRegistration::new(self.clone(), session_id.clone(), shutdown.clone());
1223        tracing::debug!(
1224            elapsed_ms = setup_start.elapsed().as_millis(),
1225            session_id = %session_id,
1226            tools_count,
1227            commands_count,
1228            has_hooks,
1229            "Client::resume_session local setup complete"
1230        );
1231
1232        let rpc_start = Instant::now();
1233        let result = match self.call("session.resume", Some(params)).await {
1234            Ok(result) => result,
1235            Err(error) => {
1236                registration.cleanup(event_loop).await;
1237                return Err(error);
1238            }
1239        };
1240        tracing::debug!(
1241            elapsed_ms = rpc_start.elapsed().as_millis(),
1242            session_id = %session_id,
1243            "Client::resume_session session resume request completed successfully"
1244        );
1245
1246        let resume_result: ResumeSessionResult = match serde_json::from_value(result) {
1247            Ok(result) => result,
1248            Err(error) => {
1249                registration.cleanup(event_loop).await;
1250                return Err(error.into());
1251            }
1252        };
1253        let cli_session_id = resume_result
1254            .session_id
1255            .clone()
1256            .unwrap_or_else(|| session_id.clone());
1257        if cli_session_id != session_id {
1258            registration.cleanup(event_loop).await;
1259            return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
1260                requested: session_id,
1261                returned: cli_session_id,
1262            })
1263            .into());
1264        }
1265        if has_mcp_auth_handler {
1266            register_mcp_auth_interest(self, &session_id).await?;
1267        }
1268
1269        // Reload skills after resume (best-effort).
1270        let skills_reload_start = Instant::now();
1271        if let Err(e) = self
1272            .call(
1273                "session.skills.reload",
1274                Some(serde_json::json!({ "sessionId": session_id })),
1275            )
1276            .await
1277        {
1278            warn!(
1279                elapsed_ms = skills_reload_start.elapsed().as_millis(),
1280                session_id = %session_id,
1281                error = %e,
1282                "Client::resume_session skills reload request failed"
1283            );
1284        } else {
1285            tracing::debug!(
1286                elapsed_ms = skills_reload_start.elapsed().as_millis(),
1287                session_id = %session_id,
1288                "Client::resume_session skills reload request completed successfully"
1289            );
1290        }
1291
1292        *capabilities.write() = resume_result.capabilities.unwrap_or_default();
1293        // Upsert resume snapshots rather than replacing wholesale. Live
1294        // `session.canvas.opened` notifications can arrive on the event loop
1295        // while `session.resume` is in flight; a wholesale replace would
1296        // discard those updates.
1297        {
1298            let mut snapshots = open_canvases.write();
1299            for snapshot in resume_result.open_canvases.unwrap_or_default() {
1300                upsert_open_canvas_snapshot(&mut snapshots, snapshot);
1301            }
1302        }
1303
1304        tracing::debug!(
1305            elapsed_ms = total_start.elapsed().as_millis(),
1306            session_id = %session_id,
1307            "Client::resume_session complete"
1308        );
1309        registration.disarm();
1310        let session = Session {
1311            id: session_id,
1312            cwd: self.cwd().clone(),
1313            workspace_path: resume_result.workspace_path,
1314            remote_url: resume_result.remote_url,
1315            client: self.clone(),
1316            event_loop: ParkingLotMutex::new(Some(event_loop)),
1317            shutdown,
1318            idle_waiter,
1319            capabilities,
1320            open_canvases,
1321            event_tx,
1322        };
1323        apply_mode_post_create_patch(
1324            &session,
1325            mode,
1326            opt_skip_custom_instructions,
1327            opt_custom_agents_local_only,
1328            opt_coauthor_enabled,
1329            opt_manage_schedule_enabled,
1330        )
1331        .await?;
1332        Ok(session)
1333    }
1334}
1335
1336type CommandHandlerMap = HashMap<String, Arc<dyn CommandHandler>>;
1337
1338async fn apply_mode_post_create_patch(
1339    session: &Session,
1340    mode: crate::ClientMode,
1341    opt_skip_custom_instructions: Option<bool>,
1342    opt_custom_agents_local_only: Option<bool>,
1343    opt_coauthor_enabled: Option<bool>,
1344    opt_manage_schedule_enabled: Option<bool>,
1345) -> Result<(), Error> {
1346    use crate::generated::api_types::SessionUpdateOptionsParams;
1347    let mut patch = SessionUpdateOptionsParams::default();
1348    let should_send = if mode == crate::ClientMode::Empty {
1349        patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true));
1350        patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true));
1351        patch.coauthor_enabled = Some(opt_coauthor_enabled.unwrap_or(false));
1352        patch.manage_schedule_enabled = Some(opt_manage_schedule_enabled.unwrap_or(false));
1353        patch.installed_plugins = Some(Vec::new());
1354        true
1355    } else {
1356        let mut any = false;
1357        if let Some(v) = opt_skip_custom_instructions {
1358            patch.skip_custom_instructions = Some(v);
1359            any = true;
1360        }
1361        if let Some(v) = opt_custom_agents_local_only {
1362            patch.custom_agents_local_only = Some(v);
1363            any = true;
1364        }
1365        if let Some(v) = opt_coauthor_enabled {
1366            patch.coauthor_enabled = Some(v);
1367            any = true;
1368        }
1369        if let Some(v) = opt_manage_schedule_enabled {
1370            patch.manage_schedule_enabled = Some(v);
1371            any = true;
1372        }
1373        any
1374    };
1375    if !should_send {
1376        return Ok(());
1377    }
1378    if let Err(error) = session.rpc().options().update(patch).await {
1379        let _ = session.disconnect().await;
1380        return Err(error);
1381    }
1382    Ok(())
1383}
1384
1385fn build_command_handler_map(commands: Option<&[CommandDefinition]>) -> Arc<CommandHandlerMap> {
1386    let map = match commands {
1387        Some(commands) => commands
1388            .iter()
1389            .filter(|cmd| !cmd.name.is_empty())
1390            .map(|cmd| (cmd.name.clone(), cmd.handler.clone()))
1391            .collect(),
1392        None => HashMap::new(),
1393    };
1394    Arc::new(map)
1395}
1396
1397fn upsert_open_canvas_snapshot(
1398    snapshots: &mut Vec<OpenCanvasInstance>,
1399    snapshot: OpenCanvasInstance,
1400) {
1401    if let Some(existing) = snapshots
1402        .iter_mut()
1403        .find(|open| open.instance_id == snapshot.instance_id)
1404    {
1405        *existing = snapshot;
1406    } else {
1407        snapshots.push(snapshot);
1408    }
1409}
1410
1411fn remove_open_canvas_snapshot(snapshots: &mut Vec<OpenCanvasInstance>, instance_id: &str) {
1412    snapshots.retain(|open| open.instance_id != instance_id);
1413}
1414
1415#[allow(clippy::too_many_arguments)]
1416fn spawn_event_loop(
1417    session_id: SessionId,
1418    client: Client,
1419    handlers: SessionHandlers,
1420    hooks: Option<Arc<dyn SessionHooks>>,
1421    transforms: Option<Arc<dyn SystemMessageTransform>>,
1422    command_handlers: Arc<CommandHandlerMap>,
1423    canvas_handler: Option<Arc<dyn CanvasHandler>>,
1424    session_fs_provider: Option<Arc<dyn SessionFsProvider>>,
1425    bearer_token_providers: HashMap<String, Arc<dyn BearerTokenProvider>>,
1426    channels: crate::router::SessionChannels,
1427    idle_waiter: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
1428    capabilities: Arc<parking_lot::RwLock<SessionCapabilities>>,
1429    open_canvases: Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
1430    event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
1431    shutdown: CancellationToken,
1432) -> JoinHandle<()> {
1433    let crate::router::SessionChannels {
1434        mut notifications,
1435        mut requests,
1436    } = channels;
1437
1438    let span = tracing::error_span!("session_event_loop", session_id = %session_id);
1439    tokio::spawn(
1440        async move {
1441            loop {
1442                // `mpsc::UnboundedReceiver::recv` and
1443                // `CancellationToken::cancelled` are both cancel-safe per
1444                // RFD 400.
1445                //
1446                // Inbound JSON-RPC *requests* are dispatched fire-and-forget:
1447                // each `handle_request` runs in its own spawned task that
1448                // awaits the handler and sends that request's response. This
1449                // mirrors the other Copilot SDKs and moves concurrency to the
1450                // request-dispatch boundary, so any slow handler — not just
1451                // `userInput.request` (which can stay pending for the full
1452                // input backstop of several minutes), but also `exitPlanMode`,
1453                // `autoModeSwitch`, hooks, transforms, or canvas/session-FS
1454                // providers — cannot park the reader loop and starve sibling
1455                // requests or co-emitted notifications. JSON-RPC permits
1456                // concurrent requests and out-of-order responses, so the SDK
1457                // does not serialize them.
1458                //
1459                // `handle_notification` is awaited inline because it only
1460                // performs fast dispatch work; its slow interactive callbacks
1461                // (permission/tool/elicitation) are themselves spawned as child
1462                // tasks. All of these spawned tasks intentionally outlive the
1463                // parent loop and own their own cleanup — RFD 400's "spawn
1464                // background tasks to perform cancel-unsafe operations" pattern.
1465                tokio::select! {
1466                    _ = shutdown.cancelled() => break,
1467                    Some(notification) = notifications.recv() => {
1468                        handle_notification(
1469                            &session_id, &client, &handlers, &command_handlers, notification, &idle_waiter, &capabilities, &open_canvases, &event_tx,
1470                        ).await;
1471                    }
1472                    Some(request) = requests.recv() => {
1473                        // Clone the Arc-backed dispatch context into the task so
1474                        // the spawned `handle_request` future is `'static`. All
1475                        // clones are cheap (Arc refcount bumps / small maps).
1476                        let span = tracing::error_span!("session_request_handler", session_id = %session_id);
1477                        let session_id = session_id.clone();
1478                        let client = client.clone();
1479                        let handlers = handlers.clone();
1480                        let hooks = hooks.clone();
1481                        let transforms = transforms.clone();
1482                        let canvas_handler = canvas_handler.clone();
1483                        let session_fs_provider = session_fs_provider.clone();
1484                        let bearer_token_providers = bearer_token_providers.clone();
1485                        tokio::spawn(
1486                            async move {
1487                                let ctx = RequestDispatchContext {
1488                                    client: &client,
1489                                    handlers: &handlers,
1490                                    hooks: hooks.as_deref(),
1491                                    transforms: transforms.as_deref(),
1492                                    canvas_handler: canvas_handler.as_ref(),
1493                                    session_fs_provider: session_fs_provider.as_ref(),
1494                                    bearer_token_providers: &bearer_token_providers,
1495                                };
1496                                handle_request(&session_id, ctx, request).await;
1497                            }
1498                            .instrument(span),
1499                        );
1500                    }
1501                    else => break,
1502                }
1503            }
1504            // Channels closed or shutdown signaled — fail any pending
1505            // send_and_wait so the caller observes a clean error.
1506            if let Some(waiter) = idle_waiter.lock().take() {
1507                let _ = waiter
1508                    .tx
1509                    .send(Err(ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()));
1510            }
1511        }
1512        .instrument(span),
1513    )
1514}
1515
1516fn extract_request_id(data: &Value) -> Option<RequestId> {
1517    data.get("requestId")
1518        .and_then(|v| v.as_str())
1519        .filter(|s| !s.is_empty())
1520        .map(RequestId::new)
1521}
1522
1523/// Map a [`PermissionResult`] to the `result` payload sent back to the
1524/// server via `session.permissions.handlePendingPermissionRequest`.
1525///
1526/// Returns `None` when the SDK must not send a response.
1527fn notification_permission_payload(result: &PermissionResult) -> Option<Value> {
1528    match result {
1529        PermissionResult::NoResult => None,
1530        PermissionResult::Decision(decision) => Some(
1531            serde_json::to_value(decision).expect("serializing permission decision should succeed"),
1532        ),
1533    }
1534}
1535
1536async fn register_mcp_auth_interest(client: &Client, session_id: &SessionId) -> Result<(), Error> {
1537    let mut params = serde_json::to_value(RegisterEventInterestParams {
1538        event_type: "mcp.oauth_required".to_string(),
1539    })?;
1540    params["sessionId"] = Value::String(session_id.to_string());
1541    client
1542        .call(rpc_methods::SESSION_EVENTLOG_REGISTERINTEREST, Some(params))
1543        .await?;
1544    Ok(())
1545}
1546
1547fn tool_failure_result(message: impl Into<String>) -> ToolResult {
1548    let message = message.into();
1549    ToolResult::Expanded(ToolResultExpanded {
1550        text_result_for_llm: message.clone(),
1551        result_type: "failure".to_string(),
1552        binary_results_for_llm: None,
1553        session_log: None,
1554        error: Some(message),
1555        tool_telemetry: None,
1556        tool_references: None,
1557    })
1558}
1559
1560/// Process a notification from the CLI's broadcast channel.
1561#[allow(clippy::too_many_arguments)]
1562async fn handle_notification(
1563    session_id: &SessionId,
1564    client: &Client,
1565    handlers: &SessionHandlers,
1566    command_handlers: &Arc<CommandHandlerMap>,
1567    notification: SessionEventNotification,
1568    idle_waiter: &Arc<ParkingLotMutex<Option<IdleWaiter>>>,
1569    capabilities: &Arc<parking_lot::RwLock<SessionCapabilities>>,
1570    open_canvases: &Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
1571    event_tx: &tokio::sync::broadcast::Sender<SessionEvent>,
1572) {
1573    let dispatch_start = Instant::now();
1574    let event = notification.event.clone();
1575    let event_type = event.parsed_type();
1576    if event_type == SessionEventType::PermissionRequested {
1577        tracing::debug!(
1578            session_id = %session_id,
1579            event_type = %event.event_type,
1580            "Session::handle_notification permission request received"
1581        );
1582    }
1583
1584    // Signal send_and_wait if active. The lock is only contended when
1585    // a send_and_wait call is in flight (idle_waiter is Some).
1586    match event_type {
1587        SessionEventType::AssistantMessage
1588        | SessionEventType::SessionIdle
1589        | SessionEventType::SessionError => {
1590            let mut guard = idle_waiter.lock();
1591            if let Some(waiter) = guard.as_mut() {
1592                match event_type {
1593                    SessionEventType::AssistantMessage => {
1594                        if !waiter.first_assistant_message_seen {
1595                            waiter.first_assistant_message_seen = true;
1596                            tracing::debug!(
1597                                elapsed_ms = waiter.started_at.elapsed().as_millis(),
1598                                session_id = %session_id,
1599                                "Session::send_and_wait first assistant message"
1600                            );
1601                        }
1602                        waiter.last_assistant_message = Some(event.clone());
1603                    }
1604                    SessionEventType::SessionIdle | SessionEventType::SessionError => {
1605                        if let Some(waiter) = guard.take() {
1606                            if event_type == SessionEventType::SessionIdle {
1607                                tracing::debug!(
1608                                    elapsed_ms = waiter.started_at.elapsed().as_millis(),
1609                                    session_id = %session_id,
1610                                    "Session::send_and_wait idle received"
1611                                );
1612                                let _ = waiter.tx.send(Ok(waiter.last_assistant_message));
1613                            } else {
1614                                let error_msg = event
1615                                    .typed_data::<SessionErrorData>()
1616                                    .map(|d| d.message)
1617                                    .or_else(|| {
1618                                        event
1619                                            .data
1620                                            .get("message")
1621                                            .and_then(|v| v.as_str())
1622                                            .map(|s| s.to_string())
1623                                    })
1624                                    .unwrap_or_else(|| "session error".to_string());
1625                                let _ = waiter.tx.send(Err(Error::with_message(
1626                                    ErrorKind::Session(SessionErrorKind::AgentError),
1627                                    error_msg,
1628                                )));
1629                            }
1630                        }
1631                    }
1632                    _ => {}
1633                }
1634            }
1635        }
1636        _ => {}
1637    }
1638
1639    // Update the snapshot caches BEFORE broadcasting so subscribers that
1640    // call `Session::capabilities()` / `Session::open_canvases()` in
1641    // response to the event observe the new state.
1642    if event_type == SessionEventType::CapabilitiesChanged {
1643        match serde_json::from_value::<SessionCapabilities>(notification.event.data.clone()) {
1644            Ok(changed) => *capabilities.write() = changed,
1645            Err(e) => warn!(error = %e, "failed to deserialize capabilities.changed payload"),
1646        }
1647    }
1648    if event_type == SessionEventType::SessionCanvasOpened {
1649        match serde_json::from_value::<OpenCanvasInstance>(notification.event.data.clone()) {
1650            Ok(open_canvas) => {
1651                upsert_open_canvas_snapshot(&mut open_canvases.write(), open_canvas);
1652            }
1653            Err(e) => warn!(error = %e, "failed to deserialize session.canvas.opened payload"),
1654        }
1655    }
1656    if event_type == SessionEventType::SessionCanvasClosed {
1657        match serde_json::from_value::<SessionCanvasClosedData>(notification.event.data.clone()) {
1658            Ok(closed) => {
1659                if closed.instance_id.is_empty() {
1660                    warn!("failed to deserialize session.canvas.closed payload");
1661                } else {
1662                    remove_open_canvas_snapshot(&mut open_canvases.write(), &closed.instance_id);
1663                }
1664            }
1665            Err(e) => warn!(error = %e, "failed to deserialize session.canvas.closed payload"),
1666        }
1667    }
1668
1669    // Fan out the event to runtime subscribers (`Session::subscribe`). `send`
1670    // only errors when there are no receivers, which is the normal case
1671    // before any consumer subscribes.
1672    let _ = event_tx.send(event.clone());
1673
1674    tracing::debug!(
1675        elapsed_ms = dispatch_start.elapsed().as_millis(),
1676        session_id = %session_id,
1677        event_type = %notification.event.event_type,
1678        "Session::handle_notification dispatch"
1679    );
1680
1681    // Notification-based permission/tool/elicitation requests require a
1682    // separate RPC callback. Spawn concurrently since the CLI doesn't block.
1683    match event_type {
1684        SessionEventType::PermissionRequested => {
1685            let Some(request_id) = extract_request_id(&notification.event.data) else {
1686                return;
1687            };
1688            // Honor the runtime's `resolvedByHook` signal — when the
1689            // server has already resolved the permission via a hook,
1690            // clients must not send a second response.
1691            if notification
1692                .event
1693                .data
1694                .get("resolvedByHook")
1695                .and_then(|v| v.as_bool())
1696                .unwrap_or(false)
1697            {
1698                return;
1699            }
1700            // Multi-client safety: if this client has no permission
1701            // handler installed, don't respond — another client on the
1702            // same CLI may handle it.
1703            let Some(permission_handler) = handlers.permission.clone() else {
1704                return;
1705            };
1706            let client = client.clone();
1707            let sid = session_id.clone();
1708            let data: PermissionRequestData =
1709                serde_json::from_value(notification.event.data.clone()).unwrap_or_else(|_| {
1710                    PermissionRequestData {
1711                        kind: None,
1712                        tool_call_id: None,
1713                        extra: notification.event.data.clone(),
1714                    }
1715                });
1716            let span = tracing::error_span!(
1717                "permission_request_handler",
1718                session_id = %sid,
1719                request_id = %request_id
1720            );
1721            tokio::spawn(
1722                async move {
1723                    let handler_start = Instant::now();
1724                    let result = permission_handler
1725                        .handle(sid.clone(), request_id.clone(), data)
1726                        .await;
1727                    tracing::debug!(
1728                        elapsed_ms = handler_start.elapsed().as_millis(),
1729                        session_id = %sid,
1730                        request_id = %request_id,
1731                        "PermissionHandler::handle dispatch"
1732                    );
1733                    let Some(result_value) = notification_permission_payload(&result) else {
1734                        // Handler returned Deferred / NoResult — it will
1735                        // call handlePendingPermissionRequest itself (or
1736                        // leave the request unanswered).
1737                        return;
1738                    };
1739                    let rpc_start = Instant::now();
1740                    let _ = client
1741                        .call(
1742                            "session.permissions.handlePendingPermissionRequest",
1743                            Some(serde_json::json!({
1744                                "sessionId": sid,
1745                                "requestId": request_id,
1746                                "result": result_value,
1747                            })),
1748                        )
1749                        .await;
1750                    tracing::debug!(
1751                        elapsed_ms = rpc_start.elapsed().as_millis(),
1752                        session_id = %sid,
1753                        request_id = %request_id,
1754                        "Session::handle_notification response sent successfully"
1755                    );
1756                }
1757                .instrument(span),
1758            );
1759        }
1760        SessionEventType::ExternalToolRequested => {
1761            let Some(request_id) = extract_request_id(&notification.event.data) else {
1762                return;
1763            };
1764            let data: ExternalToolRequestedData =
1765                match serde_json::from_value(notification.event.data.clone()) {
1766                    Ok(d) => d,
1767                    Err(e) => {
1768                        warn!(error = %e, "failed to deserialize external_tool.requested");
1769                        let client = client.clone();
1770                        let sid = session_id.clone();
1771                        let span = tracing::error_span!(
1772                            "external_tool_deserialize_error",
1773                            session_id = %sid,
1774                            request_id = %request_id
1775                        );
1776                        tokio::spawn(
1777                            async move {
1778                                let rpc_start = Instant::now();
1779                                let _ = client
1780                                .call(
1781                                    "session.tools.handlePendingToolCall",
1782                                    Some(serde_json::json!({
1783                                        "sessionId": sid,
1784                                        "requestId": request_id,
1785                                        "error": format!("Failed to deserialize tool request: {e}"),
1786                                    })),
1787                                )
1788                                .await;
1789                                tracing::debug!(
1790                                    elapsed_ms = rpc_start.elapsed().as_millis(),
1791                                    session_id = %sid,
1792                                    request_id = %request_id,
1793                                    "Session::handle_notification response sent successfully"
1794                                );
1795                            }
1796                            .instrument(span),
1797                        );
1798                        return;
1799                    }
1800                };
1801            // Multi-client safety: look up a handler for the requested
1802            // tool name. If this client has no handler installed for that
1803            // tool, don't respond — another connected client may have one.
1804            let tool_handler = if data.tool_name.is_empty() {
1805                None
1806            } else {
1807                handlers.tools.get(&data.tool_name).cloned()
1808            };
1809            let Some(tool_handler) = tool_handler else {
1810                return;
1811            };
1812            let client = client.clone();
1813            let sid = session_id.clone();
1814            let span = tracing::error_span!(
1815                "external_tool_handler",
1816                session_id = %sid,
1817                request_id = %request_id
1818            );
1819            tokio::spawn(
1820                async move {
1821                    // `tool_name.is_empty()` would have produced a `None`
1822                    // lookup in `handlers.tools` and short-circuited at the
1823                    // outer guard above, so only the tool_call_id check is
1824                    // reachable here.
1825                    if data.tool_call_id.is_empty() {
1826                        let error_msg = "Missing toolCallId";
1827                        let rpc_start = Instant::now();
1828                        let _ = client
1829                            .call(
1830                                "session.tools.handlePendingToolCall",
1831                                Some(serde_json::json!({
1832                                    "sessionId": sid,
1833                                    "requestId": request_id,
1834                                    "error": error_msg,
1835                                })),
1836                            )
1837                            .await;
1838                        tracing::debug!(
1839                            elapsed_ms = rpc_start.elapsed().as_millis(),
1840                            session_id = %sid,
1841                            request_id = %request_id,
1842                            "Session::handle_notification response sent successfully"
1843                        );
1844                        return;
1845                    }
1846                    let tool_call_id = data.tool_call_id.clone();
1847                    let tool_name = data.tool_name.clone();
1848                    // The built-in tool-search tool receives a snapshot of the
1849                    // session's currently initialized tools so an override can
1850                    // filter the live catalog without issuing its own RPC. Fetch
1851                    // it only for that tool to avoid a round-trip on every tool
1852                    // call; a failed fetch leaves the snapshot `None` rather than
1853                    // failing the tool.
1854                    let available_tools = if tool_name == TOOL_SEARCH_TOOL_NAME {
1855                        match client
1856                            .call(
1857                                rpc_methods::SESSION_TOOLS_GETCURRENTMETADATA,
1858                                Some(serde_json::json!({ "sessionId": sid })),
1859                            )
1860                            .await
1861                        {
1862                            Ok(value) => {
1863                                serde_json::from_value::<ToolsGetCurrentMetadataResult>(value)
1864                                    .ok()
1865                                    .and_then(|result| result.tools)
1866                            }
1867                            Err(_) => None,
1868                        }
1869                    } else {
1870                        None
1871                    };
1872                    let invocation = ToolInvocation {
1873                        session_id: sid.clone(),
1874                        tool_call_id: data.tool_call_id,
1875                        tool_name: data.tool_name,
1876                        arguments: data
1877                            .arguments
1878                            .unwrap_or(Value::Object(serde_json::Map::new())),
1879                        available_tools,
1880                        traceparent: data.traceparent,
1881                        tracestate: data.tracestate,
1882                    };
1883                    let handler_start = Instant::now();
1884                    let tool_result = match tool_handler.call(invocation).await {
1885                        Ok(r) => r,
1886                        Err(e) => tool_failure_result(e.to_string()),
1887                    };
1888                    tracing::debug!(
1889                        elapsed_ms = handler_start.elapsed().as_millis(),
1890                        session_id = %sid,
1891                        request_id = %request_id,
1892                        tool_call_id = %tool_call_id,
1893                        tool_name = %tool_name,
1894                        "ToolHandler::call dispatch"
1895                    );
1896                    let result_value = serde_json::to_value(tool_result).unwrap_or(Value::Null);
1897                    let rpc_start = Instant::now();
1898                    let _ = client
1899                        .call(
1900                            "session.tools.handlePendingToolCall",
1901                            Some(serde_json::json!({
1902                                "sessionId": sid,
1903                                "requestId": request_id,
1904                                "result": result_value,
1905                            })),
1906                        )
1907                        .await;
1908                    tracing::debug!(
1909                        elapsed_ms = rpc_start.elapsed().as_millis(),
1910                        session_id = %sid,
1911                        request_id = %request_id,
1912                        tool_call_id = %tool_call_id,
1913                        tool_name = %tool_name,
1914                        "Session::handle_notification response sent successfully"
1915                    );
1916                }
1917                .instrument(span),
1918            );
1919        }
1920        SessionEventType::UserInputRequested => {
1921            // Notification-only signal for observers (UI, telemetry).
1922            // The CLI follows up with a `userInput.request` JSON-RPC call
1923            // that drives the `UserInputHandler` dispatch — handling
1924            // the notification here too would double-fire the handler
1925            // and produce duplicate prompts on the consumer side. See
1926            // github/github-app#4249.
1927        }
1928        SessionEventType::ElicitationRequested => {
1929            let Some(request_id) = extract_request_id(&notification.event.data) else {
1930                return;
1931            };
1932            // Multi-client safety: if this client has no elicitation
1933            // handler installed, don't respond — another client on the
1934            // same CLI may handle it.
1935            let Some(elicitation_handler) = handlers.elicitation.clone() else {
1936                return;
1937            };
1938            let elicitation_data: ElicitationRequestedData =
1939                match serde_json::from_value(notification.event.data.clone()) {
1940                    Ok(d) => d,
1941                    Err(e) => {
1942                        warn!(error = %e, "failed to deserialize elicitation request");
1943                        return;
1944                    }
1945                };
1946            let request = ElicitationRequest {
1947                message: elicitation_data.message,
1948                requested_schema: elicitation_data
1949                    .requested_schema
1950                    .map(|s| serde_json::to_value(s).unwrap_or(Value::Null)),
1951                mode: elicitation_data.mode.map(|m| match m {
1952                    crate::generated::session_events::ElicitationRequestedMode::Form => {
1953                        crate::types::ElicitationMode::Form
1954                    }
1955                    crate::generated::session_events::ElicitationRequestedMode::Url => {
1956                        crate::types::ElicitationMode::Url
1957                    }
1958                    _ => crate::types::ElicitationMode::Unknown,
1959                }),
1960                elicitation_source: elicitation_data.elicitation_source,
1961                url: elicitation_data.url,
1962            };
1963            let client = client.clone();
1964            let sid = session_id.clone();
1965            let span = tracing::error_span!(
1966                "elicitation_request_handler",
1967                session_id = %sid,
1968                request_id = %request_id
1969            );
1970            tokio::spawn(
1971                async move {
1972                    let cancel = ElicitationResult {
1973                        action: "cancel".to_string(),
1974                        content: None,
1975                    };
1976                    // Dispatch to a nested task so panics are caught as JoinErrors.
1977                    let handler_task = tokio::spawn({
1978                        let sid = sid.clone();
1979                        let request_id = request_id.clone();
1980                        let span = tracing::error_span!(
1981                            "elicitation_callback",
1982                            session_id = %sid,
1983                            request_id = %request_id
1984                        );
1985                        async move {
1986                            let handler_start = Instant::now();
1987                            let response = elicitation_handler
1988                                .handle(sid.clone(), request_id.clone(), request)
1989                                .await;
1990                            tracing::debug!(
1991                                elapsed_ms = handler_start.elapsed().as_millis(),
1992                                session_id = %sid,
1993                                request_id = %request_id,
1994                                "ElicitationHandler::handle dispatch"
1995                            );
1996                            response
1997                        }
1998                        .instrument(span)
1999                    });
2000                    let result = match handler_task.await {
2001                        Ok(r) => r,
2002                        Err(_) => cancel.clone(),
2003                    };
2004                    let rpc_start = Instant::now();
2005                    if let Err(e) = client
2006                        .call(
2007                            "session.ui.handlePendingElicitation",
2008                            Some(serde_json::json!({
2009                                "sessionId": sid,
2010                                "requestId": request_id,
2011                                "result": result,
2012                            })),
2013                        )
2014                        .await
2015                    {
2016                        // RPC failed — attempt cancel as last resort
2017                        warn!(error = %e, "handlePendingElicitation failed, sending cancel");
2018                        let _ = client
2019                            .call(
2020                                "session.ui.handlePendingElicitation",
2021                                Some(serde_json::json!({
2022                                    "sessionId": sid,
2023                                    "requestId": request_id,
2024                                    "result": cancel,
2025                                })),
2026                            )
2027                            .await;
2028                    } else {
2029                        tracing::debug!(
2030                            elapsed_ms = rpc_start.elapsed().as_millis(),
2031                            session_id = %sid,
2032                            request_id = %request_id,
2033                            "Session::handle_notification response sent successfully"
2034                        );
2035                    }
2036                }
2037                .instrument(span),
2038            );
2039        }
2040        SessionEventType::McpOauthRequired => {
2041            let Some(request_id) = extract_request_id(&notification.event.data) else {
2042                return;
2043            };
2044            let Some(mcp_auth_handler) = handlers.mcp_auth.clone() else {
2045                warn!(
2046                    session_id = %session_id,
2047                    request_id = %request_id,
2048                    "received MCP OAuth request without a registered MCP auth handler"
2049                );
2050                return;
2051            };
2052            let data: McpOauthRequiredData =
2053                match serde_json::from_value(notification.event.data.clone()) {
2054                    Ok(d) => d,
2055                    Err(e) => {
2056                        warn!(error = %e, "failed to deserialize MCP OAuth request");
2057                        return;
2058                    }
2059                };
2060            let request = McpAuthRequest {
2061                request_id: request_id.clone(),
2062                server_name: data.server_name,
2063                server_url: data.server_url,
2064                reason: data.reason,
2065                www_authenticate_params: data.www_authenticate_params,
2066                resource_metadata: data.resource_metadata,
2067                static_client_config: data.static_client_config,
2068            };
2069            let client = client.clone();
2070            let sid = session_id.clone();
2071            let span = tracing::error_span!(
2072                "mcp_auth_request_handler",
2073                session_id = %sid,
2074                request_id = %request_id
2075            );
2076            tokio::spawn(
2077                async move {
2078                    let cancel = McpAuthResult::Cancelled;
2079                    let handler_task = tokio::spawn({
2080                        let sid = sid.clone();
2081                        let request_id = request_id.clone();
2082                        let span = tracing::error_span!(
2083                            "mcp_auth_callback",
2084                            session_id = %sid,
2085                            request_id = %request_id
2086                        );
2087                        async move {
2088                            let handler_start = Instant::now();
2089                            let response = mcp_auth_handler
2090                                .handle(sid.clone(), request_id.clone(), request)
2091                                .await;
2092                            tracing::debug!(
2093                                elapsed_ms = handler_start.elapsed().as_millis(),
2094                                session_id = %sid,
2095                                request_id = %request_id,
2096                                "McpAuthHandler::handle dispatch"
2097                            );
2098                            response
2099                        }
2100                        .instrument(span)
2101                    });
2102                    let result = match handler_task.await {
2103                        Ok(result) => result,
2104                        Err(_) => cancel,
2105                    };
2106                    let rpc_start = Instant::now();
2107                    let _ = client
2108                        .call(
2109                            "session.mcp.oauth.handlePendingRequest",
2110                            Some(serde_json::json!({
2111                                "sessionId": sid,
2112                                "requestId": request_id,
2113                                "result": result.into_wire(),
2114                            })),
2115                        )
2116                        .await;
2117                    tracing::debug!(
2118                        elapsed_ms = rpc_start.elapsed().as_millis(),
2119                        "Session::handle_notification MCP auth response sent"
2120                    );
2121                }
2122                .instrument(span),
2123            );
2124        }
2125        SessionEventType::CommandExecute => {
2126            let data: CommandExecuteData =
2127                match serde_json::from_value(notification.event.data.clone()) {
2128                    Ok(d) => d,
2129                    Err(e) => {
2130                        warn!(error = %e, "failed to deserialize command.execute");
2131                        return;
2132                    }
2133                };
2134            let client = client.clone();
2135            let command_handlers = command_handlers.clone();
2136            let sid = session_id.clone();
2137            let span = tracing::error_span!("command_handler", session_id = %sid);
2138            tokio::spawn(
2139                async move {
2140                    let request_id = data.request_id;
2141                    let ack_error = match command_handlers.get(&data.command_name).cloned() {
2142                        None => Some(format!("Unknown command: {}", data.command_name)),
2143                        Some(handler) => {
2144                            let command_name = data.command_name.clone();
2145                            let ctx = CommandContext {
2146                                session_id: sid.clone(),
2147                                command: data.command,
2148                                command_name: data.command_name,
2149                                args: data.args,
2150                            };
2151                            let handler_start = Instant::now();
2152                            let result = handler.on_command(ctx).await;
2153                            tracing::debug!(
2154                                elapsed_ms = handler_start.elapsed().as_millis(),
2155                                session_id = %sid,
2156                                request_id = %request_id,
2157                                command_name = %command_name,
2158                                "CommandHandler::call dispatch"
2159                            );
2160                            match result {
2161                                Ok(()) => None,
2162                                Err(e) => Some(e.to_string()),
2163                            }
2164                        }
2165                    };
2166                    let mut params = serde_json::json!({
2167                        "sessionId": sid,
2168                        "requestId": request_id,
2169                    });
2170                    if let Some(error_msg) = ack_error {
2171                        params["error"] = serde_json::Value::String(error_msg);
2172                    }
2173                    let rpc_start = Instant::now();
2174                    let _ = client
2175                        .call("session.commands.handlePendingCommand", Some(params))
2176                        .await;
2177                    tracing::debug!(
2178                        elapsed_ms = rpc_start.elapsed().as_millis(),
2179                        session_id = %sid,
2180                        request_id = %request_id,
2181                        "Session::handle_notification response sent successfully"
2182                    );
2183                }
2184                .instrument(span),
2185            );
2186        }
2187        _ => {}
2188    }
2189}
2190
2191struct RequestDispatchContext<'a> {
2192    client: &'a Client,
2193    handlers: &'a SessionHandlers,
2194    hooks: Option<&'a dyn SessionHooks>,
2195    transforms: Option<&'a dyn SystemMessageTransform>,
2196    canvas_handler: Option<&'a Arc<dyn CanvasHandler>>,
2197    session_fs_provider: Option<&'a Arc<dyn SessionFsProvider>>,
2198    bearer_token_providers: &'a HashMap<String, Arc<dyn BearerTokenProvider>>,
2199}
2200
2201/// Process a JSON-RPC request from the CLI.
2202async fn handle_request(
2203    session_id: &SessionId,
2204    ctx: RequestDispatchContext<'_>,
2205    request: crate::JsonRpcRequest,
2206) {
2207    let sid = session_id.clone();
2208    let client = ctx.client;
2209    let handlers = ctx.handlers;
2210    let hooks = ctx.hooks;
2211    let transforms = ctx.transforms;
2212    let canvas_handler = ctx.canvas_handler;
2213    let session_fs_provider = ctx.session_fs_provider;
2214    let bearer_token_providers = ctx.bearer_token_providers;
2215
2216    if request.method.starts_with("sessionFs.") {
2217        crate::session_fs_dispatch::dispatch(client, session_fs_provider, request).await;
2218        return;
2219    }
2220
2221    if request.method.starts_with("canvas.") {
2222        crate::canvas_dispatch::dispatch(client, canvas_handler, request).await;
2223        return;
2224    }
2225
2226    if request.method == crate::generated::api_types::rpc_methods::PROVIDERTOKEN_GETTOKEN {
2227        crate::provider_token_dispatch::dispatch(client, bearer_token_providers, request).await;
2228        return;
2229    }
2230
2231    match request.method.as_str() {
2232        "hooks.invoke" => {
2233            let params = request.params.as_ref();
2234            let hook_type = params
2235                .and_then(|p| p.get("hookType"))
2236                .and_then(|v| v.as_str())
2237                .unwrap_or("");
2238            let input = params
2239                .and_then(|p| p.get("input"))
2240                .cloned()
2241                .unwrap_or(Value::Object(Default::default()));
2242
2243            let rpc_result = if let Some(hooks) = hooks {
2244                match crate::hooks::dispatch_hook(hooks, &sid, hook_type, input).await {
2245                    Ok(output) => output,
2246                    Err(e) => {
2247                        warn!(error = %e, hook_type = hook_type, "hook dispatch failed");
2248                        serde_json::json!({ "output": {} })
2249                    }
2250                }
2251            } else {
2252                serde_json::json!({ "output": {} })
2253            };
2254
2255            let rpc_response = JsonRpcResponse {
2256                jsonrpc: "2.0".to_string(),
2257                id: request.id,
2258                result: Some(rpc_result),
2259                error: None,
2260            };
2261            let _ = client.send_response(&rpc_response).await;
2262        }
2263
2264        "userInput.request" => {
2265            let params = request.params.as_ref();
2266            let Some(question) = params
2267                .and_then(|p| p.get("question"))
2268                .and_then(|v| v.as_str())
2269            else {
2270                warn!("userInput.request missing 'question' field");
2271                let rpc_response = JsonRpcResponse {
2272                    jsonrpc: "2.0".to_string(),
2273                    id: request.id,
2274                    result: None,
2275                    error: Some(crate::JsonRpcError {
2276                        code: error_codes::INVALID_PARAMS,
2277                        message: "missing required field: question".to_string(),
2278                        data: None,
2279                    }),
2280                };
2281                let _ = client.send_response(&rpc_response).await;
2282                return;
2283            };
2284            let question = question.to_string();
2285            let choices = params
2286                .and_then(|p| p.get("choices"))
2287                .and_then(|v| v.as_array())
2288                .map(|arr| {
2289                    arr.iter()
2290                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
2291                        .collect()
2292                });
2293            let allow_freeform = params
2294                .and_then(|p| p.get("allowFreeform"))
2295                .and_then(|v| v.as_bool());
2296
2297            let handler_start = Instant::now();
2298            let response = if let Some(user_input_handler) = handlers.user_input.as_ref() {
2299                user_input_handler
2300                    .handle(sid.clone(), question, choices, allow_freeform)
2301                    .await
2302            } else {
2303                None
2304            };
2305            tracing::debug!(
2306                elapsed_ms = handler_start.elapsed().as_millis(),
2307                session_id = %sid,
2308                "UserInputHandler::handle dispatch"
2309            );
2310
2311            let rpc_result = match response {
2312                Some(UserInputResponse {
2313                    answer,
2314                    was_freeform,
2315                }) => serde_json::json!({
2316                    "answer": answer,
2317                    "wasFreeform": was_freeform,
2318                }),
2319                None => serde_json::json!({ "noResponse": true }),
2320            };
2321            let rpc_response = JsonRpcResponse {
2322                jsonrpc: "2.0".to_string(),
2323                id: request.id,
2324                result: Some(rpc_result),
2325                error: None,
2326            };
2327            let _ = client.send_response(&rpc_response).await;
2328        }
2329
2330        "exitPlanMode.request" => {
2331            let params = request
2332                .params
2333                .as_ref()
2334                .cloned()
2335                .unwrap_or(Value::Object(serde_json::Map::new()));
2336            let data: ExitPlanModeData = match serde_json::from_value(params) {
2337                Ok(d) => d,
2338                Err(e) => {
2339                    warn!(error = %e, "failed to deserialize exitPlanMode.request params, using defaults");
2340                    ExitPlanModeData::default()
2341                }
2342            };
2343
2344            let rpc_result = if let Some(exit_plan_handler) = handlers.exit_plan_mode.as_ref() {
2345                let result = exit_plan_handler.handle(sid, data).await;
2346                serde_json::to_value(result).expect("ExitPlanModeResult serialization cannot fail")
2347            } else {
2348                serde_json::json!({ "approved": true })
2349            };
2350            let rpc_response = JsonRpcResponse {
2351                jsonrpc: "2.0".to_string(),
2352                id: request.id,
2353                result: Some(rpc_result),
2354                error: None,
2355            };
2356            let _ = client.send_response(&rpc_response).await;
2357        }
2358
2359        "autoModeSwitch.request" => {
2360            let error_code = request
2361                .params
2362                .as_ref()
2363                .and_then(|p| p.get("errorCode"))
2364                .and_then(|v| v.as_str())
2365                .map(|s| s.to_string());
2366            let retry_after_seconds = request
2367                .params
2368                .as_ref()
2369                .and_then(|p| p.get("retryAfterSeconds"))
2370                .and_then(|v| v.as_f64());
2371
2372            let answer = if let Some(auto_mode_handler) = handlers.auto_mode_switch.as_ref() {
2373                auto_mode_handler
2374                    .handle(sid, error_code, retry_after_seconds)
2375                    .await
2376            } else {
2377                AutoModeSwitchResponse::No
2378            };
2379            let rpc_response = JsonRpcResponse {
2380                jsonrpc: "2.0".to_string(),
2381                id: request.id,
2382                result: Some(serde_json::json!({ "response": answer })),
2383                error: None,
2384            };
2385            let _ = client.send_response(&rpc_response).await;
2386        }
2387
2388        "systemMessage.transform" => {
2389            let params = request.params.as_ref();
2390            let sections: HashMap<String, crate::transforms::TransformSection> =
2391                match params.and_then(|p| p.get("sections")) {
2392                    Some(v) => match serde_json::from_value(v.clone()) {
2393                        Ok(s) => s,
2394                        Err(e) => {
2395                            let _ = send_error_response(
2396                                client,
2397                                request.id,
2398                                error_codes::INVALID_PARAMS,
2399                                &format!("invalid sections: {e}"),
2400                            )
2401                            .await;
2402                            return;
2403                        }
2404                    },
2405                    None => {
2406                        let _ = send_error_response(
2407                            client,
2408                            request.id,
2409                            error_codes::INVALID_PARAMS,
2410                            "missing sections parameter",
2411                        )
2412                        .await;
2413                        return;
2414                    }
2415                };
2416
2417            let rpc_result = if let Some(transforms) = transforms {
2418                let transform_start = Instant::now();
2419                let response =
2420                    crate::transforms::dispatch_transform(transforms, &sid, sections).await;
2421                tracing::debug!(
2422                    elapsed_ms = transform_start.elapsed().as_millis(),
2423                    session_id = %sid,
2424                    "SystemMessageTransform::transform_section dispatch"
2425                );
2426                match serde_json::to_value(response) {
2427                    Ok(v) => v,
2428                    Err(e) => {
2429                        warn!(error = %e, "failed to serialize transform response");
2430                        serde_json::json!({ "sections": {} })
2431                    }
2432                }
2433            } else {
2434                // No transforms registered — pass through all sections unchanged.
2435                let passthrough: HashMap<String, crate::transforms::TransformSection> = sections;
2436                serde_json::json!({ "sections": passthrough })
2437            };
2438
2439            let rpc_response = JsonRpcResponse {
2440                jsonrpc: "2.0".to_string(),
2441                id: request.id,
2442                result: Some(rpc_result),
2443                error: None,
2444            };
2445            let _ = client.send_response(&rpc_response).await;
2446        }
2447
2448        method => {
2449            warn!(
2450                method = method,
2451                "unhandled request method in session event loop"
2452            );
2453            let _ = send_error_response(
2454                client,
2455                request.id,
2456                error_codes::METHOD_NOT_FOUND,
2457                &format!("unknown method: {method}"),
2458            )
2459            .await;
2460        }
2461    }
2462}
2463
2464async fn send_error_response(
2465    client: &Client,
2466    id: u64,
2467    code: i32,
2468    message: &str,
2469) -> Result<(), Error> {
2470    let response = JsonRpcResponse {
2471        jsonrpc: "2.0".to_string(),
2472        id,
2473        result: None,
2474        error: Some(crate::JsonRpcError {
2475            code,
2476            message: message.to_string(),
2477            data: None,
2478        }),
2479    };
2480    client.send_response(&response).await
2481}
2482
2483/// Inject `action: "transform"` sections into a `SystemMessageConfig`,
2484/// forcing `mode: "customize"` (required by the CLI for transforms to fire).
2485/// Preserves any existing caller-provided section overrides.
2486fn apply_transform_sections(
2487    sys_msg: &mut SystemMessageConfig,
2488    transforms: &dyn SystemMessageTransform,
2489) {
2490    sys_msg.mode = Some("customize".to_string());
2491    let sections = sys_msg.sections.get_or_insert_with(HashMap::new);
2492    for id in transforms.section_ids() {
2493        sections.entry(id).or_insert_with(|| SectionOverride {
2494            action: Some("transform".to_string()),
2495            content: None,
2496        });
2497    }
2498}
2499
2500fn inject_transform_sections(config: &mut SessionConfig, transforms: &dyn SystemMessageTransform) {
2501    let sys_msg = config.system_message.get_or_insert_with(Default::default);
2502    apply_transform_sections(sys_msg, transforms);
2503}
2504
2505fn inject_transform_sections_resume(
2506    config: &mut ResumeSessionConfig,
2507    transforms: &dyn SystemMessageTransform,
2508) {
2509    let sys_msg = config.system_message.get_or_insert_with(Default::default);
2510    apply_transform_sections(sys_msg, transforms);
2511}
2512
2513#[cfg(test)]
2514mod tests {
2515    use serde_json::json;
2516
2517    use super::notification_permission_payload;
2518    use crate::handler::PermissionResult;
2519
2520    #[test]
2521    fn notification_payload_suppresses_no_result() {
2522        assert!(notification_permission_payload(&PermissionResult::NoResult).is_none());
2523    }
2524
2525    #[test]
2526    fn notification_payload_serializes_decisions() {
2527        assert_eq!(
2528            notification_permission_payload(&PermissionResult::approve_once()),
2529            Some(json!({ "kind": "approve-once" }))
2530        );
2531        assert_eq!(
2532            notification_permission_payload(&PermissionResult::reject(None)),
2533            Some(json!({ "kind": "reject" }))
2534        );
2535        assert_eq!(
2536            notification_permission_payload(&PermissionResult::reject(Some("bad".to_string()))),
2537            Some(json!({ "kind": "reject", "feedback": "bad" }))
2538        );
2539        assert_eq!(
2540            notification_permission_payload(&PermissionResult::user_not_available()),
2541            Some(json!({ "kind": "user-not-available" }))
2542        );
2543    }
2544}