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