Skip to main content

rpi_extensions/
actions.rs

1//! B5a — the plugin→host `runtime_action` bridge (inverted FFI).
2//!
3//! A plugin invokes `PluginApiVt::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 [`PluginApiVt::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::ffi::c_void;
38use std::collections::{HashMap, VecDeque};
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}
70
71/// Thread-safe mailbox connecting plugin runtime actions to an interactive
72/// host (normally the TUI). It deliberately carries only JSON and primitives,
73/// so the extension crate remains independent of the UI crate.
74#[derive(Clone)]
75pub struct UiDialogMailbox {
76    state: Arc<Mutex<UiDialogState>>,
77}
78
79impl Default for UiDialogMailbox {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl UiDialogMailbox {
86    pub fn new() -> Self {
87        Self {
88            state: Arc::new(Mutex::new(UiDialogState {
89                attached: false,
90                pending: VecDeque::new(),
91                active: HashMap::new(),
92            })),
93        }
94    }
95
96    /// Mark an interactive consumer as available. Re-attaching is idempotent.
97    pub fn attach(&self) {
98        self.state.lock().expect("ui dialog mailbox poisoned").attached = true;
99    }
100
101    /// Detach the consumer and cancel every outstanding request. This prevents
102    /// a TUI restart/reload from leaving plugin polls blocked forever.
103    pub fn detach(&self) {
104        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
105        state.attached = false;
106        state.pending.clear();
107        for entry in state.active.values_mut() {
108            entry.cancelled = true;
109            entry.answer = None;
110        }
111    }
112
113    pub fn is_attached(&self) -> bool {
114        self.state
115            .lock()
116            .expect("ui dialog mailbox poisoned")
117            .attached
118    }
119
120    /// Remove and return the next request for presentation by the TUI.
121    /// Cancelled/answered requests are skipped; duplicate `open` calls never
122    /// enqueue the same request id twice.
123    pub fn take_pending(&self) -> Option<UiDialogRequest> {
124        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
125        while let Some(request_id) = state.pending.pop_front() {
126            if let Some(entry) = state.active.get(&request_id) {
127                if !entry.cancelled && entry.answer.is_none() {
128                    return Some(entry.request.clone());
129                }
130            }
131        }
132        None
133    }
134
135    pub fn pending_len(&self) -> usize {
136        self.state
137            .lock()
138            .expect("ui dialog mailbox poisoned")
139            .pending
140            .len()
141    }
142
143    /// Supply an answer for a request. Repeating the same operation is
144    /// idempotent; answering a cancelled/unknown request is an explicit error.
145    pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
146        let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
147        let entry = state
148            .active
149            .get_mut(request_id)
150            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
151        if entry.cancelled {
152            return Err(format!("UI request `{request_id}` was cancelled"));
153        }
154        if entry.answer.is_none() {
155            entry.answer = Some(answer);
156        }
157        Ok(())
158    }
159
160    /// Mark one request cancelled. The terminal state remains visible to the
161    /// plugin's next `poll`, then is reclaimed by that poll.
162    pub fn cancel(&self, request_id: &str) -> Result<(), String> {
163        let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
164        let entry = state
165            .active
166            .get_mut(request_id)
167            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
168        entry.cancelled = true;
169        entry.answer = None;
170        state.pending.retain(|id| id != request_id);
171        Ok(())
172    }
173
174    /// Cancel all requests currently known to the mailbox.
175    pub fn cancel_all(&self) {
176        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
177        state.pending.clear();
178        for entry in state.active.values_mut() {
179            entry.cancelled = true;
180            entry.answer = None;
181        }
182    }
183
184    /// Poll a request and consume a terminal answer/cancellation. Pending
185    /// polls are cheap and preserve the request for subsequent calls.
186    pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
187        let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
188        let terminal = match state.active.get(request_id) {
189            Some(entry) if entry.cancelled => Some(serde_json::json!({
190                "status": "cancelled",
191                "requestId": request_id,
192                "toolCallId": entry.request.tool_call_id,
193            })),
194            Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
195                "status": "answered",
196                "requestId": request_id,
197                "toolCallId": entry.request.tool_call_id,
198                "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
199            })),
200            Some(entry) => Some(serde_json::json!({
201                "status": "pending",
202                "requestId": request_id,
203                "toolCallId": entry.request.tool_call_id,
204            })),
205            None => None,
206        };
207        let Some(result) = terminal else {
208            return Err(format!("unknown UI request `{request_id}`"));
209        };
210        if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
211            state.active.remove(request_id);
212        }
213        Ok(result)
214    }
215
216    /// Parse and execute the JSON action envelope used by runtime action 17.
217    /// Supported operations are `open`, `poll`, `cancel`; `response` is also
218    /// accepted as a convenience for hosts that proxy answers through JSON.
219    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
220        let op = args.get("op").and_then(serde_json::Value::as_str).unwrap_or("open");
221        let request_id = || {
222            args.get("requestId")
223                .or_else(|| args.get("request_id"))
224                .and_then(serde_json::Value::as_str)
225                .map(str::to_owned)
226                .filter(|id| !id.trim().is_empty())
227                .ok_or_else(|| "missing non-empty `requestId`".to_string())
228        };
229        match op {
230            "open" => {
231                let id = request_id()?;
232                let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
233                if !state.attached {
234                    return Err("ask_user requires an interactive UI".to_string());
235                }
236                if let Some(entry) = state.active.get(&id) {
237                    return Ok(if entry.cancelled {
238                        serde_json::json!({"status":"cancelled","requestId":id})
239                    } else if let Some(answer) = &entry.answer {
240                        serde_json::json!({"status":"answered","requestId":id,"answer":answer})
241                    } else {
242                        serde_json::json!({"status":"pending","requestId":id})
243                    });
244                }
245                let tool_call_id = args
246                    .get("toolCallId")
247                    .or_else(|| args.get("tool_call_id"))
248                    .and_then(serde_json::Value::as_str)
249                    .map(str::to_owned);
250                let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
251                state.active.insert(
252                    id.clone(),
253                    UiDialogEntry {
254                        request: UiDialogRequest {
255                            request_id: id.clone(),
256                            tool_call_id: tool_call_id.clone(),
257                            ui,
258                            raw: args,
259                        },
260                        answer: None,
261                        cancelled: false,
262                    },
263                );
264                state.pending.push_back(id.clone());
265                Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
266            }
267            "poll" => self.poll(&request_id()?),
268            "cancel" => {
269                let id = request_id()?;
270                self.cancel(&id)?;
271                Ok(serde_json::json!({"status":"cancelled","requestId":id}))
272            }
273            "response" | "respond" => {
274                let id = request_id()?;
275                let answer = args
276                    .get("answer")
277                    .or_else(|| args.get("value"))
278                    .cloned()
279                    .unwrap_or(serde_json::Value::Null);
280                self.respond(&id, answer)?;
281                Ok(serde_json::json!({"status":"answered","requestId":id}))
282            }
283            other => Err(format!("unknown UI dialog operation `{other}`")),
284        }
285    }
286}
287
288/// The host-side implementation the bridge delegates to. Defined in
289/// `rpi-extensions` (NOT `rpi-harness`) so the crate DAG stays a leaf: this is a
290/// trait the **host** (`rpi-cli`) implements over the harness — `rpi-extensions`
291/// only names the async surface + carries JSON params/results. No `rpi-harness`
292/// types appear in the trait.
293///
294/// Each method corresponds to one [`RuntimeActionId`] variant. Complex args
295/// arrive as parsed JSON (`serde_json::Value`); complex results return as
296/// `serde_json::Value`. The host maps its native types (Model, AgentMessage, …)
297/// to/from JSON at the impl boundary. Errors are `String` (become the action's
298/// nonzero `i32` + `{"error": msg}` JSON on the plugin side).
299///
300/// The 18 methods map 1:1 to [`RuntimeActionId`]; `dispatch` below is the
301/// exhaustive switch that connects the FFI id to the method.
302#[async_trait::async_trait]
303pub trait RuntimeActionHost: Send + Sync {
304    /// `SendMessage` — drive a full agent run from an assistant/user message.
305    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
306    /// `SendUserMessage` — drive a run from a user-text message.
307    async fn send_user_message(&self, args: serde_json::Value)
308        -> Result<serde_json::Value, String>;
309    /// `AppendEntry` — append a raw entry to the session transcript (no run).
310    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
311    /// `SetSessionName` — set the session's display name.
312    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
313    /// `GetActiveTools` — the active tool-name list.
314    async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
315    /// `SetActiveTools` — replace the active tool-name list.
316    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
317    /// `SetModel` — switch the active model (by id; host resolves to a `Model`).
318    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
319    /// `GetThinkingLevel` — the current thinking level.
320    async fn get_thinking_level(
321        &self,
322        args: serde_json::Value,
323    ) -> Result<serde_json::Value, String>;
324    /// `SetThinkingLevel` — set the thinking level.
325    async fn set_thinking_level(
326        &self,
327        args: serde_json::Value,
328    ) -> Result<serde_json::Value, String>;
329    /// `Compact` — compact the session transcript.
330    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
331    /// `GetSystemPrompt` — the live composed system prompt.
332    async fn get_system_prompt(&self, args: serde_json::Value)
333        -> Result<serde_json::Value, String>;
334    /// `NewSession` — start a fresh session and switch to it.
335    async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
336    /// `Fork` — fork the current session and switch to the fork.
337    async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
338    /// `NavigateTree` — navigate/rewind the session tree.
339    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
340    /// `SwitchSession` — hot-switch to an existing session by id.
341    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
342    /// `Reload` — re-run extension discovery + Part-A resource loaders (a CLI
343    /// concern; the host impl wires it to the `ActionBridge.reload` callback in
344    /// B5d — until then this returns an "unsupported" error string).
345    async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
346
347    /// `GetCliFlag` — read a parsed extension CLI flag. Args are
348    /// `{"name":"flag"}` and the result is `{"value": <bool|string|null>}`.
349    async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
350        Err("CLI flag lookup is not configured".to_string())
351    }
352
353    /// `UiDialog` fallback for hosts that do not install an interactive
354    /// mailbox. The default is deliberately an error so headless runs cannot
355    /// accidentally report a fabricated user answer.
356    async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
357        Err("ask_user requires an interactive UI".to_string())
358    }
359}
360
361/// The host-side bridge carried in [`PluginApiVt::user_data`] so
362/// [`trampoline_runtime_action`] can recover the harness state from any thread.
363///
364/// `runtime: Handle` is captured at build time (the host is on the runtime when
365/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
366/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
367/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
368/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
369///
370/// ## Staleness (B5d)
371///
372/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
373/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
374/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
375/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
376/// In-flight `runtime_action` calls that recovered the OLD bridge from
377/// `user_data` (the pointer a plugin stored during the prior `register`) then
378/// hit the staleness guard in [`run_action`] and fail with a structured error
379/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
380/// handing them the NEW bridge pointer; the guard only catches the race window
381/// where an old call is still parked on `rx.recv()`.)
382///
383/// Held behind `Arc` (pointer-stable for the bridge's lifetime via
384/// [`Arc::as_ptr`]); `rpi-cli` keeps one clone for the session lifetime so the
385/// pointer a plugin stored during register stays valid post-register. (The
386/// transient `HostApi` built per `load_one` holds a clone only during register
387/// — when it drops after `take_registry`, the master `Arc` in `rpi-cli` keeps
388/// the allocation alive.)
389pub struct ActionBridge {
390    /// Captured at build time from a thread running the target runtime.
391    pub(crate) runtime: Handle,
392    /// The host impl (`HarnessActionHost` in rpi-cli).
393    pub(crate) host: Arc<dyn RuntimeActionHost>,
394    /// B5d reload callback; `None` until the TUI wires `/reload`.
395    pub(crate) reload:
396        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
397    /// Session-scoped UI mailbox. It is cloned into reload-created bridges so
398    /// a TUI attachment and in-flight requests survive extension reloads.
399    ui_dialog: UiDialogMailbox,
400    /// Session-scoped extension status registry (`SetStatus` runtime action).
401    /// Shared with the TUI, which polls [`ExtensionStatusMailbox::revision`]
402    /// and repaints the footer only when an extension actually wrote.
403    ext_status: crate::status::ExtensionStatusMailbox,
404    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
405    /// `true` while this bridge is the live session's bridge.
406    active: Arc<AtomicBool>,
407}
408
409impl ActionBridge {
410    /// Build a bridge. The `Handle` MUST be captured from a thread running the
411    /// target runtime (pi-cli builds the bridge on the async main thread).
412    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
413        Arc::new(Self {
414            runtime,
415            host,
416            reload: None,
417            ui_dialog: UiDialogMailbox::new(),
418            ext_status: crate::status::ExtensionStatusMailbox::new(),
419            active: Arc::new(AtomicBool::new(true)),
420        })
421    }
422
423    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
424    /// [`reload_callback_from_mailbox`]).
425    pub fn with_reload(
426        runtime: Handle,
427        host: Arc<dyn RuntimeActionHost>,
428        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
429    ) -> Arc<Self> {
430        Self::with_reload_and_ui(
431            runtime,
432            host,
433            reload,
434            UiDialogMailbox::new(),
435            crate::status::ExtensionStatusMailbox::new(),
436        )
437    }
438
439    /// Build a bridge with an explicit session-scoped UI mailbox.
440    pub fn with_ui_dialog(
441        runtime: Handle,
442        host: Arc<dyn RuntimeActionHost>,
443        ui_dialog: UiDialogMailbox,
444    ) -> Arc<Self> {
445        Arc::new(Self {
446            runtime,
447            host,
448            reload: None,
449            ui_dialog,
450            ext_status: crate::status::ExtensionStatusMailbox::new(),
451            active: Arc::new(AtomicBool::new(true)),
452        })
453    }
454
455    /// `with_reload` variant that preserves a mailbox across bridge swaps.
456    pub fn with_reload_and_ui(
457        runtime: Handle,
458        host: Arc<dyn RuntimeActionHost>,
459        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
460        ui_dialog: UiDialogMailbox,
461        ext_status: crate::status::ExtensionStatusMailbox,
462    ) -> Arc<Self> {
463        Arc::new(Self {
464            runtime,
465            host,
466            reload: Some(reload),
467            ui_dialog,
468            ext_status,
469            active: Arc::new(AtomicBool::new(true)),
470        })
471    }
472
473    /// Clone the session mailbox for TUI attachment or a reload-created bridge.
474    pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
475        self.ui_dialog.clone()
476    }
477
478    /// Clone the session status registry (TUI rendering + reload-created bridges).
479    pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
480        self.ext_status.clone()
481    }
482
483    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
484    /// calls this on the old one so in-flight `runtime_action` calls parked on
485    /// the old `user_data` pointer fail fast with a staleness error instead of
486    /// driving the swapped-out session. Idempotent.
487    pub fn invalidate(&self) {
488        self.active.store(false, Ordering::SeqCst);
489    }
490
491    /// Whether this bridge is still the live session's bridge.
492    pub fn is_active(&self) -> bool {
493        self.active.load(Ordering::SeqCst)
494    }
495
496    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
497    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
498    /// at the live harness — the harness is NOT rebuilt on reload — so the host
499    /// is reusable across reloads; only the bridge's staleness flag + reload
500    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
501    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
502    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
503        Arc::clone(&self.host)
504    }
505}
506
507/// A reload-signal mail slot (B5d). The reload callback (built by
508/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
509/// `tokio` unbounded sender after it starts. When a plugin calls
510/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
511/// and the TUI performs the reload **asynchronously** — the plugin's call
512/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
513/// unmapped while its `runtime_action` frame is still on the stack (the reload,
514/// which drops the old keepalive, happens after the call returns). This breaks
515/// the self-unmapping race a synchronous plugin-initiated reload would have.
516///
517/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
518/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
519#[derive(Clone)]
520pub struct ReloadMailbox {
521    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
522}
523
524impl Default for ReloadMailbox {
525    fn default() -> Self {
526        Self {
527            tx: Arc::new(std::sync::Mutex::new(None)),
528        }
529    }
530}
531
532impl ReloadMailbox {
533    /// A fresh empty mail slot (no TUI installed yet).
534    pub fn new() -> Self {
535        Self::default()
536    }
537
538    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
539    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
540    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
541    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
542        *self.tx.lock().unwrap() = Some(tx);
543    }
544
545    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
546    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
547    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
548    /// a "reload not available" error to the plugin).
549    pub fn signal(&self) -> Result<(), ()> {
550        let g = self.tx.lock().unwrap();
551        match &*g {
552            Some(tx) => {
553                let _ = tx.send(());
554                Ok(())
555            }
556            None => Err(()),
557        }
558    }
559
560    /// Drop the installed sender (TUI shutdown). Idempotent.
561    pub fn clear(&self) {
562        *self.tx.lock().unwrap() = None;
563    }
564}
565
566/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
567/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
568/// this callback, which signals the TUI (if installed) and returns; the plugin
569/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
570/// TUI is installed, the callback returns without signalling and the host's
571/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
572pub fn reload_callback_from_mailbox(
573    mailbox: ReloadMailbox,
574) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
575    Arc::new(move || {
576        let m = mailbox.clone();
577        Box::pin(async move {
578            let _ = m.signal();
579        })
580    })
581}
582
583// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
584// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
585// naturally Send+Sync; no manual unsafe impl needed.
586
587/// Dispatch one action to the host. Async — runs on the bridge's runtime.
588/// Handles all 18 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
589/// (the "no callback configured" fallback). When the bridge has a reload
590/// callback, the spawn site intercepts `Reload` and awaits the callback
591/// instead (a CLI concern, not a harness op) — this helper is the plain
592/// host-only path.
593async fn dispatch(
594    host: &Arc<dyn RuntimeActionHost>,
595    ui_dialog: &UiDialogMailbox,
596    ext_status: &crate::status::ExtensionStatusMailbox,
597    action: RuntimeActionId,
598    args: serde_json::Value,
599) -> Result<serde_json::Value, String> {
600    match action {
601        RuntimeActionId::SendMessage => host.send_message(args).await,
602        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
603        RuntimeActionId::AppendEntry => host.append_entry(args).await,
604        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
605        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
606        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
607        RuntimeActionId::SetModel => host.set_model(args).await,
608        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
609        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
610        RuntimeActionId::Compact => host.compact(args).await,
611        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
612        RuntimeActionId::NewSession => host.new_session(args).await,
613        RuntimeActionId::Fork => host.fork(args).await,
614        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
615        RuntimeActionId::SwitchSession => host.switch_session(args).await,
616        RuntimeActionId::Reload => host.reload(args).await,
617        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
618        RuntimeActionId::UiDialog => ui_dialog.handle(args),
619        // UI-only action: handled by the bridge (no harness call), exactly like
620        // `UiDialog`. Written straight into the shared registry the TUI polls.
621        RuntimeActionId::SetStatus => ext_status.handle(args),
622    }
623}
624
625/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
626/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
627///
628/// Recovers `&ActionBridge` from `user_data`, parses `args_json`, drives the
629/// host's async dispatch on the bridge's runtime via `Handle::spawn`, and parks
630/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
631/// runtime needed, which is the load-bearing property for foreign plugin
632/// threads).
633///
634/// ## Return codes
635/// - `0` — success; `*out` written with the result JSON (host-owned
636///   [`StbString`]; the plugin frees it via the host `free_string` from the
637///   vtable).
638/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
639/// - `2` — unknown numeric action id; `*out` contains a structured error and
640///   no host method is dispatched.
641/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
642/// - `-2` — the spawned task dropped its sender without sending (runtime
643///   shutdown / dispatch panic); no result available.
644///
645/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
646/// policy as the tool partial callback, `tool.rs:206`).
647pub extern "C" fn trampoline_runtime_action(
648    action_id: u32,
649    args_json: StbStringRef,
650    out: *mut StbString,
651    user_data: *mut c_void,
652) -> i32 {
653    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
654        run_action(
655            action_id,
656            // Highest id this ABI defines — bump when adding an action.
657            u32::from(RuntimeActionId::SetStatus),
658            args_json,
659            out,
660            user_data,
661        )
662    }));
663    match outcome {
664        Ok(rc) => rc,
665        Err(_) => {
666            // A panic in the host-side dispatch was caught. Do not abort the
667            // process: report a host-level error to the caller (documented
668            // code `1`) with a structured payload when `out` is writable.
669            tracing::error!("runtime_action trampoline panicked — reporting host error");
670            if !out.is_null() {
671                unsafe {
672                    *out = StbString::from_string(
673                        r#"{"error":"runtime_action panicked"}"#.to_string(),
674                    );
675                }
676            }
677            1
678        }
679    }
680}
681
682/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
683fn run_action(
684    action_id: u32,
685    max_action_id: u32,
686    args_json: StbStringRef,
687    out: *mut StbString,
688    user_data: *mut c_void,
689) -> i32 {
690    // Validate the raw FFI integer before dereferencing host state or entering
691    // the enum-based dispatch. Unknown values are ordinary protocol errors;
692    // they never become invalid Rust enum discriminants.
693    let action = match RuntimeActionId::try_from(action_id) {
694        Ok(action) if action_id <= max_action_id => action,
695        Ok(_) | Err(_) => {
696            if !out.is_null() {
697                let json = serde_json::json!({
698                    "error": format!("unknown runtime action id {action_id}")
699                })
700                .to_string();
701                // SAFETY: `out` is non-null and points to the caller-provided
702                // output slot. The plugin reclaims this host allocation via
703                // `host_free_string` from the vtable.
704                unsafe {
705                    *out = StbString::from_string(json);
706                }
707            }
708            return 2;
709        }
710    };
711
712    if user_data.is_null() {
713        return -1;
714    }
715    // SAFETY: the host guarantees `user_data` points at a live `ActionBridge`.
716    // `rpi-cli` builds one `Arc<ActionBridge>` per session and keeps it for the
717    // harness lifetime; `Arc::as_ptr` is pointer-stable while any clone lives.
718    // We only borrow for the duration of this call.
719    let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
720
721    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
722    // `invalidate` on the old one. A plugin that still holds the old pointer
723    // (stored during the prior `register`) must not drive the swapped-out
724    // session. Surface a structured "stale bridge" error so the plugin's
725    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
726    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
727    // this guard only catches the race window where an old call is still parked.)
728    if !bridge.is_active() {
729        if out.is_null() {
730            return 1;
731        }
732        let json = serde_json::json!({
733            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
734        })
735        .to_string();
736        unsafe {
737            *out = StbString::from_string(json);
738        }
739        return 1;
740    }
741
742    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
743    // args; setters that require a field surface a clear error string.
744    let args_str = unsafe { args_json.as_str() };
745    let args: serde_json::Value = if args_str.is_empty() {
746        serde_json::Value::Object(serde_json::Map::new())
747    } else {
748        serde_json::from_str(args_str)
749            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
750    };
751
752    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
753    // plugin thread can recv from any thread (no ambient runtime). sync_channel
754    // (1): bounded; the send completes once the value is delivered. rx.recv()
755    // parks the plugin thread until the spawn finishes (or its sender drops).
756    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
757    // Clone the bridge's host + reload callback into the spawned task. We pass
758    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
759    // `dispatch` helper has everything it needs without borrowing `bridge`.
760    let host = Arc::clone(&bridge.host);
761    let ui_dialog = bridge.ui_dialog.clone();
762    let ext_status = bridge.ext_status.clone();
763    let reload_cb = bridge.reload.clone();
764    if action == RuntimeActionId::SetStatus {
765        // Same reasoning as `UiDialog`: this action never touches the harness,
766        // so it must not need a Tokio worker. A plugin calling it from inside a
767        // single-threaded runtime (a tool poll callback, say) would otherwise
768        // park that executor and deadlock.
769        std::thread::spawn(move || {
770            let _ = tx.send(ext_status.handle(args));
771        });
772    } else if action == RuntimeActionId::UiDialog {
773        // UiDialog is intentionally synchronous at the plugin ABI boundary:
774        // `open` waits until the TUI answers. Never park that wait inside the
775        // Tokio worker that services the bridge. A SEARCH/extension tool that
776        // invokes `ui_request/open` from a single-thread runtime would
777        // otherwise block the executor before the TUI can deliver the answer.
778        std::thread::spawn(move || {
779            let _ = tx.send(ui_dialog.handle(args));
780        });
781    } else {
782        bridge.runtime.spawn(async move {
783            let r = if action == RuntimeActionId::Reload {
784                if let Some(cb) = reload_cb {
785                    cb().await;
786                    Ok(serde_json::Value::Null)
787                } else {
788                    host.reload(args).await
789                }
790            } else {
791                dispatch(&host, &ui_dialog, &ext_status, action, args).await
792            };
793            // If the plugin thread already moved on (dropped rx), discard — a
794            // send error is NOT a host fault.
795            let _ = tx.send(r);
796        });
797    }
798
799    let result = match rx.recv() {
800        Ok(r) => r,
801        Err(_) => {
802            // Spawned task dropped the sender without sending: runtime shutdown
803            // or the dispatch future panicked (caught inside dispatch? no —
804            // dispatch is plain async, a panic would propagate to the spawn and
805            // drop the sender). No result to return.
806            return -2;
807        }
808    };
809
810    // Write the result (or error) into `*out` as a host-owned StbString. The
811    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
812    if out.is_null() {
813        // Nothing to write to; still report the outcome via the return code.
814        return match result {
815            Ok(_) => 0,
816            Err(_) => 1,
817        };
818    }
819
820    let (rc, payload) = match result {
821        Ok(value) => {
822            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
823            (0, json)
824        }
825        Err(msg) => {
826            let json = serde_json::json!({ "error": msg }).to_string();
827            (1, json)
828        }
829    };
830    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
831    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
832    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
833    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
834    // used by translate.rs/tool.rs).
835    unsafe {
836        *out = StbString::from_string(payload);
837    }
838    rc
839}
840
841// ===========================================================================
842// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
843// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
844// recovers the `ActionBridge` via `user_data`, drives the host's async method on
845// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
846// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
847// is the same `extern "C" fn` a plugin's vtable carries.
848// ===========================================================================
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use std::sync::Mutex;
853
854    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
855    /// canned string and `reload` with Ok(null); every other action returns an
856    /// "unimplemented" error. Enough to prove the dispatch switch + the async
857    /// spawn + the mpsc round-trip + the StbString write.
858    struct MockHost {
859        prompt: String,
860        saw: Mutex<Vec<RuntimeActionId>>,
861    }
862
863    #[async_trait::async_trait]
864    impl RuntimeActionHost for MockHost {
865        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
866            unreachable!("not under test")
867        }
868        async fn send_user_message(
869            &self,
870            _: serde_json::Value,
871        ) -> Result<serde_json::Value, String> {
872            unreachable!("not under test")
873        }
874        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
875            unreachable!("not under test")
876        }
877        async fn set_session_name(
878            &self,
879            _: serde_json::Value,
880        ) -> Result<serde_json::Value, String> {
881            unreachable!("not under test")
882        }
883        async fn get_active_tools(
884            &self,
885            _: serde_json::Value,
886        ) -> Result<serde_json::Value, String> {
887            unreachable!("not under test")
888        }
889        async fn set_active_tools(
890            &self,
891            _: serde_json::Value,
892        ) -> Result<serde_json::Value, String> {
893            unreachable!("not under test")
894        }
895        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
896            unreachable!("not under test")
897        }
898        async fn get_thinking_level(
899            &self,
900            _: serde_json::Value,
901        ) -> Result<serde_json::Value, String> {
902            unreachable!("not under test")
903        }
904        async fn set_thinking_level(
905            &self,
906            _: serde_json::Value,
907        ) -> Result<serde_json::Value, String> {
908            unreachable!("not under test")
909        }
910        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
911            unreachable!("not under test")
912        }
913        async fn get_system_prompt(
914            &self,
915            _: serde_json::Value,
916        ) -> Result<serde_json::Value, String> {
917            self.saw
918                .lock()
919                .unwrap()
920                .push(RuntimeActionId::GetSystemPrompt);
921            Ok(serde_json::json!({ "prompt": self.prompt }))
922        }
923        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
924            unreachable!("not under test")
925        }
926        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
927            unreachable!("not under test")
928        }
929        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
930            unreachable!("not under test")
931        }
932        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
933            unreachable!("not under test")
934        }
935        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
936            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
937            Ok(serde_json::Value::Null)
938        }
939        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
940            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
941            Ok(serde_json::json!({ "value": true }))
942        }
943    }
944
945    /// NOTE: these tests use `flavor = "multi_thread"`. The trampoline parks the
946    /// caller on a std `mpsc::rx.recv()` (sync blocking) while the dispatch runs
947    /// via `Handle::spawn` on the runtime. Under a current-thread runtime the
948    /// test's own worker is the only thread that can poll the spawned task —
949    /// blocking it on `recv` self-deadlocks. In the real host the caller is a
950    /// plugin / `spawn_blocking` thread (never a runtime worker), so there is no
951    /// deadlock; multi_thread here mirrors that (another worker runs the spawn
952    /// while the test thread parks).
953    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
954    async fn trampoline_round_trips_get_system_prompt() {
955        let host = Arc::new(MockHost {
956            prompt: "hello from host".to_string(),
957            saw: Mutex::new(Vec::new()),
958        });
959        let host_for_assert = Arc::clone(&host);
960        let host_dyn: Arc<dyn RuntimeActionHost> = host;
961        let runtime = tokio::runtime::Handle::current();
962        let bridge = ActionBridge::new(runtime, host_dyn);
963        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
964
965        // Build a StbStringRef for the args. `{}` — getters ignore it.
966        let args_str = "{}";
967        let args_ref = StbStringRef::from_str(args_str);
968
969        let mut out = StbString::empty();
970        let rc = trampoline_runtime_action(
971            RuntimeActionId::GetSystemPrompt.into(),
972            args_ref,
973            &mut out as *mut StbString,
974            user_data,
975        );
976        assert_eq!(rc, 0, "success return code");
977
978        // Read the result JSON back + reclaim the host-owned StbString.
979        let json_text = out.to_string_lossy();
980        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
981        assert_eq!(parsed["prompt"], "hello from host");
982        crate::host_free_string(out);
983
984        // The host saw exactly the one action. `host_for_assert` is a clone of
985        // the `Arc<MockHost>` kept before it was coerced to the trait object.
986        let saw = host_for_assert.saw.lock().unwrap().clone();
987        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
988    }
989
990    #[tokio::test]
991    async fn trampoline_null_user_data_returns_minus_one() {
992        let args_ref = StbStringRef::from_str("{}");
993        let mut out = StbString::empty();
994        let rc = trampoline_runtime_action(
995            RuntimeActionId::GetSystemPrompt.into(),
996            args_ref,
997            &mut out as *mut StbString,
998            std::ptr::null_mut(),
999        );
1000        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1001    }
1002
1003    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1004    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1005        // A bridge with a reload callback: the spawn site intercepts Reload and
1006        // runs the callback instead of the host's `reload` method. Proves the
1007        // bridge-level special-casing (the host impl is the fallback only).
1008        use std::sync::atomic::{AtomicUsize, Ordering};
1009        let reload_calls = Arc::new(AtomicUsize::new(0));
1010        let reload_calls_for_cb = Arc::clone(&reload_calls);
1011        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1012            Arc::new(move || {
1013                let c = Arc::clone(&reload_calls_for_cb);
1014                Box::pin(async move {
1015                    c.fetch_add(1, Ordering::SeqCst);
1016                })
1017            });
1018        let host = Arc::new(MockHost {
1019            prompt: String::new(),
1020            saw: Mutex::new(Vec::new()),
1021        });
1022        let host_for_assert = Arc::clone(&host);
1023        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1024        let runtime = tokio::runtime::Handle::current();
1025        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1026        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1027
1028        let args_ref = StbStringRef::from_str("{}");
1029        let mut out = StbString::empty();
1030        let rc = trampoline_runtime_action(
1031            RuntimeActionId::Reload.into(),
1032            args_ref,
1033            &mut out as *mut StbString,
1034            user_data,
1035        );
1036        assert_eq!(rc, 0);
1037        // The callback ran once; the host's `reload` did NOT.
1038        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1039        assert!(host_for_assert.saw.lock().unwrap().is_empty());
1040        crate::host_free_string(out);
1041    }
1042
1043    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
1044    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
1045    /// bridge; an in-flight call that still holds the old pointer must fail
1046    /// fast rather than drive the swapped-out session.
1047    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1048    async fn trampoline_rejects_stale_bridge_with_invalidate() {
1049        let host = Arc::new(MockHost {
1050            prompt: String::new(),
1051            saw: Mutex::new(Vec::new()),
1052        });
1053        let host_for_assert = Arc::clone(&host);
1054        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1055        let runtime = tokio::runtime::Handle::current();
1056        let bridge = ActionBridge::new(runtime, host_dyn);
1057        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1058
1059        // Invalidate (as a `/reload` would on the old bridge).
1060        bridge.invalidate();
1061        assert!(!bridge.is_active());
1062
1063        let args_ref = StbStringRef::from_str("{}");
1064        let mut out = StbString::empty();
1065        let rc = trampoline_runtime_action(
1066            RuntimeActionId::GetSystemPrompt.into(),
1067            args_ref,
1068            &mut out as *mut StbString,
1069            user_data,
1070        );
1071        // Nonzero (error), and the host method never ran (no dispatch).
1072        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1073        let json_text = out.to_string_lossy();
1074        assert!(
1075            json_text.contains("stale"),
1076            "stale-bridge error payload: {json_text}"
1077        );
1078        crate::host_free_string(out);
1079        assert!(
1080            host_for_assert.saw.lock().unwrap().is_empty(),
1081            "host dispatch must NOT run on a stale bridge"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1087        let host = Arc::new(MockHost {
1088            prompt: String::new(),
1089            saw: Mutex::new(Vec::new()),
1090        });
1091        let host_for_assert = Arc::clone(&host);
1092        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1093        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1094        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1095
1096        let mut out = StbString::empty();
1097        let rc = trampoline_runtime_action(
1098            0xFFFF_FFFE,
1099            StbStringRef::from_str("{}"),
1100            &mut out,
1101            user_data,
1102        );
1103
1104        assert_eq!(rc, 2, "unknown action id must be a protocol error");
1105        let payload: serde_json::Value =
1106            serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1107        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1108        crate::host_free_string(out);
1109        assert!(
1110            host_for_assert.saw.lock().unwrap().is_empty(),
1111            "unknown action id must not reach host dispatch"
1112        );
1113    }
1114
1115    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1116    async fn v2_trampoline_accepts_new_action() {
1117        let host = Arc::new(MockHost {
1118            prompt: "v2".to_string(),
1119            saw: Mutex::new(Vec::new()),
1120        });
1121        let host_for_assert = Arc::clone(&host);
1122        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1123        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1124        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1125
1126        let mut v2_out = StbString::empty();
1127        assert_eq!(
1128            trampoline_runtime_action(
1129                RuntimeActionId::GetCliFlag.into(),
1130                StbStringRef::from_str(r#"{"name":"flag"}"#),
1131                &mut v2_out,
1132                user_data,
1133            ),
1134            0
1135        );
1136        crate::host_free_string(v2_out);
1137
1138        assert_eq!(
1139            *host_for_assert.saw.lock().unwrap(),
1140            vec![RuntimeActionId::GetCliFlag]
1141        );
1142    }
1143
1144    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
1145    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
1146    /// host's "not configured" fallback). Exercises the B5d reload-signal path
1147    /// the TUI installs.
1148    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1149    async fn reload_mailbox_signals_installed_receiver() {
1150        let mailbox = ReloadMailbox::new();
1151        // No receiver installed yet ⇒ signal fails.
1152        assert!(matches!(mailbox.signal(), Err(())));
1153
1154        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1155        mailbox.install(tx);
1156        let cb = reload_callback_from_mailbox(mailbox.clone());
1157        cb().await;
1158        assert_eq!(
1159            rx.recv().await,
1160            Some(()),
1161            "installed receiver saw the signal"
1162        );
1163        mailbox.clear();
1164    }
1165
1166    // ---- UiDialogMailbox (runtime action 17 / ask_user) ----
1167
1168    fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1169        serde_json::json!({
1170            "op": "open",
1171            "requestId": id,
1172            "toolCallId": tool_call,
1173            "ui": {"kind": "selector", "questions": [
1174                {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1175            ]},
1176        })
1177    }
1178
1179    /// Headless hosts (nothing attached) must fail loudly rather than fabricate
1180    /// a user answer.
1181    #[test]
1182    fn ui_dialog_open_requires_an_attached_ui() {
1183        let mailbox = UiDialogMailbox::new();
1184        let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1185        assert!(error.contains("interactive UI"), "got: {error}");
1186    }
1187
1188    #[test]
1189    fn ui_dialog_open_poll_answer_round_trip() {
1190        let mailbox = UiDialogMailbox::new();
1191        mailbox.attach();
1192
1193        let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1194        assert_eq!(opened["status"], "pending");
1195        assert_eq!(opened["toolCallId"], "c1");
1196
1197        let request = mailbox.take_pending().expect("request visible to TUI");
1198        assert_eq!(request.request_id, "r1");
1199        assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1200        // A pending poll is non-destructive and remains available.
1201        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1202        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1203
1204        mailbox
1205            .respond("r1", serde_json::json!("7890"))
1206            .expect("respond");
1207        let answered = mailbox.poll("r1").unwrap();
1208        assert_eq!(answered["status"], "answered");
1209        assert_eq!(answered["answer"], "7890");
1210        // Polling consumes the terminal entry.
1211        assert!(mailbox.poll("r1").is_err());
1212    }
1213
1214    #[test]
1215    fn ui_dialog_cancel_is_reported_once() {
1216        let mailbox = UiDialogMailbox::new();
1217        mailbox.attach();
1218        mailbox.handle(open_args("r1", "c1")).unwrap();
1219        mailbox.cancel("r1").unwrap();
1220        let cancelled = mailbox.poll("r1").unwrap();
1221        assert_eq!(cancelled["status"], "cancelled");
1222        assert!(mailbox.poll("r1").is_err());
1223    }
1224
1225    #[test]
1226    fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1227        let mailbox = UiDialogMailbox::new();
1228        mailbox.attach();
1229        mailbox.handle(open_args("r1", "c1")).unwrap();
1230        mailbox.handle(open_args("r2", "c2")).unwrap();
1231
1232        let first = mailbox.take_pending().unwrap().request_id;
1233        let second = mailbox.take_pending().unwrap().request_id;
1234        assert_ne!(first, second);
1235
1236        // Answer the SECOND request first; the first must stay pending.
1237        mailbox.respond(&second, serde_json::json!("second")).unwrap();
1238        assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1239        let second_answer = mailbox.poll(&second).unwrap();
1240        assert_eq!(second_answer["answer"], "second");
1241
1242        mailbox.respond(&first, serde_json::json!("first")).unwrap();
1243        assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1244    }
1245
1246    #[test]
1247    fn ui_dialog_detach_cancels_outstanding() {
1248        let mailbox = UiDialogMailbox::new();
1249        mailbox.attach();
1250        mailbox.handle(open_args("r1", "c1")).unwrap();
1251        mailbox.detach();
1252        assert!(!mailbox.is_attached());
1253        assert_eq!(mailbox.pending_len(), 0);
1254        // Re-opening without a consumer is an explicit error again.
1255        assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1256        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1257    }
1258
1259    #[test]
1260    fn ui_dialog_duplicate_open_does_not_reenqueue() {
1261        let mailbox = UiDialogMailbox::new();
1262        mailbox.attach();
1263        mailbox.handle(open_args("r1", "c1")).unwrap();
1264        let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1265        assert_eq!(again["status"], "pending");
1266        assert_eq!(mailbox.pending_len(), 1);
1267        mailbox.take_pending().unwrap();
1268        assert!(mailbox.take_pending().is_none());
1269    }
1270
1271    /// The v2 trampoline must route action 17 into the session mailbox rather
1272    /// than rejecting it as unknown, and a detached mailbox must surface the
1273    /// explicit "requires an interactive UI" error (headless contract).
1274    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1275    async fn v2_dispatch_routes_ui_dialog_action_17() {
1276        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1277            prompt: String::new(),
1278            saw: Mutex::new(Vec::new()),
1279        });
1280        let bridge = ActionBridge::with_ui_dialog(
1281            tokio::runtime::Handle::current(),
1282            host,
1283            UiDialogMailbox::new(),
1284        );
1285        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1286
1287        let mut out = StbString::empty();
1288        let rc = trampoline_runtime_action(
1289            17,
1290            StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1291            &mut out,
1292            user_data,
1293        );
1294        assert_eq!(rc, 1);
1295        let payload: serde_json::Value =
1296            serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1297        assert!(
1298            payload["error"]
1299                .as_str()
1300                .is_some_and(|error| error.contains("interactive UI")),
1301            "unexpected payload: {payload}"
1302        );
1303        crate::host_free_string(out);
1304
1305        // A genuinely unknown id is still rejected (no silent acceptance).
1306        let mut out = StbString::empty();
1307        assert_eq!(
1308            trampoline_runtime_action(19, StbStringRef::from_str("{}"), &mut out, user_data),
1309            2
1310        );
1311        crate::host_free_string(out);
1312    }
1313
1314    /// Action 18 (`SetStatus`) is bridge-local, like `UiDialog`: it writes the
1315    /// shared registry the TUI polls and never touches the harness.
1316    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1317    async fn v2_dispatch_routes_set_status_action_18() {
1318        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1319            prompt: String::new(),
1320            saw: Mutex::new(Vec::new()),
1321        });
1322        let status = crate::status::ExtensionStatusMailbox::new();
1323        let bridge = ActionBridge::with_reload_and_ui(
1324            tokio::runtime::Handle::current(),
1325            host,
1326            Arc::new(|| Box::pin(async {})),
1327            UiDialogMailbox::new(),
1328            status.clone(),
1329        );
1330        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1331
1332        let mut out = StbString::empty();
1333        let rc = trampoline_runtime_action(
1334            18,
1335            StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1336            &mut out,
1337            user_data,
1338        );
1339        assert_eq!(rc, 0);
1340        assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1341        crate::host_free_string(out);
1342
1343        // Clearing through the same action empties the line again.
1344        let mut out = StbString::empty();
1345        assert_eq!(
1346            trampoline_runtime_action(
1347                18,
1348                StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1349                &mut out,
1350                user_data
1351            ),
1352            0
1353        );
1354        assert_eq!(status.text(), "");
1355        crate::host_free_string(out);
1356
1357        // A missing key is a host-level error (rc=1), not an unknown action.
1358        let mut out = StbString::empty();
1359        assert_eq!(
1360            trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1361            1
1362        );
1363        crate::host_free_string(out);
1364    }
1365}