Skip to main content

supercode_harness/tui/
handlers.rs

1//! P5-4's three load-bearing interactive handlers — the THIS-MODULE side of
2//! the three deferred chains P5-1/P5-2/P5-3 each explicitly left for `tui`:
3//!
4//! 1. [`TuiApprovalHandler`] implements
5//!    [`crate::permissions::PermissionsApprovalHandler`] (P5-1's seam):
6//!    installed via [`crate::agent::Agent::set_permissions_approval_handler`].
7//! 2. [`TuiElicitationHandler`] implements [`crate::mcp::McpElicitationHandler`]
8//!    (P5-2's seam): installed via
9//!    [`crate::mcp::McpClient::set_elicitation_handler`].
10//! 3. [`TuiChildApprovalHandler`] — also a `PermissionsApprovalHandler`,
11//!    installed via the factory
12//!    [`crate::agent::Agent::set_child_approval_handler_factory`] (P5-4's
13//!    OWN new seam, added specifically to close P5-3's §2.2 C6 "queued,
14//!    never-blocking" chain into a genuinely answerable one).
15//!
16//! All three follow the same shape: push a `Pending*` request (from
17//! [`crate::tui::bridge`]) onto a channel the render loop polls, then block
18//! (synchronously for the two `PermissionsApprovalHandler`s — `ask` is a
19//! sync trait method by design, see that trait's doc comment; via `.await`
20//! for the async `McpElicitationHandler`) for the reply. If the reply
21//! channel is ever dropped without a reply (the render loop panicked, or
22//! the TUI process is shutting down mid-request), every handler here
23//! resolves to the FAIL-CLOSED outcome (`Deny`/decline) — never a hang,
24//! and never an implicit allow.
25//!
26//! **Security invariant (repeated at each handler below).** None of these
27//! handlers can escalate what the permissions/elicitation engines already
28//! decided: [`crate::permissions::approval::resolve_ask`] only ever calls a
29//! `PermissionsApprovalHandler::ask` when the rule engine already resolved
30//! the call to `Ask` (`Deny` short-circuits before any handler runs;
31//! `Allow` never needs one) — a TUI "allow" here can only grant what the
32//! policy already routed to a human prompt. Likewise an elicitation answer
33//! is exactly what the user typed into the modal — never fabricated,
34//! never auto-accepted.
35
36use std::sync::{mpsc, Arc, Mutex};
37
38use async_trait::async_trait;
39
40use crate::mcp::{ElicitationRequest, ElicitationResponse, McpElicitationHandler};
41use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
42use crate::subagents::QueuedApproval;
43
44use super::bridge::{
45    PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
46};
47
48/// P5-1's interactive ask-UI, closing the deferred chain
49/// `crate::permissions::approval`'s module doc comment names. `ask` pushes
50/// the request onto `tx` and blocks on a fresh one-shot reply channel;
51/// `Self::tx` being closed (the render loop is gone) makes the blocking
52/// `recv()` return an `Err`, which resolves to
53/// [`ApprovalOutcome::Deny`] — fail-closed, matching the trait's own "no
54/// handler ⇒ deny" default posture for the "handler installed but
55/// unreachable" case too.
56pub struct TuiApprovalHandler {
57    tx: mpsc::Sender<PendingApprovalRequest>,
58}
59
60impl TuiApprovalHandler {
61    /// Construct a handler that feeds `tx` — the matching `Receiver` half
62    /// is [`TuiBridge::approval_rx`].
63    pub fn new(tx: mpsc::Sender<PendingApprovalRequest>) -> Self {
64        TuiApprovalHandler { tx }
65    }
66}
67
68impl PermissionsApprovalHandler for TuiApprovalHandler {
69    fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
70        let (reply_tx, reply_rx) = mpsc::channel();
71        let pending = PendingApprovalRequest {
72            tool: req.tool.to_string(),
73            subject: req.subject.map(String::from),
74            raw_args: req.raw_args.clone(),
75            reply_tx,
76        };
77        if self.tx.send(pending).is_err() {
78            return ApprovalOutcome::Deny;
79        }
80        reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
81    }
82}
83
84/// P5-3's answerable child-approval handler, closing the §2.2 C6 deferred
85/// chain — see [`crate::agent::Agent::set_child_approval_handler_factory`]'s
86/// doc comment for how this REPLACES (only when installed) the default
87/// never-blocking [`crate::subagents::ParentQueueApprovalHandler`]. Also
88/// records every request into the shared `queue` (the SAME
89/// `Agent::pending_child_approvals` audit trail `ParentQueueApprovalHandler`
90/// itself writes to), so [`crate::agent::Agent::pending_child_approvals`]
91/// stays a complete audit log regardless of which handler answered a given
92/// request.
93pub struct TuiChildApprovalHandler {
94    child_agent_id: String,
95    queue: Arc<Mutex<Vec<QueuedApproval>>>,
96    tx: mpsc::Sender<PendingChildApproval>,
97}
98
99impl TuiChildApprovalHandler {
100    /// Construct a per-child handler — see
101    /// `TuiBridge::child_approval_handler_factory` for the usual way one
102    /// of these gets built (one per spawn, via
103    /// [`crate::agent::Agent::set_child_approval_handler_factory`]).
104    pub fn new(
105        child_agent_id: String,
106        queue: Arc<Mutex<Vec<QueuedApproval>>>,
107        tx: mpsc::Sender<PendingChildApproval>,
108    ) -> Self {
109        TuiChildApprovalHandler {
110            child_agent_id,
111            queue,
112            tx,
113        }
114    }
115}
116
117impl PermissionsApprovalHandler for TuiChildApprovalHandler {
118    fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
119        // Recorded with no outcome: this handler genuinely blocks, so the
120        // entry IS a pending request until the operator answers it.
121        let queued = crate::subagents::queue_approval(
122            &self.queue,
123            QueuedApproval {
124                child_agent_id: self.child_agent_id.clone(),
125                tool: req.tool.to_string(),
126                subject: req.subject.map(String::from),
127                queued_at_ms: now_ms(),
128                outcome: None,
129            },
130        );
131        let (reply_tx, reply_rx) = mpsc::channel();
132        let pending = PendingChildApproval {
133            child_agent_id: self.child_agent_id.clone(),
134            tool: req.tool.to_string(),
135            subject: req.subject.map(String::from),
136            raw_args: req.raw_args.clone(),
137            reply_tx,
138        };
139        let outcome = if self.tx.send(pending).is_err() {
140            ApprovalOutcome::Deny
141        } else {
142            reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
143        };
144        if let Some(index) = queued {
145            crate::subagents::record_queued_outcome(&self.queue, index, outcome.into());
146        }
147        outcome
148    }
149}
150
151fn now_ms() -> i64 {
152    std::time::SystemTime::now()
153        .duration_since(std::time::UNIX_EPOCH)
154        .map(|d| d.as_millis() as i64)
155        .unwrap_or(0)
156}
157
158/// P5-2's interactive elicitation UI, closing the deferred chain
159/// [`crate::mcp::HeadlessElicitationHandler`]'s doc comment names. `handle`
160/// is ASYNC (the MCP trait's own shape), so this uses a `tokio::sync::
161/// oneshot` reply rather than blocking a thread — awaiting it yields the
162/// executor to other work while the modal is up. A dropped reply sender
163/// (render loop gone) resolves to [`crate::mcp::ElicitationAction::Cancel`]
164/// (the MCP spec's own "dismissed without a decision" outcome — the
165/// honest shape for "nobody answered", distinct from an explicit
166/// `Decline`).
167pub struct TuiElicitationHandler {
168    tx: mpsc::Sender<PendingElicitation>,
169}
170
171impl TuiElicitationHandler {
172    /// Construct a handler that feeds `tx` — the matching `Receiver` half
173    /// is [`TuiBridge::elicitation_rx`].
174    pub fn new(tx: mpsc::Sender<PendingElicitation>) -> Self {
175        TuiElicitationHandler { tx }
176    }
177}
178
179#[async_trait]
180impl McpElicitationHandler for TuiElicitationHandler {
181    async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
182        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
183        let pending = PendingElicitation {
184            message: request.message.clone(),
185            requested_schema: request.requested_schema.clone(),
186            reply_tx,
187        };
188        if self.tx.send(pending).is_err() {
189            return ElicitationResponse {
190                action: crate::mcp::ElicitationAction::Cancel,
191                content: None,
192            };
193        }
194        reply_rx.await.unwrap_or(ElicitationResponse {
195            action: crate::mcp::ElicitationAction::Cancel,
196            content: None,
197        })
198    }
199}
200
201/// The aggregate wiring point a `crates/cli` embedder uses: constructs
202/// every channel pair once, installs the SENDING halves onto the `Agent`/
203/// `McpClient`s that need them, and hands the RECEIVING halves to the
204/// render loop to poll each frame. See [`crate::tui::should_activate`]'s
205/// doc comment for why this is only ever built when the TUI is confirmed
206/// active — installing these on an agent that no render loop is draining
207/// would hang the first `Ask`-tier prompt or elicitation forever.
208pub struct TuiBridge {
209    approval_tx: mpsc::Sender<PendingApprovalRequest>,
210    /// Poll this each render frame (`try_recv`) for a new top-level
211    /// approval prompt to show.
212    pub approval_rx: mpsc::Receiver<PendingApprovalRequest>,
213    child_approval_tx: mpsc::Sender<PendingChildApproval>,
214    /// Poll this each render frame for a new child-approval prompt.
215    pub child_approval_rx: mpsc::Receiver<PendingChildApproval>,
216    elicitation_tx: mpsc::Sender<PendingElicitation>,
217    /// Poll this each render frame for a new elicitation prompt.
218    pub elicitation_rx: mpsc::Receiver<PendingElicitation>,
219    oauth_tx: mpsc::Sender<PendingOAuthDisplay>,
220    /// Poll this each render frame for a new OAuth device-code display.
221    pub oauth_rx: mpsc::Receiver<PendingOAuthDisplay>,
222}
223
224impl Default for TuiBridge {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl TuiBridge {
231    /// Build a fresh bridge — four independent channel pairs, all cheap
232    /// (unbounded `mpsc`, no background threads spawned here).
233    pub fn new() -> Self {
234        let (approval_tx, approval_rx) = mpsc::channel();
235        let (child_approval_tx, child_approval_rx) = mpsc::channel();
236        let (elicitation_tx, elicitation_rx) = mpsc::channel();
237        let (oauth_tx, oauth_rx) = mpsc::channel();
238        TuiBridge {
239            approval_tx,
240            approval_rx,
241            child_approval_tx,
242            child_approval_rx,
243            elicitation_tx,
244            elicitation_rx,
245            oauth_tx,
246            oauth_rx,
247        }
248    }
249
250    /// Install this bridge's top-level-approval and child-approval-factory
251    /// handlers onto `agent`. Does NOT touch MCP elicitation — an
252    /// `McpClient` needs [`Self::elicitation_handler`] installed on IT
253    /// directly (before the client is consumed into tool registration),
254    /// which is why that's a separate method the caller invokes per
255    /// client, earlier in its own connect sequence.
256    pub fn install_on(&self, agent: &mut crate::agent::Agent) {
257        agent.set_permissions_approval_handler(TuiApprovalHandler::new(self.approval_tx.clone()));
258        // BP-3 (§2 module 6 `tools.question`): the TUI is the other
259        // interactive surface §2.1's `tools_question → tui|server` edge
260        // names, so `ask_user` asks through the SAME overlay an MCP
261        // elicitation already uses here.
262        agent.set_user_question_handler(self.elicitation_handler());
263        agent.set_child_approval_handler_factory({
264            let tx = self.child_approval_tx.clone();
265            move |child_id, queue| {
266                Arc::new(TuiChildApprovalHandler::new(child_id, queue, tx.clone()))
267                    as Arc<dyn PermissionsApprovalHandler>
268            }
269        });
270    }
271
272    /// A fresh [`TuiElicitationHandler`] wired to this bridge — install on
273    /// each `McpClient` via
274    /// [`crate::mcp::McpClient::set_elicitation_handler`] before that
275    /// client is consumed into tool registration.
276    pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
277        Arc::new(TuiElicitationHandler::new(self.elicitation_tx.clone()))
278    }
279
280    /// The sender half for a one-shot OAuth device-code display push (P5-2
281    /// device flow's `on_prompt` callback) — see `crates/cli`'s OAuth login
282    /// wiring for the call site.
283    pub fn oauth_sender(&self) -> mpsc::Sender<PendingOAuthDisplay> {
284        self.oauth_tx.clone()
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::permissions::approval::resolve_ask;
292    use crate::permissions::ApprovalCache;
293
294    // ---- P5-1 chain: ask → modal → allow-for-session → cache ----
295
296    #[test]
297    fn approval_ask_blocks_until_render_loop_replies_allow_for_session_and_cache_then_skips_handler(
298    ) {
299        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
300        let handler = Arc::new(TuiApprovalHandler::new(tx));
301        let cache = Arc::new(ApprovalCache::new());
302
303        // First call: spawn a thread that calls `resolve_ask` (which
304        // blocks inside `ask` until we reply below).
305        let h = handler.clone();
306        let c = cache.clone();
307        let worker = std::thread::spawn(move || {
308            let args = serde_json::json!({});
309            let req = ApprovalRequest {
310                tool: "bash",
311                subject: Some("ls -la"),
312                raw_args: &args,
313            };
314            resolve_ask(&c, Some(h.as_ref()), &req)
315        });
316
317        // Act as the render loop: receive the pending request, verify its
318        // shape, and reply "allow for session".
319        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
320        assert_eq!(pending.tool, "bash");
321        assert_eq!(pending.subject.as_deref(), Some("ls -la"));
322        pending
323            .reply_tx
324            .send(ApprovalOutcome::AllowForSession)
325            .unwrap();
326
327        let approved = worker.join().unwrap();
328        assert!(approved, "first Ask must be approved via the modal");
329
330        // Second, IDENTICAL call: the cache must short-circuit — no
331        // request should reach the handler's channel this time, proving
332        // "approve for session" was actually recorded.
333        let args = serde_json::json!({});
334        let req2 = ApprovalRequest {
335            tool: "bash",
336            subject: Some("ls -la"),
337            raw_args: &args,
338        };
339        let approved2 = resolve_ask(&cache, Some(handler.as_ref()), &req2);
340        assert!(approved2);
341        assert!(
342            rx.try_recv().is_err(),
343            "a cached AllowForSession must skip the handler entirely"
344        );
345    }
346
347    #[test]
348    fn approval_ask_deny_is_not_cached() {
349        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
350        let handler = Arc::new(TuiApprovalHandler::new(tx));
351        let cache = Arc::new(ApprovalCache::new());
352        let h = handler.clone();
353        let c = cache.clone();
354        let worker = std::thread::spawn(move || {
355            let args = serde_json::json!({});
356            let req = ApprovalRequest {
357                tool: "bash",
358                subject: Some("curl evil.example"),
359                raw_args: &args,
360            };
361            resolve_ask(&c, Some(h.as_ref()), &req)
362        });
363        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
364        pending.reply_tx.send(ApprovalOutcome::Deny).unwrap();
365        let approved = worker.join().unwrap();
366        assert!(!approved);
367        assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("curl evil.example"))));
368    }
369
370    #[test]
371    fn approval_ask_fails_closed_when_render_loop_is_gone() {
372        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
373        let handler = TuiApprovalHandler::new(tx);
374        drop(rx); // "render loop" gone before any request is sent
375        let args = serde_json::json!({});
376        let req = ApprovalRequest {
377            tool: "bash",
378            subject: None,
379            raw_args: &args,
380        };
381        assert_eq!(handler.ask(&req), ApprovalOutcome::Deny);
382    }
383
384    #[test]
385    fn approval_ask_fails_closed_when_reply_sender_is_dropped_without_replying() {
386        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
387        let handler = Arc::new(TuiApprovalHandler::new(tx));
388        let h = handler.clone();
389        let worker = std::thread::spawn(move || {
390            let args = serde_json::json!({});
391            let req = ApprovalRequest {
392                tool: "bash",
393                subject: None,
394                raw_args: &args,
395            };
396            h.ask(&req)
397        });
398        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
399        drop(pending.reply_tx); // render loop drops the request without answering
400        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
401    }
402
403    // ---- P5-1's escalation-floor invariant: `ask` is never called at all
404    // for a `Deny`/`Allow` decision — `resolve_ask`/`decision_to_approved`
405    // already enforce this (P5-1's own tests cover it); this just proves
406    // the TUI handler doesn't change that wiring.
407
408    #[test]
409    fn approval_handler_never_consulted_when_rule_engine_already_denies() {
410        use crate::permissions::approval::decision_to_approved;
411        use crate::permissions::rules::Decision;
412        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
413        let handler = TuiApprovalHandler::new(tx);
414        // `decision_to_approved` never even calls the closure for `Deny` —
415        // if it did, `handler.ask` would block forever (nothing ever
416        // drains `rx`) and this test would hang/timeout instead of
417        // passing, so a passing test IS the proof.
418        let approved = decision_to_approved(Decision::Deny, || {
419            handler.ask(&ApprovalRequest {
420                tool: "bash",
421                subject: None,
422                raw_args: &serde_json::json!({}),
423            }) == ApprovalOutcome::Allow
424        });
425        assert!(!approved);
426        assert!(rx.try_recv().is_err(), "handler must never have been asked");
427    }
428
429    // ---- P5-3 chain: child ask → modal → allow → answered, not immediate-deny ----
430
431    #[test]
432    fn child_approval_handler_blocks_for_an_answer_instead_of_immediate_deny() {
433        let (tx, rx) = mpsc::channel::<PendingChildApproval>();
434        let queue = Arc::new(Mutex::new(Vec::new()));
435        let handler = Arc::new(TuiChildApprovalHandler::new(
436            "agent-bg-7".to_string(),
437            queue.clone(),
438            tx,
439        ));
440        let h = handler.clone();
441        let worker = std::thread::spawn(move || {
442            let args = serde_json::json!({});
443            let subject = "/workspace/out.txt".to_string();
444            let req = ApprovalRequest {
445                tool: "write_file",
446                subject: Some(subject.as_str()),
447                raw_args: &args,
448            };
449            h.ask(&req)
450        });
451        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
452        assert_eq!(pending.child_agent_id, "agent-bg-7");
453        assert_eq!(pending.tool, "write_file");
454        pending.reply_tx.send(ApprovalOutcome::Allow).unwrap();
455        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Allow);
456
457        // Still recorded in the shared audit queue, same as the default
458        // `ParentQueueApprovalHandler` would.
459        let recorded = queue.lock().unwrap();
460        assert_eq!(recorded.len(), 1);
461        assert_eq!(recorded[0].child_agent_id, "agent-bg-7");
462    }
463
464    #[test]
465    fn child_approval_handler_fails_closed_when_nobody_answers() {
466        let (tx, rx) = mpsc::channel::<PendingChildApproval>();
467        let queue = Arc::new(Mutex::new(Vec::new()));
468        let handler = Arc::new(TuiChildApprovalHandler::new(
469            "agent-bg-8".to_string(),
470            queue,
471            tx,
472        ));
473        let h = handler.clone();
474        let worker = std::thread::spawn(move || {
475            let args = serde_json::json!({});
476            let req = ApprovalRequest {
477                tool: "bash",
478                subject: None,
479                raw_args: &args,
480            };
481            h.ask(&req)
482        });
483        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
484        drop(pending); // dropped without a reply
485        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
486    }
487
488    // ---- P5-2 chain: elicitation request → modal → answer ----
489
490    #[tokio::test]
491    async fn elicitation_handler_returns_the_modals_accept_answer() {
492        let (tx, rx) = mpsc::channel::<PendingElicitation>();
493        let handler = TuiElicitationHandler::new(tx);
494        let request = ElicitationRequest {
495            message: "What's the deploy tag?".to_string(),
496            requested_schema: serde_json::json!({"properties": {"tag": {"type": "string"}}}),
497        };
498
499        let handle_fut = handler.handle(&request);
500        // Poll the channel from a blocking thread (mpsc::Receiver::recv is
501        // sync) concurrently with the future above via `tokio::join!`.
502        let reply_task = tokio::task::spawn_blocking(move || {
503            let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
504            assert_eq!(pending.message, "What's the deploy tag?");
505            pending
506                .reply_tx
507                .send(ElicitationResponse {
508                    action: crate::mcp::ElicitationAction::Accept,
509                    content: Some(serde_json::json!({"tag": "v1.2.3"})),
510                })
511                .unwrap();
512        });
513
514        let (resp, _) = tokio::join!(handle_fut, reply_task);
515        assert_eq!(resp.action, crate::mcp::ElicitationAction::Accept);
516        assert_eq!(resp.content, Some(serde_json::json!({"tag": "v1.2.3"})));
517    }
518
519    #[tokio::test]
520    async fn elicitation_handler_cancels_when_render_loop_is_gone() {
521        let (tx, rx) = mpsc::channel::<PendingElicitation>();
522        let handler = TuiElicitationHandler::new(tx);
523        drop(rx);
524        let request = ElicitationRequest {
525            message: "…".to_string(),
526            requested_schema: serde_json::json!({}),
527        };
528        let resp = handler.handle(&request).await;
529        assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
530        assert_eq!(resp.content, None);
531    }
532
533    #[tokio::test]
534    async fn elicitation_handler_cancels_when_reply_sender_dropped_without_replying() {
535        let (tx, rx) = mpsc::channel::<PendingElicitation>();
536        let handler = TuiElicitationHandler::new(tx);
537        let request = ElicitationRequest {
538            message: "…".to_string(),
539            requested_schema: serde_json::json!({}),
540        };
541        let handle_fut = handler.handle(&request);
542        let drop_task = tokio::task::spawn_blocking(move || {
543            let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
544            drop(pending); // drops reply_tx without sending
545        });
546        let (resp, _) = tokio::join!(handle_fut, drop_task);
547        assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
548    }
549
550    // ---- TuiBridge wiring smoke tests ----
551
552    #[test]
553    fn bridge_install_on_wires_a_working_approval_handler() {
554        let bridge = TuiBridge::new();
555        let mut agent = crate::agent::Agent::new(
556            crate::Config::builder()
557                .model("test/model")
558                .api_key("test-key")
559                .build(),
560        )
561        .expect("agent construction");
562        bridge.install_on(&mut agent);
563        // No direct accessor for the installed handler (by design — it's
564        // `Agent`-private); this just proves `install_on` doesn't panic
565        // and the bridge's receivers are still usable afterward.
566        assert!(bridge.approval_rx.try_recv().is_err());
567        assert!(bridge.child_approval_rx.try_recv().is_err());
568    }
569
570    #[test]
571    fn bridge_elicitation_handler_feeds_the_bridges_receiver() {
572        let bridge = TuiBridge::new();
573        let handler = bridge.elicitation_handler();
574        let h = handler.clone();
575        let worker = std::thread::spawn(move || {
576            let rt = tokio::runtime::Builder::new_current_thread()
577                .enable_all()
578                .build()
579                .unwrap();
580            rt.block_on(async {
581                let req = ElicitationRequest {
582                    message: "hi".to_string(),
583                    requested_schema: serde_json::json!({}),
584                };
585                h.handle(&req).await
586            })
587        });
588        let pending = bridge
589            .elicitation_rx
590            .recv_timeout(std::time::Duration::from_secs(5))
591            .unwrap();
592        pending
593            .reply_tx
594            .send(ElicitationResponse {
595                action: crate::mcp::ElicitationAction::Decline,
596                content: None,
597            })
598            .unwrap();
599        let resp = worker.join().unwrap();
600        assert_eq!(resp.action, crate::mcp::ElicitationAction::Decline);
601    }
602}