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