Skip to main content

github_copilot_sdk/
session.rs

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