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**, from (a) a `spawn_blocking`
9//! pool thread mid-tool-drive, or (b) a foreign thread the plugin spawned
10//! itself. Neither has the thread-local set, and the host cannot predict which
11//! threads a plugin will call from. Thread-local is the wrong tool here.
12//!
13//! The verified-correct recovery channel is [`PluginApi::user_data`]: it is
14//! `Send+Sync`, populated at vtable build, passed back unchanged on every call,
15//! and the SDK designates it "the host's opaque context". Today every register
16//! trampoline ignores `user_data` (the register path uses the thread-local), so
17//! repurposing `user_data` for the action bridge breaks nothing.
18//!
19//! [`ActionBridge`] holds a [`tokio::runtime::Handle`] **captured at build
20//! time** (the host is on the runtime when it constructs the bridge) — the fix
21//! for the foreign-thread case: `Handle::spawn` works from any thread, no
22//! ambient runtime needed. The real [`trampoline_runtime_action`] derefs
23//! `user_data` as `&ActionBridge`, drives the host's async dispatch on the
24//! runtime via a `std::sync::mpsc::sync_channel(1)`, and parks the plugin thread
25//! on `rx.recv()` (STD — not `tokio::oneshot`, whose `recv` needs a runtime the
26//! plugin's foreign thread lacks).
27//!
28//! ## Cycle-free leaf DAG
29//!
30//! [`RuntimeActionHost`] is defined here (NOT in `rpi-harness`) so
31//! `rpi-extensions` stays a leaf: the trait names only JSON + primitives — no
32//! `rpi-harness` types cross. The host impl (`HarnessActionHost`) lives in
33//! `rpi-cli`, where it can name the harness freely; `rpi-extensions` only
34//! carries the async surface. This preserves the documented DAG
35//! (`lib.rs:10-16`: `rpi-extensions` does NOT depend on `rpi-harness`).
36
37use std::collections::{HashMap, VecDeque};
38use std::ffi::c_void;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::sync::{mpsc, Arc, Mutex};
41
42use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
43use tokio::runtime::Handle;
44
45/// A pending host UI request emitted by a plugin runtime action.
46///
47/// The request and tool-call ids are intentionally separate: a single tool
48/// call may ask more than one question, while concurrent tool calls must never
49/// consume one another's answers. `raw` retains the complete action envelope;
50/// `ui` is the normalized UI payload that TUI renderers generally need.
51#[derive(Clone, Debug, PartialEq)]
52pub struct UiDialogRequest {
53    pub request_id: String,
54    pub tool_call_id: Option<String>,
55    pub ui: serde_json::Value,
56    pub raw: serde_json::Value,
57}
58
59struct UiDialogEntry {
60    request: UiDialogRequest,
61    answer: Option<serde_json::Value>,
62    cancelled: bool,
63}
64
65struct UiDialogState {
66    attached: bool,
67    pending: VecDeque<String>,
68    active: HashMap<String, UiDialogEntry>,
69    cancelled_tool_calls: std::collections::HashSet<String>,
70}
71
72/// Thread-safe mailbox connecting plugin runtime actions to an interactive
73/// host (normally the TUI). It deliberately carries only JSON and primitives,
74/// so the extension crate remains independent of the UI crate.
75#[derive(Clone)]
76pub struct UiDialogMailbox {
77    state: Arc<Mutex<UiDialogState>>,
78}
79
80impl Default for UiDialogMailbox {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl UiDialogMailbox {
87    pub fn new() -> Self {
88        Self {
89            state: Arc::new(Mutex::new(UiDialogState {
90                attached: false,
91                pending: VecDeque::new(),
92                active: HashMap::new(),
93                cancelled_tool_calls: std::collections::HashSet::new(),
94            })),
95        }
96    }
97
98    /// Mark an interactive consumer as available. Re-attaching is idempotent.
99    pub fn attach(&self) {
100        self.state
101            .lock()
102            .expect("ui dialog mailbox poisoned")
103            .attached = true;
104    }
105
106    /// Detach the consumer and cancel every outstanding request. This prevents
107    /// a TUI restart/reload from leaving plugin polls blocked forever.
108    pub fn detach(&self) {
109        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
110        state.attached = false;
111        state.pending.clear();
112        for entry in state.active.values_mut() {
113            entry.cancelled = true;
114            entry.answer = None;
115        }
116    }
117
118    pub fn is_attached(&self) -> bool {
119        self.state
120            .lock()
121            .expect("ui dialog mailbox poisoned")
122            .attached
123    }
124
125    /// Remove and return the next request for presentation by the TUI.
126    /// Cancelled/answered requests are skipped; duplicate `open` calls never
127    /// enqueue the same request id twice.
128    pub fn take_pending(&self) -> Option<UiDialogRequest> {
129        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
130        while let Some(request_id) = state.pending.pop_front() {
131            if let Some(entry) = state.active.get(&request_id) {
132                if !entry.cancelled && entry.answer.is_none() {
133                    return Some(entry.request.clone());
134                }
135            }
136        }
137        None
138    }
139
140    pub fn pending_len(&self) -> usize {
141        self.state
142            .lock()
143            .expect("ui dialog mailbox poisoned")
144            .pending
145            .len()
146    }
147
148    /// Inspect lifecycle without consuming the answer reserved for a plugin.
149    pub fn is_pending(&self, request_id: &str) -> bool {
150        self.state
151            .lock()
152            .expect("ui dialog mailbox poisoned")
153            .active
154            .get(request_id)
155            .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
156    }
157
158    /// Supply an answer for a request. Repeating the same operation is
159    /// idempotent; answering a cancelled/unknown request is an explicit error.
160    pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
161        let mut state = self
162            .state
163            .lock()
164            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
165        let entry = state
166            .active
167            .get_mut(request_id)
168            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
169        if entry.cancelled {
170            return Err(format!("UI request `{request_id}` was cancelled"));
171        }
172        if entry.answer.is_none() {
173            entry.answer = Some(answer);
174        }
175        Ok(())
176    }
177
178    /// Mark one request cancelled. The terminal state remains visible to the
179    /// plugin's next `poll`, then is reclaimed by that poll.
180    pub fn cancel(&self, request_id: &str) -> Result<(), String> {
181        let mut state = self
182            .state
183            .lock()
184            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
185        let entry = state
186            .active
187            .get_mut(request_id)
188            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
189        entry.cancelled = true;
190        entry.answer = None;
191        state.pending.retain(|id| id != request_id);
192        Ok(())
193    }
194
195    /// Cancel all requests currently known to the mailbox.
196    pub fn cancel_all(&self) {
197        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
198        state.pending.clear();
199        for entry in state.active.values_mut() {
200            entry.cancelled = true;
201            entry.answer = None;
202        }
203    }
204
205    /// Cancel approval requests belonging to one tool without disturbing
206    /// questions from concurrent tool calls.
207    pub fn cancel_tool_call(&self, tool_call_id: &str) {
208        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
209        // A callback can reach `open` just after the host cancels its call.
210        // Remember the cancellation so it cannot enqueue an orphan question.
211        state.cancelled_tool_calls.insert(tool_call_id.to_string());
212        for entry in state.active.values_mut() {
213            if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
214                entry.cancelled = true;
215                entry.answer = None;
216            }
217        }
218        let cancelled: std::collections::HashSet<String> = state
219            .active
220            .iter()
221            .filter(|(_, entry)| entry.cancelled)
222            .map(|(id, _)| id.clone())
223            .collect();
224        state.pending.retain(|id| !cancelled.contains(id));
225    }
226
227    /// Poll a request and consume a terminal answer/cancellation. Pending
228    /// polls are cheap and preserve the request for subsequent calls.
229    pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
230        let mut state = self
231            .state
232            .lock()
233            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
234        let terminal = match state.active.get(request_id) {
235            Some(entry) if entry.cancelled => Some(serde_json::json!({
236                "status": "cancelled",
237                "requestId": request_id,
238                "toolCallId": entry.request.tool_call_id,
239            })),
240            Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
241                "status": "answered",
242                "requestId": request_id,
243                "toolCallId": entry.request.tool_call_id,
244                "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
245            })),
246            Some(entry) => Some(serde_json::json!({
247                "status": "pending",
248                "requestId": request_id,
249                "toolCallId": entry.request.tool_call_id,
250            })),
251            None => None,
252        };
253        let Some(result) = terminal else {
254            return Err(format!("unknown UI request `{request_id}`"));
255        };
256        if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
257            state.active.remove(request_id);
258        }
259        Ok(result)
260    }
261
262    /// Parse and execute the JSON action envelope used by runtime action 17.
263    /// Supported operations are `open`, `poll`, `cancel`; `response` is also
264    /// accepted as a convenience for hosts that proxy answers through JSON.
265    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
266        let op = args
267            .get("op")
268            .and_then(serde_json::Value::as_str)
269            .unwrap_or("open");
270        let request_id = || {
271            args.get("requestId")
272                .or_else(|| args.get("request_id"))
273                .and_then(serde_json::Value::as_str)
274                .map(str::to_owned)
275                .filter(|id| !id.trim().is_empty())
276                .ok_or_else(|| "missing non-empty `requestId`".to_string())
277        };
278        match op {
279            "open" => {
280                let id = request_id()?;
281                let mut state = self
282                    .state
283                    .lock()
284                    .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
285                if !state.attached {
286                    return Err("ask_user requires an interactive UI".to_string());
287                }
288                if let Some(entry) = state.active.get(&id) {
289                    return Ok(if entry.cancelled {
290                        serde_json::json!({"status":"cancelled","requestId":id})
291                    } else if let Some(answer) = &entry.answer {
292                        serde_json::json!({"status":"answered","requestId":id,"answer":answer})
293                    } else {
294                        serde_json::json!({"status":"pending","requestId":id})
295                    });
296                }
297                let tool_call_id = args
298                    .get("toolCallId")
299                    .or_else(|| args.get("tool_call_id"))
300                    .and_then(serde_json::Value::as_str)
301                    .map(str::to_owned);
302                if tool_call_id
303                    .as_ref()
304                    .is_some_and(|id| state.cancelled_tool_calls.contains(id))
305                {
306                    return Ok(serde_json::json!({
307                        "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
308                    }));
309                }
310                let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
311                rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
312                state.active.insert(
313                    id.clone(),
314                    UiDialogEntry {
315                        request: UiDialogRequest {
316                            request_id: id.clone(),
317                            tool_call_id: tool_call_id.clone(),
318                            ui,
319                            raw: args,
320                        },
321                        answer: None,
322                        cancelled: false,
323                    },
324                );
325                state.pending.push_back(id.clone());
326                Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
327            }
328            "poll" => self.poll(&request_id()?),
329            "cancel" => {
330                let id = request_id()?;
331                self.cancel(&id)?;
332                Ok(serde_json::json!({"status":"cancelled","requestId":id}))
333            }
334            "response" | "respond" => {
335                let id = request_id()?;
336                let answer = args
337                    .get("answer")
338                    .or_else(|| args.get("value"))
339                    .cloned()
340                    .unwrap_or(serde_json::Value::Null);
341                self.respond(&id, answer)?;
342                Ok(serde_json::json!({"status":"answered","requestId":id}))
343            }
344            other => Err(format!("unknown UI dialog operation `{other}`")),
345        }
346    }
347}
348
349/// The host-side implementation the bridge delegates to. Defined in
350/// `rpi-extensions` (NOT `rpi-harness`) so the crate DAG stays a leaf: this is a
351/// trait the **host** (`rpi-cli`) implements over the harness — `rpi-extensions`
352/// only names the async surface + carries JSON params/results. No `rpi-harness`
353/// types appear in the trait.
354///
355/// Each method corresponds to one [`RuntimeActionId`] variant. Complex args
356/// arrive as parsed JSON (`serde_json::Value`); complex results return as
357/// `serde_json::Value`. The host maps its native types (Model, AgentMessage, …)
358/// to/from JSON at the impl boundary. Errors are `String` (become the action's
359/// nonzero `i32` + `{"error": msg}` JSON on the plugin side).
360///
361/// The 18 methods map 1:1 to [`RuntimeActionId`]; `dispatch` below is the
362/// exhaustive switch that connects the FFI id to the method.
363#[async_trait::async_trait]
364pub trait RuntimeActionHost: Send + Sync {
365    /// `SendMessage` — Pi-compatible `triggerTurn` and `deliverAs` scheduling.
366    /// Defaults append while idle and steer while running; explicit `true`
367    /// starts an idle run, `false` only updates context, and `nextTurn` waits
368    /// for a new explicit prompt. Results distinguish appended/queued/run.
369    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
370    /// `SendUserMessage` — drive a run from a user-text message.
371    async fn send_user_message(&self, args: serde_json::Value)
372        -> Result<serde_json::Value, String>;
373    /// `AppendEntry` — append without starting a run. `customType`/`data`
374    /// persists extension data only. A `message` is durably queued during an
375    /// operation and committed after its current turn; the returned `entryId`
376    /// is reserved and may not yet resolve to a transcript entry.
377    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
378    /// `SetSessionName` — set the session's display name.
379    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
380    /// `GetActiveTools` — the active tool-name list.
381    async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
382    /// `SetActiveTools` — replace the active tool-name list.
383    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
384    /// `SetModel` — switch the active model (by id; host resolves to a `Model`).
385    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
386    /// `GetThinkingLevel` — the current thinking level.
387    async fn get_thinking_level(
388        &self,
389        args: serde_json::Value,
390    ) -> Result<serde_json::Value, String>;
391    /// `SetThinkingLevel` — set the thinking level.
392    async fn set_thinking_level(
393        &self,
394        args: serde_json::Value,
395    ) -> Result<serde_json::Value, String>;
396    /// `Compact` — compact the session transcript.
397    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
398    /// `GetSystemPrompt` — the live composed system prompt.
399    async fn get_system_prompt(&self, args: serde_json::Value)
400        -> Result<serde_json::Value, String>;
401    /// `NewSession` — start a fresh session and switch to it.
402    async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
403    /// `Fork` — fork the current session and switch to the fork.
404    async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
405    /// `NavigateTree` — navigate/rewind the session tree.
406    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
407    /// `SwitchSession` — hot-switch to an existing session by id.
408    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
409    /// `Reload` — re-run extension discovery + Part-A resource loaders (a CLI
410    /// concern; the host impl wires it to the `ActionBridge.reload` callback in
411    /// B5d — until then this returns an "unsupported" error string).
412    async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
413
414    /// `GetCliFlag` — read a parsed extension CLI flag. Args are
415    /// `{"name":"flag"}` and the result is `{"value": <bool|string|null>}`.
416    async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
417        Err("CLI flag lookup is not configured".to_string())
418    }
419
420    /// `UiDialog` fallback for hosts that do not install an interactive
421    /// mailbox. The default is deliberately an error so headless runs cannot
422    /// accidentally report a fabricated user answer.
423    async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
424        Err("ask_user requires an interactive UI".to_string())
425    }
426
427    /// `GetSessionBranch` — current main branch entries, oldest-first.
428    async fn get_session_branch(
429        &self,
430        _args: serde_json::Value,
431    ) -> Result<serde_json::Value, String> {
432        Err("session branch lookup is not configured".to_string())
433    }
434}
435
436/// The host-side bridge carried in [`PluginApi::user_data`] so
437/// [`trampoline_runtime_action`] can recover the harness state from any thread.
438///
439/// `runtime: Handle` is captured at build time (the host is on the runtime when
440/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
441/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
442/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
443/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
444///
445/// ## Staleness (B5d)
446///
447/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
448/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
449/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
450/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
451/// In-flight `runtime_action` calls that recovered the OLD bridge from
452/// `user_data` (the pointer a plugin stored during the prior `register`) then
453/// hit the staleness guard in [`run_action`] and fail with a structured error
454/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
455/// handing them the NEW bridge pointer; the guard only catches the race window
456/// where an old call is still parked on `rx.recv()`.)
457///
458/// Held behind `Arc` (pointer-stable for the bridge's lifetime via
459/// [`Arc::as_ptr`]); `rpi-cli` keeps one clone for the session lifetime so the
460/// pointer a plugin stored during register stays valid post-register. (The
461/// transient `HostApi` built per `load_one` holds a clone only during register
462/// — when it drops after `take_registry`, the master `Arc` in `rpi-cli` keeps
463/// the allocation alive.)
464pub struct ActionBridge {
465    stdio: crate::StdioLifecycle,
466    plugin_identity: Option<(u64, String)>,
467    component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
468    /// Captured at build time from a thread running the target runtime.
469    pub(crate) runtime: Handle,
470    /// The host impl (`HarnessActionHost` in rpi-cli).
471    pub(crate) host: Arc<dyn RuntimeActionHost>,
472    /// B5d reload callback; `None` until the TUI wires `/reload`.
473    pub(crate) reload:
474        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
475    /// Session-scoped UI mailbox. It is cloned into reload-created bridges so
476    /// a TUI attachment and in-flight requests survive extension reloads.
477    ui_dialog: UiDialogMailbox,
478    /// Session-scoped extension status registry (`SetStatus` runtime action).
479    /// Shared with the TUI, which polls [`ExtensionStatusMailbox::revision`]
480    /// and repaints the footer only when an extension actually wrote.
481    ext_status: crate::status::ExtensionStatusMailbox,
482    /// Session-scoped editor-text queue (`SetEditorText` runtime action). Shared
483    /// with the TUI, which drains it once per tick and writes the draft into the
484    /// prompt editor.
485    editor_text: crate::editor_text::EditorTextMailbox,
486    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
487    /// `true` while this bridge is the live session's bridge.
488    active: Arc<AtomicBool>,
489}
490
491impl ActionBridge {
492    /// Build a bridge. The `Handle` MUST be captured from a thread running the
493    /// target runtime (pi-cli builds the bridge on the async main thread).
494    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
495        Arc::new(Self {
496            stdio: crate::StdioLifecycle::default(),
497            plugin_identity: None,
498            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
499            runtime,
500            host,
501            reload: None,
502            ui_dialog: UiDialogMailbox::new(),
503            ext_status: crate::status::ExtensionStatusMailbox::new(),
504            editor_text: crate::editor_text::EditorTextMailbox::new(),
505            active: Arc::new(AtomicBool::new(true)),
506        })
507    }
508
509    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
510    /// [`reload_callback_from_mailbox`]).
511    pub fn with_reload(
512        runtime: Handle,
513        host: Arc<dyn RuntimeActionHost>,
514        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
515    ) -> Arc<Self> {
516        Self::with_reload_and_ui(
517            runtime,
518            host,
519            reload,
520            UiDialogMailbox::new(),
521            crate::status::ExtensionStatusMailbox::new(),
522            crate::editor_text::EditorTextMailbox::new(),
523        )
524    }
525
526    /// Build a bridge with an explicit session-scoped UI mailbox.
527    pub fn with_ui_dialog(
528        runtime: Handle,
529        host: Arc<dyn RuntimeActionHost>,
530        ui_dialog: UiDialogMailbox,
531    ) -> Arc<Self> {
532        Arc::new(Self {
533            stdio: crate::StdioLifecycle::default(),
534            plugin_identity: None,
535            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
536            runtime,
537            host,
538            reload: None,
539            ui_dialog,
540            ext_status: crate::status::ExtensionStatusMailbox::new(),
541            editor_text: crate::editor_text::EditorTextMailbox::new(),
542            active: Arc::new(AtomicBool::new(true)),
543        })
544    }
545
546    /// `with_reload` variant that preserves a mailbox across bridge swaps.
547    pub fn with_reload_and_ui(
548        runtime: Handle,
549        host: Arc<dyn RuntimeActionHost>,
550        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
551        ui_dialog: UiDialogMailbox,
552        ext_status: crate::status::ExtensionStatusMailbox,
553        editor_text: crate::editor_text::EditorTextMailbox,
554    ) -> Arc<Self> {
555        Arc::new(Self {
556            stdio: crate::StdioLifecycle::default(),
557            plugin_identity: None,
558            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
559            runtime,
560            host,
561            reload: Some(reload),
562            ui_dialog,
563            ext_status,
564            editor_text,
565            active: Arc::new(AtomicBool::new(true)),
566        })
567    }
568
569    pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
570        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
571        let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
572        self.component_owners
573            .lock()
574            .unwrap_or_else(|e| e.into_inner())
575            .push(owner);
576        Arc::new(Self {
577            component_owners: self.component_owners.clone(),
578            stdio: self.stdio.clone(),
579            plugin_identity: Some((owner, name.to_owned())),
580            runtime: self.runtime.clone(),
581            host: self.host.clone(),
582            reload: self.reload.clone(),
583            ui_dialog: self.ui_dialog.clone(),
584            ext_status: self.ext_status.clone(),
585            editor_text: self.editor_text.clone(),
586            active: self.active.clone(),
587        })
588    }
589
590    pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
591        self.stdio.clone()
592    }
593
594    pub(crate) fn claim_stdio(&self) -> Result<(), String> {
595        let (id, name) = self
596            .plugin_identity
597            .as_ref()
598            .ok_or("missing plugin identity")?;
599        self.stdio.claim(*id, name)
600    }
601
602    pub(crate) fn release_stdio_claim(&self) {
603        if let Some((id, _)) = &self.plugin_identity {
604            self.stdio.release_failed_registration(*id);
605            self.ext_status.remove_owner(*id);
606        }
607    }
608
609    /// Clone the session mailbox for TUI attachment or a reload-created bridge.
610    pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
611        self.ui_dialog.clone()
612    }
613
614    /// Clone the session status registry (TUI rendering + reload-created bridges).
615    pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
616        self.ext_status.clone()
617    }
618
619    /// Clone the session editor-text queue (TUI drain + reload-created bridges).
620    pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
621        self.editor_text.clone()
622    }
623
624    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
625    /// calls this on the old one so in-flight `runtime_action` calls parked on
626    /// the old `user_data` pointer fail fast with a staleness error instead of
627    /// driving the swapped-out session. Idempotent.
628    pub fn invalidate(&self) {
629        let owners = self
630            .component_owners
631            .lock()
632            .unwrap_or_else(|e| e.into_inner());
633        self.active.store(false, Ordering::SeqCst);
634        for owner in owners.iter() {
635            self.ext_status.remove_owner(*owner);
636        }
637    }
638
639    /// Whether this bridge is still the live session's bridge.
640    pub fn is_active(&self) -> bool {
641        self.active.load(Ordering::SeqCst)
642    }
643
644    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
645    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
646    /// at the live harness — the harness is NOT rebuilt on reload — so the host
647    /// is reusable across reloads; only the bridge's staleness flag + reload
648    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
649    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
650    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
651        Arc::clone(&self.host)
652    }
653}
654
655/// A reload-signal mail slot (B5d). The reload callback (built by
656/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
657/// `tokio` unbounded sender after it starts. When a plugin calls
658/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
659/// and the TUI performs the reload **asynchronously** — the plugin's call
660/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
661/// unmapped while its `runtime_action` frame is still on the stack (the reload,
662/// which drops the old keepalive, happens after the call returns). This breaks
663/// the self-unmapping race a synchronous plugin-initiated reload would have.
664///
665/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
666/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
667#[derive(Clone)]
668pub struct ReloadMailbox {
669    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
670}
671
672impl Default for ReloadMailbox {
673    fn default() -> Self {
674        Self {
675            tx: Arc::new(std::sync::Mutex::new(None)),
676        }
677    }
678}
679
680impl ReloadMailbox {
681    /// A fresh empty mail slot (no TUI installed yet).
682    pub fn new() -> Self {
683        Self::default()
684    }
685
686    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
687    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
688    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
689    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
690        *self.tx.lock().unwrap() = Some(tx);
691    }
692
693    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
694    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
695    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
696    /// a "reload not available" error to the plugin).
697    pub fn signal(&self) -> Result<(), ()> {
698        let g = self.tx.lock().unwrap();
699        match &*g {
700            Some(tx) => {
701                let _ = tx.send(());
702                Ok(())
703            }
704            None => Err(()),
705        }
706    }
707
708    /// Drop the installed sender (TUI shutdown). Idempotent.
709    pub fn clear(&self) {
710        *self.tx.lock().unwrap() = None;
711    }
712}
713
714/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
715/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
716/// this callback, which signals the TUI (if installed) and returns; the plugin
717/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
718/// TUI is installed, the callback returns without signalling and the host's
719/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
720pub fn reload_callback_from_mailbox(
721    mailbox: ReloadMailbox,
722) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
723    Arc::new(move || {
724        let m = mailbox.clone();
725        Box::pin(async move {
726            let _ = m.signal();
727        })
728    })
729}
730
731// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
732// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
733// naturally Send+Sync; no manual unsafe impl needed.
734
735/// Dispatch one action to the host. Async — runs on the bridge's runtime.
736/// Handles all 19 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
737/// (the "no callback configured" fallback). When the bridge has a reload
738/// callback, the spawn site intercepts `Reload` and awaits the callback
739/// instead (a CLI concern, not a harness op) — this helper is the plain
740/// host-only path.
741async fn dispatch(
742    host: &Arc<dyn RuntimeActionHost>,
743    ui_dialog: &UiDialogMailbox,
744    ext_status: &crate::status::ExtensionStatusMailbox,
745    editor_text: &crate::editor_text::EditorTextMailbox,
746    action: RuntimeActionId,
747    args: serde_json::Value,
748) -> Result<serde_json::Value, String> {
749    match action {
750        RuntimeActionId::SendMessage => host.send_message(args).await,
751        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
752        RuntimeActionId::AppendEntry => host.append_entry(args).await,
753        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
754        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
755        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
756        RuntimeActionId::SetModel => host.set_model(args).await,
757        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
758        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
759        RuntimeActionId::Compact => host.compact(args).await,
760        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
761        RuntimeActionId::NewSession => host.new_session(args).await,
762        RuntimeActionId::Fork => host.fork(args).await,
763        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
764        RuntimeActionId::SwitchSession => host.switch_session(args).await,
765        RuntimeActionId::Reload => host.reload(args).await,
766        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
767        RuntimeActionId::UiDialog => ui_dialog.handle(args),
768        // UI-only action: handled by the bridge (no harness call), exactly like
769        // `UiDialog`. Written straight into the shared registry the TUI polls.
770        RuntimeActionId::SetStatus => ext_status.handle(args),
771        RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
772        // Editor-only action: served by the bridge (no harness call), exactly
773        // like `SetStatus`/`UiDialog`. The TUI drains the queue each tick.
774        RuntimeActionId::SetEditorText => editor_text.handle(args),
775        RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
776        RuntimeActionId::StdioLifecycle => {
777            Err("stdio lifecycle requires a plugin context".to_owned())
778        }
779    }
780}
781
782/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
783/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
784///
785/// Recovers `&ActionBridge` from `user_data`, parses `args_json`, drives the
786/// host's async dispatch on the bridge's runtime via `Handle::spawn`, and parks
787/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
788/// runtime needed, which is the load-bearing property for foreign plugin
789/// threads).
790///
791/// ## Return codes
792/// - `0` — success; `*out` written with the result JSON (host-owned
793///   [`StbString`]; the plugin frees it via the host `free_string` from the
794///   vtable).
795/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
796/// - `2` — unknown numeric action id; `*out` contains a structured error and
797///   no host method is dispatched.
798/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
799/// - `-2` — the spawned task dropped its sender without sending (runtime
800///   shutdown / dispatch panic); no result available.
801///
802/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
803/// policy as the tool partial callback, `tool.rs:206`).
804pub extern "C" fn trampoline_runtime_action(
805    action_id: u32,
806    args_json: StbStringRef,
807    out: *mut StbString,
808    user_data: *mut c_void,
809) -> i32 {
810    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
811        run_action(
812            action_id,
813            // Highest id this ABI defines — bump when adding an action.
814            u32::from(RuntimeActionId::UiComponent),
815            args_json,
816            out,
817            user_data,
818        )
819    }));
820    match outcome {
821        Ok(rc) => rc,
822        Err(_) => {
823            // A panic in the host-side dispatch was caught. Do not abort the
824            // process: report a host-level error to the caller (documented
825            // code `1`) with a structured payload when `out` is writable.
826            tracing::error!("runtime_action trampoline panicked — reporting host error");
827            if !out.is_null() {
828                unsafe {
829                    *out = StbString::from_string(
830                        r#"{"error":"runtime_action panicked"}"#.to_string(),
831                    );
832                }
833            }
834            1
835        }
836    }
837}
838
839/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
840fn run_action(
841    action_id: u32,
842    max_action_id: u32,
843    args_json: StbStringRef,
844    out: *mut StbString,
845    user_data: *mut c_void,
846) -> i32 {
847    // Validate the raw FFI integer before dereferencing host state or entering
848    // the enum-based dispatch. Unknown values are ordinary protocol errors;
849    // they never become invalid Rust enum discriminants.
850    let action = match RuntimeActionId::try_from(action_id) {
851        Ok(action) if action_id <= max_action_id => action,
852        Ok(_) | Err(_) => {
853            if !out.is_null() {
854                let json = serde_json::json!({
855                    "error": format!("unknown runtime action id {action_id}")
856                })
857                .to_string();
858                // SAFETY: `out` is non-null and points to the caller-provided
859                // output slot. The plugin reclaims this host allocation via
860                // `host_free_string` from the vtable.
861                unsafe {
862                    *out = StbString::from_string(json);
863                }
864            }
865            return 2;
866        }
867    };
868
869    if user_data.is_null() {
870        return -1;
871    }
872    // SAFETY: the host guarantees `user_data` points at a live `ActionBridge`.
873    // `rpi-cli` builds one `Arc<ActionBridge>` per session and keeps it for the
874    // harness lifetime; `Arc::as_ptr` is pointer-stable while any clone lives.
875    // We only borrow for the duration of this call.
876    let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
877
878    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
879    // `invalidate` on the old one. A plugin that still holds the old pointer
880    // (stored during the prior `register`) must not drive the swapped-out
881    // session. Surface a structured "stale bridge" error so the plugin's
882    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
883    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
884    // this guard only catches the race window where an old call is still parked.)
885    if !bridge.is_active() {
886        if out.is_null() {
887            return 1;
888        }
889        let json = serde_json::json!({
890            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
891        })
892        .to_string();
893        unsafe {
894            *out = StbString::from_string(json);
895        }
896        return 1;
897    }
898
899    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
900    // args; setters that require a field surface a clear error string.
901    let args_str = unsafe { args_json.as_str() };
902    let args: serde_json::Value = if args_str.is_empty() {
903        serde_json::Value::Object(serde_json::Map::new())
904    } else {
905        serde_json::from_str(args_str)
906            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
907    };
908
909    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
910    // plugin thread can recv from any thread (no ambient runtime). sync_channel
911    // (1): bounded; the send completes once the value is delivered. rx.recv()
912    // parks the plugin thread until the spawn finishes (or its sender drops).
913    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
914    // Clone the bridge's host + reload callback into the spawned task. We pass
915    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
916    // `dispatch` helper has everything it needs without borrowing `bridge`.
917    let host = Arc::clone(&bridge.host);
918    let ui_dialog = bridge.ui_dialog.clone();
919    let ext_status = bridge.ext_status.clone();
920    let editor_text = bridge.editor_text.clone();
921    let reload_cb = bridge.reload.clone();
922    if action == RuntimeActionId::StdioLifecycle {
923        let result = bridge.stdio.handle(
924            bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
925            args,
926        );
927        let _ = tx.send(result);
928    } else if action == RuntimeActionId::UiComponent {
929        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
930        let _owners = bridge
931            .component_owners
932            .lock()
933            .unwrap_or_else(|e| e.into_inner());
934        let result = if bridge.is_active() {
935            ext_status.component(owner, args)
936        } else {
937            Err("stale plugin bridge".into())
938        };
939        let _ = tx.send(result);
940    } else if action == RuntimeActionId::SetStatus {
941        // Same reasoning as `UiDialog`: this action never touches the harness,
942        // so it must not need a Tokio worker. A plugin calling it from inside a
943        // single-threaded runtime (a tool poll callback, say) would otherwise
944        // park that executor and deadlock.
945        std::thread::spawn(move || {
946            let _ = tx.send(ext_status.handle(args));
947        });
948    } else if action == RuntimeActionId::SetEditorText {
949        // Editor-only, like `SetStatus`: no harness call, so keep it off the
950        // Tokio worker for the same single-thread-runtime reason.
951        std::thread::spawn(move || {
952            let _ = tx.send(editor_text.handle(args));
953        });
954    } else if action == RuntimeActionId::UiDialog {
955        // UiDialog is intentionally synchronous at the plugin ABI boundary:
956        // `open` waits until the TUI answers. Never park that wait inside the
957        // Tokio worker that services the bridge. A SEARCH/extension tool that
958        // invokes `ui_request/open` from a single-thread runtime would
959        // otherwise block the executor before the TUI can deliver the answer.
960        std::thread::spawn(move || {
961            let _ = tx.send(ui_dialog.handle(args));
962        });
963    } else {
964        bridge.runtime.spawn(async move {
965            let r = if action == RuntimeActionId::Reload {
966                if let Some(cb) = reload_cb {
967                    cb().await;
968                    Ok(serde_json::Value::Null)
969                } else {
970                    host.reload(args).await
971                }
972            } else {
973                dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
974            };
975            // If the plugin thread already moved on (dropped rx), discard — a
976            // send error is NOT a host fault.
977            let _ = tx.send(r);
978        });
979    }
980
981    let result = match rx.recv() {
982        Ok(r) => r,
983        Err(_) => {
984            // Spawned task dropped the sender without sending: runtime shutdown
985            // or the dispatch future panicked (caught inside dispatch? no —
986            // dispatch is plain async, a panic would propagate to the spawn and
987            // drop the sender). No result to return.
988            return -2;
989        }
990    };
991
992    // Write the result (or error) into `*out` as a host-owned StbString. The
993    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
994    if out.is_null() {
995        // Nothing to write to; still report the outcome via the return code.
996        return match result {
997            Ok(_) => 0,
998            Err(_) => 1,
999        };
1000    }
1001
1002    let (rc, payload) = match result {
1003        Ok(value) => {
1004            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1005            (0, json)
1006        }
1007        Err(msg) => {
1008            let json = serde_json::json!({ "error": msg }).to_string();
1009            (1, json)
1010        }
1011    };
1012    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
1013    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
1014    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
1015    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
1016    // used by translate.rs/tool.rs).
1017    unsafe {
1018        *out = StbString::from_string(payload);
1019    }
1020    rc
1021}
1022
1023// ===========================================================================
1024// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
1025// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
1026// recovers the `ActionBridge` via `user_data`, drives the host's async method on
1027// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
1028// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
1029// is the same `extern "C" fn` a plugin's vtable carries.
1030// ===========================================================================
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034    use std::sync::Mutex;
1035
1036    #[tokio::test(flavor = "current_thread")]
1037    async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1038        let master = ActionBridge::new(
1039            Handle::current(),
1040            Arc::new(MockHost {
1041                prompt: String::new(),
1042                saw: Mutex::new(Vec::new()),
1043            }),
1044        );
1045        let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1046        let owner = crate::HostApi::with_action_bridge(
1047            "owner",
1048            crate::ExtensionRegistry::new(),
1049            diag.clone(),
1050            master.clone(),
1051        );
1052        let other = crate::HostApi::with_action_bridge(
1053            "other",
1054            crate::ExtensionRegistry::new(),
1055            diag,
1056            master.clone(),
1057        );
1058        let vtable = owner.build_vtable();
1059        let claim = r#"{"ownsStdio":true}"#;
1060        {
1061            let _registration = crate::enter_current_api(&owner);
1062            assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1063        }
1064        let other_vtable = other.build_vtable();
1065        let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1066        let mut out = StbString::empty();
1067        // Direct, on a single-thread executor: this action must not park on Tokio.
1068        assert_eq!(
1069            (other_vtable.runtime_action)(
1070                RuntimeActionId::StdioLifecycle as u32,
1071                finish,
1072                &mut out,
1073                other_vtable.user_data
1074            ),
1075            1
1076        );
1077        // SAFETY: the action returned a live allocation from this vtable.
1078        unsafe { (other_vtable.free_string)(out) };
1079        assert_eq!(
1080            (vtable.runtime_action)(
1081                RuntimeActionId::StdioLifecycle as u32,
1082                finish,
1083                &mut out,
1084                vtable.user_data
1085            ),
1086            0
1087        );
1088        // SAFETY: the action returned a live allocation from this vtable.
1089        unsafe { (vtable.free_string)(out) };
1090        assert_eq!(master.stdio_lifecycle().wait().await, 7);
1091        {
1092            let _registration = crate::enter_current_api(&other);
1093            assert_ne!(
1094                other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1095                0
1096            );
1097        }
1098        assert!(master
1099            .stdio_lifecycle()
1100            .validate()
1101            .unwrap_err()
1102            .contains("owner"));
1103    }
1104
1105    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
1106    /// canned string and `reload` with Ok(null); every other action returns an
1107    /// "unimplemented" error. Enough to prove the dispatch switch + the async
1108    /// spawn + the mpsc round-trip + the StbString write.
1109    struct MockHost {
1110        prompt: String,
1111        saw: Mutex<Vec<RuntimeActionId>>,
1112    }
1113
1114    #[async_trait::async_trait]
1115    impl RuntimeActionHost for MockHost {
1116        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1117            unreachable!("not under test")
1118        }
1119        async fn send_user_message(
1120            &self,
1121            _: serde_json::Value,
1122        ) -> Result<serde_json::Value, String> {
1123            unreachable!("not under test")
1124        }
1125        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1126            unreachable!("not under test")
1127        }
1128        async fn set_session_name(
1129            &self,
1130            _: serde_json::Value,
1131        ) -> Result<serde_json::Value, String> {
1132            unreachable!("not under test")
1133        }
1134        async fn get_active_tools(
1135            &self,
1136            _: serde_json::Value,
1137        ) -> Result<serde_json::Value, String> {
1138            unreachable!("not under test")
1139        }
1140        async fn set_active_tools(
1141            &self,
1142            _: serde_json::Value,
1143        ) -> Result<serde_json::Value, String> {
1144            unreachable!("not under test")
1145        }
1146        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1147            unreachable!("not under test")
1148        }
1149        async fn get_thinking_level(
1150            &self,
1151            _: serde_json::Value,
1152        ) -> Result<serde_json::Value, String> {
1153            unreachable!("not under test")
1154        }
1155        async fn set_thinking_level(
1156            &self,
1157            _: serde_json::Value,
1158        ) -> Result<serde_json::Value, String> {
1159            unreachable!("not under test")
1160        }
1161        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1162            unreachable!("not under test")
1163        }
1164        async fn get_system_prompt(
1165            &self,
1166            _: serde_json::Value,
1167        ) -> Result<serde_json::Value, String> {
1168            self.saw
1169                .lock()
1170                .unwrap()
1171                .push(RuntimeActionId::GetSystemPrompt);
1172            Ok(serde_json::json!({ "prompt": self.prompt }))
1173        }
1174        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1175            unreachable!("not under test")
1176        }
1177        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1178            unreachable!("not under test")
1179        }
1180        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1181            unreachable!("not under test")
1182        }
1183        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1184            unreachable!("not under test")
1185        }
1186        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1187            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1188            Ok(serde_json::Value::Null)
1189        }
1190        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1191            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1192            Ok(serde_json::json!({ "value": true }))
1193        }
1194    }
1195
1196    /// NOTE: these tests use `flavor = "multi_thread"`. The trampoline parks the
1197    /// caller on a std `mpsc::rx.recv()` (sync blocking) while the dispatch runs
1198    /// via `Handle::spawn` on the runtime. Under a current-thread runtime the
1199    /// test's own worker is the only thread that can poll the spawned task —
1200    /// blocking it on `recv` self-deadlocks. In the real host the caller is a
1201    /// plugin / `spawn_blocking` thread (never a runtime worker), so there is no
1202    /// deadlock; multi_thread here mirrors that (another worker runs the spawn
1203    /// while the test thread parks).
1204    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1205    async fn trampoline_round_trips_get_system_prompt() {
1206        let host = Arc::new(MockHost {
1207            prompt: "hello from host".to_string(),
1208            saw: Mutex::new(Vec::new()),
1209        });
1210        let host_for_assert = Arc::clone(&host);
1211        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1212        let runtime = tokio::runtime::Handle::current();
1213        let bridge = ActionBridge::new(runtime, host_dyn);
1214        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1215
1216        // Build a StbStringRef for the args. `{}` — getters ignore it.
1217        let args_str = "{}";
1218        let args_ref = StbStringRef::from_str(args_str);
1219
1220        let mut out = StbString::empty();
1221        let rc = trampoline_runtime_action(
1222            RuntimeActionId::GetSystemPrompt.into(),
1223            args_ref,
1224            &mut out as *mut StbString,
1225            user_data,
1226        );
1227        assert_eq!(rc, 0, "success return code");
1228
1229        // Read the result JSON back + reclaim the host-owned StbString.
1230        // SAFETY: the action wrote a live host-owned output, freed below.
1231        let json_text = unsafe {
1232            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1233        }
1234        .to_string_lossy();
1235        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1236        assert_eq!(parsed["prompt"], "hello from host");
1237
1238        // The host saw exactly the one action. `host_for_assert` is a clone of
1239        // the `Arc<MockHost>` kept before it was coerced to the trait object.
1240        let saw = host_for_assert.saw.lock().unwrap().clone();
1241        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1242    }
1243
1244    /// The `SetEditorText` runtime action must reach the bridge's editor-text
1245    /// mailbox through the ABI trampoline. This is the wiring a plugin relies on
1246    /// (a voice extension writes a transcription draft here); a broken action id
1247    /// or a dropped dispatch would be invisible to the plugin — it only sees
1248    /// `ok: true` — so pin it here.
1249    #[tokio::test]
1250    async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1251        let host = Arc::new(MockHost {
1252            prompt: String::new(),
1253            saw: Mutex::new(Vec::new()),
1254        });
1255        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1256        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1257        let mailbox = bridge.editor_text_mailbox();
1258        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1259
1260        let args_ref =
1261            StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1262        let mut out = StbString::empty();
1263        let rc = trampoline_runtime_action(
1264            RuntimeActionId::SetEditorText.into(),
1265            args_ref,
1266            &mut out as *mut StbString,
1267            user_data,
1268        );
1269        assert_eq!(rc, 0, "success return code");
1270
1271        // SAFETY: the action wrote a live host-owned output, freed below.
1272        let parsed: serde_json::Value = serde_json::from_str(
1273            &unsafe {
1274                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1275            }
1276            .to_string_lossy(),
1277        )
1278        .expect("valid json");
1279        assert_eq!(parsed["ok"], serde_json::json!(true));
1280        assert_eq!(parsed["chars"], serde_json::json!(10));
1281
1282        // The queued edit is what the host will apply to the prompt editor.
1283        let edit = mailbox.take_pending().expect("queued edit");
1284        assert_eq!(edit.text, "from voice");
1285        assert_eq!(edit.mode, crate::EditorTextMode::Append);
1286        assert_eq!(edit.auto_send_ms, Some(2000));
1287        assert!(mailbox.take_pending().is_none());
1288    }
1289
1290    #[tokio::test]
1291    async fn trampoline_null_user_data_returns_minus_one() {
1292        let args_ref = StbStringRef::from_str("{}");
1293        let mut out = StbString::empty();
1294        let rc = trampoline_runtime_action(
1295            RuntimeActionId::GetSystemPrompt.into(),
1296            args_ref,
1297            &mut out as *mut StbString,
1298            std::ptr::null_mut(),
1299        );
1300        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1301    }
1302
1303    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1304    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1305        // A bridge with a reload callback: the spawn site intercepts Reload and
1306        // runs the callback instead of the host's `reload` method. Proves the
1307        // bridge-level special-casing (the host impl is the fallback only).
1308        use std::sync::atomic::{AtomicUsize, Ordering};
1309        let reload_calls = Arc::new(AtomicUsize::new(0));
1310        let reload_calls_for_cb = Arc::clone(&reload_calls);
1311        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1312            Arc::new(move || {
1313                let c = Arc::clone(&reload_calls_for_cb);
1314                Box::pin(async move {
1315                    c.fetch_add(1, Ordering::SeqCst);
1316                })
1317            });
1318        let host = Arc::new(MockHost {
1319            prompt: String::new(),
1320            saw: Mutex::new(Vec::new()),
1321        });
1322        let host_for_assert = Arc::clone(&host);
1323        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1324        let runtime = tokio::runtime::Handle::current();
1325        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1326        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1327
1328        let args_ref = StbStringRef::from_str("{}");
1329        let mut out = StbString::empty();
1330        let rc = trampoline_runtime_action(
1331            RuntimeActionId::Reload.into(),
1332            args_ref,
1333            &mut out as *mut StbString,
1334            user_data,
1335        );
1336        assert_eq!(rc, 0);
1337        // The callback ran once; the host's `reload` did NOT.
1338        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1339        assert!(host_for_assert.saw.lock().unwrap().is_empty());
1340        // SAFETY: this action returned a live host allocation, not previously freed.
1341        drop(unsafe {
1342            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1343        });
1344    }
1345
1346    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
1347    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
1348    /// bridge; an in-flight call that still holds the old pointer must fail
1349    /// fast rather than drive the swapped-out session.
1350    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1351    async fn trampoline_rejects_stale_bridge_with_invalidate() {
1352        let host = Arc::new(MockHost {
1353            prompt: String::new(),
1354            saw: Mutex::new(Vec::new()),
1355        });
1356        let host_for_assert = Arc::clone(&host);
1357        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1358        let runtime = tokio::runtime::Handle::current();
1359        let bridge = ActionBridge::new(runtime, host_dyn);
1360        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1361
1362        // Invalidate (as a `/reload` would on the old bridge).
1363        bridge.invalidate();
1364        assert!(!bridge.is_active());
1365
1366        let args_ref = StbStringRef::from_str("{}");
1367        let mut out = StbString::empty();
1368        let rc = trampoline_runtime_action(
1369            RuntimeActionId::GetSystemPrompt.into(),
1370            args_ref,
1371            &mut out as *mut StbString,
1372            user_data,
1373        );
1374        // Nonzero (error), and the host method never ran (no dispatch).
1375        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1376        // SAFETY: the action wrote a live host-owned output, freed below.
1377        let json_text = unsafe {
1378            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1379        }
1380        .to_string_lossy();
1381        assert!(
1382            json_text.contains("stale"),
1383            "stale-bridge error payload: {json_text}"
1384        );
1385
1386        assert!(
1387            host_for_assert.saw.lock().unwrap().is_empty(),
1388            "host dispatch must NOT run on a stale bridge"
1389        );
1390    }
1391
1392    #[tokio::test]
1393    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1394        let host = Arc::new(MockHost {
1395            prompt: String::new(),
1396            saw: Mutex::new(Vec::new()),
1397        });
1398        let host_for_assert = Arc::clone(&host);
1399        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1400        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1401        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1402
1403        let mut out = StbString::empty();
1404        let rc = trampoline_runtime_action(
1405            0xFFFF_FFFE,
1406            StbStringRef::from_str("{}"),
1407            &mut out,
1408            user_data,
1409        );
1410
1411        assert_eq!(rc, 2, "unknown action id must be a protocol error");
1412        let payload: serde_json::Value = serde_json::from_str(
1413            &unsafe {
1414                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1415            }
1416            .to_string_lossy(),
1417        )
1418        .expect("structured error JSON");
1419        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1420
1421        assert!(
1422            host_for_assert.saw.lock().unwrap().is_empty(),
1423            "unknown action id must not reach host dispatch"
1424        );
1425    }
1426
1427    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1428    async fn v2_trampoline_accepts_new_action() {
1429        let host = Arc::new(MockHost {
1430            prompt: "v2".to_string(),
1431            saw: Mutex::new(Vec::new()),
1432        });
1433        let host_for_assert = Arc::clone(&host);
1434        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1435        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1436        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1437
1438        let mut v2_out = StbString::empty();
1439        assert_eq!(
1440            trampoline_runtime_action(
1441                RuntimeActionId::GetCliFlag.into(),
1442                StbStringRef::from_str(r#"{"name":"flag"}"#),
1443                &mut v2_out,
1444                user_data,
1445            ),
1446            0
1447        );
1448        // SAFETY: this action returned a live host allocation, not previously freed.
1449        drop(unsafe {
1450            rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1451        });
1452
1453        assert_eq!(
1454            *host_for_assert.saw.lock().unwrap(),
1455            vec![RuntimeActionId::GetCliFlag]
1456        );
1457    }
1458
1459    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
1460    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
1461    /// host's "not configured" fallback). Exercises the B5d reload-signal path
1462    /// the TUI installs.
1463    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1464    async fn reload_mailbox_signals_installed_receiver() {
1465        let mailbox = ReloadMailbox::new();
1466        // No receiver installed yet ⇒ signal fails.
1467        assert!(matches!(mailbox.signal(), Err(())));
1468
1469        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1470        mailbox.install(tx);
1471        let cb = reload_callback_from_mailbox(mailbox.clone());
1472        cb().await;
1473        assert_eq!(
1474            rx.recv().await,
1475            Some(()),
1476            "installed receiver saw the signal"
1477        );
1478        mailbox.clear();
1479    }
1480
1481    // ---- UiDialogMailbox (runtime action 17 / ask_user) ----
1482
1483    fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1484        serde_json::json!({
1485            "op": "open",
1486            "requestId": id,
1487            "toolCallId": tool_call,
1488            "ui": {"kind": "selector", "questions": [
1489                {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1490            ]},
1491        })
1492    }
1493
1494    /// Headless hosts (nothing attached) must fail loudly rather than fabricate
1495    /// a user answer.
1496    #[test]
1497    fn ui_dialog_open_requires_an_attached_ui() {
1498        let mailbox = UiDialogMailbox::new();
1499        let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1500        assert!(error.contains("interactive UI"), "got: {error}");
1501    }
1502
1503    #[test]
1504    fn ui_dialog_open_poll_answer_round_trip() {
1505        let mailbox = UiDialogMailbox::new();
1506        mailbox.attach();
1507
1508        let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1509        assert_eq!(opened["status"], "pending");
1510        assert_eq!(opened["toolCallId"], "c1");
1511
1512        let request = mailbox.take_pending().expect("request visible to TUI");
1513        assert_eq!(request.request_id, "r1");
1514        assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1515        // A pending poll is non-destructive and remains available.
1516        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1517        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1518
1519        mailbox
1520            .respond("r1", serde_json::json!("7890"))
1521            .expect("respond");
1522        let answered = mailbox.poll("r1").unwrap();
1523        assert_eq!(answered["status"], "answered");
1524        assert_eq!(answered["answer"], "7890");
1525        // Polling consumes the terminal entry.
1526        assert!(mailbox.poll("r1").is_err());
1527    }
1528
1529    #[test]
1530    fn ui_dialog_cancel_is_reported_once() {
1531        let mailbox = UiDialogMailbox::new();
1532        mailbox.attach();
1533        mailbox.handle(open_args("r1", "c1")).unwrap();
1534        mailbox.cancel("r1").unwrap();
1535        let cancelled = mailbox.poll("r1").unwrap();
1536        assert_eq!(cancelled["status"], "cancelled");
1537        assert!(mailbox.poll("r1").is_err());
1538    }
1539
1540    #[test]
1541    fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1542        let mailbox = UiDialogMailbox::new();
1543        mailbox.attach();
1544        mailbox.handle(open_args("r1", "c1")).unwrap();
1545        mailbox.handle(open_args("r2", "c2")).unwrap();
1546        mailbox.cancel_tool_call("c1");
1547        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1548        assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1549        assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1550        assert_eq!(
1551            mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1552            "cancelled"
1553        );
1554        assert!(mailbox.take_pending().is_none());
1555    }
1556
1557    #[test]
1558    fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1559        let mailbox = UiDialogMailbox::new();
1560        mailbox.attach();
1561        mailbox.handle(open_args("r1", "c1")).unwrap();
1562        mailbox.handle(open_args("r2", "c2")).unwrap();
1563
1564        let first = mailbox.take_pending().unwrap().request_id;
1565        let second = mailbox.take_pending().unwrap().request_id;
1566        assert_ne!(first, second);
1567
1568        // Answer the SECOND request first; the first must stay pending.
1569        mailbox
1570            .respond(&second, serde_json::json!("second"))
1571            .unwrap();
1572        assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1573        let second_answer = mailbox.poll(&second).unwrap();
1574        assert_eq!(second_answer["answer"], "second");
1575
1576        mailbox.respond(&first, serde_json::json!("first")).unwrap();
1577        assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1578    }
1579
1580    #[test]
1581    fn ui_dialog_detach_cancels_outstanding() {
1582        let mailbox = UiDialogMailbox::new();
1583        mailbox.attach();
1584        mailbox.handle(open_args("r1", "c1")).unwrap();
1585        mailbox.detach();
1586        assert!(!mailbox.is_attached());
1587        assert_eq!(mailbox.pending_len(), 0);
1588        // Re-opening without a consumer is an explicit error again.
1589        assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1590        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1591    }
1592
1593    #[test]
1594    fn ui_dialog_duplicate_open_does_not_reenqueue() {
1595        let mailbox = UiDialogMailbox::new();
1596        mailbox.attach();
1597        mailbox.handle(open_args("r1", "c1")).unwrap();
1598        let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1599        assert_eq!(again["status"], "pending");
1600        assert_eq!(mailbox.pending_len(), 1);
1601        mailbox.take_pending().unwrap();
1602        assert!(mailbox.take_pending().is_none());
1603    }
1604
1605    /// The v2 trampoline must route action 17 into the session mailbox rather
1606    /// than rejecting it as unknown, and a detached mailbox must surface the
1607    /// explicit "requires an interactive UI" error (headless contract).
1608    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1609    async fn v2_dispatch_routes_ui_dialog_action_17() {
1610        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1611            prompt: String::new(),
1612            saw: Mutex::new(Vec::new()),
1613        });
1614        let bridge = ActionBridge::with_ui_dialog(
1615            tokio::runtime::Handle::current(),
1616            host,
1617            UiDialogMailbox::new(),
1618        );
1619        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1620
1621        let mut out = StbString::empty();
1622        let rc = trampoline_runtime_action(
1623            17,
1624            StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1625            &mut out,
1626            user_data,
1627        );
1628        assert_eq!(rc, 1);
1629        let payload: serde_json::Value = serde_json::from_str(
1630            &unsafe {
1631                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1632            }
1633            .to_string_lossy(),
1634        )
1635        .expect("structured error JSON");
1636        assert!(
1637            payload["error"]
1638                .as_str()
1639                .is_some_and(|error| error.contains("interactive UI")),
1640            "unexpected payload: {payload}"
1641        );
1642
1643        // A genuinely unknown id is still rejected (no silent acceptance).
1644        // 23 is one past the highest defined action.
1645        let mut out = StbString::empty();
1646        assert_eq!(
1647            trampoline_runtime_action(23, StbStringRef::from_str("{}"), &mut out, user_data),
1648            2
1649        );
1650        // SAFETY: this action returned a live host allocation, not previously freed.
1651        drop(unsafe {
1652            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1653        });
1654    }
1655
1656    #[tokio::test(flavor = "current_thread")]
1657    async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1658        let host = Arc::new(MockHost {
1659            prompt: String::new(),
1660            saw: Mutex::new(Vec::new()),
1661        });
1662        let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1663        let a = root.for_plugin("a");
1664        let b = root.for_plugin("b");
1665        let mailbox = root.extension_status_mailbox();
1666        for plugin in [&a, &b] {
1667            let mut out = StbString::empty();
1668            assert_eq!(
1669                trampoline_runtime_action(
1670                    22,
1671                    StbStringRef::from_str(
1672                        r#"{"op":"register","id":"same","slot":"footer.after"}"#
1673                    ),
1674                    &mut out,
1675                    Arc::as_ptr(plugin) as *mut c_void
1676                ),
1677                0
1678            );
1679            // SAFETY: this action returned a live host allocation, not previously freed.
1680            drop(unsafe {
1681                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1682            });
1683        }
1684        assert_eq!(mailbox.panels().len(), 2);
1685        drop(a);
1686        assert_eq!(mailbox.panels().len(), 1);
1687        root.invalidate();
1688        assert!(mailbox.panels().is_empty());
1689    }
1690
1691    /// Action 18 (`SetStatus`) is bridge-local, like `UiDialog`: it writes the
1692    /// shared registry the TUI polls and never touches the harness.
1693    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1694    async fn v2_dispatch_routes_set_status_action_18() {
1695        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1696            prompt: String::new(),
1697            saw: Mutex::new(Vec::new()),
1698        });
1699        let status = crate::status::ExtensionStatusMailbox::new();
1700        let bridge = ActionBridge::with_reload_and_ui(
1701            tokio::runtime::Handle::current(),
1702            host,
1703            Arc::new(|| Box::pin(async {})),
1704            UiDialogMailbox::new(),
1705            status.clone(),
1706            crate::editor_text::EditorTextMailbox::new(),
1707        );
1708        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1709
1710        let mut out = StbString::empty();
1711        let rc = trampoline_runtime_action(
1712            18,
1713            StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1714            &mut out,
1715            user_data,
1716        );
1717        assert_eq!(rc, 0);
1718        assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1719        // SAFETY: this action returned a live host allocation, not previously freed.
1720        drop(unsafe {
1721            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1722        });
1723
1724        // Clearing through the same action empties the line again.
1725        let mut out = StbString::empty();
1726        assert_eq!(
1727            trampoline_runtime_action(
1728                18,
1729                StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1730                &mut out,
1731                user_data
1732            ),
1733            0
1734        );
1735        assert_eq!(status.text(), "");
1736        // SAFETY: this action returned a live host allocation, not previously freed.
1737        drop(unsafe {
1738            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1739        });
1740
1741        // A missing key is a host-level error (rc=1), not an unknown action.
1742        let mut out = StbString::empty();
1743        assert_eq!(
1744            trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1745            1
1746        );
1747        // SAFETY: this action returned a live host allocation, not previously freed.
1748        drop(unsafe {
1749            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1750        });
1751    }
1752}
1753
1754impl Drop for ActionBridge {
1755    fn drop(&mut self) {
1756        if let Some((owner, _)) = &self.plugin_identity {
1757            self.ext_status.remove_owner(*owner);
1758        }
1759    }
1760}