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