Skip to main content

github_copilot_sdk/
session.rs

1use std::collections::HashMap;
2use std::panic::AssertUnwindSafe;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use futures_util::FutureExt;
8use parking_lot::Mutex as ParkingLotMutex;
9use serde_json::Value;
10use tokio::sync::oneshot;
11use tokio::task::JoinHandle;
12use tokio_util::sync::CancellationToken;
13use tracing::{Instrument, error, warn};
14
15use crate::canvas::CanvasHandler;
16use crate::generated::api_types::{
17    LogRequest, ModelSwitchAutoTierRequest, ModelSwitchAutoTierResult, ModelSwitchToRequest,
18    OpenCanvasInstance, PermissionDecisionRequest, RegisterEventInterestParams,
19    ToolsGetCurrentMetadataResult, rpc_methods,
20};
21use crate::generated::session_events::{
22    CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData,
23    SessionCanvasClosedData, SessionErrorData, SessionEventType, SessionIdleData, SessionMode,
24};
25use crate::handler::{
26    AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler,
27    McpAuthHandler, McpAuthRequest, McpAuthResult, PermissionHandler, PermissionResult,
28    UserInputHandler, UserInputResponse,
29};
30use crate::hooks::SessionHooks;
31use crate::provider_token::BearerTokenProvider;
32use crate::session_fs::SessionFsProvider;
33use crate::trace_context::inject_trace_context;
34use crate::transforms::SystemMessageTransform;
35use crate::types::{
36    AutoTier, AutoTierPreference, CommandContext, CommandDefinition, CommandHandler,
37    CreateSessionResult, ElicitationRequest, ElicitationResult, ExitPlanModeData,
38    GetMessagesResponse, MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig,
39    ResumeSessionResult, SectionOverride, SessionCapabilities, SessionConfig, SessionEvent,
40    SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, ToolResult,
41    ToolResultExpanded, TraceContext, UiInputOptions, ensure_attachment_display_names,
42};
43use crate::{
44    Client, Error, ErrorKind, JsonRpcResponse, SessionErrorKind, SessionEventNotification,
45    error_codes,
46};
47
48/// Fixed name of the runtime's built-in tool-search tool. A client can replace
49/// its behavior by registering a tool with this exact name and
50/// `overrides_built_in_tool` set to `true`.
51const TOOL_SEARCH_TOOL_NAME: &str = "tool_search_tool";
52
53/// Default capacity of the per-session event broadcast buffer backing
54/// [`Session::subscribe`] and [`PreparedSession::subscribe`].
55///
56/// Override per session with
57/// [`SessionConfig::event_buffer_capacity`](crate::types::SessionConfig::event_buffer_capacity)
58/// or
59/// [`ResumeSessionConfig::event_buffer_capacity`](crate::types::ResumeSessionConfig::event_buffer_capacity).
60pub const DEFAULT_EVENT_BUFFER_CAPACITY: usize = 512;
61
62/// Validate a caller-supplied event buffer capacity and resolve the default.
63///
64/// Zero is rejected rather than clamped: a zero-capacity broadcast channel
65/// cannot exist, and silently substituting a different capacity would hide a
66/// caller bug.
67fn resolve_event_buffer_capacity(capacity: Option<usize>) -> Result<usize, Error> {
68    match capacity {
69        Some(0) => Err(Error::with_message(
70            ErrorKind::InvalidConfig,
71            "event_buffer_capacity must be greater than zero",
72        )),
73        Some(capacity) => Ok(capacity),
74        None => Ok(DEFAULT_EVENT_BUFFER_CAPACITY),
75    }
76}
77
78/// Bundle of the per-session callbacks the SDK dispatches to. Built from a
79/// [`SessionConfig`] / [`ResumeSessionConfig`] at
80/// [`Client::create_session`] / [`Client::resume_session`] time. Each
81/// field is `None` (or an empty map for tools) when the caller didn't
82/// install a handler -- in that case the SDK skips dispatch for that
83/// event type. The wire flags on `session.create` / `session.resume`
84/// are derived from these fields.
85#[derive(Clone)]
86pub(crate) struct SessionHandlers {
87    pub permission: Option<Arc<dyn PermissionHandler>>,
88    pub managed_settings_enabled: bool,
89    pub elicitation: Option<Arc<dyn ElicitationHandler>>,
90    pub mcp_auth: Option<Arc<dyn McpAuthHandler>>,
91    pub user_input: Option<Arc<dyn UserInputHandler>>,
92    pub exit_plan_mode: Option<Arc<dyn ExitPlanModeHandler>>,
93    pub auto_mode_switch: Option<Arc<dyn AutoModeSwitchHandler>>,
94    pub tools: Arc<HashMap<String, Arc<dyn crate::tool::ToolHandler>>>,
95}
96
97type PendingExternalTools = Arc<ParkingLotMutex<HashMap<RequestId, Arc<CancellationToken>>>>;
98
99struct PendingExternalToolGuard {
100    request_id: RequestId,
101    token: Arc<CancellationToken>,
102    pending: PendingExternalTools,
103}
104
105impl Drop for PendingExternalToolGuard {
106    fn drop(&mut self) {
107        let mut pending = self.pending.lock();
108        if pending
109            .get(&self.request_id)
110            .is_some_and(|token| Arc::ptr_eq(token, &self.token))
111        {
112            pending.remove(&self.request_id);
113        }
114    }
115}
116
117impl PendingExternalToolGuard {
118    fn claim(&self) -> bool {
119        let mut pending = self.pending.lock();
120        if pending
121            .get(&self.request_id)
122            .is_some_and(|token| Arc::ptr_eq(token, &self.token))
123        {
124            pending.remove(&self.request_id);
125            true
126        } else {
127            false
128        }
129    }
130}
131
132fn has_managed_settings(
133    enable_managed_settings: Option<bool>,
134    managed_settings: Option<&crate::types::ManagedSettings>,
135) -> bool {
136    enable_managed_settings == Some(true) || managed_settings.is_some()
137}
138
139/// Shared state between a [`Session`] and its event loop, used by [`Session::send_and_wait`].
140struct IdleWaiter {
141    tx: oneshot::Sender<Result<Option<SessionEvent>, Error>>,
142    last_assistant_message: Option<SessionEvent>,
143    started_at: Instant,
144    first_assistant_message_seen: bool,
145}
146
147/// RAII guard that clears the [`Session::idle_waiter`] slot on drop. Used
148/// by [`Session::send_and_wait`] to ensure the slot doesn't leak if the
149/// caller's future is cancelled (outer `tokio::time::timeout` / `select!`
150/// / dropped JoinHandle). Synchronous clear via `parking_lot::Mutex` —
151/// no async drop needed.
152///
153/// Without this, an outer cancellation between "install waiter" and
154/// "drain channel" would leave the slot occupied, causing all subsequent
155/// `send` and `send_and_wait` calls on the session to return
156/// [`SendWhileWaiting`](SessionErrorKind::SendWhileWaiting). Closes RFD-400
157/// review finding #2.
158struct WaiterGuard {
159    slot: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
160}
161
162impl Drop for WaiterGuard {
163    fn drop(&mut self) {
164        self.slot.lock().take();
165    }
166}
167
168struct PendingSessionRegistration {
169    client: Client,
170    session_id: PendingSessionId,
171    shutdown: CancellationToken,
172    external_tools_shutdown: CancellationToken,
173    disarmed: bool,
174}
175
176/// Which session ID a [`PendingSessionRegistration`] should unregister on
177/// cleanup.
178///
179/// `session.create` for cloud sessions without a caller-pinned ID does not
180/// know the ID until the response arrives, at which point the inline
181/// response callback registers it and stashes it. The guard therefore reads
182/// the stash at cleanup time instead of capturing an ID up front.
183enum PendingSessionId {
184    /// The ID was known before the RPC was issued (resume, and create with a
185    /// client- or caller-supplied ID).
186    Known(SessionId, crate::router::RegistrationToken),
187    /// Server-assigned ID, populated by the `session.create` inline response
188    /// callback. `None` in the stash means nothing was ever registered.
189    Deferred(Arc<ParkingLotMutex<Option<(SessionId, crate::router::SessionRegistration)>>>),
190}
191
192impl PendingSessionRegistration {
193    fn new(
194        client: Client,
195        session_id: SessionId,
196        token: crate::router::RegistrationToken,
197        shutdown: CancellationToken,
198        external_tools_shutdown: CancellationToken,
199    ) -> Self {
200        Self {
201            client,
202            session_id: PendingSessionId::Known(session_id, token),
203            shutdown,
204            external_tools_shutdown,
205            disarmed: false,
206        }
207    }
208
209    /// Guard for a registration whose session ID is assigned by the server.
210    fn deferred(
211        client: Client,
212        stash: Arc<ParkingLotMutex<Option<(SessionId, crate::router::SessionRegistration)>>>,
213        shutdown: CancellationToken,
214        external_tools_shutdown: CancellationToken,
215    ) -> Self {
216        Self {
217            client,
218            session_id: PendingSessionId::Deferred(stash),
219            shutdown,
220            external_tools_shutdown,
221            disarmed: false,
222        }
223    }
224
225    fn registered_id(&self) -> Option<SessionId> {
226        match &self.session_id {
227            PendingSessionId::Known(id, _) => Some(id.clone()),
228            PendingSessionId::Deferred(stash) => stash.lock().as_ref().map(|(id, _)| id.clone()),
229        }
230    }
231
232    /// Re-target the guard at a now-known session ID. Used by
233    /// `session.create` once the response has been parsed and the stash has
234    /// been drained into the event loop.
235    fn resolve_to(&mut self, session_id: SessionId, token: crate::router::RegistrationToken) {
236        self.session_id = PendingSessionId::Known(session_id, token);
237    }
238
239    async fn cleanup(mut self, event_loop: JoinHandle<()>) {
240        self.external_tools_shutdown.cancel();
241        self.shutdown.cancel();
242        let _ = event_loop.await;
243        if let Some(id) = self.registered_id() {
244            if let PendingSessionId::Known(_, token) = self.session_id {
245                self.client.unregister_session_owned(&id, token);
246            } else if let PendingSessionId::Deferred(stash) = &self.session_id
247                && let Some((id, registration)) = stash.lock().as_ref()
248            {
249                self.client.unregister_session_owned(id, registration.token);
250            }
251        }
252        self.disarmed = true;
253    }
254
255    fn disarm(&mut self) {
256        self.disarmed = true;
257    }
258}
259
260impl Drop for PendingSessionRegistration {
261    fn drop(&mut self) {
262        if !self.disarmed {
263            self.external_tools_shutdown.cancel();
264            self.shutdown.cancel();
265            if let Some(id) = self.registered_id() {
266                if let PendingSessionId::Known(_, token) = self.session_id {
267                    self.client.unregister_session_owned(&id, token);
268                } else if let PendingSessionId::Deferred(stash) = &self.session_id
269                    && let Some((id, registration)) = stash.lock().as_ref()
270                {
271                    self.client.unregister_session_owned(id, registration.token);
272                }
273            }
274        }
275    }
276}
277
278/// A session on a GitHub Copilot CLI server.
279///
280/// Created via [`Client::create_session`] or [`Client::resume_session`].
281/// Owns an internal event loop that dispatches events to the per-callback
282/// handlers installed on the session config.
283///
284/// Protocol methods (`send`, `get_events`, `abort`, etc.) automatically
285/// inject the session ID into RPC params.
286///
287/// Call [`destroy`](Self::destroy) for graceful cleanup (RPC + local). If dropped
288/// without calling `destroy`, the `Drop` impl aborts the event loop and
289/// unregisters from the router as a best-effort safety net.
290pub struct Session {
291    id: SessionId,
292    cwd: PathBuf,
293    workspace_path: Option<PathBuf>,
294    remote_url: Option<String>,
295    client: Client,
296    /// Handle to the spawned event-loop task. Sync `parking_lot::Mutex`
297    /// because the lock is never held across an `.await` and the `Drop`
298    /// impl needs to take the handle synchronously without `try_lock`
299    /// fallibility.
300    event_loop: ParkingLotMutex<Option<JoinHandle<()>>>,
301    /// Cooperative shutdown signal for the event loop. The loop selects
302    /// on [`shutdown.cancelled()`](CancellationToken::cancelled) alongside
303    /// its inbound channels; [`Session::stop_event_loop`] and [`Drop`]
304    /// both call [`cancel()`](CancellationToken::cancel) to ask the loop
305    /// to exit between iterations rather than aborting the task (which
306    /// can land at any await point and leave the session mid-protocol).
307    /// See RFD-400 review finding #3.
308    ///
309    /// `CancellationToken` is the canonical signalling primitive in
310    /// `tokio_util`; it is what `tonic` uses for the equivalent task-
311    /// coordination case. Advanced consumers can obtain a child token
312    /// via [`Session::cancellation_token`] to bind their own work to
313    /// the session lifetime.
314    shutdown: CancellationToken,
315    /// Cancels only host-owned external tool callbacks. Disconnect signals this
316    /// before the destroy RPC without stopping unrelated event delivery.
317    external_tools_shutdown: CancellationToken,
318    /// Only populated while a `send_and_wait` call is in flight.
319    ///
320    /// Sync `parking_lot::Mutex` because the lock is never held across an
321    /// `.await`, and synchronous access lets the `WaiterGuard` RAII helper
322    /// in `send_and_wait` clear the slot from a `Drop` impl on caller-side
323    /// cancellation. See RFD-400 review (cancel-safety hardening).
324    idle_waiter: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
325    /// Capabilities negotiated with the CLI, updated on `capabilities.changed` events.
326    capabilities: Arc<parking_lot::RwLock<SessionCapabilities>>,
327    /// Canvas instances currently known to be open for this session.
328    open_canvases: Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
329    /// Broadcast channel for runtime event subscribers — see [`Session::subscribe`].
330    event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
331    github_token_registration:
332        ParkingLotMutex<Option<crate::github_token::GitHubTokenRegistration>>,
333    /// Identity of this session's router registration.
334    registration_token: crate::router::RegistrationToken,
335}
336
337impl Session {
338    /// Session ID assigned by the CLI.
339    pub fn id(&self) -> &SessionId {
340        &self.id
341    }
342
343    /// Working directory of the CLI process.
344    pub fn cwd(&self) -> &PathBuf {
345        &self.cwd
346    }
347
348    /// Workspace directory for the session (if using infinite sessions).
349    pub fn workspace_path(&self) -> Option<&Path> {
350        self.workspace_path.as_deref()
351    }
352
353    /// Remote session URL, if the session is running remotely.
354    pub fn remote_url(&self) -> Option<&str> {
355        self.remote_url.as_deref()
356    }
357
358    /// Session capabilities negotiated with the CLI.
359    ///
360    /// Capabilities are set during session creation and updated at runtime
361    /// via `capabilities.changed` events.
362    pub fn capabilities(&self) -> SessionCapabilities {
363        self.capabilities.read().clone()
364    }
365
366    /// Open canvas instances reported by the most recent `session.resume`
367    /// response or surfaced by inbound `canvas.opened` events.
368    pub fn open_canvases(&self) -> Vec<OpenCanvasInstance> {
369        self.open_canvases.read().clone()
370    }
371
372    /// Returns a [`CancellationToken`] that fires when this session shuts
373    /// down (via [`Session::stop_event_loop`], [`Session::destroy`], or
374    /// [`Drop`]).
375    ///
376    /// Use this to bind an external task's lifetime to the session — when
377    /// the session shuts down, awaiting [`cancelled()`](CancellationToken::cancelled)
378    /// resolves so cooperative consumers can stop cleanly.
379    ///
380    /// The returned handle is a *child* token: calling
381    /// [`cancel()`](CancellationToken::cancel) on it cancels only the
382    /// caller's child, not the session itself. To cancel the session, call
383    /// [`Session::stop_event_loop`].
384    ///
385    /// # Example
386    ///
387    /// ```no_run
388    /// # async fn example(session: github_copilot_sdk::session::Session) {
389    /// let token = session.cancellation_token();
390    /// tokio::select! {
391    ///     _ = token.cancelled() => println!("session shut down"),
392    ///     _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
393    ///         println!("60s elapsed, session still alive");
394    ///     }
395    /// }
396    /// # }
397    /// ```
398    pub fn cancellation_token(&self) -> CancellationToken {
399        self.shutdown.child_token()
400    }
401
402    /// Subscribe to events for this session.
403    ///
404    /// Returns an [`EventSubscription`](crate::subscription::EventSubscription)
405    /// that yields every [`SessionEvent`] dispatched on this session's
406    /// event loop. Drop the value to unsubscribe; there is no separate
407    /// cancel handle.
408    ///
409    /// **Observe-only.** Subscribers receive a clone of every
410    /// [`SessionEvent`] but cannot influence permission decisions, tool
411    /// results, or anything else that requires returning a value. Those
412    /// remain the responsibility of the per-callback handlers passed via
413    /// [`SessionConfig`]'s `with_*_handler`
414    /// builder methods.
415    ///
416    /// The returned handle implements both an inherent
417    /// [`recv`](crate::subscription::EventSubscription::recv) method and
418    /// [`Stream`](tokio_stream::Stream), so callers can use a `while let`
419    /// loop or any combinator from `tokio_stream::StreamExt` /
420    /// `futures::StreamExt`.
421    ///
422    /// Each subscriber maintains its own queue. If a consumer cannot keep
423    /// up, the oldest events are dropped and `recv` returns
424    /// [`RecvErrorKind::Lagged`](crate::subscription::RecvErrorKind::Lagged)
425    /// reporting the count of skipped events. Slow consumers do not block
426    /// the session's event loop.
427    ///
428    /// # Example
429    ///
430    /// ```no_run
431    /// # async fn example(session: github_copilot_sdk::session::Session) {
432    /// let mut events = session.subscribe();
433    /// tokio::spawn(async move {
434    ///     while let Ok(event) = events.recv().await {
435    ///         println!("[{}] event {}", event.id, event.event_type);
436    ///     }
437    /// });
438    /// # }
439    /// ```
440    pub fn subscribe(&self) -> crate::subscription::EventSubscription {
441        crate::subscription::EventSubscription::new(self.event_tx.subscribe())
442    }
443
444    /// The underlying Client (for advanced use cases).
445    pub fn client(&self) -> &Client {
446        &self.client
447    }
448
449    /// Typed RPC namespace for this session.
450    ///
451    /// Every protocol method lives here under its schema-aligned path —
452    /// e.g. `session.rpc().workspaces().list_files()`. Wire method names
453    /// and request/response types are generated from the protocol schema,
454    /// so the typed namespace can't drift from the wire contract.
455    ///
456    /// The hand-authored helpers on [`Session`] delegate to this namespace
457    /// and remain the recommended entry point for everyday use; reach for
458    /// `rpc()` when you want a method without a hand-written wrapper.
459    pub fn rpc(&self) -> crate::generated::rpc::SessionRpc<'_> {
460        crate::generated::rpc::SessionRpc { session: self }
461    }
462
463    /// Stop the internal event loop. Called automatically on [`destroy`](Self::destroy).
464    ///
465    /// Cooperative: signals shutdown via the session's [`CancellationToken`]
466    /// and awaits the loop's natural exit rather than aborting the task, so
467    /// the loop always stops between iterations instead of at an arbitrary
468    /// await point. See RFD-400 review finding #3.
469    ///
470    /// Inbound requests are dispatched to their own spawned tasks, which this
471    /// call does not await. A handler (permission callback, tool call,
472    /// elicitation response) still running at teardown may therefore outlive
473    /// the loop, and its response can be lost if the connection closes first.
474    /// Await your own handler work before calling this if it must complete.
475    pub async fn stop_event_loop(&self) {
476        self.shutdown.cancel();
477        let handle = self.event_loop.lock().take();
478        if let Some(handle) = handle {
479            let _ = handle.await;
480        }
481        // Fail any pending send_and_wait so it returns immediately.
482        if let Some(waiter) = self.idle_waiter.lock().take() {
483            let _ = waiter.tx.send(Err(
484                ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()
485            ));
486        }
487    }
488
489    /// Send a user message to the agent.
490    ///
491    /// Accepts anything convertible to [`MessageOptions`] — pass a `&str` for the
492    /// trivial case, or build a `MessageOptions` for mode/attachments. The
493    /// `wait_timeout` field on `MessageOptions` is ignored here (use
494    /// [`send_and_wait`](Self::send_and_wait) if you need to wait).
495    ///
496    /// Returns the assigned message ID, which can be used to correlate the
497    /// send with later [`SessionEvent`]s emitted in
498    /// response (assistant messages, tool requests, etc.).
499    ///
500    /// Returns an error if a [`send_and_wait`](Self::send_and_wait) call is
501    /// currently in flight, since the plain send would race with the waiter.
502    ///
503    /// # Cancel safety
504    ///
505    /// **Cancel-safe.** The underlying `session.send` RPC is dispatched
506    /// through the writer-actor (see [`Client::call`](crate::Client::call)),
507    /// so dropping this future after the actor has committed to writing
508    /// will not produce a partial frame on the wire. If the caller's
509    /// future is dropped between "frame enqueued" and "response received",
510    /// the message has already landed on the wire — the agent will process
511    /// it and emit events normally; the caller just won't see the returned
512    /// message ID.
513    pub async fn send(&self, opts: impl Into<MessageOptions>) -> Result<String, Error> {
514        if self.idle_waiter.lock().is_some() {
515            return Err(ErrorKind::Session(SessionErrorKind::SendWhileWaiting).into());
516        }
517        self.send_inner(opts.into()).await
518    }
519
520    async fn send_inner(&self, opts: MessageOptions) -> Result<String, Error> {
521        let mut params = serde_json::json!({
522            "sessionId": self.id,
523            "prompt": opts.prompt,
524        });
525        if let Some(source) = opts.source {
526            params["source"] = serde_json::to_value(source)?;
527        }
528        if let Some(m) = opts.mode {
529            params["mode"] = serde_json::to_value(m)?;
530        }
531        if let Some(am) = opts.agent_mode {
532            params["agentMode"] = serde_json::to_value(am)?;
533        }
534        if let Some(mut a) = opts.attachments {
535            ensure_attachment_display_names(&mut a);
536            params["attachments"] = serde_json::to_value(a)?;
537        }
538        if let Some(headers) = opts.request_headers
539            && !headers.is_empty()
540        {
541            params["requestHeaders"] = serde_json::to_value(headers)?;
542        }
543        if let Some(display_prompt) = opts.display_prompt {
544            params["displayPrompt"] = serde_json::to_value(display_prompt)?;
545        }
546        let trace_ctx = if opts.traceparent.is_some() || opts.tracestate.is_some() {
547            TraceContext {
548                traceparent: opts.traceparent,
549                tracestate: opts.tracestate,
550            }
551        } else {
552            self.client.resolve_trace_context().await
553        };
554        inject_trace_context(&mut params, &trace_ctx);
555        let rpc_start = Instant::now();
556        let result = self.client.call("session.send", Some(params)).await?;
557        let message_id = result
558            .get("messageId")
559            .and_then(|v| v.as_str())
560            .map(|s| s.to_string())
561            .unwrap_or_default();
562        tracing::debug!(
563            elapsed_ms = rpc_start.elapsed().as_millis(),
564            session_id = %self.id,
565            message_id = %message_id,
566            "Session::send completed successfully"
567        );
568        Ok(message_id)
569    }
570
571    /// Send a user message and wait for the agent to finish processing.
572    ///
573    /// Accepts anything convertible to [`MessageOptions`] — pass a `&str` for the
574    /// trivial case, or build a `MessageOptions` for mode/attachments/timeout.
575    /// Blocks until `session.idle` (success) or `session.error` (failure),
576    /// returning the last `assistant.message` event captured during streaming.
577    /// Times out after `MessageOptions::wait_timeout` (default 60 seconds).
578    ///
579    /// Only one `send_and_wait` call may be active per session at a time.
580    /// Calling [`send`](Self::send) while a `send_and_wait`
581    /// is in flight will also return an error.
582    ///
583    /// # Cancel safety
584    ///
585    /// **Cancel-safe.** A `WaiterGuard` clears the in-flight slot on every
586    /// exit path (success, internal failure, internal timeout, *and*
587    /// external cancellation via `tokio::time::timeout` / `select!` /
588    /// dropped JoinHandle). Subsequent `send` and `send_and_wait` calls on
589    /// this session will succeed normally — the slot is never leaked.
590    pub async fn send_and_wait(
591        &self,
592        opts: impl Into<MessageOptions>,
593    ) -> Result<Option<SessionEvent>, Error> {
594        let total_start = Instant::now();
595        let opts = opts.into();
596        let timeout_duration = opts.wait_timeout.unwrap_or(Duration::from_secs(60));
597        let (tx, rx) = oneshot::channel();
598
599        {
600            let mut guard = self.idle_waiter.lock();
601            if guard.is_some() {
602                return Err(ErrorKind::Session(SessionErrorKind::SendWhileWaiting).into());
603            }
604            *guard = Some(IdleWaiter {
605                tx,
606                last_assistant_message: None,
607                started_at: total_start,
608                first_assistant_message_seen: false,
609            });
610        }
611
612        // RAII: clears the idle_waiter slot on every exit path, including
613        // external cancellation (caller's outer `select!` / `timeout` /
614        // dropped future). Without this, an outer cancellation would leak
615        // the slot and brick subsequent `send`/`send_and_wait` calls.
616        let _waiter_guard = WaiterGuard {
617            slot: self.idle_waiter.clone(),
618        };
619
620        let result = tokio::time::timeout(timeout_duration, async {
621            self.send_inner(opts).await?;
622            match rx.await {
623                Ok(result) => result,
624                Err(_) => Err(ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()),
625            }
626        })
627        .await;
628
629        match result {
630            Ok(inner) => {
631                tracing::debug!(
632                    elapsed_ms = total_start.elapsed().as_millis(),
633                    session_id = %self.id,
634                    completed_by = if inner.is_ok() { "idle" } else { "error" },
635                    "Session::send_and_wait complete"
636                );
637                inner
638            }
639            Err(_) => {
640                tracing::warn!(
641                    elapsed_ms = total_start.elapsed().as_millis(),
642                    session_id = %self.id,
643                    completed_by = "timeout",
644                    "Session::send_and_wait failed"
645                );
646                Err(ErrorKind::Session(SessionErrorKind::Timeout(timeout_duration)).into())
647            }
648        }
649    }
650
651    /// Retrieve the session's timeline events.
652    pub async fn get_events(&self) -> Result<Vec<SessionEvent>, Error> {
653        let result = self
654            .client
655            .call(
656                "session.getMessages",
657                Some(serde_json::json!({ "sessionId": self.id })),
658            )
659            .await?;
660        let response: GetMessagesResponse = serde_json::from_value(result)?;
661        Ok(response.events)
662    }
663
664    /// Deprecated alias for [`get_events`](Self::get_events).
665    #[deprecated(since = "0.1.0", note = "Use `get_events()` instead")]
666    pub async fn get_messages(&self) -> Result<Vec<SessionEvent>, Error> {
667        self.get_events().await
668    }
669
670    /// Abort the current agent turn.
671    ///
672    /// # Cancel safety
673    ///
674    /// **Cancel-safe.** Single `session.abort` RPC; the underlying
675    /// [`Client::call`](crate::Client::call) is cancel-safe via the
676    /// writer-actor.
677    pub async fn abort(&self) -> Result<(), Error> {
678        self.client
679            .call(
680                "session.abort",
681                Some(serde_json::json!({ "sessionId": self.id })),
682            )
683            .await?;
684        Ok(())
685    }
686
687    /// Switch to a different model.
688    ///
689    /// Pass `None` for `opts` if no extra configuration is needed.
690    pub async fn set_model(&self, model: &str, opts: Option<SetModelOptions>) -> Result<(), Error> {
691        let opts = opts.unwrap_or_default();
692        let auto_tier = opts.auto_tier.clone();
693        let request = ModelSwitchToRequest {
694            auto_tier: match &auto_tier {
695                Some(AutoTierPreference::Tier(tier)) => Some(tier.clone()),
696                _ => None,
697            },
698            compaction_decision: None,
699            context_tier: opts.context_tier,
700            defer_if_model_change_queued: None,
701            model_capabilities: opts.model_capabilities,
702            model_change_scope: None,
703            model_id: model.to_string(),
704            picker_persistence: None,
705            reasoning_effort: opts.reasoning_effort,
706            reasoning_summary: opts.reasoning_summary,
707            repo_scope: None,
708            require_available: None,
709            run_compaction_preflight: None,
710            source: None,
711            verbosity: None,
712        };
713
714        if matches!(auto_tier, Some(AutoTierPreference::Reset)) {
715            // The generated request skips a `None` tier, which the runtime reads
716            // as "leave the preference alone" rather than "use provider-default
717            // routing", so send an explicit null instead.
718            let mut wire_params = serde_json::to_value(request)?;
719            wire_params["sessionId"] = serde_json::Value::String(self.id.to_string());
720            wire_params["autoTier"] = serde_json::Value::Null;
721            self.client
722                .call("session.model.switchTo", Some(wire_params))
723                .await?;
724            return Ok(());
725        }
726
727        self.rpc().model().switch_to(request).await?;
728        Ok(())
729    }
730
731    /// Change the Auto routing preference without changing the selected model.
732    ///
733    /// The runtime does not apply the preference immediately. It records the
734    /// request and commits it only when a later user turn using the `auto`
735    /// model successfully obtains a usable model from the provider. A
736    /// [`ModelSwitchAutoTierStatus::Pending`] status therefore confirms that the
737    /// request was accepted, not that it took effect.
738    ///
739    /// Watch for the outcome through the `session.model_change` event on
740    /// success, or the ephemeral `session.auto_tier_switch_failed` event on
741    /// failure. You can also read the current committed and in-flight state at
742    /// any time through `session.rpc().model().get_current()`.
743    ///
744    /// Only the most recent request survives: issuing a new request replaces any
745    /// earlier one that has not yet been claimed by a turn.
746    ///
747    /// Pass `None` to return to the provider's default Auto routing.
748    ///
749    /// **Experimental.** Part of an experimental Auto routing surface and may
750    /// change or be removed in a future release.
751    ///
752    /// # Cancel safety
753    ///
754    /// **Cancel-safe.** Single `session.model.switchAutoTier` RPC; the
755    /// underlying [`Client::call`](crate::Client::call) is cancel-safe via the
756    /// writer-actor.
757    ///
758    /// [`ModelSwitchAutoTierStatus::Pending`]: crate::generated::api_types::ModelSwitchAutoTierStatus::Pending
759    pub async fn set_auto_tier(
760        &self,
761        auto_tier: Option<AutoTier>,
762    ) -> Result<ModelSwitchAutoTierResult, Error> {
763        self.rpc()
764            .model()
765            .switch_auto_tier(ModelSwitchAutoTierRequest {
766                auto_tier,
767                source: None,
768            })
769            .await
770    }
771
772    /// Disconnect this session from the CLI.
773    ///
774    /// Sends the `session.detach` RPC, stops the event loop, and unregisters
775    /// the session from the client. **Session state on disk** (conversation
776    /// history, planning state, artifacts) is **preserved**, so the
777    /// conversation can be resumed later via [`Client::resume_session`]
778    /// using this session's ID. To permanently remove all on-disk session
779    /// data, use [`Client::delete_session`] instead.
780    ///
781    /// The caller should ensure the session is idle (e.g. [`send_and_wait`]
782    /// has returned) before disconnecting; in-flight tool or event handlers
783    /// may otherwise observe failures.
784    ///
785    /// [`Client::resume_session`]: crate::Client::resume_session
786    /// [`Client::delete_session`]: crate::Client::delete_session
787    /// [`send_and_wait`]: Self::send_and_wait
788    pub async fn disconnect(&self) -> Result<(), Error> {
789        self.client.detach_session(&self.id).await?;
790        self.external_tools_shutdown.cancel();
791        self.stop_event_loop().await;
792        self.github_token_registration.lock().take();
793        self.client
794            .unregister_session_owned(&self.id, self.registration_token);
795        Ok(())
796    }
797
798    /// Deprecated alias for [`disconnect`](Self::disconnect).
799    /// Prefer `disconnect` in new code.
800    #[deprecated(since = "0.1.0", note = "Use `disconnect()` instead")]
801    pub async fn destroy(&self) -> Result<(), Error> {
802        self.disconnect().await
803    }
804
805    /// Write a log message to the session.
806    ///
807    /// Pass `None` for `opts` to use defaults (info level, persisted).
808    pub async fn log(
809        &self,
810        message: &str,
811        opts: Option<crate::types::LogOptions>,
812    ) -> Result<(), Error> {
813        let opts = opts.unwrap_or_default();
814        let level = match opts.level {
815            Some(level) => Some(serde_json::from_value(serde_json::to_value(level)?)?),
816            None => None,
817        };
818        let request = LogRequest {
819            message: message.to_string(),
820            level,
821            ephemeral: opts.ephemeral,
822            r#type: None,
823            tip: None,
824            url: None,
825        };
826        self.rpc().log(request).await?;
827        Ok(())
828    }
829
830    /// Returns the UI sub-API for elicitation, confirmation, selection, and
831    /// free-form input.
832    ///
833    /// All UI methods route through `session.ui.*` RPCs and require host
834    /// support — check `session.capabilities().ui.elicitation` before use.
835    pub fn ui(&self) -> SessionUi<'_> {
836        SessionUi { session: self }
837    }
838
839    /// Returns an error if the host doesn't support elicitation.
840    fn assert_elicitation(&self) -> Result<(), Error> {
841        if self
842            .capabilities
843            .read()
844            .ui
845            .as_ref()
846            .and_then(|u| u.elicitation)
847            != Some(true)
848        {
849            return Err(ErrorKind::Session(SessionErrorKind::ElicitationNotSupported).into());
850        }
851        Ok(())
852    }
853}
854
855impl Drop for Session {
856    fn drop(&mut self) {
857        // Cooperative shutdown: cancel the event loop's token to signal
858        // exit between iterations. The loop will see the cancellation on
859        // its next select poll and break cleanly. We do NOT abort the
860        // JoinHandle — that would land at any await point in the loop body,
861        // potentially leaving the CLI with an unanswered request id.
862        // RFD-400 review finding #3. Requests already dispatched to their
863        // own tasks are not tracked here and may outlive the session.
864        //
865        // The handle itself is left in `event_loop` to be reaped by the
866        // tokio runtime when it next polls; we intentionally don't await
867        // it here because Drop is sync.
868        self.shutdown.cancel();
869        self.external_tools_shutdown.cancel();
870        self.github_token_registration.lock().take();
871        self.client
872            .unregister_session_owned(&self.id, self.registration_token);
873    }
874}
875
876/// UI sub-API for a [`Session`] — elicitation, confirmation, selection,
877/// and free-form input.
878///
879/// Acquired via [`Session::ui`]. Methods route to `session.ui.*` RPCs and
880/// require host elicitation support — check
881/// `session.capabilities().ui.elicitation` before use.
882pub struct SessionUi<'a> {
883    session: &'a Session,
884}
885
886impl<'a> SessionUi<'a> {
887    /// Request user input via an interactive UI form (elicitation).
888    ///
889    /// Sends a JSON Schema describing form fields to the CLI host. The host
890    /// renders a form dialog and returns the user's response.
891    ///
892    /// Prefer the typed convenience methods [`confirm`](Self::confirm),
893    /// [`select`](Self::select), and [`input`](Self::input) for common cases.
894    pub async fn elicitation(
895        &self,
896        message: &str,
897        schema: Value,
898    ) -> Result<ElicitationResult, Error> {
899        self.session.assert_elicitation()?;
900        let result = self
901            .session
902            .client
903            .call(
904                "session.ui.elicitation",
905                Some(serde_json::json!({
906                    "sessionId": self.session.id,
907                    "message": message,
908                    "requestedSchema": schema,
909                })),
910            )
911            .await?;
912        let elicitation: ElicitationResult = serde_json::from_value(result)?;
913        Ok(elicitation)
914    }
915
916    /// Ask the user a yes/no confirmation question.
917    ///
918    /// Returns `true` if the user accepted and confirmed, `false` otherwise.
919    pub async fn confirm(&self, message: &str) -> Result<bool, Error> {
920        self.session.assert_elicitation()?;
921        let schema = serde_json::json!({
922            "type": "object",
923            "properties": {
924                "confirmed": {
925                    "type": "boolean",
926                    "default": true,
927                }
928            },
929            "required": ["confirmed"]
930        });
931        let result = self.elicitation(message, schema).await?;
932        Ok(result.action == "accept"
933            && result
934                .content
935                .and_then(|c| c.get("confirmed").and_then(|v| v.as_bool()))
936                == Some(true))
937    }
938
939    /// Ask the user to select from a list of options.
940    ///
941    /// Returns the selected option string on accept, or `None` on decline/cancel.
942    pub async fn select(&self, message: &str, options: &[&str]) -> Result<Option<String>, Error> {
943        self.session.assert_elicitation()?;
944        let schema = serde_json::json!({
945            "type": "object",
946            "properties": {
947                "selection": {
948                    "type": "string",
949                    "enum": options,
950                }
951            },
952            "required": ["selection"]
953        });
954        let result = self.elicitation(message, schema).await?;
955        if result.action != "accept" {
956            return Ok(None);
957        }
958        let selection = result.content.and_then(|c| {
959            c.get("selection")
960                .and_then(|v| v.as_str())
961                .map(String::from)
962        });
963        Ok(selection)
964    }
965
966    /// Ask the user for free-form text input.
967    ///
968    /// Returns the input string on accept, or `None` on decline/cancel.
969    /// Use [`UiInputOptions`] to set validation constraints and field metadata.
970    pub async fn input(
971        &self,
972        message: &str,
973        options: Option<&UiInputOptions<'_>>,
974    ) -> Result<Option<String>, Error> {
975        self.session.assert_elicitation()?;
976        let mut field = serde_json::json!({ "type": "string" });
977        if let Some(opts) = options {
978            if let Some(title) = opts.title {
979                field["title"] = Value::String(title.to_string());
980            }
981            if let Some(desc) = opts.description {
982                field["description"] = Value::String(desc.to_string());
983            }
984            if let Some(min) = opts.min_length {
985                field["minLength"] = Value::Number(min.into());
986            }
987            if let Some(max) = opts.max_length {
988                field["maxLength"] = Value::Number(max.into());
989            }
990            if let Some(fmt) = &opts.format {
991                field["format"] = Value::String(fmt.as_str().to_string());
992            }
993            if let Some(default) = opts.default {
994                field["default"] = Value::String(default.to_string());
995            }
996        }
997        let schema = serde_json::json!({
998            "type": "object",
999            "properties": { "value": field },
1000            "required": ["value"]
1001        });
1002        let result = self.elicitation(message, schema).await?;
1003        if result.action != "accept" {
1004            return Ok(None);
1005        }
1006        let value = result
1007            .content
1008            .and_then(|c| c.get("value").and_then(|v| v.as_str()).map(String::from));
1009        Ok(value)
1010    }
1011}
1012
1013impl Client {
1014    /// Prepare a new session without touching the transport.
1015    ///
1016    /// Returns a [`PreparedSession`] that owns the session's event broadcast
1017    /// channel, so callers can install an
1018    /// [`EventSubscription`](crate::subscription::EventSubscription) via
1019    /// [`PreparedSession::subscribe`] *before* any protocol activity starts.
1020    /// Call [`PreparedSession::start`] to actually create the session.
1021    ///
1022    /// This is the loss-free entry point for consumers that must observe
1023    /// every *routed* event a session emits, including events the CLI emits
1024    /// while `session.create` is still in flight and ephemeral events (such
1025    /// as `session.idle`) that cannot be recovered from
1026    /// [`Session::get_messages`]. [`create_session`](Self::create_session)
1027    /// is a thin wrapper over `prepare_session(...)?.start()` and cannot
1028    /// offer the same guarantee, because the subscription can only be
1029    /// installed after the returned `Session` exists.
1030    ///
1031    /// Routing requires a known session ID. When the server assigns the ID,
1032    /// the SDK cannot register the session on its notification router until
1033    /// the `session.create` response arrives, so notifications emitted
1034    /// before that point are not routable and stay unobservable. Pin
1035    /// [`SessionConfig::session_id`](crate::types::SessionConfig::session_id)
1036    /// for complete pre-response coverage — see the "Server-assigned session
1037    /// IDs" section on [`PreparedSession`].
1038    ///
1039    /// # Inertness
1040    ///
1041    /// `prepare_session` performs no router registration, spawns no task,
1042    /// and writes nothing to the wire. It only validates
1043    /// [`event_buffer_capacity`](SessionConfig::event_buffer_capacity),
1044    /// allocates a local broadcast channel and cancellation token, and
1045    /// stores the config. Dropping the returned handle without starting it
1046    /// leaves no client-side or server-side state behind and closes every
1047    /// subscription taken from it.
1048    ///
1049    /// # Errors
1050    ///
1051    /// Returns [`ErrorKind::InvalidConfig`] if
1052    /// [`event_buffer_capacity`](SessionConfig::event_buffer_capacity) is
1053    /// `Some(0)`. All other configuration and protocol errors surface from
1054    /// [`PreparedSession::start`], with the same
1055    /// [`ErrorKind`]s [`create_session`](Self::create_session) has always
1056    /// returned.
1057    ///
1058    /// # Example
1059    ///
1060    /// ```no_run
1061    /// # use github_copilot_sdk::{Client, SessionConfig};
1062    /// # async fn example(client: Client) -> Result<(), github_copilot_sdk::Error> {
1063    /// let prepared = client.prepare_session(SessionConfig::default())?;
1064    /// let mut events = prepared.subscribe();
1065    /// let drain = tokio::spawn(async move {
1066    ///     while let Ok(event) = events.recv().await {
1067    ///         println!("{}", event.event_type);
1068    ///     }
1069    /// });
1070    /// let session = prepared.start().await?;
1071    /// # let _ = (session, drain);
1072    /// # Ok(())
1073    /// # }
1074    /// ```
1075    pub fn prepare_session(&self, config: SessionConfig) -> Result<PreparedSession, Error> {
1076        let capacity = resolve_event_buffer_capacity(config.event_buffer_capacity)?;
1077        Ok(PreparedSession::new(
1078            self.clone(),
1079            PreparedKind::Create(Box::new(config)),
1080            capacity,
1081        ))
1082    }
1083
1084    /// Prepare a session resume without touching the transport.
1085    ///
1086    /// The resume counterpart of [`prepare_session`](Self::prepare_session);
1087    /// see that method for the inertness guarantee, error semantics, and
1088    /// rationale. Particularly relevant on resume with
1089    /// [`continue_pending_work`](ResumeSessionConfig::continue_pending_work),
1090    /// where the runtime can start emitting events (and reach
1091    /// `session.idle`) while `session.resume` is still in flight.
1092    ///
1093    /// # Errors
1094    ///
1095    /// Returns [`ErrorKind::InvalidConfig`] if
1096    /// [`event_buffer_capacity`](ResumeSessionConfig::event_buffer_capacity)
1097    /// is `Some(0)`.
1098    pub fn prepare_resume_session(
1099        &self,
1100        config: ResumeSessionConfig,
1101    ) -> Result<PreparedSession, Error> {
1102        let capacity = resolve_event_buffer_capacity(config.event_buffer_capacity)?;
1103        Ok(PreparedSession::new(
1104            self.clone(),
1105            PreparedKind::Resume(Box::new(config)),
1106            capacity,
1107        ))
1108    }
1109
1110    /// Create a new session on the CLI.
1111    ///
1112    /// Sends `session.create`, registers the session on the router,
1113    /// and spawns an internal event loop that dispatches to the handler.
1114    ///
1115    /// All callbacks (per-event handlers, tool handlers, hooks, transform)
1116    /// are configured via [`SessionConfig`] using its `with_*_handler` /
1117    /// `with_tools` / `with_hooks` / `with_system_message_transform` builder
1118    /// methods.
1119    ///
1120    /// If [`hooks_handler`](SessionConfig::hooks_handler) is set, the
1121    /// wire-level `hooks` flag is automatically enabled.
1122    ///
1123    /// If [`system_message_transform`](SessionConfig::system_message_transform) is set, the SDK injects
1124    /// `action: "transform"` sections into the [`SystemMessageConfig`] wire
1125    /// format and handles `systemMessage.transform` RPC callbacks during
1126    /// the session.
1127    ///
1128    /// Each per-event handler is independently optional. If a handler is
1129    /// not installed, the SDK signals the runtime not to emit the matching
1130    /// broadcast (and silently skips dispatch if one arrives anyway).
1131    ///
1132    /// # Event delivery
1133    ///
1134    /// Equivalent to `prepare_session(config)?.start().await`. Because the
1135    /// first subscription can only be taken from the returned [`Session`],
1136    /// events the runtime emits before this call returns are broadcast with
1137    /// no receiver installed and are therefore not delivered to
1138    /// [`Session::subscribe`]. Use
1139    /// [`prepare_session`](Self::prepare_session) when startup events
1140    /// matter.
1141    pub async fn create_session(&self, config: SessionConfig) -> Result<Session, Error> {
1142        self.prepare_session(config)?.start().await
1143    }
1144
1145    /// Resume an existing session on the CLI.
1146    ///
1147    /// Sends `session.resume` and `session.skills.reload`, registers the
1148    /// session on the router, and spawns the event loop.
1149    ///
1150    /// All callbacks (event handler, hooks, transform) are configured
1151    /// via [`ResumeSessionConfig`] using its `with_*` builder methods.
1152    ///
1153    /// See [`Self::create_session`] for the defaults applied when callback
1154    /// fields are unset.
1155    ///
1156    /// # Event delivery
1157    ///
1158    /// Equivalent to `prepare_resume_session(config)?.start().await`, and
1159    /// carries the same startup-event caveat documented on
1160    /// [`create_session`](Self::create_session). Use
1161    /// [`prepare_resume_session`](Self::prepare_resume_session) when
1162    /// startup events matter.
1163    pub async fn resume_session(&self, config: ResumeSessionConfig) -> Result<Session, Error> {
1164        self.prepare_resume_session(config)?.start().await
1165    }
1166
1167    async fn start_prepared_create(
1168        &self,
1169        mut config: SessionConfig,
1170        event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
1171        shutdown: CancellationToken,
1172    ) -> Result<Session, Error> {
1173        let total_start = Instant::now();
1174        // For cloud sessions, let the CLI/server assign the session id and
1175        // register the session lazily once the response arrives. For non-cloud
1176        // sessions we generate the id client-side (when the caller didn't
1177        // supply one) so the session can be registered BEFORE the RPC — the
1178        // CLI may issue session-scoped requests (e.g. sessionFs.writeFile for
1179        // workspace metadata) during session.create processing, before it has
1180        // sent the response.
1181        let caller_session_id = config.session_id.clone();
1182        let use_server_generated_id = config.cloud.is_some() && caller_session_id.is_none();
1183        let local_session_id: Option<SessionId> = if use_server_generated_id {
1184            None
1185        } else {
1186            Some(
1187                caller_session_id
1188                    .clone()
1189                    .unwrap_or_else(|| SessionId::new(uuid::Uuid::new_v4().to_string())),
1190            )
1191        };
1192        if config.hooks_handler.is_some() && config.hooks.is_none() {
1193            config.hooks = Some(true);
1194        }
1195        if let Some(transforms) = config.system_message_transform.clone() {
1196            inject_transform_sections(&mut config, transforms.as_ref());
1197        }
1198        let mode = self.inner.mode;
1199        if mode == crate::ClientMode::Empty && config.available_tools.is_none() {
1200            return Err(Error::with_message(
1201                ErrorKind::InvalidConfig,
1202                "ClientMode::Empty requires available_tools to be set on the session config. \
1203                 Use ToolSet to specify which tools the session may use (e.g. \
1204                 ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED)).",
1205            ));
1206        }
1207        crate::mode::validate_tool_filter_list(
1208            "available_tools",
1209            config.available_tools.as_deref(),
1210        )?;
1211        crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?;
1212        config.system_message =
1213            crate::mode::system_message_for_mode(mode, config.system_message.take());
1214        config.memory = crate::mode::memory_for_mode(mode, config.memory.take());
1215        config.enable_experimental_mode =
1216            crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode);
1217        if mode == crate::ClientMode::Empty {
1218            if config.enable_session_telemetry.is_none() {
1219                config.enable_session_telemetry = Some(false);
1220            }
1221            if config.skip_embedding_retrieval.is_none() {
1222                config.skip_embedding_retrieval = Some(true);
1223            }
1224            if config.enable_on_demand_instruction_discovery.is_none() {
1225                config.enable_on_demand_instruction_discovery = Some(false);
1226            }
1227            if config.enable_file_hooks.is_none() {
1228                config.enable_file_hooks = Some(false);
1229            }
1230            if config.enable_host_git_operations.is_none() {
1231                config.enable_host_git_operations = Some(false);
1232            }
1233            if config.enable_session_store.is_none() {
1234                config.enable_session_store = Some(false);
1235            }
1236            if config.enable_skills.is_none() {
1237                config.enable_skills = Some(false);
1238            }
1239        }
1240        if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() {
1241            config.mcp_oauth_token_storage = Some("in-memory".into());
1242        }
1243        if mode == crate::ClientMode::Empty && config.embedding_cache_storage.is_none() {
1244            config.embedding_cache_storage = Some("in-memory".into());
1245        }
1246        config.custom_agents_local_only =
1247            crate::mode::resolve_custom_agents_local_only(mode, config.custom_agents_local_only);
1248        let opt_skip_custom_instructions = config.skip_custom_instructions;
1249        let opt_custom_agents_local_only = config.custom_agents_local_only;
1250        let opt_coauthor_enabled = config.coauthor_enabled;
1251        let opt_manage_schedule_enabled = config.manage_schedule_enabled;
1252        let opt_included_builtin_skills = config.included_builtin_skills.take();
1253        let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?;
1254        wire.enable_github_telemetry_forwarding =
1255            self.inner.on_github_telemetry.is_some().then_some(true);
1256
1257        let permission_handler = crate::permission::resolve_handler(
1258            runtime.permission_handler.take(),
1259            runtime.permission_policy.take(),
1260        );
1261        let handlers = SessionHandlers {
1262            permission: permission_handler,
1263            managed_settings_enabled: has_managed_settings(
1264                wire.enable_managed_settings,
1265                wire.managed_settings.as_ref(),
1266            ),
1267            elicitation: runtime.elicitation_handler.take(),
1268            mcp_auth: runtime.mcp_auth_handler.take(),
1269            user_input: runtime.user_input_handler.take(),
1270            exit_plan_mode: runtime.exit_plan_mode_handler.take(),
1271            auto_mode_switch: runtime.auto_mode_switch_handler.take(),
1272            tools: Arc::new(std::mem::take(&mut runtime.tool_handlers)),
1273        };
1274        let hooks = runtime.hooks_handler.take();
1275        let transforms = runtime.system_message_transform.take();
1276        let tools_count = wire.tools.as_ref().map_or(0, Vec::len);
1277        let commands_count = runtime.commands.as_ref().map_or(0, Vec::len);
1278        let has_hooks = hooks.is_some();
1279        let command_handlers = build_command_handler_map(runtime.commands.as_deref());
1280        let canvas_handler = runtime.canvas_handler.take();
1281        let session_fs_provider = runtime.session_fs_provider.take();
1282        let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers);
1283        let github_token_registration = runtime
1284            .github_token_provider
1285            .take()
1286            .map(|provider| self.register_github_token_provider(provider));
1287        wire.github_token_provider_registration_id = github_token_registration
1288            .as_ref()
1289            .map(|registration| registration.id().to_string());
1290        let has_mcp_auth_handler = handlers.mcp_auth.is_some();
1291        if self.inner.session_fs_configured && session_fs_provider.is_none() {
1292            return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into());
1293        }
1294        if self.inner.session_fs_sqlite_declared
1295            && let Some(ref provider) = session_fs_provider
1296            && provider.sqlite().is_none()
1297        {
1298            return Err(Error::with_message(
1299                ErrorKind::InvalidConfig,
1300                "SessionFs capabilities declare SQLite support but the provider \
1301                 does not implement SessionFsSqliteProvider",
1302            ));
1303        }
1304
1305        let mut params = serde_json::to_value(&wire)?;
1306        let trace_ctx = self.resolve_trace_context().await;
1307        inject_trace_context(&mut params, &trace_ctx);
1308
1309        let setup_start = Instant::now();
1310        let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default()));
1311        let idle_waiter = Arc::new(ParkingLotMutex::new(None));
1312        let open_canvases = Arc::new(parking_lot::RwLock::new(Vec::new()));
1313        let external_tools_shutdown = self.inner.rpc.connection_closed_token();
1314
1315        // For cloud sessions (use_server_generated_id), defer session
1316        // registration to the inline callback so the read task registers
1317        // the session synchronously the instant the response arrives.
1318        // For non-cloud sessions, register up-front so the CLI can issue
1319        // session-scoped requests during session.create processing.
1320        let inline_stash: Arc<
1321            ParkingLotMutex<Option<(SessionId, crate::router::SessionRegistration)>>,
1322        > = Arc::new(ParkingLotMutex::new(None));
1323
1324        let inline_callback: Option<crate::jsonrpc::InlineResponseCallback> = if let Some(ref sid) =
1325            local_session_id
1326        {
1327            let channels = self.register_session(sid);
1328            *inline_stash.lock() = Some((sid.clone(), channels));
1329            None
1330        } else {
1331            let client = self.clone();
1332            let stash = inline_stash.clone();
1333            let expected = caller_session_id.clone();
1334            Some(Box::new(move |response| {
1335                let result = response.result.as_ref().ok_or_else(|| {
1336                    Error::with_message(ErrorKind::Json, "session.create response had no result")
1337                })?;
1338                let parsed: CreateSessionResult =
1339                    serde_json::from_value(result.clone()).map_err(Error::from)?;
1340                if let Some(requested) = expected.as_ref()
1341                    && parsed.session_id != *requested
1342                {
1343                    return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
1344                        requested: requested.clone(),
1345                        returned: parsed.session_id,
1346                    })
1347                    .into());
1348                }
1349                // Register and stash under a single stash-lock hold. The
1350                // cancellation guard identifies the session to unregister by
1351                // peeking this stash, so registering outside the lock would
1352                // leave a window where a concurrent guard drop (caller
1353                // cancellation) sees `None` and leaks the registration.
1354                // `register_session` takes the router lock, never the stash
1355                // lock, so there is no lock-order inversion here.
1356                let mut stashed = stash.lock();
1357                let registration = client.register_session(&parsed.session_id);
1358                *stashed = Some((parsed.session_id, registration));
1359                Ok(())
1360            }))
1361        };
1362
1363        // Armed for the whole startup sequence: any early return, and any
1364        // drop of this future (caller cancellation), cancels the session
1365        // token and unregisters whatever was registered on the router. For
1366        // the cloud path the ID is only known once the inline callback has
1367        // run, so the guard reads the stash at cleanup time.
1368        let mut pending_registration = match local_session_id {
1369            Some(ref sid) => {
1370                let token = inline_stash
1371                    .lock()
1372                    .as_ref()
1373                    .expect("session registration must exist")
1374                    .1
1375                    .token;
1376                PendingSessionRegistration::new(
1377                    self.clone(),
1378                    sid.clone(),
1379                    token,
1380                    shutdown.clone(),
1381                    external_tools_shutdown.clone(),
1382                )
1383            }
1384            None => PendingSessionRegistration::deferred(
1385                self.clone(),
1386                inline_stash.clone(),
1387                shutdown.clone(),
1388                external_tools_shutdown.clone(),
1389            ),
1390        };
1391
1392        let rpc_start = Instant::now();
1393        let result = self
1394            .call_with_inline_callback("session.create", Some(params), inline_callback)
1395            .await?;
1396        tracing::debug!(
1397            elapsed_ms = rpc_start.elapsed().as_millis(),
1398            "Client::create_session session creation request completed successfully"
1399        );
1400        let create_result: CreateSessionResult = serde_json::from_value(result)?;
1401
1402        if let Some(ref requested) = local_session_id
1403            && create_result.session_id != *requested
1404        {
1405            return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
1406                requested: requested.clone(),
1407                returned: create_result.session_id.clone(),
1408            })
1409            .into());
1410        }
1411
1412        let (session_id, registration) = inline_stash
1413            .lock()
1414            .take()
1415            .expect("session registration must have populated stash on success");
1416        let channels = registration.channels;
1417        let registration_token = registration.token;
1418        pending_registration.resolve_to(session_id.clone(), registration_token);
1419        let event_loop = spawn_event_loop(
1420            session_id.clone(),
1421            self.clone(),
1422            handlers,
1423            hooks,
1424            transforms,
1425            command_handlers,
1426            canvas_handler,
1427            session_fs_provider,
1428            bearer_token_providers,
1429            channels,
1430            idle_waiter.clone(),
1431            capabilities.clone(),
1432            open_canvases.clone(),
1433            event_tx.clone(),
1434            shutdown.clone(),
1435            external_tools_shutdown.clone(),
1436        );
1437        tracing::debug!(
1438            elapsed_ms = setup_start.elapsed().as_millis(),
1439            session_id = %session_id,
1440            tools_count,
1441            commands_count,
1442            has_hooks,
1443            "Client::create_session local setup complete"
1444        );
1445        *capabilities.write() = create_result.capabilities.unwrap_or_default();
1446        if has_mcp_auth_handler
1447            && let Err(error) = register_mcp_auth_interest(self, &session_id).await
1448        {
1449            pending_registration.cleanup(event_loop).await;
1450            return Err(error);
1451        }
1452
1453        tracing::debug!(
1454            elapsed_ms = total_start.elapsed().as_millis(),
1455            session_id = %session_id,
1456            "Client::create_session complete"
1457        );
1458        pending_registration.disarm();
1459        let session = Session {
1460            id: session_id,
1461            cwd: self.cwd().clone(),
1462            workspace_path: create_result.workspace_path,
1463            remote_url: create_result.remote_url,
1464            client: self.clone(),
1465            event_loop: ParkingLotMutex::new(Some(event_loop)),
1466            shutdown,
1467            external_tools_shutdown,
1468            idle_waiter,
1469            capabilities,
1470            open_canvases,
1471            event_tx,
1472            github_token_registration: ParkingLotMutex::new(github_token_registration),
1473            registration_token,
1474        };
1475        apply_mode_post_create_patch(
1476            &session,
1477            mode,
1478            opt_skip_custom_instructions,
1479            opt_custom_agents_local_only,
1480            opt_coauthor_enabled,
1481            opt_manage_schedule_enabled,
1482            opt_included_builtin_skills,
1483        )
1484        .await?;
1485        if let Some(registration) = session.github_token_registration.lock().as_ref() {
1486            registration.claim(session.id.clone());
1487        } else {
1488            self.retire_github_token_provider(&session.id);
1489        }
1490        Ok(session)
1491    }
1492
1493    /// Resume an existing session on the CLI.
1494    ///
1495    /// Sends `session.resume` and `session.skills.reload`, registers the
1496    /// session on the router, and spawns the event loop.
1497    ///
1498    /// All callbacks (event handler, hooks, transform) are configured
1499    /// via [`ResumeSessionConfig`] using its `with_*` builder methods.
1500    ///
1501    /// See [`Self::create_session`] for the defaults applied when callback
1502    /// fields are unset.
1503    async fn start_prepared_resume(
1504        &self,
1505        mut config: ResumeSessionConfig,
1506        event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
1507        shutdown: CancellationToken,
1508    ) -> Result<Session, Error> {
1509        let total_start = Instant::now();
1510        let session_id = config.session_id.clone();
1511        if config.hooks_handler.is_some() && config.hooks.is_none() {
1512            config.hooks = Some(true);
1513        }
1514        if let Some(transforms) = config.system_message_transform.clone() {
1515            inject_transform_sections_resume(&mut config, transforms.as_ref());
1516        }
1517        let mode = self.inner.mode;
1518        if mode == crate::ClientMode::Empty && config.available_tools.is_none() {
1519            return Err(Error::with_message(
1520                ErrorKind::InvalidConfig,
1521                "ClientMode::Empty requires available_tools to be set on the session config. \
1522                 Use ToolSet to specify which tools the session may use (e.g. \
1523                 ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED)).",
1524            ));
1525        }
1526        crate::mode::validate_tool_filter_list(
1527            "available_tools",
1528            config.available_tools.as_deref(),
1529        )?;
1530        crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?;
1531        config.system_message =
1532            crate::mode::system_message_for_mode(mode, config.system_message.take());
1533        config.memory = crate::mode::memory_for_mode(mode, config.memory.take());
1534        config.enable_experimental_mode =
1535            crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode);
1536        if mode == crate::ClientMode::Empty {
1537            if config.enable_session_telemetry.is_none() {
1538                config.enable_session_telemetry = Some(false);
1539            }
1540            if config.skip_embedding_retrieval.is_none() {
1541                config.skip_embedding_retrieval = Some(true);
1542            }
1543            if config.enable_on_demand_instruction_discovery.is_none() {
1544                config.enable_on_demand_instruction_discovery = Some(false);
1545            }
1546            if config.enable_file_hooks.is_none() {
1547                config.enable_file_hooks = Some(false);
1548            }
1549            if config.enable_host_git_operations.is_none() {
1550                config.enable_host_git_operations = Some(false);
1551            }
1552            if config.enable_session_store.is_none() {
1553                config.enable_session_store = Some(false);
1554            }
1555            if config.enable_skills.is_none() {
1556                config.enable_skills = Some(false);
1557            }
1558        }
1559        if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() {
1560            config.mcp_oauth_token_storage = Some("in-memory".into());
1561        }
1562        if mode == crate::ClientMode::Empty && config.embedding_cache_storage.is_none() {
1563            config.embedding_cache_storage = Some("in-memory".into());
1564        }
1565        config.custom_agents_local_only =
1566            crate::mode::resolve_custom_agents_local_only(mode, config.custom_agents_local_only);
1567        let opt_skip_custom_instructions = config.skip_custom_instructions;
1568        let opt_custom_agents_local_only = config.custom_agents_local_only;
1569        let opt_coauthor_enabled = config.coauthor_enabled;
1570        let opt_manage_schedule_enabled = config.manage_schedule_enabled;
1571        let opt_included_builtin_skills = config.included_builtin_skills.take();
1572        let (mut wire, mut runtime) = config.into_wire()?;
1573        wire.enable_github_telemetry_forwarding =
1574            self.inner.on_github_telemetry.is_some().then_some(true);
1575
1576        let permission_handler = crate::permission::resolve_handler(
1577            runtime.permission_handler.take(),
1578            runtime.permission_policy.take(),
1579        );
1580        let handlers = SessionHandlers {
1581            permission: permission_handler,
1582            managed_settings_enabled: has_managed_settings(
1583                wire.enable_managed_settings,
1584                wire.managed_settings.as_ref(),
1585            ),
1586            elicitation: runtime.elicitation_handler.take(),
1587            mcp_auth: runtime.mcp_auth_handler.take(),
1588            user_input: runtime.user_input_handler.take(),
1589            exit_plan_mode: runtime.exit_plan_mode_handler.take(),
1590            auto_mode_switch: runtime.auto_mode_switch_handler.take(),
1591            tools: Arc::new(std::mem::take(&mut runtime.tool_handlers)),
1592        };
1593        let hooks = runtime.hooks_handler.take();
1594        let transforms = runtime.system_message_transform.take();
1595        let tools_count = wire.tools.as_ref().map_or(0, Vec::len);
1596        let commands_count = runtime.commands.as_ref().map_or(0, Vec::len);
1597        let has_hooks = hooks.is_some();
1598        let command_handlers = build_command_handler_map(runtime.commands.as_deref());
1599        let canvas_handler = runtime.canvas_handler.take();
1600        let session_fs_provider = runtime.session_fs_provider.take();
1601        let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers);
1602        let github_token_registration = runtime
1603            .github_token_provider
1604            .take()
1605            .map(|provider| self.register_github_token_provider(provider));
1606        wire.github_token_provider_registration_id = github_token_registration
1607            .as_ref()
1608            .map(|registration| registration.id().to_string());
1609        let has_mcp_auth_handler = handlers.mcp_auth.is_some();
1610        if self.inner.session_fs_configured && session_fs_provider.is_none() {
1611            return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into());
1612        }
1613        if self.inner.session_fs_sqlite_declared
1614            && let Some(ref provider) = session_fs_provider
1615            && provider.sqlite().is_none()
1616        {
1617            return Err(Error::with_message(
1618                ErrorKind::InvalidConfig,
1619                "SessionFs capabilities declare SQLite support but the provider \
1620                 does not implement SessionFsSqliteProvider",
1621            ));
1622        }
1623
1624        let mut params = serde_json::to_value(&wire)?;
1625        let trace_ctx = self.resolve_trace_context().await;
1626        inject_trace_context(&mut params, &trace_ctx);
1627
1628        let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default()));
1629        let setup_start = Instant::now();
1630        let registration = self.register_session(&session_id);
1631        let registration_token = registration.token;
1632        let channels = registration.channels;
1633        let idle_waiter = Arc::new(ParkingLotMutex::new(None));
1634        let open_canvases = Arc::new(parking_lot::RwLock::new(Vec::new()));
1635        let external_tools_shutdown = self.inner.rpc.connection_closed_token();
1636        let event_loop = spawn_event_loop(
1637            session_id.clone(),
1638            self.clone(),
1639            handlers,
1640            hooks,
1641            transforms,
1642            command_handlers,
1643            canvas_handler,
1644            session_fs_provider,
1645            bearer_token_providers,
1646            channels,
1647            idle_waiter.clone(),
1648            capabilities.clone(),
1649            open_canvases.clone(),
1650            event_tx.clone(),
1651            shutdown.clone(),
1652            external_tools_shutdown.clone(),
1653        );
1654        let mut registration = PendingSessionRegistration::new(
1655            self.clone(),
1656            session_id.clone(),
1657            registration_token,
1658            shutdown.clone(),
1659            external_tools_shutdown.clone(),
1660        );
1661        tracing::debug!(
1662            elapsed_ms = setup_start.elapsed().as_millis(),
1663            session_id = %session_id,
1664            tools_count,
1665            commands_count,
1666            has_hooks,
1667            "Client::resume_session local setup complete"
1668        );
1669
1670        let rpc_start = Instant::now();
1671        let result = match self.call("session.resume", Some(params)).await {
1672            Ok(result) => result,
1673            Err(error) => {
1674                registration.cleanup(event_loop).await;
1675                return Err(error);
1676            }
1677        };
1678        tracing::debug!(
1679            elapsed_ms = rpc_start.elapsed().as_millis(),
1680            session_id = %session_id,
1681            "Client::resume_session session resume request completed successfully"
1682        );
1683
1684        let resume_result: ResumeSessionResult = match serde_json::from_value(result) {
1685            Ok(result) => result,
1686            Err(error) => {
1687                registration.cleanup(event_loop).await;
1688                return Err(error.into());
1689            }
1690        };
1691        let cli_session_id = resume_result
1692            .session_id
1693            .clone()
1694            .unwrap_or_else(|| session_id.clone());
1695        if cli_session_id != session_id {
1696            registration.cleanup(event_loop).await;
1697            return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch {
1698                requested: session_id,
1699                returned: cli_session_id,
1700            })
1701            .into());
1702        }
1703        if has_mcp_auth_handler
1704            && let Err(error) = register_mcp_auth_interest(self, &session_id).await
1705        {
1706            registration.cleanup(event_loop).await;
1707            return Err(error);
1708        }
1709        // Reload skills after resume (best-effort).
1710        let skills_reload_start = Instant::now();
1711        if let Err(e) = self
1712            .call(
1713                "session.skills.reload",
1714                Some(serde_json::json!({ "sessionId": session_id })),
1715            )
1716            .await
1717        {
1718            warn!(
1719                elapsed_ms = skills_reload_start.elapsed().as_millis(),
1720                session_id = %session_id,
1721                error = %e,
1722                "Client::resume_session skills reload request failed"
1723            );
1724        } else {
1725            tracing::debug!(
1726                elapsed_ms = skills_reload_start.elapsed().as_millis(),
1727                session_id = %session_id,
1728                "Client::resume_session skills reload request completed successfully"
1729            );
1730        }
1731
1732        *capabilities.write() = resume_result.capabilities.unwrap_or_default();
1733        // Upsert resume snapshots rather than replacing wholesale. Live
1734        // `session.canvas.opened` notifications can arrive on the event loop
1735        // while `session.resume` is in flight; a wholesale replace would
1736        // discard those updates.
1737        {
1738            let mut snapshots = open_canvases.write();
1739            for snapshot in resume_result.open_canvases.unwrap_or_default() {
1740                upsert_open_canvas_snapshot(&mut snapshots, snapshot);
1741            }
1742        }
1743
1744        tracing::debug!(
1745            elapsed_ms = total_start.elapsed().as_millis(),
1746            session_id = %session_id,
1747            "Client::resume_session complete"
1748        );
1749        registration.disarm();
1750        let session = Session {
1751            id: session_id,
1752            cwd: self.cwd().clone(),
1753            workspace_path: resume_result.workspace_path,
1754            remote_url: resume_result.remote_url,
1755            client: self.clone(),
1756            event_loop: ParkingLotMutex::new(Some(event_loop)),
1757            shutdown,
1758            external_tools_shutdown,
1759            idle_waiter,
1760            capabilities,
1761            open_canvases,
1762            event_tx,
1763            github_token_registration: ParkingLotMutex::new(github_token_registration),
1764            registration_token,
1765        };
1766        apply_mode_post_create_patch(
1767            &session,
1768            mode,
1769            opt_skip_custom_instructions,
1770            opt_custom_agents_local_only,
1771            opt_coauthor_enabled,
1772            opt_manage_schedule_enabled,
1773            opt_included_builtin_skills,
1774        )
1775        .await?;
1776        if let Some(registration) = session.github_token_registration.lock().as_ref() {
1777            registration.claim(session.id.clone());
1778        } else {
1779            self.retire_github_token_provider(&session.id);
1780        }
1781        Ok(session)
1782    }
1783}
1784
1785/// A session that has been configured but not yet created on the CLI.
1786///
1787/// Returned by [`Client::prepare_session`] and
1788/// [`Client::prepare_resume_session`]. Its purpose is to make the session's
1789/// event stream observable *before* any protocol activity starts:
1790/// [`subscribe`](Self::subscribe) installs a receiver on the same broadcast
1791/// channel the eventual [`Session`] uses, so events the runtime emits while
1792/// `session.create` / `session.resume` is still in flight are delivered
1793/// rather than dropped for lack of a receiver.
1794///
1795/// # Lifecycle
1796///
1797/// A prepared handle is inert. It holds only a broadcast sender, a
1798/// cancellation token, the client handle, and the config — it performs no
1799/// router registration, spawns no task, and writes nothing to the wire
1800/// until [`start`](Self::start) is first polled.
1801///
1802/// * Dropping it without starting leaves no client-side or server-side
1803///   state, and closes every subscription taken from it.
1804/// * Dropping the [`start`](Self::start) future mid-flight cancels the
1805///   session token, unregisters the session from the router if it was
1806///   registered, and closes early subscriptions. A retry with the same
1807///   session ID succeeds. Cleanup of already-spawned tasks is signalled,
1808///   not awaited: `Drop` is synchronous and cannot await, so the event loop
1809///   terminates promptly but not synchronously.
1810/// * A startup error from [`start`](Self::start) performs the same cleanup
1811///   and preserves the [`ErrorKind`] the equivalent
1812///   [`Client::create_session`] / [`Client::resume_session`] call has always
1813///   returned.
1814///
1815/// [`start`](Self::start) consumes `self` and the type is deliberately not
1816/// [`Clone`], so a prepared session can be started at most once and can
1817/// never produce two event loops.
1818///
1819/// # Buffering
1820///
1821/// The broadcast buffer is finite —
1822/// [`DEFAULT_EVENT_BUFFER_CAPACITY`] unless
1823/// [`SessionConfig::event_buffer_capacity`] /
1824/// [`ResumeSessionConfig::event_buffer_capacity`] overrides it. Subscribers
1825/// that fall behind observe
1826/// [`Lagged`](crate::subscription::Lagged) instead of applying backpressure
1827/// to the event loop. Consumers that need a lossless view of a large
1828/// startup burst must either configure a capacity that covers it or drain
1829/// the subscription concurrently with [`start`](Self::start).
1830///
1831/// # Server-assigned session IDs
1832///
1833/// For cloud sessions without a caller-supplied session ID, the CLI assigns
1834/// the ID and the SDK can only register the session on its notification
1835/// router once the `session.create` response arrives. Notifications the
1836/// server emits before that point are not routable to any session and are
1837/// therefore not observable. The guarantee this type provides is narrower
1838/// and precise: **routed** events are never dropped for lack of an
1839/// installed receiver. Pin
1840/// [`SessionConfig::session_id`](crate::types::SessionConfig::session_id)
1841/// to get registration before the RPC and full pre-response coverage.
1842#[must_use = "a PreparedSession does nothing until started"]
1843pub struct PreparedSession {
1844    client: Client,
1845    kind: PreparedKind,
1846    event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
1847    shutdown: CancellationToken,
1848}
1849
1850/// Which startup path a [`PreparedSession`] runs when started. Boxed
1851/// because the two config types are large and differently sized.
1852enum PreparedKind {
1853    Create(Box<SessionConfig>),
1854    Resume(Box<ResumeSessionConfig>),
1855}
1856
1857impl PreparedSession {
1858    fn new(client: Client, kind: PreparedKind, event_buffer_capacity: usize) -> Self {
1859        let (event_tx, _) = tokio::sync::broadcast::channel(event_buffer_capacity);
1860        Self {
1861            client,
1862            kind,
1863            event_tx,
1864            shutdown: CancellationToken::new(),
1865        }
1866    }
1867
1868    /// Subscribe to this session's events before it starts.
1869    ///
1870    /// The returned [`EventSubscription`](crate::subscription::EventSubscription)
1871    /// is backed by the same broadcast channel
1872    /// [`Session::subscribe`] returns after [`start`](Self::start)
1873    /// succeeds, so a subscription taken here observes the full event
1874    /// stream from the session's first routed event onward — including
1875    /// ephemeral events such as `session.idle` that
1876    /// [`Session::get_messages`] cannot recover.
1877    ///
1878    /// May be called any number of times, and each subscriber receives its
1879    /// own copy of the stream — subject to the buffering contract above. A
1880    /// subscriber that falls further behind than the configured capacity
1881    /// observes [`Lagged`](crate::subscription::Lagged) and skips the
1882    /// events it missed, rather than stalling the session's event loop.
1883    /// Subscriptions taken here close if the prepared session is dropped
1884    /// without starting, or if startup fails.
1885    pub fn subscribe(&self) -> crate::subscription::EventSubscription {
1886        crate::subscription::EventSubscription::new(self.event_tx.subscribe())
1887    }
1888
1889    /// Create or resume the session on the CLI.
1890    ///
1891    /// This is where all protocol activity happens: config validation,
1892    /// router registration, the `session.create` / `session.resume` RPC,
1893    /// and the event loop spawn. Nothing observable occurs until this
1894    /// future is first polled.
1895    ///
1896    /// # Errors
1897    ///
1898    /// Returns the same errors as [`Client::create_session`] /
1899    /// [`Client::resume_session`] — including
1900    /// [`ErrorKind::InvalidConfig`] for invalid configs, transport and RPC
1901    /// failures, and
1902    /// [`SessionIdMismatch`](crate::SessionErrorKind::SessionIdMismatch)
1903    /// when the CLI returns a different session ID than the one requested.
1904    /// Every error path unregisters the session and closes subscriptions
1905    /// taken from this handle.
1906    pub async fn start(self) -> Result<Session, Error> {
1907        let Self {
1908            client,
1909            kind,
1910            event_tx,
1911            shutdown,
1912        } = self;
1913        match kind {
1914            PreparedKind::Create(config) => {
1915                client
1916                    .start_prepared_create(*config, event_tx, shutdown)
1917                    .await
1918            }
1919            PreparedKind::Resume(config) => {
1920                client
1921                    .start_prepared_resume(*config, event_tx, shutdown)
1922                    .await
1923            }
1924        }
1925    }
1926}
1927
1928type CommandHandlerMap = HashMap<String, Arc<dyn CommandHandler>>;
1929
1930async fn apply_mode_post_create_patch(
1931    session: &Session,
1932    mode: crate::ClientMode,
1933    opt_skip_custom_instructions: Option<bool>,
1934    opt_custom_agents_local_only: Option<bool>,
1935    opt_coauthor_enabled: Option<bool>,
1936    opt_manage_schedule_enabled: Option<bool>,
1937    opt_included_builtin_skills: Option<Vec<String>>,
1938) -> Result<(), Error> {
1939    let Some(patch) = build_mode_post_create_patch(
1940        mode,
1941        opt_skip_custom_instructions,
1942        opt_custom_agents_local_only,
1943        opt_coauthor_enabled,
1944        opt_manage_schedule_enabled,
1945        opt_included_builtin_skills,
1946    ) else {
1947        return Ok(());
1948    };
1949    if let Err(error) = session.rpc().options().update(patch).await {
1950        let _ = session.disconnect().await;
1951        return Err(error);
1952    }
1953    Ok(())
1954}
1955
1956/// Builds the `session.options.update` patch applied immediately after a session
1957/// is created or resumed, or returns `None` when no patch should be sent.
1958///
1959/// Under [`ClientMode::Empty`](crate::ClientMode::Empty) the overridable feature
1960/// flags fall back to safe defaults (caller values win), while
1961/// `installed_plugins` is unconditionally empty. `included_builtin_skills`
1962/// defaults to an empty list, but callers can explicitly allow selected
1963/// runtime-bundled skills. Under other modes only explicitly-set fields are
1964/// forwarded.
1965fn build_mode_post_create_patch(
1966    mode: crate::ClientMode,
1967    opt_skip_custom_instructions: Option<bool>,
1968    opt_custom_agents_local_only: Option<bool>,
1969    opt_coauthor_enabled: Option<bool>,
1970    opt_manage_schedule_enabled: Option<bool>,
1971    opt_included_builtin_skills: Option<Vec<String>>,
1972) -> Option<crate::generated::api_types::SessionUpdateOptionsParams> {
1973    use crate::generated::api_types::SessionUpdateOptionsParams;
1974    let mut patch = SessionUpdateOptionsParams::default();
1975    let should_send = if mode == crate::ClientMode::Empty {
1976        patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true));
1977        patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true));
1978        patch.coauthor_enabled = Some(opt_coauthor_enabled.unwrap_or(false));
1979        patch.manage_schedule_enabled = Some(opt_manage_schedule_enabled.unwrap_or(false));
1980        patch.installed_plugins = Some(Vec::new());
1981        patch.included_builtin_skills = Some(opt_included_builtin_skills.unwrap_or_default());
1982        true
1983    } else {
1984        let mut any = false;
1985        if let Some(v) = opt_skip_custom_instructions {
1986            patch.skip_custom_instructions = Some(v);
1987            any = true;
1988        }
1989        if let Some(v) = opt_custom_agents_local_only {
1990            patch.custom_agents_local_only = Some(v);
1991            any = true;
1992        }
1993        if let Some(v) = opt_coauthor_enabled {
1994            patch.coauthor_enabled = Some(v);
1995            any = true;
1996        }
1997        if let Some(v) = opt_manage_schedule_enabled {
1998            patch.manage_schedule_enabled = Some(v);
1999            any = true;
2000        }
2001        if let Some(v) = opt_included_builtin_skills {
2002            patch.included_builtin_skills = Some(v);
2003            any = true;
2004        }
2005        any
2006    };
2007    if !should_send {
2008        return None;
2009    }
2010    Some(patch)
2011}
2012
2013fn build_command_handler_map(commands: Option<&[CommandDefinition]>) -> Arc<CommandHandlerMap> {
2014    let map = match commands {
2015        Some(commands) => commands
2016            .iter()
2017            .filter(|cmd| !cmd.name.is_empty())
2018            .map(|cmd| (cmd.name.clone(), cmd.handler.clone()))
2019            .collect(),
2020        None => HashMap::new(),
2021    };
2022    Arc::new(map)
2023}
2024
2025fn upsert_open_canvas_snapshot(
2026    snapshots: &mut Vec<OpenCanvasInstance>,
2027    snapshot: OpenCanvasInstance,
2028) {
2029    if let Some(existing) = snapshots
2030        .iter_mut()
2031        .find(|open| open.instance_id == snapshot.instance_id)
2032    {
2033        *existing = snapshot;
2034    } else {
2035        snapshots.push(snapshot);
2036    }
2037}
2038
2039fn remove_open_canvas_snapshot(snapshots: &mut Vec<OpenCanvasInstance>, instance_id: &str) {
2040    snapshots.retain(|open| open.instance_id != instance_id);
2041}
2042
2043#[allow(clippy::too_many_arguments)]
2044fn spawn_event_loop(
2045    session_id: SessionId,
2046    client: Client,
2047    handlers: SessionHandlers,
2048    hooks: Option<Arc<dyn SessionHooks>>,
2049    transforms: Option<Arc<dyn SystemMessageTransform>>,
2050    command_handlers: Arc<CommandHandlerMap>,
2051    canvas_handler: Option<Arc<dyn CanvasHandler>>,
2052    session_fs_provider: Option<Arc<dyn SessionFsProvider>>,
2053    bearer_token_providers: HashMap<String, Arc<dyn BearerTokenProvider>>,
2054    channels: crate::router::SessionChannels,
2055    idle_waiter: Arc<ParkingLotMutex<Option<IdleWaiter>>>,
2056    capabilities: Arc<parking_lot::RwLock<SessionCapabilities>>,
2057    open_canvases: Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
2058    event_tx: tokio::sync::broadcast::Sender<SessionEvent>,
2059    shutdown: CancellationToken,
2060    external_tools_shutdown: CancellationToken,
2061) -> JoinHandle<()> {
2062    let crate::router::SessionChannels {
2063        mut notifications,
2064        mut requests,
2065    } = channels;
2066    let pending_external_tools: PendingExternalTools =
2067        Arc::new(ParkingLotMutex::new(HashMap::new()));
2068
2069    let span = tracing::error_span!("session_event_loop", session_id = %session_id);
2070    tokio::spawn(
2071        async move {
2072            loop {
2073                // `mpsc::UnboundedReceiver::recv` and
2074                // `CancellationToken::cancelled` are both cancel-safe per
2075                // RFD 400.
2076                //
2077                // Inbound JSON-RPC *requests* are dispatched fire-and-forget:
2078                // each `handle_request` runs in its own spawned task that
2079                // awaits the handler and sends that request's response. This
2080                // mirrors the other Copilot SDKs and moves concurrency to the
2081                // request-dispatch boundary, so any slow handler — not just
2082                // `userInput.request` (which can stay pending for the full
2083                // input backstop of several minutes), but also `exitPlanMode`,
2084                // `autoModeSwitch`, hooks, transforms, or canvas/session-FS
2085                // providers — cannot park the reader loop and starve sibling
2086                // requests or co-emitted notifications. JSON-RPC permits
2087                // concurrent requests and out-of-order responses, so the SDK
2088                // does not serialize them.
2089                //
2090                // `handle_notification` is awaited inline because it only
2091                // performs fast dispatch work; its slow interactive callbacks
2092                // (permission/tool/elicitation) are themselves spawned as child
2093                // tasks. All of these spawned tasks intentionally outlive the
2094                // parent loop and own their own cleanup — RFD 400's "spawn
2095                // background tasks to perform cancel-unsafe operations" pattern.
2096                tokio::select! {
2097                    _ = shutdown.cancelled() => break,
2098                    Some(notification) = notifications.recv() => {
2099                        handle_notification(
2100                            &session_id, &client, &handlers, &command_handlers, notification, &idle_waiter, &capabilities, &open_canvases, &event_tx, &shutdown, &external_tools_shutdown, &pending_external_tools,
2101                        ).await;
2102                    }
2103                    Some(request) = requests.recv() => {
2104                        // Clone the Arc-backed dispatch context into the task so
2105                        // the spawned `handle_request` future is `'static`. All
2106                        // clones are cheap (Arc refcount bumps / small maps).
2107                        let span = tracing::error_span!("session_request_handler", session_id = %session_id);
2108                        let session_id = session_id.clone();
2109                        let client = client.clone();
2110                        let handlers = handlers.clone();
2111                        let hooks = hooks.clone();
2112                        let transforms = transforms.clone();
2113                        let canvas_handler = canvas_handler.clone();
2114                        let session_fs_provider = session_fs_provider.clone();
2115                        let bearer_token_providers = bearer_token_providers.clone();
2116                        let request_id = request.id;
2117                        let method = request.method.clone();
2118                        tokio::spawn(
2119                            async move {
2120                                let ctx = RequestDispatchContext {
2121                                    client: &client,
2122                                    handlers: &handlers,
2123                                    hooks: hooks.as_deref(),
2124                                    transforms: transforms.as_deref(),
2125                                    canvas_handler: canvas_handler.as_ref(),
2126                                    session_fs_provider: session_fs_provider.as_ref(),
2127                                    bearer_token_providers: &bearer_token_providers,
2128                                };
2129                                let dispatch = handle_request(&session_id, ctx, request);
2130                                if AssertUnwindSafe(dispatch).catch_unwind().await.is_err() {
2131                                    // Tokio isolates the panic to this task, so without a
2132                                    // reply the CLI waits out its own timeout on this id.
2133                                    error!(method = %method, "request handler panicked");
2134                                    let _ = send_error_response(
2135                                        &client,
2136                                        request_id,
2137                                        error_codes::INTERNAL_ERROR,
2138                                        "request handler panicked",
2139                                    )
2140                                    .await;
2141                                }
2142                            }
2143                            .instrument(span),
2144                        );
2145                    }
2146                    else => break,
2147                }
2148            }
2149            // Channels closed or shutdown signaled — fail any pending
2150            // send_and_wait so the caller observes a clean error.
2151            if let Some(waiter) = idle_waiter.lock().take() {
2152                let _ = waiter
2153                    .tx
2154                    .send(Err(ErrorKind::Session(SessionErrorKind::EventLoopClosed).into()));
2155            }
2156        }
2157        .instrument(span),
2158    )
2159}
2160
2161fn extract_request_id(data: &Value) -> Option<RequestId> {
2162    data.get("requestId")
2163        .and_then(|v| v.as_str())
2164        .filter(|s| !s.is_empty())
2165        .map(RequestId::new)
2166}
2167
2168fn permission_request_data(
2169    event_data: &Value,
2170    managed_settings_enabled: bool,
2171) -> PermissionRequestData {
2172    let request_data = event_data
2173        .get("permissionRequest")
2174        .cloned()
2175        .unwrap_or_else(|| event_data.clone());
2176    let managed_approval_required = match request_data.get("managedApprovalRequired") {
2177        None => None,
2178        Some(Value::Bool(value)) => Some(*value),
2179        Some(_) => Some(true),
2180    };
2181    match serde_json::from_value::<PermissionRequestData>(request_data) {
2182        Ok(mut data) => {
2183            data.extra = event_data.clone();
2184            data.managed_settings_enabled = managed_settings_enabled;
2185            data
2186        }
2187        Err(_) => PermissionRequestData {
2188            kind: None,
2189            tool_call_id: None,
2190            managed_approval_required,
2191            managed_settings_enabled,
2192            extra: event_data.clone(),
2193        },
2194    }
2195}
2196
2197/// Build the full `session.permissions.handlePendingPermissionRequest`
2198/// params for a permission result.
2199///
2200/// `decisionContext` is a sibling of `result` and is only present when the
2201/// handler attributed the decision — omitting it preserves legacy behavior.
2202///
2203/// Returns `None` when the SDK must not send a response.
2204fn permission_response_params(
2205    session_id: &SessionId,
2206    request_id: &RequestId,
2207    result: &PermissionResult,
2208) -> Option<Value> {
2209    let (decision, decision_context) = match result {
2210        PermissionResult::Decision { decision, context } => (decision, context.clone()),
2211        PermissionResult::NoResult => return None,
2212    };
2213    let mut params = serde_json::to_value(PermissionDecisionRequest {
2214        decision_context,
2215        request_id: request_id.clone(),
2216        result: decision.clone(),
2217    })
2218    .expect("serializing permission response should succeed");
2219    params["sessionId"] =
2220        serde_json::to_value(session_id).expect("serializing session ID should succeed");
2221    Some(params)
2222}
2223
2224async fn register_mcp_auth_interest(client: &Client, session_id: &SessionId) -> Result<(), Error> {
2225    let mut params = serde_json::to_value(RegisterEventInterestParams {
2226        event_type: "mcp.oauth_required".to_string(),
2227    })?;
2228    params["sessionId"] = Value::String(session_id.to_string());
2229    client
2230        .call(rpc_methods::SESSION_EVENTLOG_REGISTERINTEREST, Some(params))
2231        .await?;
2232    Ok(())
2233}
2234
2235fn tool_failure_result(message: impl Into<String>) -> ToolResult {
2236    let message = message.into();
2237    ToolResult::Expanded(ToolResultExpanded {
2238        text_result_for_llm: message.clone(),
2239        result_type: "failure".to_string(),
2240        binary_results_for_llm: None,
2241        session_log: None,
2242        error: Some(message),
2243        tool_telemetry: None,
2244        tool_references: None,
2245    })
2246}
2247
2248fn is_autopilot_continuation_idle(event: &SessionEvent) -> bool {
2249    event
2250        .typed_data::<SessionIdleData>()
2251        .is_some_and(|data| data.mode == Some(SessionMode::Autopilot))
2252}
2253
2254/// Process a notification from the CLI's broadcast channel.
2255#[allow(clippy::too_many_arguments)]
2256async fn handle_notification(
2257    session_id: &SessionId,
2258    client: &Client,
2259    handlers: &SessionHandlers,
2260    command_handlers: &Arc<CommandHandlerMap>,
2261    notification: SessionEventNotification,
2262    idle_waiter: &Arc<ParkingLotMutex<Option<IdleWaiter>>>,
2263    capabilities: &Arc<parking_lot::RwLock<SessionCapabilities>>,
2264    open_canvases: &Arc<parking_lot::RwLock<Vec<OpenCanvasInstance>>>,
2265    event_tx: &tokio::sync::broadcast::Sender<SessionEvent>,
2266    shutdown: &CancellationToken,
2267    external_tools_shutdown: &CancellationToken,
2268    pending_external_tools: &PendingExternalTools,
2269) {
2270    let dispatch_start = Instant::now();
2271    let event = notification.event.clone();
2272    let event_type = event.parsed_type();
2273    if event_type == SessionEventType::PermissionRequested {
2274        tracing::debug!(
2275            session_id = %session_id,
2276            event_type = %event.event_type,
2277            "Session::handle_notification permission request received"
2278        );
2279    }
2280
2281    // Signal send_and_wait if active. The lock is only contended when
2282    // a send_and_wait call is in flight (idle_waiter is Some).
2283    match event_type {
2284        SessionEventType::AssistantMessage
2285        | SessionEventType::SessionIdle
2286        | SessionEventType::SessionError => {
2287            let mut guard = idle_waiter.lock();
2288            if let Some(waiter) = guard.as_mut() {
2289                match event_type {
2290                    SessionEventType::AssistantMessage => {
2291                        if !waiter.first_assistant_message_seen {
2292                            waiter.first_assistant_message_seen = true;
2293                            tracing::debug!(
2294                                elapsed_ms = waiter.started_at.elapsed().as_millis(),
2295                                session_id = %session_id,
2296                                "Session::send_and_wait first assistant message"
2297                            );
2298                        }
2299                        waiter.last_assistant_message = Some(event.clone());
2300                    }
2301                    SessionEventType::SessionIdle if is_autopilot_continuation_idle(&event) => {}
2302                    SessionEventType::SessionIdle | SessionEventType::SessionError => {
2303                        if let Some(waiter) = guard.take() {
2304                            if event_type == SessionEventType::SessionIdle {
2305                                tracing::debug!(
2306                                    elapsed_ms = waiter.started_at.elapsed().as_millis(),
2307                                    session_id = %session_id,
2308                                    "Session::send_and_wait idle received"
2309                                );
2310                                let _ = waiter.tx.send(Ok(waiter.last_assistant_message));
2311                            } else {
2312                                let error_msg = event
2313                                    .typed_data::<SessionErrorData>()
2314                                    .map(|d| d.message)
2315                                    .or_else(|| {
2316                                        event
2317                                            .data
2318                                            .get("message")
2319                                            .and_then(|v| v.as_str())
2320                                            .map(|s| s.to_string())
2321                                    })
2322                                    .unwrap_or_else(|| "session error".to_string());
2323                                let _ = waiter.tx.send(Err(Error::with_message(
2324                                    ErrorKind::Session(SessionErrorKind::AgentError),
2325                                    error_msg,
2326                                )));
2327                            }
2328                        }
2329                    }
2330                    _ => {}
2331                }
2332            }
2333        }
2334        _ => {}
2335    }
2336
2337    // Update the snapshot caches BEFORE broadcasting so subscribers that
2338    // call `Session::capabilities()` / `Session::open_canvases()` in
2339    // response to the event observe the new state.
2340    if event_type == SessionEventType::CapabilitiesChanged {
2341        match serde_json::from_value::<SessionCapabilities>(notification.event.data.clone()) {
2342            Ok(changed) => *capabilities.write() = changed,
2343            Err(e) => warn!(error = %e, "failed to deserialize capabilities.changed payload"),
2344        }
2345    }
2346    if event_type == SessionEventType::SessionCanvasOpened {
2347        match serde_json::from_value::<OpenCanvasInstance>(notification.event.data.clone()) {
2348            Ok(open_canvas) => {
2349                upsert_open_canvas_snapshot(&mut open_canvases.write(), open_canvas);
2350            }
2351            Err(e) => warn!(error = %e, "failed to deserialize session.canvas.opened payload"),
2352        }
2353    }
2354    if event_type == SessionEventType::SessionCanvasClosed {
2355        match serde_json::from_value::<SessionCanvasClosedData>(notification.event.data.clone()) {
2356            Ok(closed) => {
2357                if closed.instance_id.is_empty() {
2358                    warn!("failed to deserialize session.canvas.closed payload");
2359                } else {
2360                    remove_open_canvas_snapshot(&mut open_canvases.write(), &closed.instance_id);
2361                }
2362            }
2363            Err(e) => warn!(error = %e, "failed to deserialize session.canvas.closed payload"),
2364        }
2365    }
2366
2367    // Fan out the event to runtime subscribers (`Session::subscribe`). `send`
2368    // only errors when there are no receivers, which is the normal case
2369    // before any consumer subscribes.
2370    let _ = event_tx.send(event.clone());
2371
2372    tracing::debug!(
2373        elapsed_ms = dispatch_start.elapsed().as_millis(),
2374        session_id = %session_id,
2375        event_type = %notification.event.event_type,
2376        "Session::handle_notification dispatch"
2377    );
2378
2379    // Notification-based permission/tool/elicitation requests require a
2380    // separate RPC callback. Spawn concurrently since the CLI doesn't block.
2381    match event_type {
2382        SessionEventType::ExternalToolCompleted => {
2383            if let Some(request_id) = extract_request_id(&notification.event.data)
2384                && let Some(token) = pending_external_tools.lock().remove(&request_id)
2385            {
2386                token.cancel();
2387            }
2388        }
2389        SessionEventType::PermissionRequested => {
2390            let Some(request_id) = extract_request_id(&notification.event.data) else {
2391                return;
2392            };
2393            // Honor the runtime's `resolvedByHook` signal — when the
2394            // server has already resolved the permission via a hook,
2395            // clients must not send a second response.
2396            if notification
2397                .event
2398                .data
2399                .get("resolvedByHook")
2400                .and_then(|v| v.as_bool())
2401                .unwrap_or(false)
2402            {
2403                return;
2404            }
2405            // Multi-client safety: if this client has no permission
2406            // handler installed, don't respond — another client on the
2407            // same CLI may handle it.
2408            let Some(permission_handler) = handlers.permission.clone() else {
2409                return;
2410            };
2411            let client = client.clone();
2412            let sid = session_id.clone();
2413            let shutdown = shutdown.clone();
2414            let data = permission_request_data(
2415                &notification.event.data,
2416                handlers.managed_settings_enabled,
2417            );
2418            let span = tracing::error_span!(
2419                "permission_request_handler",
2420                session_id = %sid,
2421                request_id = %request_id
2422            );
2423            tokio::spawn(
2424                async move {
2425                    let handler_start = Instant::now();
2426                    let result = permission_handler
2427                        .handle(sid.clone(), request_id.clone(), data)
2428                        .await;
2429                    tracing::debug!(
2430                        elapsed_ms = handler_start.elapsed().as_millis(),
2431                        session_id = %sid,
2432                        request_id = %request_id,
2433                        "PermissionHandler::handle dispatch"
2434                    );
2435                    let Some(params) = permission_response_params(&sid, &request_id, &result)
2436                    else {
2437                        // Handler returned Deferred / NoResult — it will
2438                        // call handlePendingPermissionRequest itself (or
2439                        // leave the request unanswered).
2440                        return;
2441                    };
2442                    let rpc_start = Instant::now();
2443                    let method =
2444                        rpc_methods::SESSION_PERMISSIONS_HANDLEPENDINGPERMISSIONREQUEST;
2445                    tokio::select! {
2446                        biased;
2447                        response = client.call(method, Some(params)) => {
2448                            match response {
2449                                Ok(_) => tracing::debug!(
2450                                    elapsed_ms = rpc_start.elapsed().as_millis(),
2451                                    session_id = %sid,
2452                                    request_id = %request_id,
2453                                    method,
2454                                    "Session::handle_notification response sent successfully"
2455                                ),
2456                                Err(error) => warn!(
2457                                    error = %error,
2458                                    session_id = %sid,
2459                                    request_id = %request_id,
2460                                    method,
2461                                    "failed to deliver permission decision back to the runtime"
2462                                ),
2463                            }
2464                        }
2465                        _ = shutdown.cancelled() => {
2466                            warn!(
2467                                elapsed_ms = rpc_start.elapsed().as_millis(),
2468                                session_id = %sid,
2469                                request_id = %request_id,
2470                                method,
2471                                delivery_outcome = "unknown",
2472                                "permission confirmation acknowledgement wait cancelled during session shutdown"
2473                            );
2474                        }
2475                    }
2476                }
2477                .instrument(span),
2478            );
2479        }
2480        SessionEventType::ExternalToolRequested => {
2481            let Some(request_id) = extract_request_id(&notification.event.data) else {
2482                return;
2483            };
2484            let data: ExternalToolRequestedData =
2485                match serde_json::from_value(notification.event.data.clone()) {
2486                    Ok(d) => d,
2487                    Err(e) => {
2488                        warn!(error = %e, "failed to deserialize external_tool.requested");
2489                        let client = client.clone();
2490                        let sid = session_id.clone();
2491                        let span = tracing::error_span!(
2492                            "external_tool_deserialize_error",
2493                            session_id = %sid,
2494                            request_id = %request_id
2495                        );
2496                        tokio::spawn(
2497                            async move {
2498                                let rpc_start = Instant::now();
2499                                let _ = client
2500                                .call(
2501                                    "session.tools.handlePendingToolCall",
2502                                    Some(serde_json::json!({
2503                                        "sessionId": sid,
2504                                        "requestId": request_id,
2505                                        "error": format!("Failed to deserialize tool request: {e}"),
2506                                    })),
2507                                )
2508                                .await;
2509                                tracing::debug!(
2510                                    elapsed_ms = rpc_start.elapsed().as_millis(),
2511                                    session_id = %sid,
2512                                    request_id = %request_id,
2513                                    "Session::handle_notification response sent successfully"
2514                                );
2515                            }
2516                            .instrument(span),
2517                        );
2518                        return;
2519                    }
2520                };
2521            // Multi-client safety: look up a handler for the requested
2522            // tool name. If this client has no handler installed for that
2523            // tool, don't respond — another connected client may have one.
2524            let tool_handler = if data.tool_name.is_empty() {
2525                None
2526            } else {
2527                handlers.tools.get(&data.tool_name).cloned()
2528            };
2529            let Some(tool_handler) = tool_handler else {
2530                return;
2531            };
2532            let cancellation = Arc::new(external_tools_shutdown.child_token());
2533            {
2534                let mut pending = pending_external_tools.lock();
2535                if external_tools_shutdown.is_cancelled() || pending.contains_key(&request_id) {
2536                    return;
2537                }
2538                pending.insert(request_id.clone(), cancellation.clone());
2539            }
2540            let client = client.clone();
2541            let sid = session_id.clone();
2542            let pending_external_tools = pending_external_tools.clone();
2543            let guard_request_id = request_id.clone();
2544            let guard_cancellation = cancellation.clone();
2545            let span = tracing::error_span!(
2546                "external_tool_handler",
2547                session_id = %sid,
2548                request_id = %request_id
2549            );
2550            tokio::spawn(
2551                async move {
2552                    let guard = PendingExternalToolGuard {
2553                        request_id: guard_request_id,
2554                        token: guard_cancellation,
2555                        pending: pending_external_tools,
2556                    };
2557                    if cancellation.is_cancelled() {
2558                        return;
2559                    }
2560                    // `tool_name.is_empty()` would have produced a `None`
2561                    // lookup in `handlers.tools` and short-circuited at the
2562                    // outer guard above, so only the tool_call_id check is
2563                    // reachable here.
2564                    if data.tool_call_id.is_empty() {
2565                        if !guard.claim() {
2566                            return;
2567                        }
2568                        let error_msg = "Missing toolCallId";
2569                        let rpc_start = Instant::now();
2570                        let _ = client
2571                            .call(
2572                                "session.tools.handlePendingToolCall",
2573                                Some(serde_json::json!({
2574                                    "sessionId": sid,
2575                                    "requestId": request_id,
2576                                    "error": error_msg,
2577                                })),
2578                            )
2579                            .await;
2580                        tracing::debug!(
2581                            elapsed_ms = rpc_start.elapsed().as_millis(),
2582                            session_id = %sid,
2583                            request_id = %request_id,
2584                            "Session::handle_notification response sent successfully"
2585                        );
2586                        return;
2587                    }
2588                    let tool_call_id = data.tool_call_id.clone();
2589                    let tool_name = data.tool_name.clone();
2590                    // The built-in tool-search tool receives a snapshot of the
2591                    // session's currently initialized tools so an override can
2592                    // filter the live catalog without issuing its own RPC. Fetch
2593                    // it only for that tool to avoid a round-trip on every tool
2594                    // call; a failed fetch leaves the snapshot `None` rather than
2595                    // failing the tool.
2596                    let available_tools = if tool_name == TOOL_SEARCH_TOOL_NAME {
2597                        let metadata_result = tokio::select! {
2598                            biased;
2599                            _ = cancellation.cancelled() => return,
2600                            result = client.call(
2601                                rpc_methods::SESSION_TOOLS_GETCURRENTMETADATA,
2602                                Some(serde_json::json!({ "sessionId": sid })),
2603                            ) => result,
2604                        };
2605                        match metadata_result {
2606                            Ok(value) => {
2607                                serde_json::from_value::<ToolsGetCurrentMetadataResult>(value)
2608                                    .ok()
2609                                    .and_then(|result| result.tools)
2610                            }
2611                            Err(_) => None,
2612                        }
2613                    } else {
2614                        None
2615                    };
2616                    let invocation = ToolInvocation {
2617                        session_id: sid.clone(),
2618                        tool_call_id: data.tool_call_id,
2619                        tool_name: data.tool_name,
2620                        arguments: data
2621                            .arguments
2622                            .unwrap_or(Value::Object(serde_json::Map::new())),
2623                        available_tools,
2624                        traceparent: data.traceparent,
2625                        tracestate: data.tracestate,
2626                    };
2627                    let handler_start = Instant::now();
2628                    let tool_result = tokio::select! {
2629                        biased;
2630                        _ = cancellation.cancelled() => return,
2631                        result = tool_handler.call(invocation) => match result {
2632                            Ok(r) => r,
2633                            Err(e) => tool_failure_result(e.to_string()),
2634                        },
2635                    };
2636                    tracing::debug!(
2637                        elapsed_ms = handler_start.elapsed().as_millis(),
2638                        session_id = %sid,
2639                        request_id = %request_id,
2640                        tool_call_id = %tool_call_id,
2641                        tool_name = %tool_name,
2642                        "ToolHandler::call dispatch"
2643                    );
2644                    if !guard.claim() {
2645                        return;
2646                    }
2647                    let result_value = serde_json::to_value(tool_result).unwrap_or(Value::Null);
2648                    let rpc_start = Instant::now();
2649                    let _ = client
2650                        .call(
2651                            "session.tools.handlePendingToolCall",
2652                            Some(serde_json::json!({
2653                                "sessionId": sid,
2654                                "requestId": request_id,
2655                                "result": result_value,
2656                            })),
2657                        )
2658                        .await;
2659                    tracing::debug!(
2660                        elapsed_ms = rpc_start.elapsed().as_millis(),
2661                        session_id = %sid,
2662                        request_id = %request_id,
2663                        tool_call_id = %tool_call_id,
2664                        tool_name = %tool_name,
2665                        "Session::handle_notification response sent successfully"
2666                    );
2667                }
2668                .instrument(span),
2669            );
2670        }
2671        SessionEventType::UserInputRequested => {
2672            // Notification-only signal for observers (UI, telemetry).
2673            // The CLI follows up with a `userInput.request` JSON-RPC call
2674            // that drives the `UserInputHandler` dispatch — handling
2675            // the notification here too would double-fire the handler
2676            // and produce duplicate prompts on the consumer side. See
2677            // github/github-app#4249.
2678        }
2679        SessionEventType::ElicitationRequested => {
2680            let Some(request_id) = extract_request_id(&notification.event.data) else {
2681                return;
2682            };
2683            // Multi-client safety: if this client has no elicitation
2684            // handler installed, don't respond — another client on the
2685            // same CLI may handle it.
2686            let Some(elicitation_handler) = handlers.elicitation.clone() else {
2687                return;
2688            };
2689            let elicitation_data: ElicitationRequestedData =
2690                match serde_json::from_value(notification.event.data.clone()) {
2691                    Ok(d) => d,
2692                    Err(e) => {
2693                        warn!(error = %e, "failed to deserialize elicitation request");
2694                        return;
2695                    }
2696                };
2697            let request = ElicitationRequest {
2698                message: elicitation_data.message,
2699                requested_schema: elicitation_data
2700                    .requested_schema
2701                    .map(|s| serde_json::to_value(s).unwrap_or(Value::Null)),
2702                mode: elicitation_data.mode.map(|m| match m {
2703                    crate::generated::session_events::ElicitationRequestedMode::Form => {
2704                        crate::types::ElicitationMode::Form
2705                    }
2706                    crate::generated::session_events::ElicitationRequestedMode::Url => {
2707                        crate::types::ElicitationMode::Url
2708                    }
2709                    _ => crate::types::ElicitationMode::Unknown,
2710                }),
2711                elicitation_source: elicitation_data.elicitation_source,
2712                url: elicitation_data.url,
2713            };
2714            let client = client.clone();
2715            let sid = session_id.clone();
2716            let span = tracing::error_span!(
2717                "elicitation_request_handler",
2718                session_id = %sid,
2719                request_id = %request_id
2720            );
2721            tokio::spawn(
2722                async move {
2723                    let cancel = ElicitationResult {
2724                        action: "cancel".to_string(),
2725                        content: None,
2726                    };
2727                    // Dispatch to a nested task so panics are caught as JoinErrors.
2728                    let handler_task = tokio::spawn({
2729                        let sid = sid.clone();
2730                        let request_id = request_id.clone();
2731                        let span = tracing::error_span!(
2732                            "elicitation_callback",
2733                            session_id = %sid,
2734                            request_id = %request_id
2735                        );
2736                        async move {
2737                            let handler_start = Instant::now();
2738                            let response = elicitation_handler
2739                                .handle(sid.clone(), request_id.clone(), request)
2740                                .await;
2741                            tracing::debug!(
2742                                elapsed_ms = handler_start.elapsed().as_millis(),
2743                                session_id = %sid,
2744                                request_id = %request_id,
2745                                "ElicitationHandler::handle dispatch"
2746                            );
2747                            response
2748                        }
2749                        .instrument(span)
2750                    });
2751                    let result = match handler_task.await {
2752                        Ok(r) => r,
2753                        Err(_) => cancel.clone(),
2754                    };
2755                    let rpc_start = Instant::now();
2756                    if let Err(e) = client
2757                        .call(
2758                            "session.ui.handlePendingElicitation",
2759                            Some(serde_json::json!({
2760                                "sessionId": sid,
2761                                "requestId": request_id,
2762                                "result": result,
2763                            })),
2764                        )
2765                        .await
2766                    {
2767                        // RPC failed — attempt cancel as last resort
2768                        warn!(error = %e, "handlePendingElicitation failed, sending cancel");
2769                        let _ = client
2770                            .call(
2771                                "session.ui.handlePendingElicitation",
2772                                Some(serde_json::json!({
2773                                    "sessionId": sid,
2774                                    "requestId": request_id,
2775                                    "result": cancel,
2776                                })),
2777                            )
2778                            .await;
2779                    } else {
2780                        tracing::debug!(
2781                            elapsed_ms = rpc_start.elapsed().as_millis(),
2782                            session_id = %sid,
2783                            request_id = %request_id,
2784                            "Session::handle_notification response sent successfully"
2785                        );
2786                    }
2787                }
2788                .instrument(span),
2789            );
2790        }
2791        SessionEventType::McpOauthRequired => {
2792            let Some(request_id) = extract_request_id(&notification.event.data) else {
2793                return;
2794            };
2795            let Some(mcp_auth_handler) = handlers.mcp_auth.clone() else {
2796                warn!(
2797                    session_id = %session_id,
2798                    request_id = %request_id,
2799                    "received MCP OAuth request without a registered MCP auth handler"
2800                );
2801                return;
2802            };
2803            let data: McpOauthRequiredData =
2804                match serde_json::from_value(notification.event.data.clone()) {
2805                    Ok(d) => d,
2806                    Err(e) => {
2807                        warn!(error = %e, "failed to deserialize MCP OAuth request");
2808                        return;
2809                    }
2810                };
2811            let request = McpAuthRequest {
2812                request_id: request_id.clone(),
2813                server_name: data.server_name,
2814                server_url: data.server_url,
2815                reason: data.reason,
2816                www_authenticate_params: data.www_authenticate_params,
2817                resource_metadata: data.resource_metadata,
2818                static_client_config: data.static_client_config,
2819            };
2820            let client = client.clone();
2821            let sid = session_id.clone();
2822            let span = tracing::error_span!(
2823                "mcp_auth_request_handler",
2824                session_id = %sid,
2825                request_id = %request_id
2826            );
2827            tokio::spawn(
2828                async move {
2829                    let cancel = McpAuthResult::Cancelled;
2830                    let handler_task = tokio::spawn({
2831                        let sid = sid.clone();
2832                        let request_id = request_id.clone();
2833                        let span = tracing::error_span!(
2834                            "mcp_auth_callback",
2835                            session_id = %sid,
2836                            request_id = %request_id
2837                        );
2838                        async move {
2839                            let handler_start = Instant::now();
2840                            let response = mcp_auth_handler
2841                                .handle(sid.clone(), request_id.clone(), request)
2842                                .await;
2843                            tracing::debug!(
2844                                elapsed_ms = handler_start.elapsed().as_millis(),
2845                                session_id = %sid,
2846                                request_id = %request_id,
2847                                "McpAuthHandler::handle dispatch"
2848                            );
2849                            response
2850                        }
2851                        .instrument(span)
2852                    });
2853                    let result = match handler_task.await {
2854                        Ok(result) => result,
2855                        Err(_) => cancel,
2856                    };
2857                    let rpc_start = Instant::now();
2858                    let _ = client
2859                        .call(
2860                            "session.mcp.oauth.handlePendingRequest",
2861                            Some(serde_json::json!({
2862                                "sessionId": sid,
2863                                "requestId": request_id,
2864                                "result": result.into_wire(),
2865                            })),
2866                        )
2867                        .await;
2868                    tracing::debug!(
2869                        elapsed_ms = rpc_start.elapsed().as_millis(),
2870                        "Session::handle_notification MCP auth response sent"
2871                    );
2872                }
2873                .instrument(span),
2874            );
2875        }
2876        SessionEventType::CommandExecute => {
2877            let data: CommandExecuteData =
2878                match serde_json::from_value(notification.event.data.clone()) {
2879                    Ok(d) => d,
2880                    Err(e) => {
2881                        warn!(error = %e, "failed to deserialize command.execute");
2882                        return;
2883                    }
2884                };
2885            let client = client.clone();
2886            let command_handlers = command_handlers.clone();
2887            let sid = session_id.clone();
2888            let span = tracing::error_span!("command_handler", session_id = %sid);
2889            tokio::spawn(
2890                async move {
2891                    let request_id = data.request_id;
2892                    let ack_error = match command_handlers.get(&data.command_name).cloned() {
2893                        None => Some(format!("Unknown command: {}", data.command_name)),
2894                        Some(handler) => {
2895                            let command_name = data.command_name.clone();
2896                            let ctx = CommandContext {
2897                                session_id: sid.clone(),
2898                                command: data.command,
2899                                command_name: data.command_name,
2900                                args: data.args,
2901                            };
2902                            let handler_start = Instant::now();
2903                            let result = handler.on_command(ctx).await;
2904                            tracing::debug!(
2905                                elapsed_ms = handler_start.elapsed().as_millis(),
2906                                session_id = %sid,
2907                                request_id = %request_id,
2908                                command_name = %command_name,
2909                                "CommandHandler::call dispatch"
2910                            );
2911                            match result {
2912                                Ok(()) => None,
2913                                Err(e) => Some(e.to_string()),
2914                            }
2915                        }
2916                    };
2917                    let mut params = serde_json::json!({
2918                        "sessionId": sid,
2919                        "requestId": request_id,
2920                    });
2921                    if let Some(error_msg) = ack_error {
2922                        params["error"] = serde_json::Value::String(error_msg);
2923                    }
2924                    let rpc_start = Instant::now();
2925                    let _ = client
2926                        .call("session.commands.handlePendingCommand", Some(params))
2927                        .await;
2928                    tracing::debug!(
2929                        elapsed_ms = rpc_start.elapsed().as_millis(),
2930                        session_id = %sid,
2931                        request_id = %request_id,
2932                        "Session::handle_notification response sent successfully"
2933                    );
2934                }
2935                .instrument(span),
2936            );
2937        }
2938        _ => {}
2939    }
2940}
2941
2942struct RequestDispatchContext<'a> {
2943    client: &'a Client,
2944    handlers: &'a SessionHandlers,
2945    hooks: Option<&'a dyn SessionHooks>,
2946    transforms: Option<&'a dyn SystemMessageTransform>,
2947    canvas_handler: Option<&'a Arc<dyn CanvasHandler>>,
2948    session_fs_provider: Option<&'a Arc<dyn SessionFsProvider>>,
2949    bearer_token_providers: &'a HashMap<String, Arc<dyn BearerTokenProvider>>,
2950}
2951
2952/// Process a JSON-RPC request from the CLI.
2953async fn handle_request(
2954    session_id: &SessionId,
2955    ctx: RequestDispatchContext<'_>,
2956    request: crate::JsonRpcRequest,
2957) {
2958    let sid = session_id.clone();
2959    let client = ctx.client;
2960    let handlers = ctx.handlers;
2961    let hooks = ctx.hooks;
2962    let transforms = ctx.transforms;
2963    let canvas_handler = ctx.canvas_handler;
2964    let session_fs_provider = ctx.session_fs_provider;
2965    let bearer_token_providers = ctx.bearer_token_providers;
2966
2967    if request.method.starts_with("sessionFs.") {
2968        crate::session_fs_dispatch::dispatch(client, session_fs_provider, request).await;
2969        return;
2970    }
2971
2972    if request.method.starts_with("canvas.") {
2973        crate::canvas_dispatch::dispatch(client, canvas_handler, request).await;
2974        return;
2975    }
2976
2977    if request.method == crate::generated::api_types::rpc_methods::PROVIDERTOKEN_GETTOKEN {
2978        crate::provider_token_dispatch::dispatch(client, bearer_token_providers, request).await;
2979        return;
2980    }
2981
2982    match request.method.as_str() {
2983        "hooks.invoke" => {
2984            let params = request.params.as_ref();
2985            let hook_type = params
2986                .and_then(|p| p.get("hookType"))
2987                .and_then(|v| v.as_str())
2988                .unwrap_or("");
2989            let input = params
2990                .and_then(|p| p.get("input"))
2991                .cloned()
2992                .unwrap_or(Value::Object(Default::default()));
2993
2994            let rpc_result = if let Some(hooks) = hooks {
2995                match crate::hooks::dispatch_hook(hooks, &sid, hook_type, input).await {
2996                    Ok(output) => output,
2997                    Err(e) => {
2998                        warn!(error = %e, hook_type = hook_type, "hook dispatch failed");
2999                        serde_json::json!({ "output": {} })
3000                    }
3001                }
3002            } else {
3003                serde_json::json!({ "output": {} })
3004            };
3005
3006            let rpc_response = JsonRpcResponse {
3007                jsonrpc: "2.0".to_string(),
3008                id: request.id,
3009                result: Some(rpc_result),
3010                error: None,
3011            };
3012            let _ = client.send_response(&rpc_response).await;
3013        }
3014
3015        "userInput.request" => {
3016            let params = request.params.as_ref();
3017            let Some(question) = params
3018                .and_then(|p| p.get("question"))
3019                .and_then(|v| v.as_str())
3020            else {
3021                warn!("userInput.request missing 'question' field");
3022                let rpc_response = JsonRpcResponse {
3023                    jsonrpc: "2.0".to_string(),
3024                    id: request.id,
3025                    result: None,
3026                    error: Some(crate::JsonRpcError {
3027                        code: error_codes::INVALID_PARAMS,
3028                        message: "missing required field: question".to_string(),
3029                        data: None,
3030                    }),
3031                };
3032                let _ = client.send_response(&rpc_response).await;
3033                return;
3034            };
3035            let question = question.to_string();
3036            let choices = params
3037                .and_then(|p| p.get("choices"))
3038                .and_then(|v| v.as_array())
3039                .map(|arr| {
3040                    arr.iter()
3041                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
3042                        .collect()
3043                });
3044            let allow_freeform = params
3045                .and_then(|p| p.get("allowFreeform"))
3046                .and_then(|v| v.as_bool());
3047
3048            let handler_start = Instant::now();
3049            let response = if let Some(user_input_handler) = handlers.user_input.as_ref() {
3050                user_input_handler
3051                    .handle(sid.clone(), question, choices, allow_freeform)
3052                    .await
3053            } else {
3054                None
3055            };
3056            tracing::debug!(
3057                elapsed_ms = handler_start.elapsed().as_millis(),
3058                session_id = %sid,
3059                "UserInputHandler::handle dispatch"
3060            );
3061
3062            let rpc_result = match response {
3063                Some(UserInputResponse {
3064                    answer,
3065                    was_freeform,
3066                }) => serde_json::json!({
3067                    "answer": answer,
3068                    "wasFreeform": was_freeform,
3069                }),
3070                None => serde_json::json!({ "noResponse": true }),
3071            };
3072            let rpc_response = JsonRpcResponse {
3073                jsonrpc: "2.0".to_string(),
3074                id: request.id,
3075                result: Some(rpc_result),
3076                error: None,
3077            };
3078            let _ = client.send_response(&rpc_response).await;
3079        }
3080
3081        "exitPlanMode.request" => {
3082            let params = request
3083                .params
3084                .as_ref()
3085                .cloned()
3086                .unwrap_or(Value::Object(serde_json::Map::new()));
3087            let data: ExitPlanModeData = match serde_json::from_value(params) {
3088                Ok(d) => d,
3089                Err(e) => {
3090                    warn!(error = %e, "failed to deserialize exitPlanMode.request params, using defaults");
3091                    ExitPlanModeData::default()
3092                }
3093            };
3094
3095            let rpc_result = if let Some(exit_plan_handler) = handlers.exit_plan_mode.as_ref() {
3096                let result = exit_plan_handler.handle(sid, data).await;
3097                serde_json::to_value(result).expect("ExitPlanModeResult serialization cannot fail")
3098            } else {
3099                serde_json::json!({ "approved": true })
3100            };
3101            let rpc_response = JsonRpcResponse {
3102                jsonrpc: "2.0".to_string(),
3103                id: request.id,
3104                result: Some(rpc_result),
3105                error: None,
3106            };
3107            let _ = client.send_response(&rpc_response).await;
3108        }
3109
3110        "autoModeSwitch.request" => {
3111            let error_code = request
3112                .params
3113                .as_ref()
3114                .and_then(|p| p.get("errorCode"))
3115                .and_then(|v| v.as_str())
3116                .map(|s| s.to_string());
3117            let retry_after_seconds = request
3118                .params
3119                .as_ref()
3120                .and_then(|p| p.get("retryAfterSeconds"))
3121                .and_then(|v| v.as_f64());
3122
3123            let answer = if let Some(auto_mode_handler) = handlers.auto_mode_switch.as_ref() {
3124                auto_mode_handler
3125                    .handle(sid, error_code, retry_after_seconds)
3126                    .await
3127            } else {
3128                AutoModeSwitchResponse::No
3129            };
3130            let rpc_response = JsonRpcResponse {
3131                jsonrpc: "2.0".to_string(),
3132                id: request.id,
3133                result: Some(serde_json::json!({ "response": answer })),
3134                error: None,
3135            };
3136            let _ = client.send_response(&rpc_response).await;
3137        }
3138
3139        "systemMessage.transform" => {
3140            let params = request.params.as_ref();
3141            let sections: HashMap<String, crate::transforms::TransformSection> =
3142                match params.and_then(|p| p.get("sections")) {
3143                    Some(v) => match serde_json::from_value(v.clone()) {
3144                        Ok(s) => s,
3145                        Err(e) => {
3146                            let _ = send_error_response(
3147                                client,
3148                                request.id,
3149                                error_codes::INVALID_PARAMS,
3150                                &format!("invalid sections: {e}"),
3151                            )
3152                            .await;
3153                            return;
3154                        }
3155                    },
3156                    None => {
3157                        let _ = send_error_response(
3158                            client,
3159                            request.id,
3160                            error_codes::INVALID_PARAMS,
3161                            "missing sections parameter",
3162                        )
3163                        .await;
3164                        return;
3165                    }
3166                };
3167
3168            let rpc_result = if let Some(transforms) = transforms {
3169                let transform_start = Instant::now();
3170                let response =
3171                    crate::transforms::dispatch_transform(transforms, &sid, sections).await;
3172                tracing::debug!(
3173                    elapsed_ms = transform_start.elapsed().as_millis(),
3174                    session_id = %sid,
3175                    "SystemMessageTransform::transform_section dispatch"
3176                );
3177                match serde_json::to_value(response) {
3178                    Ok(v) => v,
3179                    Err(e) => {
3180                        warn!(error = %e, "failed to serialize transform response");
3181                        serde_json::json!({ "sections": {} })
3182                    }
3183                }
3184            } else {
3185                // No transforms registered — pass through all sections unchanged.
3186                let passthrough: HashMap<String, crate::transforms::TransformSection> = sections;
3187                serde_json::json!({ "sections": passthrough })
3188            };
3189
3190            let rpc_response = JsonRpcResponse {
3191                jsonrpc: "2.0".to_string(),
3192                id: request.id,
3193                result: Some(rpc_result),
3194                error: None,
3195            };
3196            let _ = client.send_response(&rpc_response).await;
3197        }
3198
3199        method => {
3200            warn!(
3201                method = method,
3202                "unhandled request method in session event loop"
3203            );
3204            let _ = send_error_response(
3205                client,
3206                request.id,
3207                error_codes::METHOD_NOT_FOUND,
3208                &format!("unknown method: {method}"),
3209            )
3210            .await;
3211        }
3212    }
3213}
3214
3215async fn send_error_response(
3216    client: &Client,
3217    id: u64,
3218    code: i32,
3219    message: &str,
3220) -> Result<(), Error> {
3221    let response = JsonRpcResponse {
3222        jsonrpc: "2.0".to_string(),
3223        id,
3224        result: None,
3225        error: Some(crate::JsonRpcError {
3226            code,
3227            message: message.to_string(),
3228            data: None,
3229        }),
3230    };
3231    client.send_response(&response).await
3232}
3233
3234/// Inject `action: "transform"` sections into a `SystemMessageConfig`,
3235/// forcing `mode: "customize"` (required by the CLI for transforms to fire).
3236/// Preserves any existing caller-provided section overrides.
3237fn apply_transform_sections(
3238    sys_msg: &mut SystemMessageConfig,
3239    transforms: &dyn SystemMessageTransform,
3240) {
3241    sys_msg.mode = Some("customize".to_string());
3242    let sections = sys_msg.sections.get_or_insert_with(HashMap::new);
3243    for id in transforms.section_ids() {
3244        sections.entry(id).or_insert_with(|| SectionOverride {
3245            action: Some("transform".to_string()),
3246            content: None,
3247        });
3248    }
3249}
3250
3251fn inject_transform_sections(config: &mut SessionConfig, transforms: &dyn SystemMessageTransform) {
3252    let sys_msg = config.system_message.get_or_insert_with(Default::default);
3253    apply_transform_sections(sys_msg, transforms);
3254}
3255
3256fn inject_transform_sections_resume(
3257    config: &mut ResumeSessionConfig,
3258    transforms: &dyn SystemMessageTransform,
3259) {
3260    let sys_msg = config.system_message.get_or_insert_with(Default::default);
3261    apply_transform_sections(sys_msg, transforms);
3262}
3263
3264#[cfg(test)]
3265mod tests {
3266    use serde_json::json;
3267
3268    use super::{
3269        build_mode_post_create_patch, has_managed_settings, is_autopilot_continuation_idle,
3270        permission_request_data, permission_response_params,
3271    };
3272    use crate::handler::PermissionResult;
3273    use crate::types::{
3274        PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource,
3275        PermissionDecisionSurface, RequestId, SessionEvent, SessionId,
3276    };
3277
3278    #[test]
3279    fn identifies_only_autopilot_continuation_idles() {
3280        let mut event = SessionEvent {
3281            id: "event-1".to_string(),
3282            timestamp: "2026-01-01T00:00:00Z".to_string(),
3283            parent_id: None,
3284            ephemeral: None,
3285            agent_id: None,
3286            debug_cli_received_at_ms: None,
3287            debug_ws_forwarded_at_ms: None,
3288            event_type: "session.idle".to_string(),
3289            data: json!({ "mode": "autopilot" }),
3290        };
3291
3292        assert!(is_autopilot_continuation_idle(&event));
3293
3294        event.data = json!({ "mode": "interactive" });
3295        assert!(!is_autopilot_continuation_idle(&event));
3296
3297        event.data = json!({});
3298        assert!(!is_autopilot_continuation_idle(&event));
3299    }
3300
3301    #[test]
3302    fn empty_mode_post_patch_sets_empty_included_builtin_skills() {
3303        let patch =
3304            build_mode_post_create_patch(crate::ClientMode::Empty, None, None, None, None, None)
3305                .expect("empty mode always sends a patch");
3306        assert_eq!(
3307            patch.included_builtin_skills,
3308            Some(Vec::new()),
3309            "empty mode must fail closed with an empty includedBuiltinSkills list"
3310        );
3311        assert_eq!(patch.installed_plugins.as_ref().map(|p| p.len()), Some(0));
3312        // Serializes as an explicit empty array (not omitted).
3313        let value = serde_json::to_value(&patch).expect("serialize patch");
3314        assert_eq!(value["includedBuiltinSkills"], serde_json::json!([]));
3315    }
3316
3317    #[test]
3318    fn empty_mode_post_patch_preserves_explicit_builtin_skill_allowlist() {
3319        let patch = build_mode_post_create_patch(
3320            crate::ClientMode::Empty,
3321            Some(false),
3322            Some(false),
3323            Some(true),
3324            Some(true),
3325            Some(vec!["code-review".to_string()]),
3326        )
3327        .expect("empty mode always sends a patch");
3328        assert_eq!(
3329            patch.included_builtin_skills,
3330            Some(vec!["code-review".to_string()])
3331        );
3332    }
3333
3334    #[test]
3335    fn copilot_cli_mode_does_not_inject_included_builtin_skills() {
3336        // No fields set -> no patch at all.
3337        assert!(
3338            build_mode_post_create_patch(
3339                crate::ClientMode::CopilotCli,
3340                None,
3341                None,
3342                None,
3343                None,
3344                None
3345            )
3346            .is_none()
3347        );
3348        // A field set -> patch sent, but skills field stays absent.
3349        let patch = build_mode_post_create_patch(
3350            crate::ClientMode::CopilotCli,
3351            Some(true),
3352            None,
3353            None,
3354            None,
3355            None,
3356        )
3357        .expect("a set field triggers a patch");
3358        assert_eq!(patch.included_builtin_skills, None);
3359        assert!(patch.installed_plugins.is_none());
3360        let value = serde_json::to_value(&patch).expect("serialize patch");
3361        assert!(value.get("includedBuiltinSkills").is_none());
3362
3363        let patch = build_mode_post_create_patch(
3364            crate::ClientMode::CopilotCli,
3365            None,
3366            None,
3367            None,
3368            None,
3369            Some(vec!["code-review".to_string()]),
3370        )
3371        .expect("an explicit allowlist triggers a patch");
3372        assert_eq!(
3373            patch.included_builtin_skills,
3374            Some(vec!["code-review".to_string()])
3375        );
3376    }
3377
3378    #[test]
3379    fn direct_injection_enables_managed_safeguards() {
3380        let settings = crate::types::ManagedSettings::default();
3381        assert!(has_managed_settings(None, Some(&settings)));
3382        assert!(!has_managed_settings(None, None));
3383    }
3384
3385    fn attribution_context() -> PermissionDecisionContext {
3386        PermissionDecisionContext {
3387            outcome: PermissionDecisionOutcome::AutoApproved,
3388            response_capability: None,
3389            source: PermissionDecisionSource::AssistedApproval,
3390            surface: PermissionDecisionSurface::CopilotApp,
3391        }
3392    }
3393
3394    #[test]
3395    fn response_params_omit_decision_context_without_attribution() {
3396        for (result, expected) in [
3397            (
3398                PermissionResult::approve_once(),
3399                json!({ "kind": "approve-once" }),
3400            ),
3401            (PermissionResult::reject(None), json!({ "kind": "reject" })),
3402            (
3403                PermissionResult::reject(Some("bad".to_string())),
3404                json!({ "kind": "reject", "feedback": "bad" }),
3405            ),
3406            (
3407                PermissionResult::user_not_available(),
3408                json!({ "kind": "user-not-available" }),
3409            ),
3410        ] {
3411            let params = permission_response_params(
3412                &SessionId::from("session-1"),
3413                &RequestId::from("permission-1"),
3414                &result,
3415            )
3416            .unwrap();
3417            assert_eq!(
3418                params,
3419                json!({
3420                    "sessionId": "session-1",
3421                    "requestId": "permission-1",
3422                    "result": expected,
3423                })
3424            );
3425        }
3426    }
3427
3428    #[test]
3429    fn response_params_forward_decision_context_alongside_result() {
3430        let params = permission_response_params(
3431            &SessionId::from("session-1"),
3432            &RequestId::from("permission-1"),
3433            &PermissionResult::approve_once().with_context(attribution_context()),
3434        )
3435        .unwrap();
3436        assert_eq!(
3437            params,
3438            json!({
3439                "sessionId": "session-1",
3440                "requestId": "permission-1",
3441                "result": { "kind": "approve-once" },
3442                "decisionContext": {
3443                    "outcome": "auto_approved",
3444                    "source": "assisted_approval",
3445                    "surface": "copilot_app",
3446                },
3447            })
3448        );
3449        // The context is a sibling of `result`, never nested inside it.
3450        assert!(params["result"].get("decisionContext").is_none());
3451    }
3452
3453    #[test]
3454    fn response_params_suppressed_for_no_result() {
3455        assert!(
3456            permission_response_params(
3457                &SessionId::from("session-1"),
3458                &RequestId::from("permission-1"),
3459                &PermissionResult::NoResult,
3460            )
3461            .is_none()
3462        );
3463    }
3464
3465    #[test]
3466    fn with_context_is_a_no_op_on_no_result() {
3467        let result = PermissionResult::no_result().with_context(attribution_context());
3468        assert!(matches!(result, PermissionResult::NoResult));
3469    }
3470
3471    #[test]
3472    fn with_context_replaces_rather_than_nests() {
3473        let result = PermissionResult::approve_once()
3474            .with_context(attribution_context())
3475            .with_context(PermissionDecisionContext {
3476                outcome: PermissionDecisionOutcome::PromptedUser,
3477                response_capability: None,
3478                source: PermissionDecisionSource::HumanResponse,
3479                surface: PermissionDecisionSurface::Sdk,
3480            });
3481        let params = permission_response_params(
3482            &SessionId::from("session-1"),
3483            &RequestId::from("permission-1"),
3484            &result,
3485        )
3486        .unwrap();
3487        assert_eq!(
3488            params["decisionContext"],
3489            json!({
3490                "outcome": "prompted_user",
3491                "source": "human_response",
3492                "surface": "sdk",
3493            })
3494        );
3495    }
3496
3497    #[test]
3498    fn permission_request_data_reads_nested_managed_approval_metadata() {
3499        let data = permission_request_data(
3500            &json!({
3501                "requestId": "permission-1",
3502                "permissionRequest": {
3503                    "kind": "read",
3504                    "managedApprovalRequired": true,
3505                    "path": "/workspace/file.txt"
3506                }
3507            }),
3508            false,
3509        );
3510
3511        assert_eq!(data.managed_approval_required, Some(true));
3512        assert_eq!(
3513            data.extra["permissionRequest"]["path"],
3514            "/workspace/file.txt"
3515        );
3516    }
3517
3518    #[test]
3519    fn permission_request_data_preserves_managed_flag_when_other_fields_are_malformed() {
3520        let data = permission_request_data(
3521            &json!({
3522                "requestId": "permission-1",
3523                "permissionRequest": {
3524                    "kind": "read",
3525                    "managedApprovalRequired": true,
3526                    "toolCallId": 42
3527                }
3528            }),
3529            false,
3530        );
3531
3532        assert_eq!(data.managed_approval_required, Some(true));
3533        assert_eq!(data.extra["requestId"], "permission-1");
3534    }
3535
3536    #[test]
3537    fn permission_request_data_fails_closed_for_malformed_managed_flag() {
3538        let data = permission_request_data(
3539            &json!({
3540                "requestId": "permission-1",
3541                "permissionRequest": {
3542                    "kind": "read",
3543                    "managedApprovalRequired": "yes",
3544                    "path": "/workspace/file.txt"
3545                }
3546            }),
3547            false,
3548        );
3549
3550        assert_eq!(data.managed_approval_required, Some(true));
3551    }
3552
3553    #[test]
3554    fn permission_request_data_preserves_valid_false_managed_flag() {
3555        let data = permission_request_data(
3556            &json!({
3557                "requestId": "permission-1",
3558                "permissionRequest": {
3559                    "kind": "read",
3560                    "managedApprovalRequired": false,
3561                    "path": "/workspace/file.txt"
3562                }
3563            }),
3564            false,
3565        );
3566
3567        assert_eq!(data.managed_approval_required, Some(false));
3568    }
3569}