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