Skip to main content

rpi_extensions/
actions.rs

1//! B5a — the plugin→host `runtime_action` bridge (inverted FFI).
2//!
3//! A plugin invokes `PluginApi::runtime_action` to drive the harness (send a
4//! message, switch models, fork a session, reload extensions, …). Unlike the
5//! register trampolines — which run synchronously inside the selected register
6//! entrypoint
7//! and recover host state via the thread-local `CURRENT_HOST_API` —
8//! `runtime_action` is called post-register, either from a host tool driver or
9//! a foreign worker. `PluginApi::user_data` carries an opaque, non-reused handle
10//! resolved through a weak registry, never a dereferenceable bridge address.
11//! Expired registrations fail safely, including candidate reload rollback.
12//!
13//! Synchronous tool callbacks inherit a host-owned invocation via a scoped
14//! thread-local; workers forward the invocation handle and capability injected
15//! into `__rpi`. ChildAgent rejects registration-only, cancelled and child
16//! invocations. A cached registration cannot override a child's driver scope.
17//!
18//! [`ActionBridge`] captures a Tokio [`Handle`] at construction. Dispatch can
19//! therefore spawn from foreign threads and return over a standard synchronous
20//! channel without depending on the plugin thread having a Tokio runtime.
21//!
22//! ## Cycle-free leaf DAG
23//!
24//! [`RuntimeActionHost`] is defined here (NOT in `rpi-harness`) so
25//! `rpi-extensions` stays a leaf: the trait names only JSON + primitives — no
26//! `rpi-harness` types cross. The host impl (`HarnessActionHost`) lives in
27//! `rpi-cli`, where it can name the harness freely; `rpi-extensions` only
28//! carries the async surface. This preserves the documented DAG
29//! (`lib.rs:10-16`: `rpi-extensions` does NOT depend on `rpi-harness`).
30
31use std::collections::{HashMap, VecDeque};
32use std::ffi::c_void;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{mpsc, Arc, Mutex, OnceLock, Weak};
35
36// Opaque, non-reused IDs, never addresses. A copied PluginApi may outlive its
37// registration (e.g. a failed reload); resolving it must not dereference freed memory.
38struct RegistryEntry {
39    bridge: Weak<ActionBridge>,
40    active: Weak<AtomicBool>,
41}
42static BRIDGES: OnceLock<Mutex<HashMap<usize, RegistryEntry>>> = OnceLock::new();
43fn bridges() -> &'static Mutex<HashMap<usize, RegistryEntry>> {
44    BRIDGES.get_or_init(Mutex::default)
45}
46thread_local! {
47    static CURRENT_INVOCATION: std::cell::RefCell<Option<Arc<ActionBridge>>> = const { std::cell::RefCell::new(None) };
48}
49pub(crate) struct InvocationGuard {
50    previous: Option<Arc<ActionBridge>>,
51    _thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
52}
53impl Drop for InvocationGuard {
54    fn drop(&mut self) {
55        CURRENT_INVOCATION.with(|slot| *slot.borrow_mut() = self.previous.take());
56    }
57}
58
59use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
60use tokio::runtime::Handle;
61
62/// Prefix a plugin uses to mark a `requestId` as **not the TUI's to consume**.
63///
64/// Why it exists: the interactive TUI and plugins share one [`UiDialogMailbox`].
65/// Without this convention a plugin's question (a remote UI's permission gate,
66/// say) gets taken by the TUI and drawn in the terminal — while the user is
67/// looking at a remote client, which sees nothing.
68///
69/// Paired with [`UiDialogMailbox::take_pending_filtered`] (the TUI passes
70/// `|r| !is_reserved(&r.request_id)`).
71pub const PLUGIN_RESERVED_PREFIX: &str = "pidingo:";
72
73/// A pending host UI request emitted by a plugin runtime action.
74///
75/// The request and tool-call ids are intentionally separate: a single tool
76/// call may ask more than one question, while concurrent tool calls must never
77/// consume one another's answers. `raw` retains the complete action envelope;
78/// `ui` is the normalized UI payload that TUI renderers generally need.
79#[derive(Clone, Debug, PartialEq)]
80pub struct UiDialogRequest {
81    pub request_id: String,
82    pub tool_call_id: Option<String>,
83    pub ui: serde_json::Value,
84    pub raw: serde_json::Value,
85}
86
87struct UiDialogEntry {
88    request: UiDialogRequest,
89    answer: Option<serde_json::Value>,
90    cancelled: bool,
91}
92
93struct UiDialogState {
94    attached: bool,
95    /// An external UI (a relay) has taken over dialog delivery.
96    ///
97    /// While set, the local TUI must not take requests: both consumers draw
98    /// from the same `pending` queue, so whoever runs first wins and the other
99    /// never sees the question. With a remote client attached, the question has
100    /// to reach *that* client — the terminal is not where the user is looking.
101    external_takeover: bool,
102    pending: VecDeque<String>,
103    active: HashMap<String, UiDialogEntry>,
104    cancelled_tool_calls: std::collections::HashSet<String>,
105}
106
107/// Thread-safe mailbox connecting plugin runtime actions to an interactive
108/// host (normally the TUI). It deliberately carries only JSON and primitives,
109/// so the extension crate remains independent of the UI crate.
110#[derive(Clone)]
111pub struct UiDialogMailbox {
112    state: Arc<Mutex<UiDialogState>>,
113}
114
115impl Default for UiDialogMailbox {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl UiDialogMailbox {
122    pub fn new() -> Self {
123        Self {
124            state: Arc::new(Mutex::new(UiDialogState {
125                attached: false,
126                external_takeover: false,
127                pending: VecDeque::new(),
128                active: HashMap::new(),
129                cancelled_tool_calls: std::collections::HashSet::new(),
130            })),
131        }
132    }
133
134    /// Mark an interactive consumer as available. Re-attaching is idempotent.
135    pub fn attach(&self) {
136        self.state
137            .lock()
138            .expect("ui dialog mailbox poisoned")
139            .attached = true;
140    }
141
142    /// Hand dialog delivery to an external UI (or take it back).
143    ///
144    /// The TUI and a relay both call "take"; without an owner, whichever polls
145    /// first wins and the other never sees the request — a third-party
146    /// extension's question can end up drawn in a terminal nobody is watching.
147    /// A caller that owns the user's attention (a remote client) sets this, and
148    /// the local TUI then skips taking entirely.
149    ///
150    /// Idempotent, and independent of [`Self::attach`]: the TUI stays "attached"
151    /// while a remote client is in charge, it just does not consume.
152    pub fn set_external_takeover(&self, takeover: bool) {
153        self.state
154            .lock()
155            .expect("ui dialog mailbox poisoned")
156            .external_takeover = takeover;
157    }
158
159    /// Whether an external UI currently owns dialog delivery.
160    pub fn external_takeover(&self) -> bool {
161        self.state
162            .lock()
163            .expect("ui dialog mailbox poisoned")
164            .external_takeover
165    }
166
167    /// Detach the consumer and cancel every outstanding request. This prevents
168    /// a TUI restart/reload from leaving plugin polls blocked forever.
169    pub fn detach(&self) {
170        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
171        state.attached = false;
172        // Release ownership with the consumer. A stale takeover flag would make
173        // the TUI refuse every later dialog, so losing the relay client would
174        // leave the user unable to answer anything.
175        state.external_takeover = false;
176        state.pending.clear();
177        for entry in state.active.values_mut() {
178            entry.cancelled = true;
179            entry.answer = None;
180        }
181    }
182
183    /// Give dialog delivery back to the local TUI.
184    ///
185    /// Requests already taken by the relay but never answered are put back in
186    /// the queue so the TUI draws them. They are not answered or cancelled
187    /// here: fabricating a reply for a question nobody read would be worse than
188    /// asking it again.
189    ///
190    /// Returns the number of requests handed back.
191    pub fn release_takeover(&self) -> usize {
192        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
193        state.external_takeover = false;
194        let mut requeued: Vec<String> = state
195            .active
196            .iter()
197            .filter(|(_, entry)| !entry.cancelled && entry.answer.is_none())
198            .map(|(request_id, _)| request_id.clone())
199            .filter(|request_id| !state.pending.contains(request_id))
200            .collect();
201        if requeued.is_empty() {
202            return 0;
203        }
204        let count = requeued.len();
205        // Stable order so a restored queue does not reshuffle the prompts.
206        requeued.sort();
207        for request_id in requeued.into_iter().rev() {
208            state.pending.push_front(request_id);
209        }
210        count
211    }
212
213    pub fn is_attached(&self) -> bool {
214        self.state
215            .lock()
216            .expect("ui dialog mailbox poisoned")
217            .attached
218    }
219
220    /// Remove and return the next request for presentation by the TUI.
221    /// Cancelled/answered requests are skipped; duplicate `open` calls never
222    /// enqueue the same request id twice.
223    pub fn take_pending(&self) -> Option<UiDialogRequest> {
224        self.take_pending_filtered(|_| true)
225    }
226
227    /// Take a request for the local TUI, but only while no external UI owns
228    /// delivery.
229    ///
230    /// The takeover check and the dequeue must be one critical section. Checking
231    /// `external_takeover()` and then calling [`Self::take_pending_filtered`]
232    /// acquires the lock twice, and a relay can claim ownership in between: the
233    /// TUI would then dequeue a question meant for the remote client, so nobody
234    /// the user is watching ever sees it.
235    pub fn take_pending_unless_external<F>(&self, keep: F) -> Option<UiDialogRequest>
236    where
237        F: Fn(&UiDialogRequest) -> bool,
238    {
239        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
240        if state.external_takeover {
241            return None;
242        }
243        Self::take_pending_locked(&mut state, keep)
244    }
245
246    /// Like [`take_pending`], but only takes a request `keep` accepts; the rest
247    /// **stay queued** for whoever they belong to.
248    ///
249    /// The TUI and plugins race on the same mailbox. When a plugin marks a
250    /// request as belonging to a host consumer (a remote UI's gate), the TUI
251    /// must not swallow it: the user would never see the question in the remote
252    /// client, while a duplicate pops up in the terminal.
253    ///
254    /// ## Reserved-prefix convention
255    ///
256    /// A `requestId` starting with [`PLUGIN_RESERVED_PREFIX`] is never taken by
257    /// the TUI. `take_pending()` takes everything (it passes `|_| true`), so
258    /// skipping reserved entries has to be explicit:
259    /// `take_pending_filtered(|r| !self.is_reserved(&r.request_id))`.
260    pub fn take_pending_filtered<F>(&self, keep: F) -> Option<UiDialogRequest>
261    where
262        F: Fn(&UiDialogRequest) -> bool,
263    {
264        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
265        Self::take_pending_locked(&mut state, keep)
266    }
267
268    fn take_pending_locked<F>(
269        state: &mut std::sync::MutexGuard<'_, UiDialogState>,
270        keep: F,
271    ) -> Option<UiDialogRequest>
272    where
273        F: Fn(&UiDialogRequest) -> bool,
274    {
275        let mut scanned = 0usize;
276        let total = state.pending.len();
277        while scanned < total {
278            scanned += 1;
279            let Some(request_id) = state.pending.pop_front() else {
280                break;
281            };
282            let Some(entry) = state.active.get(&request_id) else {
283                continue;
284            };
285            if entry.cancelled || entry.answer.is_some() {
286                continue;
287            }
288            let request = entry.request.clone();
289            if keep(&request) {
290                return Some(request);
291            }
292            // Not mine — put it back for whoever should handle it.
293            state.pending.push_back(request_id);
294        }
295        None
296    }
297
298    /// Take **every request no plugin reserved** (the `RelayUiDialog` `take`).
299    ///
300    /// For "a remote UI takes over": while an external client is attached, a
301    /// third-party extension's question should be forwarded there instead of
302    /// popping up in the terminal. Reserved entries stay behind.
303    ///
304    /// Taken requests leave `pending` but stay in `active`, so a later
305    /// `respond` / `cancel` with the same `requestId` still resolves them.
306    pub fn take_all_unreserved(&self) -> Vec<UiDialogRequest> {
307        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
308        let mut out: Vec<UiDialogRequest> = Vec::new();
309        // One pass does both: drain everything, then push back what stays.
310        let drained = std::mem::take(&mut state.pending);
311        for request_id in drained {
312            let Some(entry) = state.active.get(&request_id) else {
313                continue;
314            };
315            if entry.cancelled || entry.answer.is_some() {
316                continue;
317            }
318            if request_id.starts_with(PLUGIN_RESERVED_PREFIX) {
319                state.pending.push_back(request_id);
320            } else {
321                out.push(entry.request.clone());
322            }
323        }
324        out
325    }
326
327    /// Whether `request_id` is reserved by a plugin (the TUI must not consume it).
328    pub fn is_reserved(&self, request_id: &str) -> bool {
329        request_id.starts_with(PLUGIN_RESERVED_PREFIX)
330    }
331
332    pub fn pending_len(&self) -> usize {
333        self.state
334            .lock()
335            .expect("ui dialog mailbox poisoned")
336            .pending
337            .len()
338    }
339
340    /// Inspect lifecycle without consuming the answer reserved for a plugin.
341    pub fn is_pending(&self, request_id: &str) -> bool {
342        self.state
343            .lock()
344            .expect("ui dialog mailbox poisoned")
345            .active
346            .get(request_id)
347            .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
348    }
349
350    /// Supply an answer for a request. Repeating the same operation is
351    /// idempotent; answering a cancelled/unknown request is an explicit error.
352    pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
353        let mut state = self
354            .state
355            .lock()
356            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
357        let entry = state
358            .active
359            .get_mut(request_id)
360            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
361        if entry.cancelled {
362            return Err(format!("UI request `{request_id}` was cancelled"));
363        }
364        if entry.answer.is_none() {
365            entry.answer = Some(answer);
366        }
367        Ok(())
368    }
369
370    /// Mark one request cancelled. The terminal state remains visible to the
371    /// plugin's next `poll`, then is reclaimed by that poll.
372    pub fn cancel(&self, request_id: &str) -> Result<(), String> {
373        let mut state = self
374            .state
375            .lock()
376            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
377        let entry = state
378            .active
379            .get_mut(request_id)
380            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
381        entry.cancelled = true;
382        entry.answer = None;
383        state.pending.retain(|id| id != request_id);
384        Ok(())
385    }
386
387    /// Cancel all requests currently known to the mailbox.
388    pub fn cancel_all(&self) {
389        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
390        state.pending.clear();
391        for entry in state.active.values_mut() {
392            entry.cancelled = true;
393            entry.answer = None;
394        }
395    }
396
397    /// Cancel approval requests belonging to one tool without disturbing
398    /// questions from concurrent tool calls.
399    pub fn cancel_tool_call(&self, tool_call_id: &str) {
400        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
401        // A callback can reach `open` just after the host cancels its call.
402        // Remember the cancellation so it cannot enqueue an orphan question.
403        state.cancelled_tool_calls.insert(tool_call_id.to_string());
404        for entry in state.active.values_mut() {
405            if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
406                entry.cancelled = true;
407                entry.answer = None;
408            }
409        }
410        let cancelled: std::collections::HashSet<String> = state
411            .active
412            .iter()
413            .filter(|(_, entry)| entry.cancelled)
414            .map(|(id, _)| id.clone())
415            .collect();
416        state.pending.retain(|id| !cancelled.contains(id));
417    }
418
419    /// Poll a request and consume a terminal answer/cancellation. Pending
420    /// polls are cheap and preserve the request for subsequent calls.
421    pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
422        let mut state = self
423            .state
424            .lock()
425            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
426        let terminal = match state.active.get(request_id) {
427            Some(entry) if entry.cancelled => Some(serde_json::json!({
428                "status": "cancelled",
429                "requestId": request_id,
430                "toolCallId": entry.request.tool_call_id,
431            })),
432            Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
433                "status": "answered",
434                "requestId": request_id,
435                "toolCallId": entry.request.tool_call_id,
436                "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
437            })),
438            Some(entry) => Some(serde_json::json!({
439                "status": "pending",
440                "requestId": request_id,
441                "toolCallId": entry.request.tool_call_id,
442            })),
443            None => None,
444        };
445        let Some(result) = terminal else {
446            return Err(format!("unknown UI request `{request_id}`"));
447        };
448        if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
449            state.active.remove(request_id);
450        }
451        Ok(result)
452    }
453
454    /// Parse and execute the JSON action envelope used by runtime action 17.
455    /// Supported operations are `open`, `poll`, `cancel`; `response` is also
456    /// accepted as a convenience for hosts that proxy answers through JSON.
457    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
458        let op = args
459            .get("op")
460            .and_then(serde_json::Value::as_str)
461            .unwrap_or("open");
462        let request_id = || {
463            args.get("requestId")
464                .or_else(|| args.get("request_id"))
465                .and_then(serde_json::Value::as_str)
466                .map(str::to_owned)
467                .filter(|id| !id.trim().is_empty())
468                .ok_or_else(|| "missing non-empty `requestId`".to_string())
469        };
470        match op {
471            "open" => {
472                let id = request_id()?;
473                let mut state = self
474                    .state
475                    .lock()
476                    .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
477                if !state.attached {
478                    return Err("ask_user requires an interactive UI".to_string());
479                }
480                if let Some(entry) = state.active.get(&id) {
481                    return Ok(if entry.cancelled {
482                        serde_json::json!({"status":"cancelled","requestId":id})
483                    } else if let Some(answer) = &entry.answer {
484                        serde_json::json!({"status":"answered","requestId":id,"answer":answer})
485                    } else {
486                        serde_json::json!({"status":"pending","requestId":id})
487                    });
488                }
489                let tool_call_id = args
490                    .get("toolCallId")
491                    .or_else(|| args.get("tool_call_id"))
492                    .and_then(serde_json::Value::as_str)
493                    .map(str::to_owned);
494                if tool_call_id
495                    .as_ref()
496                    .is_some_and(|id| state.cancelled_tool_calls.contains(id))
497                {
498                    return Ok(serde_json::json!({
499                        "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
500                    }));
501                }
502                let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
503                rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
504                state.active.insert(
505                    id.clone(),
506                    UiDialogEntry {
507                        request: UiDialogRequest {
508                            request_id: id.clone(),
509                            tool_call_id: tool_call_id.clone(),
510                            ui,
511                            raw: args,
512                        },
513                        answer: None,
514                        cancelled: false,
515                    },
516                );
517                state.pending.push_back(id.clone());
518                Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
519            }
520            "poll" => self.poll(&request_id()?),
521            "cancel" => {
522                let id = request_id()?;
523                self.cancel(&id)?;
524                Ok(serde_json::json!({"status":"cancelled","requestId":id}))
525            }
526            "response" | "respond" => {
527                let id = request_id()?;
528                let answer = args
529                    .get("answer")
530                    .or_else(|| args.get("value"))
531                    .cloned()
532                    .unwrap_or(serde_json::Value::Null);
533                self.respond(&id, answer)?;
534                Ok(serde_json::json!({"status":"answered","requestId":id}))
535            }
536            other => Err(format!("unknown UI dialog operation `{other}`")),
537        }
538    }
539}
540
541/// Host-only, owner-bound UI writer. It contains no plugin callbacks or pointers.
542pub type SideQuestionSink =
543    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>;
544
545/// The host-side implementation the bridge delegates to. Defined in
546/// `rpi-extensions` (NOT `rpi-harness`) so the crate DAG stays a leaf: this is a
547/// trait the **host** (`rpi-cli`) implements over the harness — `rpi-extensions`
548/// only names the async surface + carries JSON params/results. No `rpi-harness`
549/// types appear in the trait.
550///
551/// Each method corresponds to one [`RuntimeActionId`] variant. Complex args
552/// arrive as parsed JSON (`serde_json::Value`); complex results return as
553/// `serde_json::Value`. The host maps its native types (Model, AgentMessage, …)
554/// to/from JSON at the impl boundary. Errors are `String` (become the action's
555/// nonzero `i32` + `{"error": msg}` JSON on the plugin side).
556///
557/// `dispatch` below connects the FFI id to the method; owner-scoped actions
558/// are routed directly by the plugin bridge.
559#[async_trait::async_trait]
560pub trait RuntimeActionHost: Send + Sync {
561    /// Action 30: an authenticated host-owned executing invocation, never registry JSON.
562    async fn tool_program(
563        &self,
564        capability: Arc<rpi_agent::tool_program::ToolProgram>,
565        args: serde_json::Value,
566    ) -> Result<serde_json::Value, String> {
567        capability.handle(args).await
568    }
569    /// Revoke side questions when their plugin bridge is retired.
570    fn cancel_side_questions(&self, _owner: u64) {}
571    /// Host-internal TUI snapshots; never exposed as a cross-owner plugin action.
572    fn side_question_revision(&self) -> u64 {
573        0
574    }
575    fn side_question_views(&self) -> Vec<serde_json::Value> {
576        Vec::new()
577    }
578    async fn side_question_ui(
579        &self,
580        _owner: u64,
581        _thread_id: String,
582        _args: serde_json::Value,
583    ) -> Result<serde_json::Value, String> {
584        Err("SideQuestion UI is not supported by this host".into())
585    }
586    /// Owner-scoped read-only inference, including host-owned UI publication.
587    async fn side_question(
588        &self,
589        _owner: u64,
590        _args: serde_json::Value,
591        _sink: SideQuestionSink,
592    ) -> Result<serde_json::Value, String> {
593        Err("SideQuestion is not supported by this host; upgrade rpi".into())
594    }
595
596    /// Refresh the host-side model selector after provider packages reload.
597    fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
598    /// Invalidate child handles owned by an unloading/reloaded plugin.
599    fn cancel_child_agents(&self, _owner: u64) {}
600    /// `SendMessage` — Pi-compatible `triggerTurn` and `deliverAs` scheduling.
601    /// Defaults append while idle and steer while running; explicit `true`
602    /// starts an idle run, `false` only updates context, and `nextTurn` waits
603    /// for a new explicit prompt. Results distinguish appended/queued/run.
604    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
605    /// `SendUserMessage` — drive a run from a user-text message.
606    async fn send_user_message(&self, args: serde_json::Value)
607        -> Result<serde_json::Value, String>;
608    /// `AppendEntry` — append without starting a run. `customType`/`data`
609    /// persists extension data only. A `message` is durably queued during an
610    /// operation and committed after its current turn; the returned `entryId`
611    /// is reserved and may not yet resolve to a transcript entry. Optional
612    /// `expected: {sessionId, leafId, customType, entryId}` performs a conditional
613    /// write against that exact branch snapshot; a successful reply must confirm
614    /// `scoped: true`. Scoped messages additionally require `outboxKey` and must
615    /// be custom messages: acceptance is idempotent on the current branch and
616    /// pending context stays with its source state entry.
617    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
618    /// `SetSessionName` — set the session's display name.
619    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
620    /// `GetActiveTools` — the active tool-name list.
621    async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
622    /// `SetActiveTools` — replace the active tool-name list.
623    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
624    /// `SetModel` — switch the active model (by id; host resolves to a `Model`).
625    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
626    /// `GetThinkingLevel` — the current thinking level.
627    async fn get_thinking_level(
628        &self,
629        args: serde_json::Value,
630    ) -> Result<serde_json::Value, String>;
631    /// `SetThinkingLevel` — set the thinking level.
632    async fn set_thinking_level(
633        &self,
634        args: serde_json::Value,
635    ) -> Result<serde_json::Value, String>;
636    /// `Compact` — compact the session transcript.
637    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
638    /// `GetSystemPrompt` — the live composed system prompt.
639    async fn get_system_prompt(&self, args: serde_json::Value)
640        -> Result<serde_json::Value, String>;
641    /// `NewSession` — start a fresh session and switch to it.
642    async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
643    /// `Fork` — fork the current session and switch to the fork.
644    async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
645    /// `NavigateTree` — navigate/rewind the session tree.
646    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
647    /// `SwitchSession` — hot-switch to an existing session by id.
648    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
649    /// `Reload` — re-run extension discovery + Part-A resource loaders (a CLI
650    /// concern; the host impl wires it to the `ActionBridge.reload` callback in
651    /// B5d — until then this returns an "unsupported" error string).
652    async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
653
654    /// `ReloadModels` — re-read the model/provider catalog from disk **without**
655    /// reloading extensions.
656    ///
657    /// The cheap counterpart of [`Self::reload`]: it swaps the model catalog
658    /// and the provider runtime bindings, but leaves the extension registry,
659    /// the emitter and the event snapshot untouched — so an in-flight run keeps
660    /// receiving plugin events.
661    ///
662    /// Defaults to "not supported" so hosts that never wired it keep working.
663    async fn reload_models(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
664        Err("reload_models is not supported by this host".to_string())
665    }
666
667    /// `GetCliFlag` — read a parsed extension CLI flag. Args are
668    /// `{"name":"flag"}` and the result is `{"value": <bool|string|null>}`.
669    async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
670        Err("CLI flag lookup is not configured".to_string())
671    }
672
673    /// `UiDialog` fallback for hosts that do not install an interactive
674    /// mailbox. The default is deliberately an error so headless runs cannot
675    /// accidentally report a fabricated user answer.
676    async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
677        Err("ask_user requires an interactive UI".to_string())
678    }
679
680    /// Enumerate the registered slash commands.
681    ///
682    /// Args: `{}`. Result: `{"commands":[{name, description, source}]}` where
683    /// `source` is `builtin` / `extension` / `prompt`. A host with no registry
684    /// returns an empty list (genuinely "none registered"), not an error: the
685    /// remote UI should degrade to "no completion available".
686    ///
687    /// Added because the plugin ABI had no way to enumerate commands, so a
688    /// remote client could not offer slash-command completion at all — it saw
689    /// an empty list and could not tell that apart from "no host attached".
690    async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
691        Ok(serde_json::json!({ "commands": [] }))
692    }
693
694    /// Publish a freshly loaded extension registry to the host.
695    ///
696    /// Called after extension discovery **and after a successful `/reload`**, so
697    /// `ListCommands` keeps answering with the current command set. Hosts that
698    /// do not cache a registry (RPC mode reads the snapshot directly) keep the
699    /// default no-op.
700    ///
701    /// Why it is on the trait instead of a downcast: `/reload` only has the
702    /// `Arc<dyn RuntimeActionHost>` from the old bridge, so there is no concrete
703    /// type to downcast to. A default no-op keeps every other host source-compatible.
704    fn publish_registry(&self, _snapshot: std::sync::Arc<crate::registry::RegistrySnapshot>) {}
705
706    /// Publish the registry, or `None` when a reload found no plugins at all.
707    ///
708    /// Distinct from [`Self::publish_registry`] because "no extensions" is a
709    /// real state: a nonempty -> empty reload must replace the cached snapshot
710    /// rather than leave removed commands advertised.
711    fn publish_registry_opt(
712        &self,
713        snapshot: Option<std::sync::Arc<crate::registry::RegistrySnapshot>>,
714    ) {
715        if let Some(snapshot) = snapshot {
716            self.publish_registry(snapshot);
717        }
718    }
719
720    /// Abort the current run on the main lane.
721    ///
722    /// Args: `{}`. Result: `{"aborted": bool, "runId": "…"?, "steer": [...],
723    /// "followUp": [...]}`. Reclaimed queue entries are `{"text","images"}`.
724    ///
725    /// The default implementation reports "nothing to cancel": a host with no
726    /// run loop has nothing to abort, and a stop button must stay idempotent.
727    async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
728        Ok(serde_json::json!({
729            "aborted": false,
730            "steer": [],
731            "followUp": [],
732        }))
733    }
734
735    /// Finish the current turn, then stop — the gentle counterpart of
736    /// [`Self::abort`].
737    ///
738    /// Args: `{}`; result: `{"requested": true}`. The flag is one-shot and
739    /// cleared when the next run starts, so setting it while idle is harmless.
740    ///
741    /// The default implementation reports "requested" without acting: a host
742    /// with no run loop has nothing to stop at a turn boundary, and returning an
743    /// error would make a plugin treat a benign call as a failure.
744    fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
745        Ok(serde_json::json!({ "requested": true }))
746    }
747
748    /// Safe, effective runtime metadata; never serialize credential bindings.
749    async fn get_execution_context(
750        &self,
751        _args: serde_json::Value,
752    ) -> Result<serde_json::Value, String> {
753        Err("execution context is not supported by this host".into())
754    }
755
756    /// Independent child lifecycle. `owner` is supplied by the bridge, NOT JSON.
757    async fn child_agent(
758        &self,
759        _owner: u64,
760        _args: serde_json::Value,
761    ) -> Result<serde_json::Value, String> {
762        Err("ChildAgent is not supported by this host".into())
763    }
764
765    /// `GetSessionBranch` — query custom entries on the current main branch.
766    /// Required: non-empty `customTypes` string array. Optional: `afterSeq`
767    /// (exclusive), `limit` (positive), `order` (`oldestFirst` default or
768    /// `newestFirst`). Unknown/legacy keys and unfiltered `{}` are rejected.
769    /// Sorting/limit apply globally across the requested types; identifiers
770    /// match exactly. Consumers must require `filtered == true` in the reply
771    /// rather than accepting an older host's unfiltered successful response.
772    /// This host also returns pinned `sessionId`, `leafId`, and `scopedAppend:
773    /// true` for callers performing conditional state writes or outbox delivery.
774    async fn get_session_branch(
775        &self,
776        _args: serde_json::Value,
777    ) -> Result<serde_json::Value, String> {
778        Err("session branch lookup is not configured".to_string())
779    }
780}
781
782/// The host-side bridge carried in [`PluginApi::user_data`] so
783/// [`trampoline_runtime_action`] can recover the harness state from any thread.
784///
785/// `runtime: Handle` is captured at build time (the host is on the runtime when
786/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
787/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
788/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
789/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
790///
791/// ## Staleness (B5d)
792///
793/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
794/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
795/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
796/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
797/// In-flight `runtime_action` calls that recovered the OLD bridge from
798/// `user_data` (the pointer a plugin stored during the prior `register`) then
799/// hit the staleness guard in [`run_action`] and fail with a structured error
800/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
801/// handing them the NEW bridge pointer; the guard only catches the race window
802/// where an old call is still parked on `rx.recv()`.)
803///
804/// Held behind `Arc`. FFI callers carry only an opaque registry handle; each
805/// dispatch upgrades its weak entry before accessing the bridge. A tool driver
806/// owns its invocation bridge through its last foreign callback. Failed reloads
807/// and dropped candidate sessions cannot leave dereferenceable dangling pointers.
808pub struct ActionBridge {
809    /// Some(child) only for a live host-created tool invocation.
810    invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
811    execution_context: Option<serde_json::Value>,
812    tool_program: Option<Arc<rpi_agent::tool_program::ToolProgram>>,
813    stdio: crate::StdioLifecycle,
814    plugin_identity: Option<(u64, String)>,
815    component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
816    /// Captured at build time from a thread running the target runtime.
817    pub(crate) runtime: Handle,
818    /// The host impl (`HarnessActionHost` in rpi-cli).
819    pub(crate) host: Arc<dyn RuntimeActionHost>,
820    /// B5d reload callback; `None` until the TUI wires `/reload`.
821    pub(crate) reload:
822        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
823    /// Session-scoped UI mailbox. It is cloned into reload-created bridges so
824    /// a TUI attachment and in-flight requests survive extension reloads.
825    ui_dialog: UiDialogMailbox,
826    /// Session-scoped extension status registry (`SetStatus` runtime action).
827    /// Shared with the TUI, which polls [`ExtensionStatusMailbox::revision`]
828    /// and repaints the footer only when an extension actually wrote.
829    ext_status: crate::status::ExtensionStatusMailbox,
830    /// Session-scoped editor-text queue (`SetEditorText` runtime action). Shared
831    /// with the TUI, which drains it once per tick and writes the draft into the
832    /// prompt editor.
833    editor_text: crate::editor_text::EditorTextMailbox,
834    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
835    /// `true` while this bridge is the live session's bridge.
836    active: Arc<AtomicBool>,
837}
838
839impl ActionBridge {
840    /// Stable opaque context, valid only while this bridge is retained.
841    pub fn user_data(self: &Arc<Self>) -> *mut c_void {
842        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
843        let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
844        if let Some((&id, _)) = registry
845            .iter()
846            .find(|(_, entry)| entry.bridge.as_ptr() == Arc::as_ptr(self))
847        {
848            return id as *mut c_void;
849        }
850        let mut id = NEXT.load(Ordering::Relaxed);
851        loop {
852            let next = id.checked_add(1).expect("runtime handle space exhausted");
853            match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
854                Ok(_) => break,
855                Err(current) => id = current,
856            }
857        }
858        registry.insert(
859            id,
860            RegistryEntry {
861                bridge: Arc::downgrade(self),
862                active: Arc::downgrade(&self.active),
863            },
864        );
865        id as *mut c_void
866    }
867    pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
868        bridges()
869            .lock()
870            .unwrap_or_else(|e| e.into_inner())
871            .get(&id)
872            .and_then(|entry| entry.bridge.upgrade())
873    }
874    pub(crate) fn for_invocation(
875        &self,
876        child: bool,
877        signal: tokio_util::sync::CancellationToken,
878    ) -> Arc<Self> {
879        let tool_program = rpi_agent::tool_program::current();
880        if let Some(program) = &tool_program {
881            program.bind_signal(signal.clone());
882        }
883        Arc::new(Self {
884            invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
885            execution_context: rpi_agent::tool_execution_scope::execution_context(),
886            tool_program,
887            stdio: self.stdio.clone(),
888            plugin_identity: self.plugin_identity.clone(),
889            component_owners: self.component_owners.clone(),
890            runtime: self.runtime.clone(),
891            host: self.host.clone(),
892            reload: self.reload.clone(),
893            ui_dialog: self.ui_dialog.clone(),
894            ext_status: self.ext_status.clone(),
895            editor_text: self.editor_text.clone(),
896            active: self.active.clone(),
897        })
898    }
899    /// Native receipt selection uses the driver-captured invocation, never a
900    /// task-local lookup on a blocking worker or a registration/root binding.
901    pub(crate) fn resolve_result_reference(
902        &self,
903        id: &str,
904    ) -> Result<(rpi_agent::AgentToolResult, bool), String> {
905        if !self.is_active()
906            || !self
907                .invocation
908                .as_ref()
909                .is_some_and(|(_, _, signal)| !signal.is_cancelled())
910        {
911            return Err("result reference requires a live invocation bridge".into());
912        }
913        self.tool_program
914            .as_ref()
915            .ok_or("result reference requires an effective ToolProgram")?
916            .resolve_result_reference(id)
917    }
918    /// Content-only plans use the same captured live authority as full receipts.
919    pub(crate) fn resolve_content_parts(
920        &self,
921        parts: Vec<rpi_agent::tool_program::ToolProgramContentPart>,
922    ) -> Result<Vec<rpi_agent::TextContentOrImage>, String> {
923        if !self.is_active()
924            || !self
925                .invocation
926                .as_ref()
927                .is_some_and(|(_, _, signal)| !signal.is_cancelled())
928        {
929            return Err("content parts require a live invocation bridge".into());
930        }
931        self.tool_program
932            .as_ref()
933            .ok_or("content parts require an effective ToolProgram")?
934            .resolve_content_parts(parts)
935    }
936    pub(crate) fn invocation_key(&self) -> Option<&str> {
937        self.invocation.as_ref().map(|(_, key, _)| key.as_str())
938    }
939    pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
940        InvocationGuard {
941            previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
942            _thread_bound: std::marker::PhantomData,
943        }
944    }
945    /// Build a bridge. The `Handle` MUST be captured from a thread running the
946    /// target runtime (pi-cli builds the bridge on the async main thread).
947    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
948        Arc::new(Self {
949            invocation: None,
950            tool_program: None,
951            execution_context: None,
952            stdio: crate::StdioLifecycle::default(),
953            plugin_identity: None,
954            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
955            runtime,
956            host,
957            reload: None,
958            ui_dialog: UiDialogMailbox::new(),
959            ext_status: crate::status::ExtensionStatusMailbox::new(),
960            editor_text: crate::editor_text::EditorTextMailbox::new(),
961            active: Arc::new(AtomicBool::new(true)),
962        })
963    }
964
965    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
966    /// [`reload_callback_from_mailbox`]).
967    pub fn with_reload(
968        runtime: Handle,
969        host: Arc<dyn RuntimeActionHost>,
970        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
971    ) -> Arc<Self> {
972        Self::with_reload_and_ui(
973            runtime,
974            host,
975            reload,
976            UiDialogMailbox::new(),
977            crate::status::ExtensionStatusMailbox::new(),
978            crate::editor_text::EditorTextMailbox::new(),
979        )
980    }
981
982    /// Build a bridge with an explicit session-scoped UI mailbox.
983    pub fn with_ui_dialog(
984        runtime: Handle,
985        host: Arc<dyn RuntimeActionHost>,
986        ui_dialog: UiDialogMailbox,
987    ) -> Arc<Self> {
988        Arc::new(Self {
989            invocation: None,
990            tool_program: None,
991            execution_context: None,
992            stdio: crate::StdioLifecycle::default(),
993            plugin_identity: None,
994            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
995            runtime,
996            host,
997            reload: None,
998            ui_dialog,
999            ext_status: crate::status::ExtensionStatusMailbox::new(),
1000            editor_text: crate::editor_text::EditorTextMailbox::new(),
1001            active: Arc::new(AtomicBool::new(true)),
1002        })
1003    }
1004
1005    /// `with_reload` variant that preserves a mailbox across bridge swaps.
1006    pub fn with_reload_and_ui(
1007        runtime: Handle,
1008        host: Arc<dyn RuntimeActionHost>,
1009        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
1010        ui_dialog: UiDialogMailbox,
1011        ext_status: crate::status::ExtensionStatusMailbox,
1012        editor_text: crate::editor_text::EditorTextMailbox,
1013    ) -> Arc<Self> {
1014        Arc::new(Self {
1015            invocation: None,
1016            tool_program: None,
1017            execution_context: None,
1018            stdio: crate::StdioLifecycle::default(),
1019            plugin_identity: None,
1020            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
1021            runtime,
1022            host,
1023            reload: Some(reload),
1024            ui_dialog,
1025            ext_status,
1026            editor_text,
1027            active: Arc::new(AtomicBool::new(true)),
1028        })
1029    }
1030
1031    pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
1032        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1033        let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1034        self.component_owners
1035            .lock()
1036            .unwrap_or_else(|e| e.into_inner())
1037            .push(owner);
1038        Arc::new(Self {
1039            invocation: None,
1040            tool_program: None,
1041            execution_context: None,
1042            component_owners: self.component_owners.clone(),
1043            stdio: self.stdio.clone(),
1044            plugin_identity: Some((owner, name.to_owned())),
1045            runtime: self.runtime.clone(),
1046            host: self.host.clone(),
1047            reload: self.reload.clone(),
1048            ui_dialog: self.ui_dialog.clone(),
1049            ext_status: self.ext_status.clone(),
1050            editor_text: self.editor_text.clone(),
1051            active: self.active.clone(),
1052        })
1053    }
1054
1055    pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
1056        self.stdio.clone()
1057    }
1058
1059    pub(crate) fn claim_stdio(&self) -> Result<(), String> {
1060        let (id, name) = self
1061            .plugin_identity
1062            .as_ref()
1063            .ok_or("missing plugin identity")?;
1064        self.stdio.claim(*id, name)
1065    }
1066
1067    pub(crate) fn release_stdio_claim(&self) {
1068        if let Some((id, _)) = &self.plugin_identity {
1069            self.stdio.release_failed_registration(*id);
1070            self.ext_status.remove_owner(*id);
1071        }
1072    }
1073
1074    /// Clone the session mailbox for TUI attachment or a reload-created bridge.
1075    pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
1076        self.ui_dialog.clone()
1077    }
1078
1079    /// Clone the session status registry (TUI rendering + reload-created bridges).
1080    pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
1081        self.ext_status.clone()
1082    }
1083
1084    /// Clone the session editor-text queue (TUI drain + reload-created bridges).
1085    pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
1086        self.editor_text.clone()
1087    }
1088
1089    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
1090    /// calls this on the old one so in-flight `runtime_action` calls parked on
1091    /// the old `user_data` pointer fail fast with a staleness error instead of
1092    /// driving the swapped-out session. Idempotent.
1093    pub fn invalidate(&self) {
1094        let owners = {
1095            let owners = self
1096                .component_owners
1097                .lock()
1098                .unwrap_or_else(|e| e.into_inner());
1099            self.active.store(false, Ordering::SeqCst);
1100            for owner in owners.iter() {
1101                self.ext_status.remove_owner(*owner);
1102            }
1103            owners.clone()
1104        };
1105        // Cancel captured capabilities across FFI thread hops on reload/session retirement.
1106        let live = self.bridges_for_owner();
1107        for bridge in live {
1108            if let Some(program) = &bridge.tool_program {
1109                program.cancel_all();
1110            }
1111        }
1112        // Host cancellation may contend with a job publishing through its
1113        // sink. Never invoke it while holding the sink's lifecycle gate.
1114        for owner in owners {
1115            self.host.cancel_child_agents(owner);
1116            self.host.cancel_side_questions(owner);
1117        }
1118    }
1119
1120    fn bridges_for_owner(&self) -> Vec<Arc<Self>> {
1121        let active = Arc::downgrade(&self.active);
1122        // Filter weak owner identity BEFORE upgrading: even a transient upgrade
1123        // of another session's invocation delays its immediate handle retirement.
1124        // The weak metadata keeps that allocation's identity from being reused.
1125        // Collect every upgraded Arc out of the registry lock; dropping a last
1126        // Arc here would deadlock when ActionBridge::drop locks the registry.
1127        bridges()
1128            .lock()
1129            .unwrap_or_else(|e| e.into_inner())
1130            .values()
1131            .filter(|entry| entry.active.ptr_eq(&active))
1132            .filter_map(|entry| entry.bridge.upgrade())
1133            .collect()
1134    }
1135
1136    /// Whether this bridge is still the live session's bridge.
1137    pub fn is_active(&self) -> bool {
1138        self.active.load(Ordering::SeqCst)
1139    }
1140
1141    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
1142    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
1143    /// at the live harness — the harness is NOT rebuilt on reload — so the host
1144    /// is reusable across reloads; only the bridge's staleness flag + reload
1145    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
1146    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
1147    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
1148        Arc::clone(&self.host)
1149    }
1150}
1151
1152/// A reload-signal mail slot (B5d). The reload callback (built by
1153/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
1154/// `tokio` unbounded sender after it starts. When a plugin calls
1155/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
1156/// and the TUI performs the reload **asynchronously** — the plugin's call
1157/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
1158/// unmapped while its `runtime_action` frame is still on the stack (the reload,
1159/// which drops the old keepalive, happens after the call returns). This breaks
1160/// the self-unmapping race a synchronous plugin-initiated reload would have.
1161///
1162/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
1163/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
1164#[derive(Clone)]
1165pub struct ReloadMailbox {
1166    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
1167}
1168
1169impl Default for ReloadMailbox {
1170    fn default() -> Self {
1171        Self {
1172            tx: Arc::new(std::sync::Mutex::new(None)),
1173        }
1174    }
1175}
1176
1177impl ReloadMailbox {
1178    /// A fresh empty mail slot (no TUI installed yet).
1179    pub fn new() -> Self {
1180        Self::default()
1181    }
1182
1183    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
1184    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
1185    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
1186    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
1187        *self.tx.lock().unwrap() = Some(tx);
1188    }
1189
1190    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
1191    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
1192    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
1193    /// a "reload not available" error to the plugin).
1194    pub fn signal(&self) -> Result<(), ()> {
1195        let g = self.tx.lock().unwrap();
1196        match &*g {
1197            Some(tx) => {
1198                let _ = tx.send(());
1199                Ok(())
1200            }
1201            None => Err(()),
1202        }
1203    }
1204
1205    /// Drop the installed sender (TUI shutdown). Idempotent.
1206    pub fn clear(&self) {
1207        *self.tx.lock().unwrap() = None;
1208    }
1209}
1210
1211/// A request/reply mailbox for `runtime_action(ReloadModels)`.
1212///
1213/// Unlike [`ReloadMailbox`] (fire-and-forget: the plugin does not care whether
1214/// the reload succeeded), this one **waits for the result**: the caller wants
1215/// to know how many models the refreshed catalog holds, and whether the
1216/// re-read failed at all.
1217///
1218/// The TUI owns the receiver and performs the actual work (it owns the
1219/// harness); the plugin-facing side only enqueues a request and awaits the
1220/// reply.
1221pub struct ReloadModelsMailbox {
1222    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<ReloadModelsRequest>>>>,
1223}
1224
1225impl Default for ReloadModelsMailbox {
1226    fn default() -> Self {
1227        Self {
1228            tx: Arc::new(std::sync::Mutex::new(None)),
1229        }
1230    }
1231}
1232
1233/// One pending `ReloadModels` request plus the channel to answer on.
1234pub struct ReloadModelsRequest {
1235    /// Answer channel: `Ok(model_count)` or `Err(reason)`.
1236    pub reply: tokio::sync::oneshot::Sender<Result<usize, String>>,
1237}
1238
1239impl Clone for ReloadModelsMailbox {
1240    fn clone(&self) -> Self {
1241        Self {
1242            tx: Arc::clone(&self.tx),
1243        }
1244    }
1245}
1246
1247impl ReloadModelsMailbox {
1248    pub fn new() -> Self {
1249        Self::default()
1250    }
1251
1252    /// Install the TUI's request sender. Replaces any prior sender.
1253    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<ReloadModelsRequest>) {
1254        *self.tx.lock().unwrap() = Some(tx);
1255    }
1256
1257    /// Enqueue a request and await the TUI's answer.
1258    ///
1259    /// `Err` when no TUI is installed (the host surfaces "not available") or
1260    /// when the TUI went away before answering.
1261    pub async fn request(&self) -> Result<usize, String> {
1262        let (reply, wait) = tokio::sync::oneshot::channel();
1263        let tx = self.tx.lock().unwrap().clone();
1264        let Some(tx) = tx else {
1265            return Err("reload_models is not available (no TUI attached)".to_string());
1266        };
1267        tx.send(ReloadModelsRequest { reply })
1268            .map_err(|_| "reload_models request dropped (TUI gone)".to_string())?;
1269        wait.await
1270            .map_err(|_| "reload_models request dropped (TUI gone)".to_string())?
1271    }
1272
1273    /// Drop the installed sender (TUI shutdown). Idempotent.
1274    pub fn clear(&self) {
1275        *self.tx.lock().unwrap() = None;
1276    }
1277}
1278
1279/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
1280/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
1281/// this callback, which signals the TUI (if installed) and returns; the plugin
1282/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
1283/// TUI is installed, the callback returns without signalling and the host's
1284/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
1285pub fn reload_callback_from_mailbox(
1286    mailbox: ReloadMailbox,
1287) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
1288    Arc::new(move || {
1289        let m = mailbox.clone();
1290        Box::pin(async move {
1291            let _ = m.signal();
1292        })
1293    })
1294}
1295
1296// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
1297// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
1298// naturally Send+Sync; no manual unsafe impl needed.
1299
1300/// Dispatch one action to the host. Async — runs on the bridge's runtime.
1301/// Handles all 19 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
1302/// (the "no callback configured" fallback). When the bridge has a reload
1303/// callback, the spawn site intercepts `Reload` and awaits the callback
1304/// instead (a CLI concern, not a harness op) — this helper is the plain
1305/// host-only path.
1306/// The mailbox-only `RelayUiDialog` action.
1307///
1308/// Split out so the trampoline can run it on a plain thread instead of a Tokio
1309/// worker (see the `RelayUiDialog` arm at the spawn site).
1310fn dispatch_relay_ui_dialog(
1311    ui_dialog: &UiDialogMailbox,
1312    args: serde_json::Value,
1313) -> Result<serde_json::Value, String> {
1314    let op = args
1315        .get("op")
1316        .and_then(serde_json::Value::as_str)
1317        .unwrap_or("take");
1318    match op {
1319        "take" => {
1320            // Taking implies ownership: the caller is the UI that will present
1321            // these questions, so the local TUI must stand down rather than
1322            // compete for the same queue.
1323            ui_dialog.set_external_takeover(true);
1324            let requests: Vec<serde_json::Value> = ui_dialog
1325                .take_all_unreserved()
1326                .into_iter()
1327                .map(|request| {
1328                    serde_json::json!({
1329                        "requestId": request.request_id,
1330                        "toolCallId": request.tool_call_id,
1331                        "ui": request.ui,
1332                    })
1333                })
1334                .collect();
1335            Ok(serde_json::json!({ "requests": requests }))
1336        }
1337        // Give delivery back to the local TUI and re-queue anything the relay
1338        // took but never answered.
1339        "release" => {
1340            let requeued = ui_dialog.release_takeover();
1341            Ok(serde_json::json!({ "requeued": requeued }))
1342        }
1343        // Same request ids, same semantics as `UiDialog`.
1344        "respond" | "cancel" => ui_dialog.handle(args),
1345        other => Err(format!("RelayUiDialog does not support op `{other}`")),
1346    }
1347}
1348
1349async fn dispatch(
1350    host: &Arc<dyn RuntimeActionHost>,
1351    ui_dialog: &UiDialogMailbox,
1352    ext_status: &crate::status::ExtensionStatusMailbox,
1353    editor_text: &crate::editor_text::EditorTextMailbox,
1354    action: RuntimeActionId,
1355    args: serde_json::Value,
1356) -> Result<serde_json::Value, String> {
1357    match action {
1358        RuntimeActionId::SendMessage => host.send_message(args).await,
1359        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
1360        RuntimeActionId::AppendEntry => host.append_entry(args).await,
1361        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
1362        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
1363        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
1364        RuntimeActionId::SetModel => host.set_model(args).await,
1365        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
1366        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
1367        RuntimeActionId::Compact => host.compact(args).await,
1368        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
1369        RuntimeActionId::NewSession => host.new_session(args).await,
1370        RuntimeActionId::Fork => host.fork(args).await,
1371        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
1372        RuntimeActionId::SwitchSession => host.switch_session(args).await,
1373        RuntimeActionId::Reload => host.reload(args).await,
1374        RuntimeActionId::ReloadModels => host.reload_models(args).await,
1375        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
1376        RuntimeActionId::UiDialog => ui_dialog.handle(args),
1377        // UI-only action: handled by the bridge (no harness call), exactly like
1378        // `UiDialog`. Written straight into the shared registry the TUI polls.
1379        RuntimeActionId::SetStatus => ext_status.handle(args),
1380        RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
1381        // Editor-only action: served by the bridge (no harness call), exactly
1382        // like `SetStatus`/`UiDialog`. The TUI drains the queue each tick.
1383        RuntimeActionId::SetEditorText => editor_text.handle(args),
1384        RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
1385        RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
1386        RuntimeActionId::ToolProgram => Err("ToolProgram requires a live invocation bridge".into()),
1387        RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
1388        RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
1389        RuntimeActionId::ListCommands => host.list_commands(args).await,
1390        RuntimeActionId::Abort => host.abort(args).await,
1391        // Hand the mailbox to an external UI. Only correct while one is
1392        // attached — with no client, the caller should let the TUI draw the
1393        // dialog instead (otherwise nobody sees the question).
1394        RuntimeActionId::RelayUiDialog => dispatch_relay_ui_dialog(ui_dialog, args),
1395        RuntimeActionId::StopRound => host.stop_round(args),
1396        RuntimeActionId::StdioLifecycle => {
1397            Err("stdio lifecycle requires a plugin context".to_owned())
1398        }
1399    }
1400}
1401
1402/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
1403/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
1404///
1405/// Resolves an `Arc<ActionBridge>` from an opaque handle (or the active tool
1406/// invocation), parses `args_json`, drives the host's async dispatch via
1407/// `Handle::spawn`, and parks
1408/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
1409/// runtime needed, which is the load-bearing property for foreign plugin
1410/// threads).
1411///
1412/// ## Return codes
1413/// - `0` — success; `*out` written with the result JSON (host-owned
1414///   [`StbString`]; the plugin frees it via the host `free_string` from the
1415///   vtable).
1416/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
1417/// - `2` — unknown numeric action id; `*out` contains a structured error and
1418///   no host method is dispatched.
1419/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
1420/// - `-2` — the spawned task dropped its sender without sending (runtime
1421///   shutdown / dispatch panic); no result available.
1422///
1423/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
1424/// policy as the tool partial callback, `tool.rs:206`).
1425pub extern "C" fn trampoline_runtime_action(
1426    action_id: u32,
1427    args_json: StbStringRef,
1428    out: *mut StbString,
1429    user_data: *mut c_void,
1430) -> i32 {
1431    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1432        run_action(
1433            action_id,
1434            // Highest id this ABI defines.
1435            rpi_plugin_sdk::RuntimeActionId::MAX,
1436            args_json,
1437            out,
1438            user_data,
1439        )
1440    }));
1441    match outcome {
1442        Ok(rc) => rc,
1443        Err(_) => {
1444            // A panic in the host-side dispatch was caught. Do not abort the
1445            // process: report a host-level error to the caller (documented
1446            // code `1`) with a structured payload when `out` is writable.
1447            tracing::error!("runtime_action trampoline panicked — reporting host error");
1448            if !out.is_null() {
1449                unsafe {
1450                    *out = StbString::from_string(
1451                        r#"{"error":"runtime_action panicked"}"#.to_string(),
1452                    );
1453                }
1454            }
1455            1
1456        }
1457    }
1458}
1459
1460/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
1461fn run_action(
1462    action_id: u32,
1463    max_action_id: u32,
1464    args_json: StbStringRef,
1465    out: *mut StbString,
1466    user_data: *mut c_void,
1467) -> i32 {
1468    // Validate the raw FFI integer before dereferencing host state or entering
1469    // the enum-based dispatch. Unknown values are ordinary protocol errors;
1470    // they never become invalid Rust enum discriminants.
1471    let action = match RuntimeActionId::try_from(action_id) {
1472        Ok(action) if action_id <= max_action_id => action,
1473        Ok(_) | Err(_) => {
1474            if !out.is_null() {
1475                let json = serde_json::json!({
1476                    "error": format!("unknown runtime action id {action_id}")
1477                })
1478                .to_string();
1479                // SAFETY: `out` is non-null and points to the caller-provided
1480                // output slot. The plugin reclaims this host allocation via
1481                // `host_free_string` from the vtable.
1482                unsafe {
1483                    *out = StbString::from_string(json);
1484                }
1485            }
1486            return 2;
1487        }
1488    };
1489
1490    if user_data.is_null() {
1491        return -1;
1492    }
1493    // Synchronous callbacks inherit the actual driver's invocation, not a
1494    // plugin-global registration pointer. Worker threads pass its opaque ID.
1495    let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1496    let invocation_thread = invocation.is_some();
1497    let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1498    let Some(bridge) = bridge else {
1499        if !out.is_null() {
1500            // SAFETY: the ABI caller supplies a writable output slot.
1501            unsafe {
1502                *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1503            }
1504        }
1505        return 1;
1506    };
1507
1508    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
1509    // `invalidate` on the old one. A plugin that still holds the old pointer
1510    // (stored during the prior `register`) must not drive the swapped-out
1511    // session. Surface a structured "stale bridge" error so the plugin's
1512    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
1513    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
1514    // this guard only catches the race window where an old call is still parked.)
1515    if !bridge.is_active() {
1516        if out.is_null() {
1517            return 1;
1518        }
1519        let json = serde_json::json!({
1520            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1521        })
1522        .to_string();
1523        unsafe {
1524            *out = StbString::from_string(json);
1525        }
1526        return 1;
1527    }
1528
1529    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
1530    // args; setters that require a field surface a clear error string.
1531    let args_str = unsafe { args_json.as_str() };
1532    let mut args: serde_json::Value = if args_str.is_empty() {
1533        serde_json::Value::Object(serde_json::Map::new())
1534    } else {
1535        serde_json::from_str(args_str)
1536            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1537    };
1538
1539    let runtime_key = args
1540        .as_object_mut()
1541        .and_then(|args| args.remove("__rpiRuntimeKey"));
1542
1543    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
1544    // plugin thread can recv from any thread (no ambient runtime). sync_channel
1545    // (1): bounded; the send completes once the value is delivered. rx.recv()
1546    // parks the plugin thread until the spawn finishes (or its sender drops).
1547    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1548    // Clone the bridge's host + reload callback into the spawned task. We pass
1549    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
1550    // `dispatch` helper has everything it needs without borrowing `bridge`.
1551    let host = Arc::clone(&bridge.host);
1552    let ui_dialog = bridge.ui_dialog.clone();
1553    let ext_status = bridge.ext_status.clone();
1554    let editor_text = bridge.editor_text.clone();
1555    let reload_cb = bridge.reload.clone();
1556    let execution_context = bridge.execution_context.clone();
1557    if action == RuntimeActionId::StdioLifecycle {
1558        let result = bridge.stdio.handle(
1559            bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1560            args,
1561        );
1562        let _ = tx.send(result);
1563    } else if action == RuntimeActionId::UiComponent {
1564        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1565        let _owners = bridge
1566            .component_owners
1567            .lock()
1568            .unwrap_or_else(|e| e.into_inner());
1569        let result = if bridge.is_active() {
1570            ext_status.component(owner, args)
1571        } else {
1572            Err("stale plugin bridge".into())
1573        };
1574        let _ = tx.send(result);
1575    } else if action == RuntimeActionId::SideQuestion {
1576        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1577        if owner == 0 || bridge.invocation.is_some() {
1578            let _ = tx.send(Err(
1579                "SideQuestion requires a plugin command, not a tool invocation".into(),
1580            ));
1581        } else {
1582            // Serialize writes with invalidate(): no late publication can
1583            // resurrect components after owner retirement. Keep only weak state.
1584            let weak = Arc::downgrade(&bridge);
1585            let sink: SideQuestionSink = Arc::new(move |args| {
1586                let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1587                let _owners = bridge
1588                    .component_owners
1589                    .lock()
1590                    .unwrap_or_else(|e| e.into_inner());
1591                if !bridge.is_active() {
1592                    return Err("stale side question bridge".into());
1593                }
1594                if args["op"] == "check" {
1595                    return Ok(serde_json::json!({"ok":true}));
1596                }
1597                bridge.ext_status.component(owner, args)
1598            });
1599            bridge.runtime.spawn(async move {
1600                let _ = tx.send(host.side_question(owner, args, sink).await);
1601            });
1602        }
1603    } else if action == RuntimeActionId::GetActiveTools && bridge.tool_program.is_some() {
1604        let tools = bridge
1605            .tool_program
1606            .as_ref()
1607            .expect("checked")
1608            .active_tool_names();
1609        let _ = tx.send(Ok(serde_json::json!({"tools":tools})));
1610    } else if action == RuntimeActionId::SetActiveTools && bridge.execution_context.is_some() {
1611        let _ = tx.send(Err(
1612            "cannot replace active tools from a narrowed execution".into()
1613        ));
1614    } else if action == RuntimeActionId::ToolProgram {
1615        let cleanup = matches!(
1616            args.get("op").and_then(serde_json::Value::as_str),
1617            Some("poll" | "cancel" | "dispose" | "release_result")
1618        );
1619        let authenticated = bridge.invocation.as_ref().is_some_and(|(_, key, signal)| {
1620            (cleanup || !signal.is_cancelled())
1621                && (invocation_thread
1622                    || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1623                        == Some(key.as_str()))
1624        });
1625        if let Some(capability) = bridge.tool_program.clone().filter(|_| authenticated) {
1626            let signal = bridge.invocation.as_ref().expect("authenticated").2.clone();
1627            bridge.runtime.spawn(async move {
1628                if signal.is_cancelled() {
1629                    capability.cancel_all();
1630                    if !cleanup {
1631                        let _ = tx.send(Err("parent invocation is no longer active".into()));
1632                        return;
1633                    }
1634                }
1635                let _ = tx.send(host.tool_program(capability, args).await);
1636            });
1637        } else {
1638            let _ = tx.send(Err(
1639                "ToolProgram requires an authenticated executing invocation".into(),
1640            ));
1641        }
1642    } else if action == RuntimeActionId::ChildAgent {
1643        // A registration handle cannot assert that it is a parent invocation.
1644        // All child-originated invocations fail closed, regardless of tool name.
1645        // Cancellation stops admission, not authenticated reclamation. The
1646        // plugin's cancel/destroy path must still dispose its retained handles.
1647        let cleanup = matches!(
1648            args.get("op").and_then(serde_json::Value::as_str),
1649            Some("poll" | "cancel" | "dispose")
1650        );
1651        let allowed = bridge
1652            .invocation
1653            .as_ref()
1654            .is_some_and(|(child, key, signal)| {
1655                !child
1656                    && (cleanup || !signal.is_cancelled())
1657                    && (invocation_thread
1658                        || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1659                            == Some(key.as_str()))
1660            });
1661        if !allowed {
1662            let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1663        } else {
1664            let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1665            let signal = bridge
1666                .invocation
1667                .as_ref()
1668                .expect("authorized invocation")
1669                .2
1670                .clone();
1671            bridge.runtime.spawn(async move {
1672                let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1673                    signal,
1674                    host.child_agent(owner, args),
1675                )
1676                .await;
1677                let _ = tx.send(result);
1678            });
1679        }
1680    } else if action == RuntimeActionId::SetStatus {
1681        // Same reasoning as `UiDialog`: this action never touches the harness,
1682        // so it must not need a Tokio worker. A plugin calling it from inside a
1683        // single-threaded runtime (a tool poll callback, say) would otherwise
1684        // park that executor and deadlock.
1685        std::thread::spawn(move || {
1686            let _ = tx.send(ext_status.handle(args));
1687        });
1688    } else if action == RuntimeActionId::SetEditorText {
1689        // Editor-only, like `SetStatus`: no harness call, so keep it off the
1690        // Tokio worker for the same single-thread-runtime reason.
1691        std::thread::spawn(move || {
1692            let _ = tx.send(editor_text.handle(args));
1693        });
1694    } else if action == RuntimeActionId::RelayUiDialog {
1695        // Mailbox-only, like `UiDialog`/`SetStatus`/`SetEditorText`: it never
1696        // touches the harness. Routing it through the generic runtime spawn
1697        // would park the blocking `rx.recv()` on a Tokio worker that the
1698        // mailbox-only op itself needs to complete — on a current-thread bridge
1699        // that deadlocks the only thread able to finish it.
1700        std::thread::spawn(move || {
1701            let _ = tx.send(dispatch_relay_ui_dialog(&ui_dialog, args));
1702        });
1703    } else if action == RuntimeActionId::UiDialog {
1704        // UiDialog is intentionally synchronous at the plugin ABI boundary:
1705        // `open` waits until the TUI answers. Never park that wait inside the
1706        // Tokio worker that services the bridge. A SEARCH/extension tool that
1707        // invokes `ui_request/open` from a single-thread runtime would
1708        // otherwise block the executor before the TUI can deliver the answer.
1709        std::thread::spawn(move || {
1710            let _ = tx.send(ui_dialog.handle(args));
1711        });
1712    } else {
1713        bridge.runtime.spawn(async move {
1714            let mut r = if action == RuntimeActionId::Reload {
1715                if let Some(cb) = reload_cb {
1716                    cb().await;
1717                    Ok(serde_json::Value::Null)
1718                } else {
1719                    host.reload(args).await
1720                }
1721            } else {
1722                dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1723            };
1724            if action == RuntimeActionId::GetExecutionContext {
1725                if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1726                    (&mut r, execution_context)
1727                {
1728                    if let Some(object) = context.as_object_mut() {
1729                        object.extend(fields);
1730                    }
1731                }
1732            }
1733            // If the plugin thread already moved on (dropped rx), discard — a
1734            // send error is NOT a host fault.
1735            let _ = tx.send(r);
1736        });
1737    }
1738
1739    // Event observers can call back synchronously from a Tokio worker. A task
1740    // just spawned above may still occupy that worker's non-stealable LIFO
1741    // slot. Yield the scheduler before blocking so dispatch can actually run.
1742    // Foreign/plugin workers and current-thread mailbox-only calls keep the
1743    // ordinary blocking receive.
1744    let received = if Handle::try_current()
1745        .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1746    {
1747        tokio::task::block_in_place(|| rx.recv())
1748    } else {
1749        rx.recv()
1750    };
1751    let result = match received {
1752        Ok(r) => r,
1753        Err(_) => {
1754            // Spawned task dropped the sender without sending: runtime shutdown
1755            // or the dispatch future panicked (caught inside dispatch? no —
1756            // dispatch is plain async, a panic would propagate to the spawn and
1757            // drop the sender). No result to return.
1758            return -2;
1759        }
1760    };
1761
1762    // Write the result (or error) into `*out` as a host-owned StbString. The
1763    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
1764    if out.is_null() {
1765        // Nothing to write to; still report the outcome via the return code.
1766        return match result {
1767            Ok(_) => 0,
1768            Err(_) => 1,
1769        };
1770    }
1771
1772    let (rc, payload) = match result {
1773        Ok(value) => {
1774            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1775            (0, json)
1776        }
1777        Err(msg) => {
1778            let json = serde_json::json!({ "error": msg }).to_string();
1779            (1, json)
1780        }
1781    };
1782    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
1783    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
1784    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
1785    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
1786    // used by translate.rs/tool.rs).
1787    unsafe {
1788        *out = StbString::from_string(payload);
1789    }
1790    rc
1791}
1792
1793// ===========================================================================
1794// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
1795// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
1796// recovers the `ActionBridge` via `user_data`, drives the host's async method on
1797// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
1798// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
1799// is the same `extern "C" fn` a plugin's vtable carries.
1800// ===========================================================================
1801#[cfg(test)]
1802mod tests {
1803    use super::*;
1804    use std::sync::Mutex;
1805
1806    #[tokio::test(flavor = "current_thread")]
1807    async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1808        let master = ActionBridge::new(
1809            Handle::current(),
1810            Arc::new(MockHost {
1811                prompt: String::new(),
1812                saw: Mutex::new(Vec::new()),
1813            }),
1814        );
1815        let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1816        let owner = crate::HostApi::with_action_bridge(
1817            "owner",
1818            crate::ExtensionRegistry::new(),
1819            diag.clone(),
1820            master.clone(),
1821        );
1822        let other = crate::HostApi::with_action_bridge(
1823            "other",
1824            crate::ExtensionRegistry::new(),
1825            diag,
1826            master.clone(),
1827        );
1828        let vtable = owner.build_vtable();
1829        let claim = r#"{"ownsStdio":true}"#;
1830        {
1831            let _registration = crate::enter_current_api(&owner);
1832            assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1833        }
1834        let other_vtable = other.build_vtable();
1835        let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1836        let mut out = StbString::empty();
1837        // Direct, on a single-thread executor: this action must not park on Tokio.
1838        assert_eq!(
1839            (other_vtable.runtime_action)(
1840                RuntimeActionId::StdioLifecycle as u32,
1841                finish,
1842                &mut out,
1843                other_vtable.user_data
1844            ),
1845            1
1846        );
1847        // SAFETY: the action returned a live allocation from this vtable.
1848        unsafe { (other_vtable.free_string)(out) };
1849        assert_eq!(
1850            (vtable.runtime_action)(
1851                RuntimeActionId::StdioLifecycle as u32,
1852                finish,
1853                &mut out,
1854                vtable.user_data
1855            ),
1856            0
1857        );
1858        // SAFETY: the action returned a live allocation from this vtable.
1859        unsafe { (vtable.free_string)(out) };
1860        assert_eq!(master.stdio_lifecycle().wait().await, 7);
1861        {
1862            let _registration = crate::enter_current_api(&other);
1863            assert_ne!(
1864                other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1865                0
1866            );
1867        }
1868        assert!(master
1869            .stdio_lifecycle()
1870            .validate()
1871            .unwrap_err()
1872            .contains("owner"));
1873    }
1874
1875    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
1876    /// canned string and `reload` with Ok(null); every other action returns an
1877    /// "unimplemented" error. Enough to prove the dispatch switch + the async
1878    /// spawn + the mpsc round-trip + the StbString write.
1879    struct MockHost {
1880        prompt: String,
1881        saw: Mutex<Vec<RuntimeActionId>>,
1882    }
1883
1884    #[test]
1885    fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1886        const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1887        if std::env::var_os(CHILD).is_some() {
1888            let runtime = tokio::runtime::Builder::new_multi_thread()
1889                .worker_threads(1)
1890                .enable_all()
1891                .build()
1892                .unwrap();
1893            runtime.block_on(async {
1894                // Lifecycle observers run directly on a worker. Their runtime
1895                // action spawns back onto that runtime, including its LIFO slot.
1896                tokio::spawn(async {
1897                    let host = Arc::new(MockHost {
1898                        prompt: "worker callback".into(),
1899                        saw: Mutex::new(Vec::new()),
1900                    });
1901                    let bridge = ActionBridge::new(Handle::current(), host);
1902                    let mut out = StbString::empty();
1903                    assert_eq!(
1904                        trampoline_runtime_action(
1905                            RuntimeActionId::GetSystemPrompt.into(),
1906                            StbStringRef::from_str("{}"),
1907                            &mut out,
1908                            bridge.user_data(),
1909                        ),
1910                        0
1911                    );
1912                    // SAFETY: the action returned a live host-owned string.
1913                    let text = unsafe {
1914                        rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1915                            out,
1916                            Some(crate::host_free_string),
1917                        )
1918                    }
1919                    .to_string_lossy();
1920                    assert_eq!(
1921                        serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1922                        "worker callback"
1923                    );
1924                })
1925                .await
1926                .unwrap();
1927            });
1928            return;
1929        }
1930        // Isolate the old self-deadlock so a regression fails promptly instead
1931        // of hanging the test runner or leaving a blocked runtime thread behind.
1932        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1933            .args([
1934                "--exact",
1935                "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1936                "--nocapture",
1937            ])
1938            .env(CHILD, "1")
1939            .stdout(std::process::Stdio::piped())
1940            .stderr(std::process::Stdio::piped())
1941            .spawn()
1942            .unwrap();
1943        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1944        while child.try_wait().unwrap().is_none() {
1945            if std::time::Instant::now() >= deadline {
1946                let _ = child.kill();
1947                let _ = child.wait();
1948                panic!("runtime action blocked the worker needed by its own dispatch");
1949            }
1950            std::thread::sleep(std::time::Duration::from_millis(10));
1951        }
1952        let output = child.wait_with_output().unwrap();
1953        assert!(
1954            output.status.success(),
1955            "{}\n{}",
1956            String::from_utf8_lossy(&output.stdout),
1957            String::from_utf8_lossy(&output.stderr)
1958        );
1959    }
1960
1961    #[async_trait::async_trait]
1962    impl RuntimeActionHost for MockHost {
1963        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1964            unreachable!("not under test")
1965        }
1966        async fn send_user_message(
1967            &self,
1968            _: serde_json::Value,
1969        ) -> Result<serde_json::Value, String> {
1970            unreachable!("not under test")
1971        }
1972        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1973            unreachable!("not under test")
1974        }
1975        async fn set_session_name(
1976            &self,
1977            _: serde_json::Value,
1978        ) -> Result<serde_json::Value, String> {
1979            unreachable!("not under test")
1980        }
1981        async fn get_active_tools(
1982            &self,
1983            _: serde_json::Value,
1984        ) -> Result<serde_json::Value, String> {
1985            unreachable!("not under test")
1986        }
1987        async fn set_active_tools(
1988            &self,
1989            _: serde_json::Value,
1990        ) -> Result<serde_json::Value, String> {
1991            unreachable!("not under test")
1992        }
1993        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1994            unreachable!("not under test")
1995        }
1996        async fn get_thinking_level(
1997            &self,
1998            _: serde_json::Value,
1999        ) -> Result<serde_json::Value, String> {
2000            unreachable!("not under test")
2001        }
2002        async fn set_thinking_level(
2003            &self,
2004            _: serde_json::Value,
2005        ) -> Result<serde_json::Value, String> {
2006            unreachable!("not under test")
2007        }
2008        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2009            unreachable!("not under test")
2010        }
2011        async fn get_system_prompt(
2012            &self,
2013            _: serde_json::Value,
2014        ) -> Result<serde_json::Value, String> {
2015            self.saw
2016                .lock()
2017                .unwrap()
2018                .push(RuntimeActionId::GetSystemPrompt);
2019            Ok(serde_json::json!({ "prompt": self.prompt }))
2020        }
2021        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2022            unreachable!("not under test")
2023        }
2024        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2025            unreachable!("not under test")
2026        }
2027        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2028            unreachable!("not under test")
2029        }
2030        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2031            unreachable!("not under test")
2032        }
2033        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2034            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
2035            Ok(serde_json::Value::Null)
2036        }
2037        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2038            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
2039            Ok(serde_json::json!({ "value": true }))
2040        }
2041    }
2042
2043    /// Async host actions need a multi-thread runtime when called synchronously
2044    /// from inside it. The trampoline yields that worker with block_in_place;
2045    /// foreign/plugin threads instead park on the ordinary channel receive.
2046    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2047    async fn trampoline_round_trips_get_system_prompt() {
2048        let host = Arc::new(MockHost {
2049            prompt: "hello from host".to_string(),
2050            saw: Mutex::new(Vec::new()),
2051        });
2052        let host_for_assert = Arc::clone(&host);
2053        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2054        let runtime = tokio::runtime::Handle::current();
2055        let bridge = ActionBridge::new(runtime, host_dyn);
2056        let user_data = bridge.user_data();
2057
2058        // Build a StbStringRef for the args. `{}` — getters ignore it.
2059        let args_str = "{}";
2060        let args_ref = StbStringRef::from_str(args_str);
2061
2062        let mut out = StbString::empty();
2063        let rc = trampoline_runtime_action(
2064            RuntimeActionId::GetSystemPrompt.into(),
2065            args_ref,
2066            &mut out as *mut StbString,
2067            user_data,
2068        );
2069        assert_eq!(rc, 0, "success return code");
2070
2071        // Read the result JSON back + reclaim the host-owned StbString.
2072        // SAFETY: the action wrote a live host-owned output, freed below.
2073        let json_text = unsafe {
2074            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2075        }
2076        .to_string_lossy();
2077        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
2078        assert_eq!(parsed["prompt"], "hello from host");
2079
2080        // The host saw exactly the one action. `host_for_assert` is a clone of
2081        // the `Arc<MockHost>` kept before it was coerced to the trait object.
2082        let saw = host_for_assert.saw.lock().unwrap().clone();
2083        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
2084    }
2085
2086    /// The `SetEditorText` runtime action must reach the bridge's editor-text
2087    /// mailbox through the ABI trampoline. This is the wiring a plugin relies on
2088    /// (a voice extension writes a transcription draft here); a broken action id
2089    /// or a dropped dispatch would be invisible to the plugin — it only sees
2090    /// `ok: true` — so pin it here.
2091    #[tokio::test]
2092    async fn trampoline_routes_set_editor_text_into_the_mailbox() {
2093        let host = Arc::new(MockHost {
2094            prompt: String::new(),
2095            saw: Mutex::new(Vec::new()),
2096        });
2097        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2098        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2099        let mailbox = bridge.editor_text_mailbox();
2100        let user_data = bridge.user_data();
2101
2102        let args_ref =
2103            StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
2104        let mut out = StbString::empty();
2105        let rc = trampoline_runtime_action(
2106            RuntimeActionId::SetEditorText.into(),
2107            args_ref,
2108            &mut out as *mut StbString,
2109            user_data,
2110        );
2111        assert_eq!(rc, 0, "success return code");
2112
2113        // SAFETY: the action wrote a live host-owned output, freed below.
2114        let parsed: serde_json::Value = serde_json::from_str(
2115            &unsafe {
2116                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2117            }
2118            .to_string_lossy(),
2119        )
2120        .expect("valid json");
2121        assert_eq!(parsed["ok"], serde_json::json!(true));
2122        assert_eq!(parsed["chars"], serde_json::json!(10));
2123
2124        // The queued edit is what the host will apply to the prompt editor.
2125        let edit = mailbox.take_pending().expect("queued edit");
2126        assert_eq!(edit.text, "from voice");
2127        assert_eq!(edit.mode, crate::EditorTextMode::Append);
2128        assert_eq!(edit.auto_send_ms, Some(2000));
2129        assert!(mailbox.take_pending().is_none());
2130    }
2131
2132    #[tokio::test]
2133    async fn trampoline_null_user_data_returns_minus_one() {
2134        let args_ref = StbStringRef::from_str("{}");
2135        let mut out = StbString::empty();
2136        let rc = trampoline_runtime_action(
2137            RuntimeActionId::GetSystemPrompt.into(),
2138            args_ref,
2139            &mut out as *mut StbString,
2140            std::ptr::null_mut(),
2141        );
2142        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
2143    }
2144
2145    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2146    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
2147        // A bridge with a reload callback: the spawn site intercepts Reload and
2148        // runs the callback instead of the host's `reload` method. Proves the
2149        // bridge-level special-casing (the host impl is the fallback only).
2150        use std::sync::atomic::{AtomicUsize, Ordering};
2151        let reload_calls = Arc::new(AtomicUsize::new(0));
2152        let reload_calls_for_cb = Arc::clone(&reload_calls);
2153        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
2154            Arc::new(move || {
2155                let c = Arc::clone(&reload_calls_for_cb);
2156                Box::pin(async move {
2157                    c.fetch_add(1, Ordering::SeqCst);
2158                })
2159            });
2160        let host = Arc::new(MockHost {
2161            prompt: String::new(),
2162            saw: Mutex::new(Vec::new()),
2163        });
2164        let host_for_assert = Arc::clone(&host);
2165        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2166        let runtime = tokio::runtime::Handle::current();
2167        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
2168        let user_data = bridge.user_data();
2169
2170        let args_ref = StbStringRef::from_str("{}");
2171        let mut out = StbString::empty();
2172        let rc = trampoline_runtime_action(
2173            RuntimeActionId::Reload.into(),
2174            args_ref,
2175            &mut out as *mut StbString,
2176            user_data,
2177        );
2178        assert_eq!(rc, 0);
2179        // The callback ran once; the host's `reload` did NOT.
2180        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
2181        assert!(host_for_assert.saw.lock().unwrap().is_empty());
2182        // SAFETY: this action returned a live host allocation, not previously freed.
2183        drop(unsafe {
2184            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2185        });
2186    }
2187
2188    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
2189    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
2190    /// bridge; an in-flight call that still holds the old pointer must fail
2191    /// fast rather than drive the swapped-out session.
2192    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2193    async fn trampoline_rejects_stale_bridge_with_invalidate() {
2194        let host = Arc::new(MockHost {
2195            prompt: String::new(),
2196            saw: Mutex::new(Vec::new()),
2197        });
2198        let host_for_assert = Arc::clone(&host);
2199        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2200        let runtime = tokio::runtime::Handle::current();
2201        let bridge = ActionBridge::new(runtime, host_dyn);
2202        let user_data = bridge.user_data();
2203
2204        // Invalidate (as a `/reload` would on the old bridge).
2205        bridge.invalidate();
2206        assert!(!bridge.is_active());
2207
2208        let args_ref = StbStringRef::from_str("{}");
2209        let mut out = StbString::empty();
2210        let rc = trampoline_runtime_action(
2211            RuntimeActionId::GetSystemPrompt.into(),
2212            args_ref,
2213            &mut out as *mut StbString,
2214            user_data,
2215        );
2216        // Nonzero (error), and the host method never ran (no dispatch).
2217        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
2218        // SAFETY: the action wrote a live host-owned output, freed below.
2219        let json_text = unsafe {
2220            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2221        }
2222        .to_string_lossy();
2223        assert!(
2224            json_text.contains("stale"),
2225            "stale-bridge error payload: {json_text}"
2226        );
2227
2228        assert!(
2229            host_for_assert.saw.lock().unwrap().is_empty(),
2230            "host dispatch must NOT run on a stale bridge"
2231        );
2232    }
2233
2234    #[tokio::test]
2235    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
2236        let host = Arc::new(MockHost {
2237            prompt: String::new(),
2238            saw: Mutex::new(Vec::new()),
2239        });
2240        let host_for_assert = Arc::clone(&host);
2241        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2242        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2243        let user_data = bridge.user_data();
2244
2245        let mut out = StbString::empty();
2246        let rc = trampoline_runtime_action(
2247            0xFFFF_FFFE,
2248            StbStringRef::from_str("{}"),
2249            &mut out,
2250            user_data,
2251        );
2252
2253        assert_eq!(rc, 2, "unknown action id must be a protocol error");
2254        let payload: serde_json::Value = serde_json::from_str(
2255            &unsafe {
2256                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2257            }
2258            .to_string_lossy(),
2259        )
2260        .expect("structured error JSON");
2261        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
2262
2263        assert!(
2264            host_for_assert.saw.lock().unwrap().is_empty(),
2265            "unknown action id must not reach host dispatch"
2266        );
2267    }
2268
2269    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2270    async fn v2_trampoline_accepts_new_action() {
2271        let host = Arc::new(MockHost {
2272            prompt: "v2".to_string(),
2273            saw: Mutex::new(Vec::new()),
2274        });
2275        let host_for_assert = Arc::clone(&host);
2276        let host_dyn: Arc<dyn RuntimeActionHost> = host;
2277        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2278        let user_data = bridge.user_data();
2279
2280        let mut v2_out = StbString::empty();
2281        assert_eq!(
2282            trampoline_runtime_action(
2283                RuntimeActionId::GetCliFlag.into(),
2284                StbStringRef::from_str(r#"{"name":"flag"}"#),
2285                &mut v2_out,
2286                user_data,
2287            ),
2288            0
2289        );
2290        // SAFETY: this action returned a live host allocation, not previously freed.
2291        drop(unsafe {
2292            rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
2293        });
2294
2295        assert_eq!(
2296            *host_for_assert.saw.lock().unwrap(),
2297            vec![RuntimeActionId::GetCliFlag]
2298        );
2299    }
2300
2301    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
2302    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
2303    /// host's "not configured" fallback). Exercises the B5d reload-signal path
2304    /// the TUI installs.
2305    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2306    async fn reload_mailbox_signals_installed_receiver() {
2307        let mailbox = ReloadMailbox::new();
2308        // No receiver installed yet ⇒ signal fails.
2309        assert!(matches!(mailbox.signal(), Err(())));
2310
2311        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2312        mailbox.install(tx);
2313        let cb = reload_callback_from_mailbox(mailbox.clone());
2314        cb().await;
2315        assert_eq!(
2316            rx.recv().await,
2317            Some(()),
2318            "installed receiver saw the signal"
2319        );
2320        mailbox.clear();
2321    }
2322
2323    // ---- UiDialogMailbox (runtime action 17 / ask_user) ----
2324
2325    fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
2326        serde_json::json!({
2327            "op": "open",
2328            "requestId": id,
2329            "toolCallId": tool_call,
2330            "ui": {"kind": "selector", "questions": [
2331                {"id": "q1", "question": "Which target?", "options": ["Linux"]}
2332            ]},
2333        })
2334    }
2335
2336    /// Headless hosts (nothing attached) must fail loudly rather than fabricate
2337    /// a user answer.
2338    #[test]
2339    fn ui_dialog_open_requires_an_attached_ui() {
2340        let mailbox = UiDialogMailbox::new();
2341        let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
2342        assert!(error.contains("interactive UI"), "got: {error}");
2343    }
2344
2345    #[test]
2346    fn ui_dialog_open_poll_answer_round_trip() {
2347        let mailbox = UiDialogMailbox::new();
2348        mailbox.attach();
2349
2350        let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
2351        assert_eq!(opened["status"], "pending");
2352        assert_eq!(opened["toolCallId"], "c1");
2353
2354        let request = mailbox.take_pending().expect("request visible to TUI");
2355        assert_eq!(request.request_id, "r1");
2356        assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
2357        // A pending poll is non-destructive and remains available.
2358        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2359        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2360
2361        mailbox
2362            .respond("r1", serde_json::json!("7890"))
2363            .expect("respond");
2364        let answered = mailbox.poll("r1").unwrap();
2365        assert_eq!(answered["status"], "answered");
2366        assert_eq!(answered["answer"], "7890");
2367        // Polling consumes the terminal entry.
2368        assert!(mailbox.poll("r1").is_err());
2369    }
2370
2371    #[test]
2372    fn ui_dialog_cancel_is_reported_once() {
2373        let mailbox = UiDialogMailbox::new();
2374        mailbox.attach();
2375        mailbox.handle(open_args("r1", "c1")).unwrap();
2376        mailbox.cancel("r1").unwrap();
2377        let cancelled = mailbox.poll("r1").unwrap();
2378        assert_eq!(cancelled["status"], "cancelled");
2379        assert!(mailbox.poll("r1").is_err());
2380    }
2381
2382    #[test]
2383    fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
2384        let mailbox = UiDialogMailbox::new();
2385        mailbox.attach();
2386        mailbox.handle(open_args("r1", "c1")).unwrap();
2387        mailbox.handle(open_args("r2", "c2")).unwrap();
2388        mailbox.cancel_tool_call("c1");
2389        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2390        assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
2391        assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
2392        assert_eq!(
2393            mailbox.handle(open_args("late", "c1")).unwrap()["status"],
2394            "cancelled"
2395        );
2396        assert!(mailbox.take_pending().is_none());
2397    }
2398
2399    #[test]
2400    fn ui_dialog_concurrent_requests_do_not_cross_answers() {
2401        let mailbox = UiDialogMailbox::new();
2402        mailbox.attach();
2403        mailbox.handle(open_args("r1", "c1")).unwrap();
2404        mailbox.handle(open_args("r2", "c2")).unwrap();
2405
2406        let first = mailbox.take_pending().unwrap().request_id;
2407        let second = mailbox.take_pending().unwrap().request_id;
2408        assert_ne!(first, second);
2409
2410        // Answer the SECOND request first; the first must stay pending.
2411        mailbox
2412            .respond(&second, serde_json::json!("second"))
2413            .unwrap();
2414        assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
2415        let second_answer = mailbox.poll(&second).unwrap();
2416        assert_eq!(second_answer["answer"], "second");
2417
2418        mailbox.respond(&first, serde_json::json!("first")).unwrap();
2419        assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
2420    }
2421
2422    #[test]
2423    fn ui_dialog_detach_cancels_outstanding() {
2424        let mailbox = UiDialogMailbox::new();
2425        mailbox.attach();
2426        mailbox.handle(open_args("r1", "c1")).unwrap();
2427        mailbox.detach();
2428        assert!(!mailbox.is_attached());
2429        assert_eq!(mailbox.pending_len(), 0);
2430        // Re-opening without a consumer is an explicit error again.
2431        assert!(mailbox.handle(open_args("r2", "c2")).is_err());
2432        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2433    }
2434
2435    #[test]
2436    fn ui_dialog_duplicate_open_does_not_reenqueue() {
2437        let mailbox = UiDialogMailbox::new();
2438        mailbox.attach();
2439        mailbox.handle(open_args("r1", "c1")).unwrap();
2440        let again = mailbox.handle(open_args("r1", "c1")).unwrap();
2441        assert_eq!(again["status"], "pending");
2442        assert_eq!(mailbox.pending_len(), 1);
2443        mailbox.take_pending().unwrap();
2444        assert!(mailbox.take_pending().is_none());
2445    }
2446
2447    /// `ListCommands` must route through the trampoline to the host method, and
2448    /// the default implementation must answer with an **empty list** rather than
2449    /// an error — a client has to be able to tell "no commands registered" apart
2450    /// from "this host does not support the action".
2451    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2452    async fn list_commands_routes_to_the_host_and_defaults_to_empty() {
2453        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2454            prompt: String::new(),
2455            saw: Mutex::new(Vec::new()),
2456        });
2457        let bridge = ActionBridge::with_ui_dialog(
2458            tokio::runtime::Handle::current(),
2459            host,
2460            UiDialogMailbox::new(),
2461        );
2462        let user_data = bridge.user_data();
2463        let mut out = StbString::empty();
2464        let rc = trampoline_runtime_action(
2465            u32::from(RuntimeActionId::ListCommands),
2466            StbStringRef::from_str("{}"),
2467            &mut out,
2468            user_data,
2469        );
2470        assert_eq!(
2471            rc, 0,
2472            "ListCommands must not fail on a host with no registry"
2473        );
2474        let payload: serde_json::Value = serde_json::from_str(
2475            &unsafe {
2476                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2477            }
2478            .to_string_lossy(),
2479        )
2480        .expect("JSON");
2481        assert_eq!(
2482            payload
2483                .get("commands")
2484                .and_then(|c| c.as_array())
2485                .map(Vec::len),
2486            Some(0),
2487            "an empty registry is an empty list, not an error: {payload}"
2488        );
2489    }
2490
2491    /// The v2 trampoline must route action 17 into the session mailbox rather
2492    /// than rejecting it as unknown, and a detached mailbox must surface the
2493    /// explicit "requires an interactive UI" error (headless contract).
2494    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2495    async fn v2_dispatch_routes_ui_dialog_action_17() {
2496        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2497            prompt: String::new(),
2498            saw: Mutex::new(Vec::new()),
2499        });
2500        let bridge = ActionBridge::with_ui_dialog(
2501            tokio::runtime::Handle::current(),
2502            host,
2503            UiDialogMailbox::new(),
2504        );
2505        let user_data = bridge.user_data();
2506
2507        let mut out = StbString::empty();
2508        let rc = trampoline_runtime_action(
2509            17,
2510            StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
2511            &mut out,
2512            user_data,
2513        );
2514        assert_eq!(rc, 1);
2515        let payload: serde_json::Value = serde_json::from_str(
2516            &unsafe {
2517                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2518            }
2519            .to_string_lossy(),
2520        )
2521        .expect("structured error JSON");
2522        assert!(
2523            payload["error"]
2524                .as_str()
2525                .is_some_and(|error| error.contains("interactive UI")),
2526            "unexpected payload: {payload}"
2527        );
2528
2529        // A genuinely unknown id is still rejected (no silent acceptance).
2530        // Derive "one past the highest" from the enum so adding an action does
2531        // not silently turn this into a real dispatch.
2532        let one_past_last = rpi_plugin_sdk::RuntimeActionId::MAX + 1;
2533        let mut out = StbString::empty();
2534        assert_eq!(
2535            trampoline_runtime_action(
2536                one_past_last,
2537                StbStringRef::from_str("{}"),
2538                &mut out,
2539                user_data
2540            ),
2541            2
2542        );
2543        // SAFETY: this action returned a live host allocation, not previously freed.
2544        drop(unsafe {
2545            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2546        });
2547    }
2548
2549    #[tokio::test(flavor = "current_thread")]
2550    async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
2551        let host = Arc::new(MockHost {
2552            prompt: String::new(),
2553            saw: Mutex::new(Vec::new()),
2554        });
2555        let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
2556        let a = root.for_plugin("a");
2557        let b = root.for_plugin("b");
2558        let mailbox = root.extension_status_mailbox();
2559        for plugin in [&a, &b] {
2560            let mut out = StbString::empty();
2561            assert_eq!(
2562                trampoline_runtime_action(
2563                    22,
2564                    StbStringRef::from_str(
2565                        r#"{"op":"register","id":"same","slot":"footer.after"}"#
2566                    ),
2567                    &mut out,
2568                    plugin.user_data()
2569                ),
2570                0
2571            );
2572            // SAFETY: this action returned a live host allocation, not previously freed.
2573            drop(unsafe {
2574                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2575            });
2576        }
2577        assert_eq!(mailbox.panels().len(), 2);
2578        drop(a);
2579        assert_eq!(mailbox.panels().len(), 1);
2580        root.invalidate();
2581        assert!(mailbox.panels().is_empty());
2582    }
2583
2584    /// Action 28 is mailbox-only, so it must not need a Tokio worker.
2585    ///
2586    /// The route must be off-executor: on a current-thread bridge a spawn-based
2587    /// route parks the only thread able to finish the call. The timeout is the
2588    /// regression.
2589    #[test]
2590    fn relay_ui_dialog_runs_off_the_executor() {
2591        let mailbox = UiDialogMailbox::new();
2592        mailbox.attach();
2593        let out = dispatch_relay_ui_dialog(&mailbox, serde_json::json!({"op":"release"}))
2594            .expect("release is a supported op");
2595        assert_eq!(out["requeued"], serde_json::json!(0));
2596    }
2597
2598    /// Action 18 (`SetStatus`) is bridge-local, like `UiDialog`: it writes the
2599    /// shared registry the TUI polls and never touches the harness.
2600    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2601    async fn v2_dispatch_routes_set_status_action_18() {
2602        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2603            prompt: String::new(),
2604            saw: Mutex::new(Vec::new()),
2605        });
2606        let status = crate::status::ExtensionStatusMailbox::new();
2607        let bridge = ActionBridge::with_reload_and_ui(
2608            tokio::runtime::Handle::current(),
2609            host,
2610            Arc::new(|| Box::pin(async {})),
2611            UiDialogMailbox::new(),
2612            status.clone(),
2613            crate::editor_text::EditorTextMailbox::new(),
2614        );
2615        let user_data = bridge.user_data();
2616
2617        let mut out = StbString::empty();
2618        let rc = trampoline_runtime_action(
2619            18,
2620            StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2621            &mut out,
2622            user_data,
2623        );
2624        assert_eq!(rc, 0);
2625        assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2626        // SAFETY: this action returned a live host allocation, not previously freed.
2627        drop(unsafe {
2628            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2629        });
2630
2631        // Clearing through the same action empties the line again.
2632        let mut out = StbString::empty();
2633        assert_eq!(
2634            trampoline_runtime_action(
2635                18,
2636                StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2637                &mut out,
2638                user_data
2639            ),
2640            0
2641        );
2642        assert_eq!(status.text(), "");
2643        // SAFETY: this action returned a live host allocation, not previously freed.
2644        drop(unsafe {
2645            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2646        });
2647
2648        // A missing key is a host-level error (rc=1), not an unknown action.
2649        let mut out = StbString::empty();
2650        assert_eq!(
2651            trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2652            1
2653        );
2654        // SAFETY: this action returned a live host allocation, not previously freed.
2655        drop(unsafe {
2656            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2657        });
2658    }
2659    /// The FFI trampoline's ceiling must be exactly the SDK's highest action id.
2660    ///
2661    /// A branch that adds an action but leaves the ceiling behind compiles,
2662    /// keeps every other test green, and quietly makes the newest action
2663    /// un-dispatchable. The ceiling and the enum live in different crates, so
2664    /// nothing but a test can notice the drift.
2665    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2666    async fn trampoline_ceiling_matches_the_highest_defined_action() {
2667        let highest = RuntimeActionId::MAX;
2668        assert_eq!(
2669            highest,
2670            RuntimeActionId::ALL
2671                .iter()
2672                .copied()
2673                .map(u32::from)
2674                .max()
2675                .unwrap(),
2676            "RuntimeActionId::MAX must be the maximum of RuntimeActionId::ALL"
2677        );
2678        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2679            prompt: String::new(),
2680            saw: Mutex::new(Vec::new()),
2681        });
2682        let bridge = ActionBridge::with_ui_dialog(
2683            tokio::runtime::Handle::current(),
2684            host,
2685            UiDialogMailbox::new(),
2686        );
2687        let mut out = StbString::empty();
2688        let rc = trampoline_runtime_action(
2689            highest,
2690            StbStringRef::from_str("{}"),
2691            &mut out,
2692            bridge.user_data(),
2693        );
2694        assert_ne!(
2695            rc, 2,
2696            "the highest defined action id ({highest}) must not be rejected as unknown — \
2697             the trampoline ceiling is stale"
2698        );
2699        if rc == 0 {
2700            // SAFETY: success means the trampoline wrote a live host allocation.
2701            drop(unsafe {
2702                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2703            });
2704        }
2705    }
2706}
2707
2708impl Drop for ActionBridge {
2709    fn drop(&mut self) {
2710        bridges()
2711            .lock()
2712            .unwrap_or_else(|e| e.into_inner())
2713            .retain(|_, entry| !std::ptr::eq(entry.bridge.as_ptr(), self));
2714        if self.invocation.is_some() {
2715            return;
2716        }
2717        if let Some((owner, _)) = &self.plugin_identity {
2718            self.ext_status.remove_owner(*owner);
2719            self.host.cancel_child_agents(*owner);
2720            self.host.cancel_side_questions(*owner);
2721        }
2722    }
2723}
2724
2725#[cfg(test)]
2726mod runtime_tests;
2727
2728#[cfg(test)]
2729mod relay_tests {
2730    use super::*;
2731
2732    fn request(id: &str) -> UiDialogRequest {
2733        UiDialogRequest {
2734            request_id: id.to_string(),
2735            tool_call_id: Some(format!("tool-{id}")),
2736            ui: serde_json::json!({"question": "ok?"}),
2737            raw: serde_json::json!({}),
2738        }
2739    }
2740
2741    /// A reserved request must be invisible to the TUI but visible to the plugin
2742    /// that reserved it. Otherwise a remote UI's question pops up in the
2743    /// terminal while the user who can answer it is watching the remote client.
2744    #[test]
2745    fn reserved_requests_are_invisible_to_the_tui() {
2746        let mailbox = UiDialogMailbox::new();
2747        mailbox.attach();
2748        let reserved = format!("{PLUGIN_RESERVED_PREFIX}gate-1");
2749        mailbox
2750            .handle(serde_json::json!({
2751                "op": "open",
2752                "requestId": reserved,
2753                "toolCallId": "t1",
2754                "ui": {}
2755            }))
2756            .unwrap();
2757        mailbox
2758            .handle(serde_json::json!({
2759                "op": "open",
2760                "requestId": "plain-1",
2761                "toolCallId": "t2",
2762                "ui": {}
2763            }))
2764            .unwrap();
2765
2766        // The TUI skips the reserved one and takes the plain one.
2767        let taken = mailbox
2768            .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2769            .expect("the unreserved request must reach the TUI");
2770        assert_eq!(taken.request_id, "plain-1");
2771
2772        // The plugin still sees its own.
2773        let own = mailbox
2774            .take_pending_filtered(|r| mailbox.is_reserved(&r.request_id))
2775            .expect("the plugin must still see its reserved request");
2776        assert_eq!(own.request_id, reserved);
2777    }
2778
2779    /// Losing the relay consumer must not leave the TUI locked out forever.
2780    #[test]
2781    fn release_or_detach_clears_takeover_and_requeues_unanswered() {
2782        let mailbox = UiDialogMailbox::new();
2783        mailbox.attach();
2784        mailbox
2785            .handle(serde_json::json!({
2786                "op": "open", "requestId": "plain-r", "toolCallId": "t", "ui": {}
2787            }))
2788            .unwrap();
2789
2790        // Relay takes it, then gives up without answering.
2791        mailbox.set_external_takeover(true);
2792        assert_eq!(mailbox.take_all_unreserved().len(), 1);
2793        assert_eq!(mailbox.release_takeover(), 1);
2794        assert!(
2795            !mailbox.external_takeover(),
2796            "release must hand delivery back to the TUI"
2797        );
2798        // The question is visible again instead of being silently lost.
2799        assert_eq!(
2800            mailbox
2801                .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2802                .map(|r| r.request_id),
2803            Some("plain-r".to_string())
2804        );
2805
2806        // Detach releases ownership too, so a reattached TUI can consume.
2807        mailbox.set_external_takeover(true);
2808        mailbox.detach();
2809        assert!(!mailbox.external_takeover(), "detach must clear takeover");
2810    }
2811
2812    /// Takeover and dequeue share one lock, so a relay cannot claim ownership
2813    /// in the gap and lose the question.
2814    #[test]
2815    fn tui_take_is_atomic_against_takeover() {
2816        let mailbox = UiDialogMailbox::new();
2817        mailbox.attach();
2818        mailbox.set_external_takeover(true);
2819        mailbox
2820            .handle(serde_json::json!({
2821                "op": "open", "requestId": "plain-a", "toolCallId": "t", "ui": {}
2822            }))
2823            .unwrap();
2824        assert!(
2825            mailbox.take_pending_unless_external(|_| true).is_none(),
2826            "the TUI must stand down while a relay owns delivery"
2827        );
2828        // Still queued for the relay.
2829        assert_eq!(mailbox.take_all_unreserved().len(), 1);
2830    }
2831
2832    /// `RelayUiDialog {op:"take"}` hands over everything no plugin reserved and
2833    /// leaves the reserved entries queued.
2834    #[test]
2835    fn relay_take_returns_unreserved_and_keeps_reserved() {
2836        let mailbox = UiDialogMailbox::new();
2837        mailbox.attach();
2838        mailbox
2839            .handle(serde_json::json!({
2840                "op": "open",
2841                "requestId": format!("{PLUGIN_RESERVED_PREFIX}keep"),
2842                "toolCallId": "t1",
2843                "ui": {}
2844            }))
2845            .unwrap();
2846        mailbox
2847            .handle(serde_json::json!({
2848                "op": "open",
2849                "requestId": "relay-1",
2850                "toolCallId": "t2",
2851                "ui": {}
2852            }))
2853            .unwrap();
2854
2855        let taken = mailbox.take_all_unreserved();
2856        assert_eq!(taken.len(), 1);
2857        assert_eq!(taken[0].request_id, "relay-1");
2858        // The reserved one is still there for its owner.
2859        assert_eq!(mailbox.pending_len(), 1);
2860    }
2861
2862    /// A taken request is still answerable — `take` moves it out of `pending`
2863    /// but keeps it in `active`.
2864    #[test]
2865    fn a_relayed_request_can_still_be_answered() {
2866        let mailbox = UiDialogMailbox::new();
2867        mailbox.attach();
2868        mailbox
2869            .handle(serde_json::json!({
2870                "op": "open",
2871                "requestId": "relay-1",
2872                "toolCallId": "t1",
2873                "ui": {}
2874            }))
2875            .unwrap();
2876        let taken = mailbox.take_all_unreserved();
2877        assert_eq!(taken.len(), 1);
2878
2879        mailbox
2880            .handle(serde_json::json!({
2881                "op": "respond",
2882                "requestId": "relay-1",
2883                "answer": {"ok": true}
2884            }))
2885            .unwrap();
2886        // Answered requests are not handed out again.
2887        assert_eq!(
2888            mailbox
2889                .take_pending_filtered(|_| true)
2890                .map(|r| r.request_id),
2891            None,
2892            "an answered request must not resurface"
2893        );
2894    }
2895
2896    /// `take_pending()` (the un-filtered form) still takes everything, so a
2897    /// caller that wants the old behaviour keeps it.
2898    #[test]
2899    fn take_pending_still_takes_reserved_entries() {
2900        let mailbox = UiDialogMailbox::new();
2901        mailbox.attach();
2902        let reserved = format!("{PLUGIN_RESERVED_PREFIX}x");
2903        mailbox
2904            .handle(serde_json::json!({
2905                "op": "open",
2906                "requestId": reserved,
2907                "toolCallId": "t1",
2908                "ui": {}
2909            }))
2910            .unwrap();
2911        assert_eq!(
2912            mailbox.take_pending().map(|r| r.request_id),
2913            Some(format!("{PLUGIN_RESERVED_PREFIX}x"))
2914        );
2915    }
2916
2917    /// External takeover must make the TUI skip taking entirely.
2918    ///
2919    /// The two consumers draw from the same queue, so without an owner whichever
2920    /// polls first wins. A remote client that has taken over must not race a
2921    /// terminal nobody is watching.
2922    #[test]
2923    fn external_takeover_makes_the_tui_stand_down() {
2924        let mailbox = UiDialogMailbox::new();
2925        mailbox.attach();
2926        mailbox
2927            .handle(serde_json::json!({
2928                "op": "open",
2929                "requestId": "plain-1",
2930                "toolCallId": "t1",
2931                "ui": {}
2932            }))
2933            .unwrap();
2934
2935        assert!(!mailbox.external_takeover());
2936        mailbox.set_external_takeover(true);
2937        assert!(mailbox.external_takeover());
2938
2939        // The TUI's take path returns nothing while the external UI owns it…
2940        let tui_take = |m: &UiDialogMailbox| {
2941            if m.external_takeover() {
2942                return None;
2943            }
2944            m.take_pending_filtered(|r| !m.is_reserved(&r.request_id))
2945        };
2946        assert!(
2947            tui_take(&mailbox).is_none(),
2948            "the TUI must not steal a request an external UI owns"
2949        );
2950        // …and the request is still there for the external consumer.
2951        assert_eq!(mailbox.take_all_unreserved().len(), 1);
2952    }
2953
2954    /// Handing ownership back lets the TUI resume.
2955    #[test]
2956    fn clearing_takeover_returns_dialogs_to_the_tui() {
2957        let mailbox = UiDialogMailbox::new();
2958        mailbox.attach();
2959        mailbox.set_external_takeover(true);
2960        mailbox.set_external_takeover(false);
2961        mailbox
2962            .handle(serde_json::json!({
2963                "op": "open",
2964                "requestId": "plain-2",
2965                "toolCallId": "t2",
2966                "ui": {}
2967            }))
2968            .unwrap();
2969        assert_eq!(
2970            mailbox
2971                .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2972                .map(|r| r.request_id),
2973            Some("plain-2".to_string())
2974        );
2975    }
2976
2977    #[test]
2978    fn request_helper_matches_the_prefix_rule() {
2979        let mailbox = UiDialogMailbox::new();
2980        mailbox.attach();
2981        assert!(mailbox.is_reserved(&request(PLUGIN_RESERVED_PREFIX).request_id));
2982        assert!(!mailbox.is_reserved(&request("plain").request_id));
2983    }
2984}