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}