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::sync::atomic::{AtomicBool, Ordering};
39use std::sync::{mpsc, Arc};
40
41use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
42use tokio::runtime::Handle;
43
44/// The host-side implementation the bridge delegates to. Defined in
45/// `rpi-extensions` (NOT `rpi-harness`) so the crate DAG stays a leaf: this is a
46/// trait the **host** (`rpi-cli`) implements over the harness — `rpi-extensions`
47/// only names the async surface + carries JSON params/results. No `rpi-harness`
48/// types appear in the trait.
49///
50/// Each method corresponds to one [`RuntimeActionId`] variant. Complex args
51/// arrive as parsed JSON (`serde_json::Value`); complex results return as
52/// `serde_json::Value`. The host maps its native types (Model, AgentMessage, …)
53/// to/from JSON at the impl boundary. Errors are `String` (become the action's
54/// nonzero `i32` + `{"error": msg}` JSON on the plugin side).
55///
56/// The 17 methods map 1:1 to [`RuntimeActionId`]; `dispatch` below is the
57/// exhaustive switch that connects the FFI id to the method.
58#[async_trait::async_trait]
59pub trait RuntimeActionHost: Send + Sync {
60    /// `SendMessage` — drive a full agent run from an assistant/user message.
61    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
62    /// `SendUserMessage` — drive a run from a user-text message.
63    async fn send_user_message(&self, args: serde_json::Value)
64        -> Result<serde_json::Value, String>;
65    /// `AppendEntry` — append a raw entry to the session transcript (no run).
66    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
67    /// `SetSessionName` — set the session's display name.
68    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
69    /// `GetActiveTools` — the active tool-name list.
70    async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
71    /// `SetActiveTools` — replace the active tool-name list.
72    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
73    /// `SetModel` — switch the active model (by id; host resolves to a `Model`).
74    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
75    /// `GetThinkingLevel` — the current thinking level.
76    async fn get_thinking_level(
77        &self,
78        args: serde_json::Value,
79    ) -> Result<serde_json::Value, String>;
80    /// `SetThinkingLevel` — set the thinking level.
81    async fn set_thinking_level(
82        &self,
83        args: serde_json::Value,
84    ) -> Result<serde_json::Value, String>;
85    /// `Compact` — compact the session transcript.
86    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
87    /// `GetSystemPrompt` — the live composed system prompt.
88    async fn get_system_prompt(&self, args: serde_json::Value)
89        -> Result<serde_json::Value, String>;
90    /// `NewSession` — start a fresh session and switch to it.
91    async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
92    /// `Fork` — fork the current session and switch to the fork.
93    async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
94    /// `NavigateTree` — navigate/rewind the session tree.
95    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
96    /// `SwitchSession` — hot-switch to an existing session by id.
97    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
98    /// `Reload` — re-run extension discovery + Part-A resource loaders (a CLI
99    /// concern; the host impl wires it to the `ActionBridge.reload` callback in
100    /// B5d — until then this returns an "unsupported" error string).
101    async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
102
103    /// `GetCliFlag` — read a parsed extension CLI flag. Args are
104    /// `{"name":"flag"}` and the result is `{"value": <bool|string|null>}`.
105    async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
106        Err("CLI flag lookup is not configured".to_string())
107    }
108}
109
110/// The host-side bridge carried in [`PluginApiVt::user_data`] so
111/// [`trampoline_runtime_action`] can recover the harness state from any thread.
112///
113/// `runtime: Handle` is captured at build time (the host is on the runtime when
114/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
115/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
116/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
117/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
118///
119/// ## Staleness (B5d)
120///
121/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
122/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
123/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
124/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
125/// In-flight `runtime_action` calls that recovered the OLD bridge from
126/// `user_data` (the pointer a plugin stored during the prior `register`) then
127/// hit the staleness guard in [`run_action`] and fail with a structured error
128/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
129/// handing them the NEW bridge pointer; the guard only catches the race window
130/// where an old call is still parked on `rx.recv()`.)
131///
132/// Held behind `Arc` (pointer-stable for the bridge's lifetime via
133/// [`Arc::as_ptr`]); `rpi-cli` keeps one clone for the session lifetime so the
134/// pointer a plugin stored during register stays valid post-register. (The
135/// transient `HostApi` built per `load_one` holds a clone only during register
136/// — when it drops after `take_registry`, the master `Arc` in `rpi-cli` keeps
137/// the allocation alive.)
138pub struct ActionBridge {
139    /// Captured at build time from a thread running the target runtime.
140    pub(crate) runtime: Handle,
141    /// The host impl (`HarnessActionHost` in rpi-cli).
142    pub(crate) host: Arc<dyn RuntimeActionHost>,
143    /// B5d reload callback; `None` until the TUI wires `/reload`.
144    pub(crate) reload:
145        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
146    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
147    /// `true` while this bridge is the live session's bridge.
148    active: Arc<AtomicBool>,
149}
150
151impl ActionBridge {
152    /// Build a bridge. The `Handle` MUST be captured from a thread running the
153    /// target runtime (pi-cli builds the bridge on the async main thread).
154    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
155        Arc::new(Self {
156            runtime,
157            host,
158            reload: None,
159            active: Arc::new(AtomicBool::new(true)),
160        })
161    }
162
163    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
164    /// [`reload_callback_from_mailbox`]).
165    pub fn with_reload(
166        runtime: Handle,
167        host: Arc<dyn RuntimeActionHost>,
168        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
169    ) -> Arc<Self> {
170        Arc::new(Self {
171            runtime,
172            host,
173            reload: Some(reload),
174            active: Arc::new(AtomicBool::new(true)),
175        })
176    }
177
178    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
179    /// calls this on the old one so in-flight `runtime_action` calls parked on
180    /// the old `user_data` pointer fail fast with a staleness error instead of
181    /// driving the swapped-out session. Idempotent.
182    pub fn invalidate(&self) {
183        self.active.store(false, Ordering::SeqCst);
184    }
185
186    /// Whether this bridge is still the live session's bridge.
187    pub fn is_active(&self) -> bool {
188        self.active.load(Ordering::SeqCst)
189    }
190
191    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
192    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
193    /// at the live harness — the harness is NOT rebuilt on reload — so the host
194    /// is reusable across reloads; only the bridge's staleness flag + reload
195    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
196    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
197    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
198        Arc::clone(&self.host)
199    }
200}
201
202/// A reload-signal mail slot (B5d). The reload callback (built by
203/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
204/// `tokio` unbounded sender after it starts. When a plugin calls
205/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
206/// and the TUI performs the reload **asynchronously** — the plugin's call
207/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
208/// unmapped while its `runtime_action` frame is still on the stack (the reload,
209/// which drops the old keepalive, happens after the call returns). This breaks
210/// the self-unmapping race a synchronous plugin-initiated reload would have.
211///
212/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
213/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
214#[derive(Clone)]
215pub struct ReloadMailbox {
216    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
217}
218
219impl Default for ReloadMailbox {
220    fn default() -> Self {
221        Self {
222            tx: Arc::new(std::sync::Mutex::new(None)),
223        }
224    }
225}
226
227impl ReloadMailbox {
228    /// A fresh empty mail slot (no TUI installed yet).
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
234    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
235    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
236    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
237        *self.tx.lock().unwrap() = Some(tx);
238    }
239
240    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
241    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
242    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
243    /// a "reload not available" error to the plugin).
244    pub fn signal(&self) -> Result<(), ()> {
245        let g = self.tx.lock().unwrap();
246        match &*g {
247            Some(tx) => {
248                let _ = tx.send(());
249                Ok(())
250            }
251            None => Err(()),
252        }
253    }
254
255    /// Drop the installed sender (TUI shutdown). Idempotent.
256    pub fn clear(&self) {
257        *self.tx.lock().unwrap() = None;
258    }
259}
260
261/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
262/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
263/// this callback, which signals the TUI (if installed) and returns; the plugin
264/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
265/// TUI is installed, the callback returns without signalling and the host's
266/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
267pub fn reload_callback_from_mailbox(
268    mailbox: ReloadMailbox,
269) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
270    Arc::new(move || {
271        let m = mailbox.clone();
272        Box::pin(async move {
273            let _ = m.signal();
274        })
275    })
276}
277
278// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
279// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
280// naturally Send+Sync; no manual unsafe impl needed.
281
282/// Dispatch one action to the host. Async — runs on the bridge's runtime.
283/// Handles all 17 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
284/// (the "no callback configured" fallback). When the bridge has a reload
285/// callback, the spawn site intercepts `Reload` and awaits the callback
286/// instead (a CLI concern, not a harness op) — this helper is the plain
287/// host-only path.
288async fn dispatch(
289    host: &Arc<dyn RuntimeActionHost>,
290    action: RuntimeActionId,
291    args: serde_json::Value,
292) -> Result<serde_json::Value, String> {
293    match action {
294        RuntimeActionId::SendMessage => host.send_message(args).await,
295        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
296        RuntimeActionId::AppendEntry => host.append_entry(args).await,
297        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
298        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
299        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
300        RuntimeActionId::SetModel => host.set_model(args).await,
301        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
302        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
303        RuntimeActionId::Compact => host.compact(args).await,
304        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
305        RuntimeActionId::NewSession => host.new_session(args).await,
306        RuntimeActionId::Fork => host.fork(args).await,
307        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
308        RuntimeActionId::SwitchSession => host.switch_session(args).await,
309        RuntimeActionId::Reload => host.reload(args).await,
310        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
311    }
312}
313
314/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
315/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
316///
317/// Recovers `&ActionBridge` from `user_data`, parses `args_json`, drives the
318/// host's async dispatch on the bridge's runtime via `Handle::spawn`, and parks
319/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
320/// runtime needed, which is the load-bearing property for foreign plugin
321/// threads).
322///
323/// ## Return codes
324/// - `0` — success; `*out` written with the result JSON (host-owned
325///   [`StbString`]; the plugin frees it via the host `free_string` from the
326///   vtable).
327/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
328/// - `2` — unknown numeric action id; `*out` contains a structured error and
329///   no host method is dispatched.
330/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
331/// - `-2` — the spawned task dropped its sender without sending (runtime
332///   shutdown / dispatch panic); no result available.
333///
334/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
335/// policy as the tool partial callback, `tool.rs:206`).
336pub extern "C" fn trampoline_runtime_action(
337    action_id: u32,
338    args_json: StbStringRef,
339    out: *mut StbString,
340    user_data: *mut c_void,
341) -> i32 {
342    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
343        run_action(
344            action_id,
345            u32::from(RuntimeActionId::GetCliFlag),
346            args_json,
347            out,
348            user_data,
349        )
350    }));
351    match outcome {
352        Ok(rc) => rc,
353        Err(_) => {
354            tracing::error!(
355                "runtime_action trampoline panicked — aborting (cannot unwind across FFI)"
356            );
357            std::process::abort();
358        }
359    }
360}
361
362/// ABI v1 runtime-action trampoline. The legacy vtable has the same physical
363/// slot shape, but only the historical action ids `0..=15` are valid.
364pub extern "C" fn trampoline_runtime_action_v1(
365    action_id: u32,
366    args_json: StbStringRef,
367    out: *mut StbString,
368    user_data: *mut c_void,
369) -> i32 {
370    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
371        run_action(
372            action_id,
373            u32::from(RuntimeActionId::Reload),
374            args_json,
375            out,
376            user_data,
377        )
378    }));
379    match outcome {
380        Ok(rc) => rc,
381        Err(_) => {
382            tracing::error!(
383                "ABI v1 runtime_action trampoline panicked - aborting (cannot unwind across FFI)"
384            );
385            std::process::abort();
386        }
387    }
388}
389
390/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
391fn run_action(
392    action_id: u32,
393    max_action_id: u32,
394    args_json: StbStringRef,
395    out: *mut StbString,
396    user_data: *mut c_void,
397) -> i32 {
398    // Validate the raw FFI integer before dereferencing host state or entering
399    // the enum-based dispatch. Unknown values are ordinary protocol errors;
400    // they never become invalid Rust enum discriminants.
401    let action = match RuntimeActionId::try_from(action_id) {
402        Ok(action) if action_id <= max_action_id => action,
403        Ok(_) | Err(_) => {
404            if !out.is_null() {
405                let json = serde_json::json!({
406                    "error": format!("unknown runtime action id {action_id}")
407                })
408                .to_string();
409                // SAFETY: `out` is non-null and points to the caller-provided
410                // output slot. The plugin reclaims this host allocation via
411                // `host_free_string` from the vtable.
412                unsafe {
413                    *out = StbString::from_string(json);
414                }
415            }
416            return 2;
417        }
418    };
419
420    if user_data.is_null() {
421        return -1;
422    }
423    // SAFETY: the host guarantees `user_data` points at a live `ActionBridge`.
424    // `rpi-cli` builds one `Arc<ActionBridge>` per session and keeps it for the
425    // harness lifetime; `Arc::as_ptr` is pointer-stable while any clone lives.
426    // We only borrow for the duration of this call.
427    let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
428
429    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
430    // `invalidate` on the old one. A plugin that still holds the old pointer
431    // (stored during the prior `register`) must not drive the swapped-out
432    // session. Surface a structured "stale bridge" error so the plugin's
433    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
434    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
435    // this guard only catches the race window where an old call is still parked.)
436    if !bridge.is_active() {
437        if out.is_null() {
438            return 1;
439        }
440        let json = serde_json::json!({
441            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
442        })
443        .to_string();
444        unsafe {
445            *out = StbString::from_string(json);
446        }
447        return 1;
448    }
449
450    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
451    // args; setters that require a field surface a clear error string.
452    let args_str = unsafe { args_json.as_str() };
453    let args: serde_json::Value = if args_str.is_empty() {
454        serde_json::Value::Object(serde_json::Map::new())
455    } else {
456        serde_json::from_str(args_str)
457            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
458    };
459
460    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
461    // plugin thread can recv from any thread (no ambient runtime). sync_channel
462    // (1): bounded; the send completes once the value is delivered. rx.recv()
463    // parks the plugin thread until the spawn finishes (or its sender drops).
464    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
465    // Clone the bridge's host + reload callback into the spawned task. We pass
466    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
467    // `dispatch` helper has everything it needs without borrowing `bridge`.
468    let host = Arc::clone(&bridge.host);
469    let reload_cb = bridge.reload.clone();
470    bridge.runtime.spawn(async move {
471        let r = if action == RuntimeActionId::Reload {
472            if let Some(cb) = reload_cb {
473                cb().await;
474                Ok(serde_json::Value::Null)
475            } else {
476                host.reload(args).await
477            }
478        } else {
479            dispatch(&host, action, args).await
480        };
481        // If the plugin thread already moved on (dropped rx), discard — a send
482        // error is NOT a host fault.
483        let _ = tx.send(r);
484    });
485
486    let result = match rx.recv() {
487        Ok(r) => r,
488        Err(_) => {
489            // Spawned task dropped the sender without sending: runtime shutdown
490            // or the dispatch future panicked (caught inside dispatch? no —
491            // dispatch is plain async, a panic would propagate to the spawn and
492            // drop the sender). No result to return.
493            return -2;
494        }
495    };
496
497    // Write the result (or error) into `*out` as a host-owned StbString. The
498    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
499    if out.is_null() {
500        // Nothing to write to; still report the outcome via the return code.
501        return match result {
502            Ok(_) => 0,
503            Err(_) => 1,
504        };
505    }
506
507    let (rc, payload) = match result {
508        Ok(value) => {
509            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
510            (0, json)
511        }
512        Err(msg) => {
513            let json = serde_json::json!({ "error": msg }).to_string();
514            (1, json)
515        }
516    };
517    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
518    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
519    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
520    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
521    // used by translate.rs/tool.rs).
522    unsafe {
523        *out = StbString::from_string(payload);
524    }
525    rc
526}
527
528// ===========================================================================
529// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
530// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
531// recovers the `ActionBridge` via `user_data`, drives the host's async method on
532// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
533// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
534// is the same `extern "C" fn` a plugin's vtable carries.
535// ===========================================================================
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::sync::Mutex;
540
541    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
542    /// canned string and `reload` with Ok(null); every other action returns an
543    /// "unimplemented" error. Enough to prove the dispatch switch + the async
544    /// spawn + the mpsc round-trip + the StbString write.
545    struct MockHost {
546        prompt: String,
547        saw: Mutex<Vec<RuntimeActionId>>,
548    }
549
550    #[async_trait::async_trait]
551    impl RuntimeActionHost for MockHost {
552        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
553            unreachable!("not under test")
554        }
555        async fn send_user_message(
556            &self,
557            _: serde_json::Value,
558        ) -> Result<serde_json::Value, String> {
559            unreachable!("not under test")
560        }
561        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
562            unreachable!("not under test")
563        }
564        async fn set_session_name(
565            &self,
566            _: serde_json::Value,
567        ) -> Result<serde_json::Value, String> {
568            unreachable!("not under test")
569        }
570        async fn get_active_tools(
571            &self,
572            _: serde_json::Value,
573        ) -> Result<serde_json::Value, String> {
574            unreachable!("not under test")
575        }
576        async fn set_active_tools(
577            &self,
578            _: serde_json::Value,
579        ) -> Result<serde_json::Value, String> {
580            unreachable!("not under test")
581        }
582        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
583            unreachable!("not under test")
584        }
585        async fn get_thinking_level(
586            &self,
587            _: serde_json::Value,
588        ) -> Result<serde_json::Value, String> {
589            unreachable!("not under test")
590        }
591        async fn set_thinking_level(
592            &self,
593            _: serde_json::Value,
594        ) -> Result<serde_json::Value, String> {
595            unreachable!("not under test")
596        }
597        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
598            unreachable!("not under test")
599        }
600        async fn get_system_prompt(
601            &self,
602            _: serde_json::Value,
603        ) -> Result<serde_json::Value, String> {
604            self.saw
605                .lock()
606                .unwrap()
607                .push(RuntimeActionId::GetSystemPrompt);
608            Ok(serde_json::json!({ "prompt": self.prompt }))
609        }
610        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
611            unreachable!("not under test")
612        }
613        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
614            unreachable!("not under test")
615        }
616        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
617            unreachable!("not under test")
618        }
619        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
620            unreachable!("not under test")
621        }
622        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
623            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
624            Ok(serde_json::Value::Null)
625        }
626        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
627            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
628            Ok(serde_json::json!({ "value": true }))
629        }
630    }
631
632    /// NOTE: these tests use `flavor = "multi_thread"`. The trampoline parks the
633    /// caller on a std `mpsc::rx.recv()` (sync blocking) while the dispatch runs
634    /// via `Handle::spawn` on the runtime. Under a current-thread runtime the
635    /// test's own worker is the only thread that can poll the spawned task —
636    /// blocking it on `recv` self-deadlocks. In the real host the caller is a
637    /// plugin / `spawn_blocking` thread (never a runtime worker), so there is no
638    /// deadlock; multi_thread here mirrors that (another worker runs the spawn
639    /// while the test thread parks).
640    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
641    async fn trampoline_round_trips_get_system_prompt() {
642        let host = Arc::new(MockHost {
643            prompt: "hello from host".to_string(),
644            saw: Mutex::new(Vec::new()),
645        });
646        let host_for_assert = Arc::clone(&host);
647        let host_dyn: Arc<dyn RuntimeActionHost> = host;
648        let runtime = tokio::runtime::Handle::current();
649        let bridge = ActionBridge::new(runtime, host_dyn);
650        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
651
652        // Build a StbStringRef for the args. `{}` — getters ignore it.
653        let args_str = "{}";
654        let args_ref = StbStringRef::from_str(args_str);
655
656        let mut out = StbString::empty();
657        let rc = trampoline_runtime_action(
658            RuntimeActionId::GetSystemPrompt.into(),
659            args_ref,
660            &mut out as *mut StbString,
661            user_data,
662        );
663        assert_eq!(rc, 0, "success return code");
664
665        // Read the result JSON back + reclaim the host-owned StbString.
666        let json_text = out.to_string_lossy();
667        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
668        assert_eq!(parsed["prompt"], "hello from host");
669        crate::host_free_string(out);
670
671        // The host saw exactly the one action. `host_for_assert` is a clone of
672        // the `Arc<MockHost>` kept before it was coerced to the trait object.
673        let saw = host_for_assert.saw.lock().unwrap().clone();
674        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
675    }
676
677    #[tokio::test]
678    async fn trampoline_null_user_data_returns_minus_one() {
679        let args_ref = StbStringRef::from_str("{}");
680        let mut out = StbString::empty();
681        let rc = trampoline_runtime_action(
682            RuntimeActionId::GetSystemPrompt.into(),
683            args_ref,
684            &mut out as *mut StbString,
685            std::ptr::null_mut(),
686        );
687        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
688    }
689
690    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
691    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
692        // A bridge with a reload callback: the spawn site intercepts Reload and
693        // runs the callback instead of the host's `reload` method. Proves the
694        // bridge-level special-casing (the host impl is the fallback only).
695        use std::sync::atomic::{AtomicUsize, Ordering};
696        let reload_calls = Arc::new(AtomicUsize::new(0));
697        let reload_calls_for_cb = Arc::clone(&reload_calls);
698        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
699            Arc::new(move || {
700                let c = Arc::clone(&reload_calls_for_cb);
701                Box::pin(async move {
702                    c.fetch_add(1, Ordering::SeqCst);
703                })
704            });
705        let host = Arc::new(MockHost {
706            prompt: String::new(),
707            saw: Mutex::new(Vec::new()),
708        });
709        let host_for_assert = Arc::clone(&host);
710        let host_dyn: Arc<dyn RuntimeActionHost> = host;
711        let runtime = tokio::runtime::Handle::current();
712        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
713        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
714
715        let args_ref = StbStringRef::from_str("{}");
716        let mut out = StbString::empty();
717        let rc = trampoline_runtime_action(
718            RuntimeActionId::Reload.into(),
719            args_ref,
720            &mut out as *mut StbString,
721            user_data,
722        );
723        assert_eq!(rc, 0);
724        // The callback ran once; the host's `reload` did NOT.
725        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
726        assert!(host_for_assert.saw.lock().unwrap().is_empty());
727        crate::host_free_string(out);
728    }
729
730    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
731    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
732    /// bridge; an in-flight call that still holds the old pointer must fail
733    /// fast rather than drive the swapped-out session.
734    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
735    async fn trampoline_rejects_stale_bridge_with_invalidate() {
736        let host = Arc::new(MockHost {
737            prompt: String::new(),
738            saw: Mutex::new(Vec::new()),
739        });
740        let host_for_assert = Arc::clone(&host);
741        let host_dyn: Arc<dyn RuntimeActionHost> = host;
742        let runtime = tokio::runtime::Handle::current();
743        let bridge = ActionBridge::new(runtime, host_dyn);
744        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
745
746        // Invalidate (as a `/reload` would on the old bridge).
747        bridge.invalidate();
748        assert!(!bridge.is_active());
749
750        let args_ref = StbStringRef::from_str("{}");
751        let mut out = StbString::empty();
752        let rc = trampoline_runtime_action(
753            RuntimeActionId::GetSystemPrompt.into(),
754            args_ref,
755            &mut out as *mut StbString,
756            user_data,
757        );
758        // Nonzero (error), and the host method never ran (no dispatch).
759        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
760        let json_text = out.to_string_lossy();
761        assert!(
762            json_text.contains("stale"),
763            "stale-bridge error payload: {json_text}"
764        );
765        crate::host_free_string(out);
766        assert!(
767            host_for_assert.saw.lock().unwrap().is_empty(),
768            "host dispatch must NOT run on a stale bridge"
769        );
770    }
771
772    #[tokio::test]
773    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
774        let host = Arc::new(MockHost {
775            prompt: String::new(),
776            saw: Mutex::new(Vec::new()),
777        });
778        let host_for_assert = Arc::clone(&host);
779        let host_dyn: Arc<dyn RuntimeActionHost> = host;
780        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
781        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
782
783        let mut out = StbString::empty();
784        let rc = trampoline_runtime_action(
785            0xFFFF_FFFE,
786            StbStringRef::from_str("{}"),
787            &mut out,
788            user_data,
789        );
790
791        assert_eq!(rc, 2, "unknown action id must be a protocol error");
792        let payload: serde_json::Value =
793            serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
794        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
795        crate::host_free_string(out);
796        assert!(
797            host_for_assert.saw.lock().unwrap().is_empty(),
798            "unknown action id must not reach host dispatch"
799        );
800    }
801
802    #[tokio::test]
803    async fn legacy_trampoline_rejects_v2_action_without_dispatch() {
804        let host = Arc::new(MockHost {
805            prompt: String::new(),
806            saw: Mutex::new(Vec::new()),
807        });
808        let host_for_assert = Arc::clone(&host);
809        let host_dyn: Arc<dyn RuntimeActionHost> = host;
810        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
811        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
812
813        let mut out = StbString::empty();
814        let rc = trampoline_runtime_action_v1(
815            RuntimeActionId::GetCliFlag.into(),
816            StbStringRef::from_str("{}"),
817            &mut out,
818            user_data,
819        );
820
821        assert_eq!(rc, 2);
822        let payload: serde_json::Value =
823            serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
824        assert_eq!(payload["error"], "unknown runtime action id 16");
825        crate::host_free_string(out);
826        assert!(host_for_assert.saw.lock().unwrap().is_empty());
827    }
828
829    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
830    async fn legacy_accepts_v1_action_and_v2_accepts_new_action() {
831        let host = Arc::new(MockHost {
832            prompt: "legacy".to_string(),
833            saw: Mutex::new(Vec::new()),
834        });
835        let host_for_assert = Arc::clone(&host);
836        let host_dyn: Arc<dyn RuntimeActionHost> = host;
837        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
838        let user_data = Arc::as_ptr(&bridge) as *mut c_void;
839
840        let mut legacy_out = StbString::empty();
841        assert_eq!(
842            trampoline_runtime_action_v1(
843                RuntimeActionId::GetSystemPrompt.into(),
844                StbStringRef::from_str("{}"),
845                &mut legacy_out,
846                user_data,
847            ),
848            0
849        );
850        crate::host_free_string(legacy_out);
851
852        let mut v2_out = StbString::empty();
853        assert_eq!(
854            trampoline_runtime_action(
855                RuntimeActionId::GetCliFlag.into(),
856                StbStringRef::from_str(r#"{"name":"flag"}"#),
857                &mut v2_out,
858                user_data,
859            ),
860            0
861        );
862        crate::host_free_string(v2_out);
863
864        assert_eq!(
865            *host_for_assert.saw.lock().unwrap(),
866            vec![
867                RuntimeActionId::GetSystemPrompt,
868                RuntimeActionId::GetCliFlag
869            ]
870        );
871    }
872
873    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
874    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
875    /// host's "not configured" fallback). Exercises the B5d reload-signal path
876    /// the TUI installs.
877    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
878    async fn reload_mailbox_signals_installed_receiver() {
879        let mailbox = ReloadMailbox::new();
880        // No receiver installed yet ⇒ signal fails.
881        assert!(matches!(mailbox.signal(), Err(())));
882
883        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
884        mailbox.install(tx);
885        let cb = reload_callback_from_mailbox(mailbox.clone());
886        cb().await;
887        assert_eq!(
888            rx.recv().await,
889            Some(()),
890            "installed receiver saw the signal"
891        );
892        mailbox.clear();
893    }
894}