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        if let Ok(mut q) = self.queue.lock() {
120            q.push(QueuedApproval {
121                child_agent_id: self.child_agent_id.clone(),
122                tool: req.tool.to_string(),
123                subject: req.subject.map(String::from),
124                queued_at_ms: now_ms(),
125            });
126        }
127        let (reply_tx, reply_rx) = mpsc::channel();
128        let pending = PendingChildApproval {
129            child_agent_id: self.child_agent_id.clone(),
130            tool: req.tool.to_string(),
131            subject: req.subject.map(String::from),
132            raw_args: req.raw_args.clone(),
133            reply_tx,
134        };
135        if self.tx.send(pending).is_err() {
136            return ApprovalOutcome::Deny;
137        }
138        reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
139    }
140}
141
142fn now_ms() -> i64 {
143    std::time::SystemTime::now()
144        .duration_since(std::time::UNIX_EPOCH)
145        .map(|d| d.as_millis() as i64)
146        .unwrap_or(0)
147}
148
149/// P5-2's interactive elicitation UI, closing the deferred chain
150/// [`crate::mcp::HeadlessElicitationHandler`]'s doc comment names. `handle`
151/// is ASYNC (the MCP trait's own shape), so this uses a `tokio::sync::
152/// oneshot` reply rather than blocking a thread — awaiting it yields the
153/// executor to other work while the modal is up. A dropped reply sender
154/// (render loop gone) resolves to [`crate::mcp::ElicitationAction::Cancel`]
155/// (the MCP spec's own "dismissed without a decision" outcome — the
156/// honest shape for "nobody answered", distinct from an explicit
157/// `Decline`).
158pub struct TuiElicitationHandler {
159    tx: mpsc::Sender<PendingElicitation>,
160}
161
162impl TuiElicitationHandler {
163    /// Construct a handler that feeds `tx` — the matching `Receiver` half
164    /// is [`TuiBridge::elicitation_rx`].
165    pub fn new(tx: mpsc::Sender<PendingElicitation>) -> Self {
166        TuiElicitationHandler { tx }
167    }
168}
169
170#[async_trait]
171impl McpElicitationHandler for TuiElicitationHandler {
172    async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
173        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
174        let pending = PendingElicitation {
175            message: request.message.clone(),
176            requested_schema: request.requested_schema.clone(),
177            reply_tx,
178        };
179        if self.tx.send(pending).is_err() {
180            return ElicitationResponse {
181                action: crate::mcp::ElicitationAction::Cancel,
182                content: None,
183            };
184        }
185        reply_rx.await.unwrap_or(ElicitationResponse {
186            action: crate::mcp::ElicitationAction::Cancel,
187            content: None,
188        })
189    }
190}
191
192/// The aggregate wiring point a `crates/cli` embedder uses: constructs
193/// every channel pair once, installs the SENDING halves onto the `Agent`/
194/// `McpClient`s that need them, and hands the RECEIVING halves to the
195/// render loop to poll each frame. See [`crate::tui::should_activate`]'s
196/// doc comment for why this is only ever built when the TUI is confirmed
197/// active — installing these on an agent that no render loop is draining
198/// would hang the first `Ask`-tier prompt or elicitation forever.
199pub struct TuiBridge {
200    approval_tx: mpsc::Sender<PendingApprovalRequest>,
201    /// Poll this each render frame (`try_recv`) for a new top-level
202    /// approval prompt to show.
203    pub approval_rx: mpsc::Receiver<PendingApprovalRequest>,
204    child_approval_tx: mpsc::Sender<PendingChildApproval>,
205    /// Poll this each render frame for a new child-approval prompt.
206    pub child_approval_rx: mpsc::Receiver<PendingChildApproval>,
207    elicitation_tx: mpsc::Sender<PendingElicitation>,
208    /// Poll this each render frame for a new elicitation prompt.
209    pub elicitation_rx: mpsc::Receiver<PendingElicitation>,
210    oauth_tx: mpsc::Sender<PendingOAuthDisplay>,
211    /// Poll this each render frame for a new OAuth device-code display.
212    pub oauth_rx: mpsc::Receiver<PendingOAuthDisplay>,
213}
214
215impl Default for TuiBridge {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221impl TuiBridge {
222    /// Build a fresh bridge — four independent channel pairs, all cheap
223    /// (unbounded `mpsc`, no background threads spawned here).
224    pub fn new() -> Self {
225        let (approval_tx, approval_rx) = mpsc::channel();
226        let (child_approval_tx, child_approval_rx) = mpsc::channel();
227        let (elicitation_tx, elicitation_rx) = mpsc::channel();
228        let (oauth_tx, oauth_rx) = mpsc::channel();
229        TuiBridge {
230            approval_tx,
231            approval_rx,
232            child_approval_tx,
233            child_approval_rx,
234            elicitation_tx,
235            elicitation_rx,
236            oauth_tx,
237            oauth_rx,
238        }
239    }
240
241    /// Install this bridge's top-level-approval and child-approval-factory
242    /// handlers onto `agent`. Does NOT touch MCP elicitation — an
243    /// `McpClient` needs [`Self::elicitation_handler`] installed on IT
244    /// directly (before the client is consumed into tool registration),
245    /// which is why that's a separate method the caller invokes per
246    /// client, earlier in its own connect sequence.
247    pub fn install_on(&self, agent: &mut crate::agent::Agent) {
248        agent.set_permissions_approval_handler(TuiApprovalHandler::new(self.approval_tx.clone()));
249        agent.set_child_approval_handler_factory({
250            let tx = self.child_approval_tx.clone();
251            move |child_id, queue| {
252                Arc::new(TuiChildApprovalHandler::new(child_id, queue, tx.clone()))
253                    as Arc<dyn PermissionsApprovalHandler>
254            }
255        });
256    }
257
258    /// A fresh [`TuiElicitationHandler`] wired to this bridge — install on
259    /// each `McpClient` via
260    /// [`crate::mcp::McpClient::set_elicitation_handler`] before that
261    /// client is consumed into tool registration.
262    pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
263        Arc::new(TuiElicitationHandler::new(self.elicitation_tx.clone()))
264    }
265
266    /// The sender half for a one-shot OAuth device-code display push (P5-2
267    /// device flow's `on_prompt` callback) — see `crates/cli`'s OAuth login
268    /// wiring for the call site.
269    pub fn oauth_sender(&self) -> mpsc::Sender<PendingOAuthDisplay> {
270        self.oauth_tx.clone()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::permissions::approval::resolve_ask;
278    use crate::permissions::ApprovalCache;
279
280    // ---- P5-1 chain: ask → modal → allow-for-session → cache ----
281
282    #[test]
283    fn approval_ask_blocks_until_render_loop_replies_allow_for_session_and_cache_then_skips_handler(
284    ) {
285        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
286        let handler = Arc::new(TuiApprovalHandler::new(tx));
287        let cache = Arc::new(ApprovalCache::new());
288
289        // First call: spawn a thread that calls `resolve_ask` (which
290        // blocks inside `ask` until we reply below).
291        let h = handler.clone();
292        let c = cache.clone();
293        let worker = std::thread::spawn(move || {
294            let args = serde_json::json!({});
295            let req = ApprovalRequest {
296                tool: "bash",
297                subject: Some("ls -la"),
298                raw_args: &args,
299            };
300            resolve_ask(&c, Some(h.as_ref()), &req)
301        });
302
303        // Act as the render loop: receive the pending request, verify its
304        // shape, and reply "allow for session".
305        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
306        assert_eq!(pending.tool, "bash");
307        assert_eq!(pending.subject.as_deref(), Some("ls -la"));
308        pending
309            .reply_tx
310            .send(ApprovalOutcome::AllowForSession)
311            .unwrap();
312
313        let approved = worker.join().unwrap();
314        assert!(approved, "first Ask must be approved via the modal");
315
316        // Second, IDENTICAL call: the cache must short-circuit — no
317        // request should reach the handler's channel this time, proving
318        // "approve for session" was actually recorded.
319        let args = serde_json::json!({});
320        let req2 = ApprovalRequest {
321            tool: "bash",
322            subject: Some("ls -la"),
323            raw_args: &args,
324        };
325        let approved2 = resolve_ask(&cache, Some(handler.as_ref()), &req2);
326        assert!(approved2);
327        assert!(
328            rx.try_recv().is_err(),
329            "a cached AllowForSession must skip the handler entirely"
330        );
331    }
332
333    #[test]
334    fn approval_ask_deny_is_not_cached() {
335        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
336        let handler = Arc::new(TuiApprovalHandler::new(tx));
337        let cache = Arc::new(ApprovalCache::new());
338        let h = handler.clone();
339        let c = cache.clone();
340        let worker = std::thread::spawn(move || {
341            let args = serde_json::json!({});
342            let req = ApprovalRequest {
343                tool: "bash",
344                subject: Some("curl evil.example"),
345                raw_args: &args,
346            };
347            resolve_ask(&c, Some(h.as_ref()), &req)
348        });
349        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
350        pending.reply_tx.send(ApprovalOutcome::Deny).unwrap();
351        let approved = worker.join().unwrap();
352        assert!(!approved);
353        assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("curl evil.example"))));
354    }
355
356    #[test]
357    fn approval_ask_fails_closed_when_render_loop_is_gone() {
358        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
359        let handler = TuiApprovalHandler::new(tx);
360        drop(rx); // "render loop" gone before any request is sent
361        let args = serde_json::json!({});
362        let req = ApprovalRequest {
363            tool: "bash",
364            subject: None,
365            raw_args: &args,
366        };
367        assert_eq!(handler.ask(&req), ApprovalOutcome::Deny);
368    }
369
370    #[test]
371    fn approval_ask_fails_closed_when_reply_sender_is_dropped_without_replying() {
372        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
373        let handler = Arc::new(TuiApprovalHandler::new(tx));
374        let h = handler.clone();
375        let worker = std::thread::spawn(move || {
376            let args = serde_json::json!({});
377            let req = ApprovalRequest {
378                tool: "bash",
379                subject: None,
380                raw_args: &args,
381            };
382            h.ask(&req)
383        });
384        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
385        drop(pending.reply_tx); // render loop drops the request without answering
386        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
387    }
388
389    // ---- P5-1's escalation-floor invariant: `ask` is never called at all
390    // for a `Deny`/`Allow` decision — `resolve_ask`/`decision_to_approved`
391    // already enforce this (P5-1's own tests cover it); this just proves
392    // the TUI handler doesn't change that wiring.
393
394    #[test]
395    fn approval_handler_never_consulted_when_rule_engine_already_denies() {
396        use crate::permissions::approval::decision_to_approved;
397        use crate::permissions::rules::Decision;
398        let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
399        let handler = TuiApprovalHandler::new(tx);
400        // `decision_to_approved` never even calls the closure for `Deny` —
401        // if it did, `handler.ask` would block forever (nothing ever
402        // drains `rx`) and this test would hang/timeout instead of
403        // passing, so a passing test IS the proof.
404        let approved = decision_to_approved(Decision::Deny, || {
405            handler.ask(&ApprovalRequest {
406                tool: "bash",
407                subject: None,
408                raw_args: &serde_json::json!({}),
409            }) == ApprovalOutcome::Allow
410        });
411        assert!(!approved);
412        assert!(rx.try_recv().is_err(), "handler must never have been asked");
413    }
414
415    // ---- P5-3 chain: child ask → modal → allow → answered, not immediate-deny ----
416
417    #[test]
418    fn child_approval_handler_blocks_for_an_answer_instead_of_immediate_deny() {
419        let (tx, rx) = mpsc::channel::<PendingChildApproval>();
420        let queue = Arc::new(Mutex::new(Vec::new()));
421        let handler = Arc::new(TuiChildApprovalHandler::new(
422            "agent-bg-7".to_string(),
423            queue.clone(),
424            tx,
425        ));
426        let h = handler.clone();
427        let worker = std::thread::spawn(move || {
428            let args = serde_json::json!({});
429            let subject = "/workspace/out.txt".to_string();
430            let req = ApprovalRequest {
431                tool: "write_file",
432                subject: Some(subject.as_str()),
433                raw_args: &args,
434            };
435            h.ask(&req)
436        });
437        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
438        assert_eq!(pending.child_agent_id, "agent-bg-7");
439        assert_eq!(pending.tool, "write_file");
440        pending.reply_tx.send(ApprovalOutcome::Allow).unwrap();
441        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Allow);
442
443        // Still recorded in the shared audit queue, same as the default
444        // `ParentQueueApprovalHandler` would.
445        let recorded = queue.lock().unwrap();
446        assert_eq!(recorded.len(), 1);
447        assert_eq!(recorded[0].child_agent_id, "agent-bg-7");
448    }
449
450    #[test]
451    fn child_approval_handler_fails_closed_when_nobody_answers() {
452        let (tx, rx) = mpsc::channel::<PendingChildApproval>();
453        let queue = Arc::new(Mutex::new(Vec::new()));
454        let handler = Arc::new(TuiChildApprovalHandler::new(
455            "agent-bg-8".to_string(),
456            queue,
457            tx,
458        ));
459        let h = handler.clone();
460        let worker = std::thread::spawn(move || {
461            let args = serde_json::json!({});
462            let req = ApprovalRequest {
463                tool: "bash",
464                subject: None,
465                raw_args: &args,
466            };
467            h.ask(&req)
468        });
469        let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
470        drop(pending); // dropped without a reply
471        assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
472    }
473
474    // ---- P5-2 chain: elicitation request → modal → answer ----
475
476    #[tokio::test]
477    async fn elicitation_handler_returns_the_modals_accept_answer() {
478        let (tx, rx) = mpsc::channel::<PendingElicitation>();
479        let handler = TuiElicitationHandler::new(tx);
480        let request = ElicitationRequest {
481            message: "What's the deploy tag?".to_string(),
482            requested_schema: serde_json::json!({"properties": {"tag": {"type": "string"}}}),
483        };
484
485        let handle_fut = handler.handle(&request);
486        // Poll the channel from a blocking thread (mpsc::Receiver::recv is
487        // sync) concurrently with the future above via `tokio::join!`.
488        let reply_task = tokio::task::spawn_blocking(move || {
489            let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
490            assert_eq!(pending.message, "What's the deploy tag?");
491            pending
492                .reply_tx
493                .send(ElicitationResponse {
494                    action: crate::mcp::ElicitationAction::Accept,
495                    content: Some(serde_json::json!({"tag": "v1.2.3"})),
496                })
497                .unwrap();
498        });
499
500        let (resp, _) = tokio::join!(handle_fut, reply_task);
501        assert_eq!(resp.action, crate::mcp::ElicitationAction::Accept);
502        assert_eq!(resp.content, Some(serde_json::json!({"tag": "v1.2.3"})));
503    }
504
505    #[tokio::test]
506    async fn elicitation_handler_cancels_when_render_loop_is_gone() {
507        let (tx, rx) = mpsc::channel::<PendingElicitation>();
508        let handler = TuiElicitationHandler::new(tx);
509        drop(rx);
510        let request = ElicitationRequest {
511            message: "…".to_string(),
512            requested_schema: serde_json::json!({}),
513        };
514        let resp = handler.handle(&request).await;
515        assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
516        assert_eq!(resp.content, None);
517    }
518
519    #[tokio::test]
520    async fn elicitation_handler_cancels_when_reply_sender_dropped_without_replying() {
521        let (tx, rx) = mpsc::channel::<PendingElicitation>();
522        let handler = TuiElicitationHandler::new(tx);
523        let request = ElicitationRequest {
524            message: "…".to_string(),
525            requested_schema: serde_json::json!({}),
526        };
527        let handle_fut = handler.handle(&request);
528        let drop_task = tokio::task::spawn_blocking(move || {
529            let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
530            drop(pending); // drops reply_tx without sending
531        });
532        let (resp, _) = tokio::join!(handle_fut, drop_task);
533        assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
534    }
535
536    // ---- TuiBridge wiring smoke tests ----
537
538    #[test]
539    fn bridge_install_on_wires_a_working_approval_handler() {
540        let bridge = TuiBridge::new();
541        let mut agent = crate::agent::Agent::new(
542            crate::Config::builder()
543                .model("test/model")
544                .api_key("test-key")
545                .build(),
546        )
547        .expect("agent construction");
548        bridge.install_on(&mut agent);
549        // No direct accessor for the installed handler (by design — it's
550        // `Agent`-private); this just proves `install_on` doesn't panic
551        // and the bridge's receivers are still usable afterward.
552        assert!(bridge.approval_rx.try_recv().is_err());
553        assert!(bridge.child_approval_rx.try_recv().is_err());
554    }
555
556    #[test]
557    fn bridge_elicitation_handler_feeds_the_bridges_receiver() {
558        let bridge = TuiBridge::new();
559        let handler = bridge.elicitation_handler();
560        let h = handler.clone();
561        let worker = std::thread::spawn(move || {
562            let rt = tokio::runtime::Builder::new_current_thread()
563                .enable_all()
564                .build()
565                .unwrap();
566            rt.block_on(async {
567                let req = ElicitationRequest {
568                    message: "hi".to_string(),
569                    requested_schema: serde_json::json!({}),
570                };
571                h.handle(&req).await
572            })
573        });
574        let pending = bridge
575            .elicitation_rx
576            .recv_timeout(std::time::Duration::from_secs(5))
577            .unwrap();
578        pending
579            .reply_tx
580            .send(ElicitationResponse {
581                action: crate::mcp::ElicitationAction::Decline,
582                content: None,
583            })
584            .unwrap();
585        let resp = worker.join().unwrap();
586        assert_eq!(resp.action, crate::mcp::ElicitationAction::Decline);
587    }
588}