Skip to main content

github_copilot_sdk/
session.rs

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