Skip to main content

rpi_extensions/
actions.rs

1//! B5a — the plugin→host `runtime_action` bridge (inverted FFI).
2//!
3//! A plugin invokes `PluginApi::runtime_action` to drive the harness (send a
4//! message, switch models, fork a session, reload extensions, …). Unlike the
5//! register trampolines — which run synchronously inside the selected register
6//! entrypoint
7//! and recover host state via the thread-local `CURRENT_HOST_API` —
8//! `runtime_action` is called post-register, either from a host tool driver or
9//! a foreign worker. `PluginApi::user_data` carries an opaque, non-reused handle
10//! resolved through a weak registry, never a dereferenceable bridge address.
11//! Expired registrations fail safely, including candidate reload rollback.
12//!
13//! Synchronous tool callbacks inherit a host-owned invocation via a scoped
14//! thread-local; workers forward the invocation handle and capability injected
15//! into `__rpi`. ChildAgent rejects registration-only, cancelled and child
16//! invocations. A cached registration cannot override a child's driver scope.
17//!
18//! [`ActionBridge`] captures a Tokio [`Handle`] at construction. Dispatch can
19//! therefore spawn from foreign threads and return over a standard synchronous
20//! channel without depending on the plugin thread having a Tokio runtime.
21//!
22//! ## Cycle-free leaf DAG
23//!
24//! [`RuntimeActionHost`] is defined here (NOT in `rpi-harness`) so
25//! `rpi-extensions` stays a leaf: the trait names only JSON + primitives — no
26//! `rpi-harness` types cross. The host impl (`HarnessActionHost`) lives in
27//! `rpi-cli`, where it can name the harness freely; `rpi-extensions` only
28//! carries the async surface. This preserves the documented DAG
29//! (`lib.rs:10-16`: `rpi-extensions` does NOT depend on `rpi-harness`).
30
31use std::collections::{HashMap, VecDeque};
32use std::ffi::c_void;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{mpsc, Arc, Mutex, OnceLock, Weak};
35
36// Opaque, non-reused IDs, never addresses. A copied PluginApi may outlive its
37// registration (e.g. a failed reload); resolving it must not dereference freed memory.
38static BRIDGES: OnceLock<Mutex<HashMap<usize, Weak<ActionBridge>>>> = OnceLock::new();
39fn bridges() -> &'static Mutex<HashMap<usize, Weak<ActionBridge>>> {
40    BRIDGES.get_or_init(Mutex::default)
41}
42thread_local! {
43    static CURRENT_INVOCATION: std::cell::RefCell<Option<Arc<ActionBridge>>> = const { std::cell::RefCell::new(None) };
44}
45pub(crate) struct InvocationGuard {
46    previous: Option<Arc<ActionBridge>>,
47    _thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
48}
49impl Drop for InvocationGuard {
50    fn drop(&mut self) {
51        CURRENT_INVOCATION.with(|slot| *slot.borrow_mut() = self.previous.take());
52    }
53}
54
55use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
56use tokio::runtime::Handle;
57
58/// A pending host UI request emitted by a plugin runtime action.
59///
60/// The request and tool-call ids are intentionally separate: a single tool
61/// call may ask more than one question, while concurrent tool calls must never
62/// consume one another's answers. `raw` retains the complete action envelope;
63/// `ui` is the normalized UI payload that TUI renderers generally need.
64#[derive(Clone, Debug, PartialEq)]
65pub struct UiDialogRequest {
66    pub request_id: String,
67    pub tool_call_id: Option<String>,
68    pub ui: serde_json::Value,
69    pub raw: serde_json::Value,
70}
71
72struct UiDialogEntry {
73    request: UiDialogRequest,
74    answer: Option<serde_json::Value>,
75    cancelled: bool,
76}
77
78struct UiDialogState {
79    attached: bool,
80    pending: VecDeque<String>,
81    active: HashMap<String, UiDialogEntry>,
82    cancelled_tool_calls: std::collections::HashSet<String>,
83}
84
85/// Thread-safe mailbox connecting plugin runtime actions to an interactive
86/// host (normally the TUI). It deliberately carries only JSON and primitives,
87/// so the extension crate remains independent of the UI crate.
88#[derive(Clone)]
89pub struct UiDialogMailbox {
90    state: Arc<Mutex<UiDialogState>>,
91}
92
93impl Default for UiDialogMailbox {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl UiDialogMailbox {
100    pub fn new() -> Self {
101        Self {
102            state: Arc::new(Mutex::new(UiDialogState {
103                attached: false,
104                pending: VecDeque::new(),
105                active: HashMap::new(),
106                cancelled_tool_calls: std::collections::HashSet::new(),
107            })),
108        }
109    }
110
111    /// Mark an interactive consumer as available. Re-attaching is idempotent.
112    pub fn attach(&self) {
113        self.state
114            .lock()
115            .expect("ui dialog mailbox poisoned")
116            .attached = true;
117    }
118
119    /// Detach the consumer and cancel every outstanding request. This prevents
120    /// a TUI restart/reload from leaving plugin polls blocked forever.
121    pub fn detach(&self) {
122        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
123        state.attached = false;
124        state.pending.clear();
125        for entry in state.active.values_mut() {
126            entry.cancelled = true;
127            entry.answer = None;
128        }
129    }
130
131    pub fn is_attached(&self) -> bool {
132        self.state
133            .lock()
134            .expect("ui dialog mailbox poisoned")
135            .attached
136    }
137
138    /// Remove and return the next request for presentation by the TUI.
139    /// Cancelled/answered requests are skipped; duplicate `open` calls never
140    /// enqueue the same request id twice.
141    pub fn take_pending(&self) -> Option<UiDialogRequest> {
142        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
143        while let Some(request_id) = state.pending.pop_front() {
144            if let Some(entry) = state.active.get(&request_id) {
145                if !entry.cancelled && entry.answer.is_none() {
146                    return Some(entry.request.clone());
147                }
148            }
149        }
150        None
151    }
152
153    pub fn pending_len(&self) -> usize {
154        self.state
155            .lock()
156            .expect("ui dialog mailbox poisoned")
157            .pending
158            .len()
159    }
160
161    /// Inspect lifecycle without consuming the answer reserved for a plugin.
162    pub fn is_pending(&self, request_id: &str) -> bool {
163        self.state
164            .lock()
165            .expect("ui dialog mailbox poisoned")
166            .active
167            .get(request_id)
168            .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
169    }
170
171    /// Supply an answer for a request. Repeating the same operation is
172    /// idempotent; answering a cancelled/unknown request is an explicit error.
173    pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
174        let mut state = self
175            .state
176            .lock()
177            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
178        let entry = state
179            .active
180            .get_mut(request_id)
181            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
182        if entry.cancelled {
183            return Err(format!("UI request `{request_id}` was cancelled"));
184        }
185        if entry.answer.is_none() {
186            entry.answer = Some(answer);
187        }
188        Ok(())
189    }
190
191    /// Mark one request cancelled. The terminal state remains visible to the
192    /// plugin's next `poll`, then is reclaimed by that poll.
193    pub fn cancel(&self, request_id: &str) -> Result<(), String> {
194        let mut state = self
195            .state
196            .lock()
197            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
198        let entry = state
199            .active
200            .get_mut(request_id)
201            .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
202        entry.cancelled = true;
203        entry.answer = None;
204        state.pending.retain(|id| id != request_id);
205        Ok(())
206    }
207
208    /// Cancel all requests currently known to the mailbox.
209    pub fn cancel_all(&self) {
210        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
211        state.pending.clear();
212        for entry in state.active.values_mut() {
213            entry.cancelled = true;
214            entry.answer = None;
215        }
216    }
217
218    /// Cancel approval requests belonging to one tool without disturbing
219    /// questions from concurrent tool calls.
220    pub fn cancel_tool_call(&self, tool_call_id: &str) {
221        let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
222        // A callback can reach `open` just after the host cancels its call.
223        // Remember the cancellation so it cannot enqueue an orphan question.
224        state.cancelled_tool_calls.insert(tool_call_id.to_string());
225        for entry in state.active.values_mut() {
226            if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
227                entry.cancelled = true;
228                entry.answer = None;
229            }
230        }
231        let cancelled: std::collections::HashSet<String> = state
232            .active
233            .iter()
234            .filter(|(_, entry)| entry.cancelled)
235            .map(|(id, _)| id.clone())
236            .collect();
237        state.pending.retain(|id| !cancelled.contains(id));
238    }
239
240    /// Poll a request and consume a terminal answer/cancellation. Pending
241    /// polls are cheap and preserve the request for subsequent calls.
242    pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
243        let mut state = self
244            .state
245            .lock()
246            .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
247        let terminal = match state.active.get(request_id) {
248            Some(entry) if entry.cancelled => Some(serde_json::json!({
249                "status": "cancelled",
250                "requestId": request_id,
251                "toolCallId": entry.request.tool_call_id,
252            })),
253            Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
254                "status": "answered",
255                "requestId": request_id,
256                "toolCallId": entry.request.tool_call_id,
257                "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
258            })),
259            Some(entry) => Some(serde_json::json!({
260                "status": "pending",
261                "requestId": request_id,
262                "toolCallId": entry.request.tool_call_id,
263            })),
264            None => None,
265        };
266        let Some(result) = terminal else {
267            return Err(format!("unknown UI request `{request_id}`"));
268        };
269        if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
270            state.active.remove(request_id);
271        }
272        Ok(result)
273    }
274
275    /// Parse and execute the JSON action envelope used by runtime action 17.
276    /// Supported operations are `open`, `poll`, `cancel`; `response` is also
277    /// accepted as a convenience for hosts that proxy answers through JSON.
278    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
279        let op = args
280            .get("op")
281            .and_then(serde_json::Value::as_str)
282            .unwrap_or("open");
283        let request_id = || {
284            args.get("requestId")
285                .or_else(|| args.get("request_id"))
286                .and_then(serde_json::Value::as_str)
287                .map(str::to_owned)
288                .filter(|id| !id.trim().is_empty())
289                .ok_or_else(|| "missing non-empty `requestId`".to_string())
290        };
291        match op {
292            "open" => {
293                let id = request_id()?;
294                let mut state = self
295                    .state
296                    .lock()
297                    .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
298                if !state.attached {
299                    return Err("ask_user requires an interactive UI".to_string());
300                }
301                if let Some(entry) = state.active.get(&id) {
302                    return Ok(if entry.cancelled {
303                        serde_json::json!({"status":"cancelled","requestId":id})
304                    } else if let Some(answer) = &entry.answer {
305                        serde_json::json!({"status":"answered","requestId":id,"answer":answer})
306                    } else {
307                        serde_json::json!({"status":"pending","requestId":id})
308                    });
309                }
310                let tool_call_id = args
311                    .get("toolCallId")
312                    .or_else(|| args.get("tool_call_id"))
313                    .and_then(serde_json::Value::as_str)
314                    .map(str::to_owned);
315                if tool_call_id
316                    .as_ref()
317                    .is_some_and(|id| state.cancelled_tool_calls.contains(id))
318                {
319                    return Ok(serde_json::json!({
320                        "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
321                    }));
322                }
323                let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
324                rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
325                state.active.insert(
326                    id.clone(),
327                    UiDialogEntry {
328                        request: UiDialogRequest {
329                            request_id: id.clone(),
330                            tool_call_id: tool_call_id.clone(),
331                            ui,
332                            raw: args,
333                        },
334                        answer: None,
335                        cancelled: false,
336                    },
337                );
338                state.pending.push_back(id.clone());
339                Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
340            }
341            "poll" => self.poll(&request_id()?),
342            "cancel" => {
343                let id = request_id()?;
344                self.cancel(&id)?;
345                Ok(serde_json::json!({"status":"cancelled","requestId":id}))
346            }
347            "response" | "respond" => {
348                let id = request_id()?;
349                let answer = args
350                    .get("answer")
351                    .or_else(|| args.get("value"))
352                    .cloned()
353                    .unwrap_or(serde_json::Value::Null);
354                self.respond(&id, answer)?;
355                Ok(serde_json::json!({"status":"answered","requestId":id}))
356            }
357            other => Err(format!("unknown UI dialog operation `{other}`")),
358        }
359    }
360}
361
362/// Host-only, owner-bound UI writer. It contains no plugin callbacks or pointers.
363pub type SideQuestionSink =
364    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>;
365
366/// The host-side implementation the bridge delegates to. Defined in
367/// `rpi-extensions` (NOT `rpi-harness`) so the crate DAG stays a leaf: this is a
368/// trait the **host** (`rpi-cli`) implements over the harness — `rpi-extensions`
369/// only names the async surface + carries JSON params/results. No `rpi-harness`
370/// types appear in the trait.
371///
372/// Each method corresponds to one [`RuntimeActionId`] variant. Complex args
373/// arrive as parsed JSON (`serde_json::Value`); complex results return as
374/// `serde_json::Value`. The host maps its native types (Model, AgentMessage, …)
375/// to/from JSON at the impl boundary. Errors are `String` (become the action's
376/// nonzero `i32` + `{"error": msg}` JSON on the plugin side).
377///
378/// `dispatch` below connects the FFI id to the method; owner-scoped actions
379/// are routed directly by the plugin bridge.
380#[async_trait::async_trait]
381pub trait RuntimeActionHost: Send + Sync {
382    /// Revoke side questions when their plugin bridge is retired.
383    fn cancel_side_questions(&self, _owner: u64) {}
384    /// Host-internal TUI snapshots; never exposed as a cross-owner plugin action.
385    fn side_question_revision(&self) -> u64 {
386        0
387    }
388    fn side_question_views(&self) -> Vec<serde_json::Value> {
389        Vec::new()
390    }
391    async fn side_question_ui(
392        &self,
393        _owner: u64,
394        _thread_id: String,
395        _args: serde_json::Value,
396    ) -> Result<serde_json::Value, String> {
397        Err("SideQuestion UI is not supported by this host".into())
398    }
399    /// Owner-scoped read-only inference, including host-owned UI publication.
400    async fn side_question(
401        &self,
402        _owner: u64,
403        _args: serde_json::Value,
404        _sink: SideQuestionSink,
405    ) -> Result<serde_json::Value, String> {
406        Err("SideQuestion is not supported by this host; upgrade rpi".into())
407    }
408
409    /// Refresh the host-side model selector after provider packages reload.
410    fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
411    /// Invalidate child handles owned by an unloading/reloaded plugin.
412    fn cancel_child_agents(&self, _owner: u64) {}
413    /// `SendMessage` — Pi-compatible `triggerTurn` and `deliverAs` scheduling.
414    /// Defaults append while idle and steer while running; explicit `true`
415    /// starts an idle run, `false` only updates context, and `nextTurn` waits
416    /// for a new explicit prompt. Results distinguish appended/queued/run.
417    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
418    /// `SendUserMessage` — drive a run from a user-text message.
419    async fn send_user_message(&self, args: serde_json::Value)
420        -> Result<serde_json::Value, String>;
421    /// `AppendEntry` — append without starting a run. `customType`/`data`
422    /// persists extension data only. A `message` is durably queued during an
423    /// operation and committed after its current turn; the returned `entryId`
424    /// is reserved and may not yet resolve to a transcript entry.
425    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
426    /// `SetSessionName` — set the session's display name.
427    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
428    /// `GetActiveTools` — the active tool-name list.
429    async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
430    /// `SetActiveTools` — replace the active tool-name list.
431    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
432    /// `SetModel` — switch the active model (by id; host resolves to a `Model`).
433    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
434    /// `GetThinkingLevel` — the current thinking level.
435    async fn get_thinking_level(
436        &self,
437        args: serde_json::Value,
438    ) -> Result<serde_json::Value, String>;
439    /// `SetThinkingLevel` — set the thinking level.
440    async fn set_thinking_level(
441        &self,
442        args: serde_json::Value,
443    ) -> Result<serde_json::Value, String>;
444    /// `Compact` — compact the session transcript.
445    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
446    /// `GetSystemPrompt` — the live composed system prompt.
447    async fn get_system_prompt(&self, args: serde_json::Value)
448        -> Result<serde_json::Value, String>;
449    /// `NewSession` — start a fresh session and switch to it.
450    async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
451    /// `Fork` — fork the current session and switch to the fork.
452    async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
453    /// `NavigateTree` — navigate/rewind the session tree.
454    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
455    /// `SwitchSession` — hot-switch to an existing session by id.
456    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
457    /// `Reload` — re-run extension discovery + Part-A resource loaders (a CLI
458    /// concern; the host impl wires it to the `ActionBridge.reload` callback in
459    /// B5d — until then this returns an "unsupported" error string).
460    async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
461
462    /// `GetCliFlag` — read a parsed extension CLI flag. Args are
463    /// `{"name":"flag"}` and the result is `{"value": <bool|string|null>}`.
464    async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
465        Err("CLI flag lookup is not configured".to_string())
466    }
467
468    /// `UiDialog` fallback for hosts that do not install an interactive
469    /// mailbox. The default is deliberately an error so headless runs cannot
470    /// accidentally report a fabricated user answer.
471    async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
472        Err("ask_user requires an interactive UI".to_string())
473    }
474
475    /// Safe, effective runtime metadata; never serialize credential bindings.
476    async fn get_execution_context(
477        &self,
478        _args: serde_json::Value,
479    ) -> Result<serde_json::Value, String> {
480        Err("execution context is not supported by this host".into())
481    }
482
483    /// Independent child lifecycle. `owner` is supplied by the bridge, NOT JSON.
484    async fn child_agent(
485        &self,
486        _owner: u64,
487        _args: serde_json::Value,
488    ) -> Result<serde_json::Value, String> {
489        Err("ChildAgent is not supported by this host".into())
490    }
491
492    /// `GetSessionBranch` — query custom entries on the current main branch.
493    /// Required: non-empty `customTypes` string array. Optional: `afterSeq`
494    /// (exclusive), `limit` (positive), `order` (`oldestFirst` default or
495    /// `newestFirst`). Unknown/legacy keys and unfiltered `{}` are rejected.
496    /// Sorting/limit apply globally across the requested types; identifiers
497    /// match exactly. Consumers must require `filtered == true` in the reply
498    /// rather than accepting an older host's unfiltered successful response.
499    async fn get_session_branch(
500        &self,
501        _args: serde_json::Value,
502    ) -> Result<serde_json::Value, String> {
503        Err("session branch lookup is not configured".to_string())
504    }
505}
506
507/// The host-side bridge carried in [`PluginApi::user_data`] so
508/// [`trampoline_runtime_action`] can recover the harness state from any thread.
509///
510/// `runtime: Handle` is captured at build time (the host is on the runtime when
511/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
512/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
513/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
514/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
515///
516/// ## Staleness (B5d)
517///
518/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
519/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
520/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
521/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
522/// In-flight `runtime_action` calls that recovered the OLD bridge from
523/// `user_data` (the pointer a plugin stored during the prior `register`) then
524/// hit the staleness guard in [`run_action`] and fail with a structured error
525/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
526/// handing them the NEW bridge pointer; the guard only catches the race window
527/// where an old call is still parked on `rx.recv()`.)
528///
529/// Held behind `Arc`. FFI callers carry only an opaque registry handle; each
530/// dispatch upgrades its weak entry before accessing the bridge. A tool driver
531/// owns its invocation bridge through its last foreign callback. Failed reloads
532/// and dropped candidate sessions cannot leave dereferenceable dangling pointers.
533pub struct ActionBridge {
534    /// Some(child) only for a live host-created tool invocation.
535    invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
536    execution_context: Option<serde_json::Value>,
537    stdio: crate::StdioLifecycle,
538    plugin_identity: Option<(u64, String)>,
539    component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
540    /// Captured at build time from a thread running the target runtime.
541    pub(crate) runtime: Handle,
542    /// The host impl (`HarnessActionHost` in rpi-cli).
543    pub(crate) host: Arc<dyn RuntimeActionHost>,
544    /// B5d reload callback; `None` until the TUI wires `/reload`.
545    pub(crate) reload:
546        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
547    /// Session-scoped UI mailbox. It is cloned into reload-created bridges so
548    /// a TUI attachment and in-flight requests survive extension reloads.
549    ui_dialog: UiDialogMailbox,
550    /// Session-scoped extension status registry (`SetStatus` runtime action).
551    /// Shared with the TUI, which polls [`ExtensionStatusMailbox::revision`]
552    /// and repaints the footer only when an extension actually wrote.
553    ext_status: crate::status::ExtensionStatusMailbox,
554    /// Session-scoped editor-text queue (`SetEditorText` runtime action). Shared
555    /// with the TUI, which drains it once per tick and writes the draft into the
556    /// prompt editor.
557    editor_text: crate::editor_text::EditorTextMailbox,
558    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
559    /// `true` while this bridge is the live session's bridge.
560    active: Arc<AtomicBool>,
561}
562
563impl ActionBridge {
564    /// Stable opaque context, valid only while this bridge is retained.
565    pub fn user_data(self: &Arc<Self>) -> *mut c_void {
566        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
567        let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
568        if let Some((&id, _)) = registry
569            .iter()
570            .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
571        {
572            return id as *mut c_void;
573        }
574        let mut id = NEXT.load(Ordering::Relaxed);
575        loop {
576            let next = id.checked_add(1).expect("runtime handle space exhausted");
577            match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
578                Ok(_) => break,
579                Err(current) => id = current,
580            }
581        }
582        registry.insert(id, Arc::downgrade(self));
583        id as *mut c_void
584    }
585    pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
586        bridges()
587            .lock()
588            .unwrap_or_else(|e| e.into_inner())
589            .get(&id)
590            .and_then(Weak::upgrade)
591    }
592    pub(crate) fn for_invocation(
593        &self,
594        child: bool,
595        signal: tokio_util::sync::CancellationToken,
596    ) -> Arc<Self> {
597        Arc::new(Self {
598            invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
599            execution_context: rpi_agent::tool_execution_scope::execution_context(),
600            stdio: self.stdio.clone(),
601            plugin_identity: self.plugin_identity.clone(),
602            component_owners: self.component_owners.clone(),
603            runtime: self.runtime.clone(),
604            host: self.host.clone(),
605            reload: self.reload.clone(),
606            ui_dialog: self.ui_dialog.clone(),
607            ext_status: self.ext_status.clone(),
608            editor_text: self.editor_text.clone(),
609            active: self.active.clone(),
610        })
611    }
612    pub(crate) fn invocation_key(&self) -> Option<&str> {
613        self.invocation.as_ref().map(|(_, key, _)| key.as_str())
614    }
615    pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
616        InvocationGuard {
617            previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
618            _thread_bound: std::marker::PhantomData,
619        }
620    }
621    /// Build a bridge. The `Handle` MUST be captured from a thread running the
622    /// target runtime (pi-cli builds the bridge on the async main thread).
623    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
624        Arc::new(Self {
625            invocation: None,
626            execution_context: None,
627            stdio: crate::StdioLifecycle::default(),
628            plugin_identity: None,
629            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
630            runtime,
631            host,
632            reload: None,
633            ui_dialog: UiDialogMailbox::new(),
634            ext_status: crate::status::ExtensionStatusMailbox::new(),
635            editor_text: crate::editor_text::EditorTextMailbox::new(),
636            active: Arc::new(AtomicBool::new(true)),
637        })
638    }
639
640    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
641    /// [`reload_callback_from_mailbox`]).
642    pub fn with_reload(
643        runtime: Handle,
644        host: Arc<dyn RuntimeActionHost>,
645        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
646    ) -> Arc<Self> {
647        Self::with_reload_and_ui(
648            runtime,
649            host,
650            reload,
651            UiDialogMailbox::new(),
652            crate::status::ExtensionStatusMailbox::new(),
653            crate::editor_text::EditorTextMailbox::new(),
654        )
655    }
656
657    /// Build a bridge with an explicit session-scoped UI mailbox.
658    pub fn with_ui_dialog(
659        runtime: Handle,
660        host: Arc<dyn RuntimeActionHost>,
661        ui_dialog: UiDialogMailbox,
662    ) -> Arc<Self> {
663        Arc::new(Self {
664            invocation: None,
665            execution_context: None,
666            stdio: crate::StdioLifecycle::default(),
667            plugin_identity: None,
668            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
669            runtime,
670            host,
671            reload: None,
672            ui_dialog,
673            ext_status: crate::status::ExtensionStatusMailbox::new(),
674            editor_text: crate::editor_text::EditorTextMailbox::new(),
675            active: Arc::new(AtomicBool::new(true)),
676        })
677    }
678
679    /// `with_reload` variant that preserves a mailbox across bridge swaps.
680    pub fn with_reload_and_ui(
681        runtime: Handle,
682        host: Arc<dyn RuntimeActionHost>,
683        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
684        ui_dialog: UiDialogMailbox,
685        ext_status: crate::status::ExtensionStatusMailbox,
686        editor_text: crate::editor_text::EditorTextMailbox,
687    ) -> Arc<Self> {
688        Arc::new(Self {
689            invocation: None,
690            execution_context: None,
691            stdio: crate::StdioLifecycle::default(),
692            plugin_identity: None,
693            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
694            runtime,
695            host,
696            reload: Some(reload),
697            ui_dialog,
698            ext_status,
699            editor_text,
700            active: Arc::new(AtomicBool::new(true)),
701        })
702    }
703
704    pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
705        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
706        let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
707        self.component_owners
708            .lock()
709            .unwrap_or_else(|e| e.into_inner())
710            .push(owner);
711        Arc::new(Self {
712            invocation: None,
713            execution_context: None,
714            component_owners: self.component_owners.clone(),
715            stdio: self.stdio.clone(),
716            plugin_identity: Some((owner, name.to_owned())),
717            runtime: self.runtime.clone(),
718            host: self.host.clone(),
719            reload: self.reload.clone(),
720            ui_dialog: self.ui_dialog.clone(),
721            ext_status: self.ext_status.clone(),
722            editor_text: self.editor_text.clone(),
723            active: self.active.clone(),
724        })
725    }
726
727    pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
728        self.stdio.clone()
729    }
730
731    pub(crate) fn claim_stdio(&self) -> Result<(), String> {
732        let (id, name) = self
733            .plugin_identity
734            .as_ref()
735            .ok_or("missing plugin identity")?;
736        self.stdio.claim(*id, name)
737    }
738
739    pub(crate) fn release_stdio_claim(&self) {
740        if let Some((id, _)) = &self.plugin_identity {
741            self.stdio.release_failed_registration(*id);
742            self.ext_status.remove_owner(*id);
743        }
744    }
745
746    /// Clone the session mailbox for TUI attachment or a reload-created bridge.
747    pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
748        self.ui_dialog.clone()
749    }
750
751    /// Clone the session status registry (TUI rendering + reload-created bridges).
752    pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
753        self.ext_status.clone()
754    }
755
756    /// Clone the session editor-text queue (TUI drain + reload-created bridges).
757    pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
758        self.editor_text.clone()
759    }
760
761    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
762    /// calls this on the old one so in-flight `runtime_action` calls parked on
763    /// the old `user_data` pointer fail fast with a staleness error instead of
764    /// driving the swapped-out session. Idempotent.
765    pub fn invalidate(&self) {
766        let owners = {
767            let owners = self
768                .component_owners
769                .lock()
770                .unwrap_or_else(|e| e.into_inner());
771            self.active.store(false, Ordering::SeqCst);
772            for owner in owners.iter() {
773                self.ext_status.remove_owner(*owner);
774            }
775            owners.clone()
776        };
777        // Host cancellation may contend with a job publishing through its
778        // sink. Never invoke it while holding the sink's lifecycle gate.
779        for owner in owners {
780            self.host.cancel_child_agents(owner);
781            self.host.cancel_side_questions(owner);
782        }
783    }
784
785    /// Whether this bridge is still the live session's bridge.
786    pub fn is_active(&self) -> bool {
787        self.active.load(Ordering::SeqCst)
788    }
789
790    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
791    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
792    /// at the live harness — the harness is NOT rebuilt on reload — so the host
793    /// is reusable across reloads; only the bridge's staleness flag + reload
794    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
795    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
796    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
797        Arc::clone(&self.host)
798    }
799}
800
801/// A reload-signal mail slot (B5d). The reload callback (built by
802/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
803/// `tokio` unbounded sender after it starts. When a plugin calls
804/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
805/// and the TUI performs the reload **asynchronously** — the plugin's call
806/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
807/// unmapped while its `runtime_action` frame is still on the stack (the reload,
808/// which drops the old keepalive, happens after the call returns). This breaks
809/// the self-unmapping race a synchronous plugin-initiated reload would have.
810///
811/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
812/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
813#[derive(Clone)]
814pub struct ReloadMailbox {
815    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
816}
817
818impl Default for ReloadMailbox {
819    fn default() -> Self {
820        Self {
821            tx: Arc::new(std::sync::Mutex::new(None)),
822        }
823    }
824}
825
826impl ReloadMailbox {
827    /// A fresh empty mail slot (no TUI installed yet).
828    pub fn new() -> Self {
829        Self::default()
830    }
831
832    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
833    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
834    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
835    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
836        *self.tx.lock().unwrap() = Some(tx);
837    }
838
839    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
840    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
841    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
842    /// a "reload not available" error to the plugin).
843    pub fn signal(&self) -> Result<(), ()> {
844        let g = self.tx.lock().unwrap();
845        match &*g {
846            Some(tx) => {
847                let _ = tx.send(());
848                Ok(())
849            }
850            None => Err(()),
851        }
852    }
853
854    /// Drop the installed sender (TUI shutdown). Idempotent.
855    pub fn clear(&self) {
856        *self.tx.lock().unwrap() = None;
857    }
858}
859
860/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
861/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
862/// this callback, which signals the TUI (if installed) and returns; the plugin
863/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
864/// TUI is installed, the callback returns without signalling and the host's
865/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
866pub fn reload_callback_from_mailbox(
867    mailbox: ReloadMailbox,
868) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
869    Arc::new(move || {
870        let m = mailbox.clone();
871        Box::pin(async move {
872            let _ = m.signal();
873        })
874    })
875}
876
877// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
878// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
879// naturally Send+Sync; no manual unsafe impl needed.
880
881/// Dispatch one action to the host. Async — runs on the bridge's runtime.
882/// Handles all 19 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
883/// (the "no callback configured" fallback). When the bridge has a reload
884/// callback, the spawn site intercepts `Reload` and awaits the callback
885/// instead (a CLI concern, not a harness op) — this helper is the plain
886/// host-only path.
887async fn dispatch(
888    host: &Arc<dyn RuntimeActionHost>,
889    ui_dialog: &UiDialogMailbox,
890    ext_status: &crate::status::ExtensionStatusMailbox,
891    editor_text: &crate::editor_text::EditorTextMailbox,
892    action: RuntimeActionId,
893    args: serde_json::Value,
894) -> Result<serde_json::Value, String> {
895    match action {
896        RuntimeActionId::SendMessage => host.send_message(args).await,
897        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
898        RuntimeActionId::AppendEntry => host.append_entry(args).await,
899        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
900        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
901        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
902        RuntimeActionId::SetModel => host.set_model(args).await,
903        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
904        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
905        RuntimeActionId::Compact => host.compact(args).await,
906        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
907        RuntimeActionId::NewSession => host.new_session(args).await,
908        RuntimeActionId::Fork => host.fork(args).await,
909        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
910        RuntimeActionId::SwitchSession => host.switch_session(args).await,
911        RuntimeActionId::Reload => host.reload(args).await,
912        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
913        RuntimeActionId::UiDialog => ui_dialog.handle(args),
914        // UI-only action: handled by the bridge (no harness call), exactly like
915        // `UiDialog`. Written straight into the shared registry the TUI polls.
916        RuntimeActionId::SetStatus => ext_status.handle(args),
917        RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
918        // Editor-only action: served by the bridge (no harness call), exactly
919        // like `SetStatus`/`UiDialog`. The TUI drains the queue each tick.
920        RuntimeActionId::SetEditorText => editor_text.handle(args),
921        RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
922        RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
923        RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
924        RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
925        RuntimeActionId::StdioLifecycle => {
926            Err("stdio lifecycle requires a plugin context".to_owned())
927        }
928    }
929}
930
931/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
932/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
933///
934/// Resolves an `Arc<ActionBridge>` from an opaque handle (or the active tool
935/// invocation), parses `args_json`, drives the host's async dispatch via
936/// `Handle::spawn`, and parks
937/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
938/// runtime needed, which is the load-bearing property for foreign plugin
939/// threads).
940///
941/// ## Return codes
942/// - `0` — success; `*out` written with the result JSON (host-owned
943///   [`StbString`]; the plugin frees it via the host `free_string` from the
944///   vtable).
945/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
946/// - `2` — unknown numeric action id; `*out` contains a structured error and
947///   no host method is dispatched.
948/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
949/// - `-2` — the spawned task dropped its sender without sending (runtime
950///   shutdown / dispatch panic); no result available.
951///
952/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
953/// policy as the tool partial callback, `tool.rs:206`).
954pub extern "C" fn trampoline_runtime_action(
955    action_id: u32,
956    args_json: StbStringRef,
957    out: *mut StbString,
958    user_data: *mut c_void,
959) -> i32 {
960    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
961        run_action(
962            action_id,
963            // Highest id this ABI defines — bump when adding an action.
964            u32::from(RuntimeActionId::SideQuestion),
965            args_json,
966            out,
967            user_data,
968        )
969    }));
970    match outcome {
971        Ok(rc) => rc,
972        Err(_) => {
973            // A panic in the host-side dispatch was caught. Do not abort the
974            // process: report a host-level error to the caller (documented
975            // code `1`) with a structured payload when `out` is writable.
976            tracing::error!("runtime_action trampoline panicked — reporting host error");
977            if !out.is_null() {
978                unsafe {
979                    *out = StbString::from_string(
980                        r#"{"error":"runtime_action panicked"}"#.to_string(),
981                    );
982                }
983            }
984            1
985        }
986    }
987}
988
989/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
990fn run_action(
991    action_id: u32,
992    max_action_id: u32,
993    args_json: StbStringRef,
994    out: *mut StbString,
995    user_data: *mut c_void,
996) -> i32 {
997    // Validate the raw FFI integer before dereferencing host state or entering
998    // the enum-based dispatch. Unknown values are ordinary protocol errors;
999    // they never become invalid Rust enum discriminants.
1000    let action = match RuntimeActionId::try_from(action_id) {
1001        Ok(action) if action_id <= max_action_id => action,
1002        Ok(_) | Err(_) => {
1003            if !out.is_null() {
1004                let json = serde_json::json!({
1005                    "error": format!("unknown runtime action id {action_id}")
1006                })
1007                .to_string();
1008                // SAFETY: `out` is non-null and points to the caller-provided
1009                // output slot. The plugin reclaims this host allocation via
1010                // `host_free_string` from the vtable.
1011                unsafe {
1012                    *out = StbString::from_string(json);
1013                }
1014            }
1015            return 2;
1016        }
1017    };
1018
1019    if user_data.is_null() {
1020        return -1;
1021    }
1022    // Synchronous callbacks inherit the actual driver's invocation, not a
1023    // plugin-global registration pointer. Worker threads pass its opaque ID.
1024    let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1025    let invocation_thread = invocation.is_some();
1026    let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1027    let Some(bridge) = bridge else {
1028        if !out.is_null() {
1029            // SAFETY: the ABI caller supplies a writable output slot.
1030            unsafe {
1031                *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1032            }
1033        }
1034        return 1;
1035    };
1036
1037    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
1038    // `invalidate` on the old one. A plugin that still holds the old pointer
1039    // (stored during the prior `register`) must not drive the swapped-out
1040    // session. Surface a structured "stale bridge" error so the plugin's
1041    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
1042    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
1043    // this guard only catches the race window where an old call is still parked.)
1044    if !bridge.is_active() {
1045        if out.is_null() {
1046            return 1;
1047        }
1048        let json = serde_json::json!({
1049            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1050        })
1051        .to_string();
1052        unsafe {
1053            *out = StbString::from_string(json);
1054        }
1055        return 1;
1056    }
1057
1058    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
1059    // args; setters that require a field surface a clear error string.
1060    let args_str = unsafe { args_json.as_str() };
1061    let mut args: serde_json::Value = if args_str.is_empty() {
1062        serde_json::Value::Object(serde_json::Map::new())
1063    } else {
1064        serde_json::from_str(args_str)
1065            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1066    };
1067
1068    let runtime_key = args
1069        .as_object_mut()
1070        .and_then(|args| args.remove("__rpiRuntimeKey"));
1071
1072    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
1073    // plugin thread can recv from any thread (no ambient runtime). sync_channel
1074    // (1): bounded; the send completes once the value is delivered. rx.recv()
1075    // parks the plugin thread until the spawn finishes (or its sender drops).
1076    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1077    // Clone the bridge's host + reload callback into the spawned task. We pass
1078    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
1079    // `dispatch` helper has everything it needs without borrowing `bridge`.
1080    let host = Arc::clone(&bridge.host);
1081    let ui_dialog = bridge.ui_dialog.clone();
1082    let ext_status = bridge.ext_status.clone();
1083    let editor_text = bridge.editor_text.clone();
1084    let reload_cb = bridge.reload.clone();
1085    let execution_context = bridge.execution_context.clone();
1086    if action == RuntimeActionId::StdioLifecycle {
1087        let result = bridge.stdio.handle(
1088            bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1089            args,
1090        );
1091        let _ = tx.send(result);
1092    } else if action == RuntimeActionId::UiComponent {
1093        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1094        let _owners = bridge
1095            .component_owners
1096            .lock()
1097            .unwrap_or_else(|e| e.into_inner());
1098        let result = if bridge.is_active() {
1099            ext_status.component(owner, args)
1100        } else {
1101            Err("stale plugin bridge".into())
1102        };
1103        let _ = tx.send(result);
1104    } else if action == RuntimeActionId::SideQuestion {
1105        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1106        if owner == 0 || bridge.invocation.is_some() {
1107            let _ = tx.send(Err(
1108                "SideQuestion requires a plugin command, not a tool invocation".into(),
1109            ));
1110        } else {
1111            // Serialize writes with invalidate(): no late publication can
1112            // resurrect components after owner retirement. Keep only weak state.
1113            let weak = Arc::downgrade(&bridge);
1114            let sink: SideQuestionSink = Arc::new(move |args| {
1115                let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1116                let _owners = bridge
1117                    .component_owners
1118                    .lock()
1119                    .unwrap_or_else(|e| e.into_inner());
1120                if !bridge.is_active() {
1121                    return Err("stale side question bridge".into());
1122                }
1123                if args["op"] == "check" {
1124                    return Ok(serde_json::json!({"ok":true}));
1125                }
1126                bridge.ext_status.component(owner, args)
1127            });
1128            bridge.runtime.spawn(async move {
1129                let _ = tx.send(host.side_question(owner, args, sink).await);
1130            });
1131        }
1132    } else if action == RuntimeActionId::ChildAgent {
1133        // A registration handle cannot assert that it is a parent invocation.
1134        // All child-originated invocations fail closed, regardless of tool name.
1135        // Cancellation stops admission, not authenticated reclamation. The
1136        // plugin's cancel/destroy path must still dispose its retained handles.
1137        let cleanup = matches!(
1138            args.get("op").and_then(serde_json::Value::as_str),
1139            Some("poll" | "cancel" | "dispose")
1140        );
1141        let allowed = bridge
1142            .invocation
1143            .as_ref()
1144            .is_some_and(|(child, key, signal)| {
1145                !child
1146                    && (cleanup || !signal.is_cancelled())
1147                    && (invocation_thread
1148                        || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1149                            == Some(key.as_str()))
1150            });
1151        if !allowed {
1152            let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1153        } else {
1154            let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1155            let signal = bridge
1156                .invocation
1157                .as_ref()
1158                .expect("authorized invocation")
1159                .2
1160                .clone();
1161            bridge.runtime.spawn(async move {
1162                let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1163                    signal,
1164                    host.child_agent(owner, args),
1165                )
1166                .await;
1167                let _ = tx.send(result);
1168            });
1169        }
1170    } else if action == RuntimeActionId::SetStatus {
1171        // Same reasoning as `UiDialog`: this action never touches the harness,
1172        // so it must not need a Tokio worker. A plugin calling it from inside a
1173        // single-threaded runtime (a tool poll callback, say) would otherwise
1174        // park that executor and deadlock.
1175        std::thread::spawn(move || {
1176            let _ = tx.send(ext_status.handle(args));
1177        });
1178    } else if action == RuntimeActionId::SetEditorText {
1179        // Editor-only, like `SetStatus`: no harness call, so keep it off the
1180        // Tokio worker for the same single-thread-runtime reason.
1181        std::thread::spawn(move || {
1182            let _ = tx.send(editor_text.handle(args));
1183        });
1184    } else if action == RuntimeActionId::UiDialog {
1185        // UiDialog is intentionally synchronous at the plugin ABI boundary:
1186        // `open` waits until the TUI answers. Never park that wait inside the
1187        // Tokio worker that services the bridge. A SEARCH/extension tool that
1188        // invokes `ui_request/open` from a single-thread runtime would
1189        // otherwise block the executor before the TUI can deliver the answer.
1190        std::thread::spawn(move || {
1191            let _ = tx.send(ui_dialog.handle(args));
1192        });
1193    } else {
1194        bridge.runtime.spawn(async move {
1195            let mut r = if action == RuntimeActionId::Reload {
1196                if let Some(cb) = reload_cb {
1197                    cb().await;
1198                    Ok(serde_json::Value::Null)
1199                } else {
1200                    host.reload(args).await
1201                }
1202            } else {
1203                dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1204            };
1205            if action == RuntimeActionId::GetExecutionContext {
1206                if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1207                    (&mut r, execution_context)
1208                {
1209                    if let Some(object) = context.as_object_mut() {
1210                        object.extend(fields);
1211                    }
1212                }
1213            }
1214            // If the plugin thread already moved on (dropped rx), discard — a
1215            // send error is NOT a host fault.
1216            let _ = tx.send(r);
1217        });
1218    }
1219
1220    // Event observers can call back synchronously from a Tokio worker. A task
1221    // just spawned above may still occupy that worker's non-stealable LIFO
1222    // slot. Yield the scheduler before blocking so dispatch can actually run.
1223    // Foreign/plugin workers and current-thread mailbox-only calls keep the
1224    // ordinary blocking receive.
1225    let received = if Handle::try_current()
1226        .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1227    {
1228        tokio::task::block_in_place(|| rx.recv())
1229    } else {
1230        rx.recv()
1231    };
1232    let result = match received {
1233        Ok(r) => r,
1234        Err(_) => {
1235            // Spawned task dropped the sender without sending: runtime shutdown
1236            // or the dispatch future panicked (caught inside dispatch? no —
1237            // dispatch is plain async, a panic would propagate to the spawn and
1238            // drop the sender). No result to return.
1239            return -2;
1240        }
1241    };
1242
1243    // Write the result (or error) into `*out` as a host-owned StbString. The
1244    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
1245    if out.is_null() {
1246        // Nothing to write to; still report the outcome via the return code.
1247        return match result {
1248            Ok(_) => 0,
1249            Err(_) => 1,
1250        };
1251    }
1252
1253    let (rc, payload) = match result {
1254        Ok(value) => {
1255            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1256            (0, json)
1257        }
1258        Err(msg) => {
1259            let json = serde_json::json!({ "error": msg }).to_string();
1260            (1, json)
1261        }
1262    };
1263    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
1264    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
1265    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
1266    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
1267    // used by translate.rs/tool.rs).
1268    unsafe {
1269        *out = StbString::from_string(payload);
1270    }
1271    rc
1272}
1273
1274// ===========================================================================
1275// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
1276// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
1277// recovers the `ActionBridge` via `user_data`, drives the host's async method on
1278// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
1279// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
1280// is the same `extern "C" fn` a plugin's vtable carries.
1281// ===========================================================================
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use std::sync::Mutex;
1286
1287    #[tokio::test(flavor = "current_thread")]
1288    async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1289        let master = ActionBridge::new(
1290            Handle::current(),
1291            Arc::new(MockHost {
1292                prompt: String::new(),
1293                saw: Mutex::new(Vec::new()),
1294            }),
1295        );
1296        let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1297        let owner = crate::HostApi::with_action_bridge(
1298            "owner",
1299            crate::ExtensionRegistry::new(),
1300            diag.clone(),
1301            master.clone(),
1302        );
1303        let other = crate::HostApi::with_action_bridge(
1304            "other",
1305            crate::ExtensionRegistry::new(),
1306            diag,
1307            master.clone(),
1308        );
1309        let vtable = owner.build_vtable();
1310        let claim = r#"{"ownsStdio":true}"#;
1311        {
1312            let _registration = crate::enter_current_api(&owner);
1313            assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1314        }
1315        let other_vtable = other.build_vtable();
1316        let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1317        let mut out = StbString::empty();
1318        // Direct, on a single-thread executor: this action must not park on Tokio.
1319        assert_eq!(
1320            (other_vtable.runtime_action)(
1321                RuntimeActionId::StdioLifecycle as u32,
1322                finish,
1323                &mut out,
1324                other_vtable.user_data
1325            ),
1326            1
1327        );
1328        // SAFETY: the action returned a live allocation from this vtable.
1329        unsafe { (other_vtable.free_string)(out) };
1330        assert_eq!(
1331            (vtable.runtime_action)(
1332                RuntimeActionId::StdioLifecycle as u32,
1333                finish,
1334                &mut out,
1335                vtable.user_data
1336            ),
1337            0
1338        );
1339        // SAFETY: the action returned a live allocation from this vtable.
1340        unsafe { (vtable.free_string)(out) };
1341        assert_eq!(master.stdio_lifecycle().wait().await, 7);
1342        {
1343            let _registration = crate::enter_current_api(&other);
1344            assert_ne!(
1345                other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1346                0
1347            );
1348        }
1349        assert!(master
1350            .stdio_lifecycle()
1351            .validate()
1352            .unwrap_err()
1353            .contains("owner"));
1354    }
1355
1356    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
1357    /// canned string and `reload` with Ok(null); every other action returns an
1358    /// "unimplemented" error. Enough to prove the dispatch switch + the async
1359    /// spawn + the mpsc round-trip + the StbString write.
1360    struct MockHost {
1361        prompt: String,
1362        saw: Mutex<Vec<RuntimeActionId>>,
1363    }
1364
1365    #[test]
1366    fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1367        const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1368        if std::env::var_os(CHILD).is_some() {
1369            let runtime = tokio::runtime::Builder::new_multi_thread()
1370                .worker_threads(1)
1371                .enable_all()
1372                .build()
1373                .unwrap();
1374            runtime.block_on(async {
1375                // Lifecycle observers run directly on a worker. Their runtime
1376                // action spawns back onto that runtime, including its LIFO slot.
1377                tokio::spawn(async {
1378                    let host = Arc::new(MockHost {
1379                        prompt: "worker callback".into(),
1380                        saw: Mutex::new(Vec::new()),
1381                    });
1382                    let bridge = ActionBridge::new(Handle::current(), host);
1383                    let mut out = StbString::empty();
1384                    assert_eq!(
1385                        trampoline_runtime_action(
1386                            RuntimeActionId::GetSystemPrompt.into(),
1387                            StbStringRef::from_str("{}"),
1388                            &mut out,
1389                            bridge.user_data(),
1390                        ),
1391                        0
1392                    );
1393                    // SAFETY: the action returned a live host-owned string.
1394                    let text = unsafe {
1395                        rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1396                            out,
1397                            Some(crate::host_free_string),
1398                        )
1399                    }
1400                    .to_string_lossy();
1401                    assert_eq!(
1402                        serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1403                        "worker callback"
1404                    );
1405                })
1406                .await
1407                .unwrap();
1408            });
1409            return;
1410        }
1411        // Isolate the old self-deadlock so a regression fails promptly instead
1412        // of hanging the test runner or leaving a blocked runtime thread behind.
1413        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1414            .args([
1415                "--exact",
1416                "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1417                "--nocapture",
1418            ])
1419            .env(CHILD, "1")
1420            .stdout(std::process::Stdio::piped())
1421            .stderr(std::process::Stdio::piped())
1422            .spawn()
1423            .unwrap();
1424        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1425        while child.try_wait().unwrap().is_none() {
1426            if std::time::Instant::now() >= deadline {
1427                let _ = child.kill();
1428                let _ = child.wait();
1429                panic!("runtime action blocked the worker needed by its own dispatch");
1430            }
1431            std::thread::sleep(std::time::Duration::from_millis(10));
1432        }
1433        let output = child.wait_with_output().unwrap();
1434        assert!(
1435            output.status.success(),
1436            "{}\n{}",
1437            String::from_utf8_lossy(&output.stdout),
1438            String::from_utf8_lossy(&output.stderr)
1439        );
1440    }
1441
1442    #[async_trait::async_trait]
1443    impl RuntimeActionHost for MockHost {
1444        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1445            unreachable!("not under test")
1446        }
1447        async fn send_user_message(
1448            &self,
1449            _: serde_json::Value,
1450        ) -> Result<serde_json::Value, String> {
1451            unreachable!("not under test")
1452        }
1453        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1454            unreachable!("not under test")
1455        }
1456        async fn set_session_name(
1457            &self,
1458            _: serde_json::Value,
1459        ) -> Result<serde_json::Value, String> {
1460            unreachable!("not under test")
1461        }
1462        async fn get_active_tools(
1463            &self,
1464            _: serde_json::Value,
1465        ) -> Result<serde_json::Value, String> {
1466            unreachable!("not under test")
1467        }
1468        async fn set_active_tools(
1469            &self,
1470            _: serde_json::Value,
1471        ) -> Result<serde_json::Value, String> {
1472            unreachable!("not under test")
1473        }
1474        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1475            unreachable!("not under test")
1476        }
1477        async fn get_thinking_level(
1478            &self,
1479            _: serde_json::Value,
1480        ) -> Result<serde_json::Value, String> {
1481            unreachable!("not under test")
1482        }
1483        async fn set_thinking_level(
1484            &self,
1485            _: serde_json::Value,
1486        ) -> Result<serde_json::Value, String> {
1487            unreachable!("not under test")
1488        }
1489        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1490            unreachable!("not under test")
1491        }
1492        async fn get_system_prompt(
1493            &self,
1494            _: serde_json::Value,
1495        ) -> Result<serde_json::Value, String> {
1496            self.saw
1497                .lock()
1498                .unwrap()
1499                .push(RuntimeActionId::GetSystemPrompt);
1500            Ok(serde_json::json!({ "prompt": self.prompt }))
1501        }
1502        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1503            unreachable!("not under test")
1504        }
1505        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1506            unreachable!("not under test")
1507        }
1508        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1509            unreachable!("not under test")
1510        }
1511        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1512            unreachable!("not under test")
1513        }
1514        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1515            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1516            Ok(serde_json::Value::Null)
1517        }
1518        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1519            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1520            Ok(serde_json::json!({ "value": true }))
1521        }
1522    }
1523
1524    /// Async host actions need a multi-thread runtime when called synchronously
1525    /// from inside it. The trampoline yields that worker with block_in_place;
1526    /// foreign/plugin threads instead park on the ordinary channel receive.
1527    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1528    async fn trampoline_round_trips_get_system_prompt() {
1529        let host = Arc::new(MockHost {
1530            prompt: "hello from host".to_string(),
1531            saw: Mutex::new(Vec::new()),
1532        });
1533        let host_for_assert = Arc::clone(&host);
1534        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1535        let runtime = tokio::runtime::Handle::current();
1536        let bridge = ActionBridge::new(runtime, host_dyn);
1537        let user_data = bridge.user_data();
1538
1539        // Build a StbStringRef for the args. `{}` — getters ignore it.
1540        let args_str = "{}";
1541        let args_ref = StbStringRef::from_str(args_str);
1542
1543        let mut out = StbString::empty();
1544        let rc = trampoline_runtime_action(
1545            RuntimeActionId::GetSystemPrompt.into(),
1546            args_ref,
1547            &mut out as *mut StbString,
1548            user_data,
1549        );
1550        assert_eq!(rc, 0, "success return code");
1551
1552        // Read the result JSON back + reclaim the host-owned StbString.
1553        // SAFETY: the action wrote a live host-owned output, freed below.
1554        let json_text = unsafe {
1555            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1556        }
1557        .to_string_lossy();
1558        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1559        assert_eq!(parsed["prompt"], "hello from host");
1560
1561        // The host saw exactly the one action. `host_for_assert` is a clone of
1562        // the `Arc<MockHost>` kept before it was coerced to the trait object.
1563        let saw = host_for_assert.saw.lock().unwrap().clone();
1564        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1565    }
1566
1567    /// The `SetEditorText` runtime action must reach the bridge's editor-text
1568    /// mailbox through the ABI trampoline. This is the wiring a plugin relies on
1569    /// (a voice extension writes a transcription draft here); a broken action id
1570    /// or a dropped dispatch would be invisible to the plugin — it only sees
1571    /// `ok: true` — so pin it here.
1572    #[tokio::test]
1573    async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1574        let host = Arc::new(MockHost {
1575            prompt: String::new(),
1576            saw: Mutex::new(Vec::new()),
1577        });
1578        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1579        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1580        let mailbox = bridge.editor_text_mailbox();
1581        let user_data = bridge.user_data();
1582
1583        let args_ref =
1584            StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1585        let mut out = StbString::empty();
1586        let rc = trampoline_runtime_action(
1587            RuntimeActionId::SetEditorText.into(),
1588            args_ref,
1589            &mut out as *mut StbString,
1590            user_data,
1591        );
1592        assert_eq!(rc, 0, "success return code");
1593
1594        // SAFETY: the action wrote a live host-owned output, freed below.
1595        let parsed: serde_json::Value = serde_json::from_str(
1596            &unsafe {
1597                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1598            }
1599            .to_string_lossy(),
1600        )
1601        .expect("valid json");
1602        assert_eq!(parsed["ok"], serde_json::json!(true));
1603        assert_eq!(parsed["chars"], serde_json::json!(10));
1604
1605        // The queued edit is what the host will apply to the prompt editor.
1606        let edit = mailbox.take_pending().expect("queued edit");
1607        assert_eq!(edit.text, "from voice");
1608        assert_eq!(edit.mode, crate::EditorTextMode::Append);
1609        assert_eq!(edit.auto_send_ms, Some(2000));
1610        assert!(mailbox.take_pending().is_none());
1611    }
1612
1613    #[tokio::test]
1614    async fn trampoline_null_user_data_returns_minus_one() {
1615        let args_ref = StbStringRef::from_str("{}");
1616        let mut out = StbString::empty();
1617        let rc = trampoline_runtime_action(
1618            RuntimeActionId::GetSystemPrompt.into(),
1619            args_ref,
1620            &mut out as *mut StbString,
1621            std::ptr::null_mut(),
1622        );
1623        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1624    }
1625
1626    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1627    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1628        // A bridge with a reload callback: the spawn site intercepts Reload and
1629        // runs the callback instead of the host's `reload` method. Proves the
1630        // bridge-level special-casing (the host impl is the fallback only).
1631        use std::sync::atomic::{AtomicUsize, Ordering};
1632        let reload_calls = Arc::new(AtomicUsize::new(0));
1633        let reload_calls_for_cb = Arc::clone(&reload_calls);
1634        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1635            Arc::new(move || {
1636                let c = Arc::clone(&reload_calls_for_cb);
1637                Box::pin(async move {
1638                    c.fetch_add(1, Ordering::SeqCst);
1639                })
1640            });
1641        let host = Arc::new(MockHost {
1642            prompt: String::new(),
1643            saw: Mutex::new(Vec::new()),
1644        });
1645        let host_for_assert = Arc::clone(&host);
1646        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1647        let runtime = tokio::runtime::Handle::current();
1648        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1649        let user_data = bridge.user_data();
1650
1651        let args_ref = StbStringRef::from_str("{}");
1652        let mut out = StbString::empty();
1653        let rc = trampoline_runtime_action(
1654            RuntimeActionId::Reload.into(),
1655            args_ref,
1656            &mut out as *mut StbString,
1657            user_data,
1658        );
1659        assert_eq!(rc, 0);
1660        // The callback ran once; the host's `reload` did NOT.
1661        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1662        assert!(host_for_assert.saw.lock().unwrap().is_empty());
1663        // SAFETY: this action returned a live host allocation, not previously freed.
1664        drop(unsafe {
1665            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1666        });
1667    }
1668
1669    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
1670    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
1671    /// bridge; an in-flight call that still holds the old pointer must fail
1672    /// fast rather than drive the swapped-out session.
1673    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1674    async fn trampoline_rejects_stale_bridge_with_invalidate() {
1675        let host = Arc::new(MockHost {
1676            prompt: String::new(),
1677            saw: Mutex::new(Vec::new()),
1678        });
1679        let host_for_assert = Arc::clone(&host);
1680        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1681        let runtime = tokio::runtime::Handle::current();
1682        let bridge = ActionBridge::new(runtime, host_dyn);
1683        let user_data = bridge.user_data();
1684
1685        // Invalidate (as a `/reload` would on the old bridge).
1686        bridge.invalidate();
1687        assert!(!bridge.is_active());
1688
1689        let args_ref = StbStringRef::from_str("{}");
1690        let mut out = StbString::empty();
1691        let rc = trampoline_runtime_action(
1692            RuntimeActionId::GetSystemPrompt.into(),
1693            args_ref,
1694            &mut out as *mut StbString,
1695            user_data,
1696        );
1697        // Nonzero (error), and the host method never ran (no dispatch).
1698        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1699        // SAFETY: the action wrote a live host-owned output, freed below.
1700        let json_text = unsafe {
1701            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1702        }
1703        .to_string_lossy();
1704        assert!(
1705            json_text.contains("stale"),
1706            "stale-bridge error payload: {json_text}"
1707        );
1708
1709        assert!(
1710            host_for_assert.saw.lock().unwrap().is_empty(),
1711            "host dispatch must NOT run on a stale bridge"
1712        );
1713    }
1714
1715    #[tokio::test]
1716    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1717        let host = Arc::new(MockHost {
1718            prompt: String::new(),
1719            saw: Mutex::new(Vec::new()),
1720        });
1721        let host_for_assert = Arc::clone(&host);
1722        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1723        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1724        let user_data = bridge.user_data();
1725
1726        let mut out = StbString::empty();
1727        let rc = trampoline_runtime_action(
1728            0xFFFF_FFFE,
1729            StbStringRef::from_str("{}"),
1730            &mut out,
1731            user_data,
1732        );
1733
1734        assert_eq!(rc, 2, "unknown action id must be a protocol error");
1735        let payload: serde_json::Value = serde_json::from_str(
1736            &unsafe {
1737                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1738            }
1739            .to_string_lossy(),
1740        )
1741        .expect("structured error JSON");
1742        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1743
1744        assert!(
1745            host_for_assert.saw.lock().unwrap().is_empty(),
1746            "unknown action id must not reach host dispatch"
1747        );
1748    }
1749
1750    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1751    async fn v2_trampoline_accepts_new_action() {
1752        let host = Arc::new(MockHost {
1753            prompt: "v2".to_string(),
1754            saw: Mutex::new(Vec::new()),
1755        });
1756        let host_for_assert = Arc::clone(&host);
1757        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1758        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1759        let user_data = bridge.user_data();
1760
1761        let mut v2_out = StbString::empty();
1762        assert_eq!(
1763            trampoline_runtime_action(
1764                RuntimeActionId::GetCliFlag.into(),
1765                StbStringRef::from_str(r#"{"name":"flag"}"#),
1766                &mut v2_out,
1767                user_data,
1768            ),
1769            0
1770        );
1771        // SAFETY: this action returned a live host allocation, not previously freed.
1772        drop(unsafe {
1773            rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1774        });
1775
1776        assert_eq!(
1777            *host_for_assert.saw.lock().unwrap(),
1778            vec![RuntimeActionId::GetCliFlag]
1779        );
1780    }
1781
1782    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
1783    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
1784    /// host's "not configured" fallback). Exercises the B5d reload-signal path
1785    /// the TUI installs.
1786    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1787    async fn reload_mailbox_signals_installed_receiver() {
1788        let mailbox = ReloadMailbox::new();
1789        // No receiver installed yet ⇒ signal fails.
1790        assert!(matches!(mailbox.signal(), Err(())));
1791
1792        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1793        mailbox.install(tx);
1794        let cb = reload_callback_from_mailbox(mailbox.clone());
1795        cb().await;
1796        assert_eq!(
1797            rx.recv().await,
1798            Some(()),
1799            "installed receiver saw the signal"
1800        );
1801        mailbox.clear();
1802    }
1803
1804    // ---- UiDialogMailbox (runtime action 17 / ask_user) ----
1805
1806    fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1807        serde_json::json!({
1808            "op": "open",
1809            "requestId": id,
1810            "toolCallId": tool_call,
1811            "ui": {"kind": "selector", "questions": [
1812                {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1813            ]},
1814        })
1815    }
1816
1817    /// Headless hosts (nothing attached) must fail loudly rather than fabricate
1818    /// a user answer.
1819    #[test]
1820    fn ui_dialog_open_requires_an_attached_ui() {
1821        let mailbox = UiDialogMailbox::new();
1822        let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1823        assert!(error.contains("interactive UI"), "got: {error}");
1824    }
1825
1826    #[test]
1827    fn ui_dialog_open_poll_answer_round_trip() {
1828        let mailbox = UiDialogMailbox::new();
1829        mailbox.attach();
1830
1831        let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1832        assert_eq!(opened["status"], "pending");
1833        assert_eq!(opened["toolCallId"], "c1");
1834
1835        let request = mailbox.take_pending().expect("request visible to TUI");
1836        assert_eq!(request.request_id, "r1");
1837        assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1838        // A pending poll is non-destructive and remains available.
1839        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1840        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1841
1842        mailbox
1843            .respond("r1", serde_json::json!("7890"))
1844            .expect("respond");
1845        let answered = mailbox.poll("r1").unwrap();
1846        assert_eq!(answered["status"], "answered");
1847        assert_eq!(answered["answer"], "7890");
1848        // Polling consumes the terminal entry.
1849        assert!(mailbox.poll("r1").is_err());
1850    }
1851
1852    #[test]
1853    fn ui_dialog_cancel_is_reported_once() {
1854        let mailbox = UiDialogMailbox::new();
1855        mailbox.attach();
1856        mailbox.handle(open_args("r1", "c1")).unwrap();
1857        mailbox.cancel("r1").unwrap();
1858        let cancelled = mailbox.poll("r1").unwrap();
1859        assert_eq!(cancelled["status"], "cancelled");
1860        assert!(mailbox.poll("r1").is_err());
1861    }
1862
1863    #[test]
1864    fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1865        let mailbox = UiDialogMailbox::new();
1866        mailbox.attach();
1867        mailbox.handle(open_args("r1", "c1")).unwrap();
1868        mailbox.handle(open_args("r2", "c2")).unwrap();
1869        mailbox.cancel_tool_call("c1");
1870        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1871        assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1872        assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1873        assert_eq!(
1874            mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1875            "cancelled"
1876        );
1877        assert!(mailbox.take_pending().is_none());
1878    }
1879
1880    #[test]
1881    fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1882        let mailbox = UiDialogMailbox::new();
1883        mailbox.attach();
1884        mailbox.handle(open_args("r1", "c1")).unwrap();
1885        mailbox.handle(open_args("r2", "c2")).unwrap();
1886
1887        let first = mailbox.take_pending().unwrap().request_id;
1888        let second = mailbox.take_pending().unwrap().request_id;
1889        assert_ne!(first, second);
1890
1891        // Answer the SECOND request first; the first must stay pending.
1892        mailbox
1893            .respond(&second, serde_json::json!("second"))
1894            .unwrap();
1895        assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1896        let second_answer = mailbox.poll(&second).unwrap();
1897        assert_eq!(second_answer["answer"], "second");
1898
1899        mailbox.respond(&first, serde_json::json!("first")).unwrap();
1900        assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1901    }
1902
1903    #[test]
1904    fn ui_dialog_detach_cancels_outstanding() {
1905        let mailbox = UiDialogMailbox::new();
1906        mailbox.attach();
1907        mailbox.handle(open_args("r1", "c1")).unwrap();
1908        mailbox.detach();
1909        assert!(!mailbox.is_attached());
1910        assert_eq!(mailbox.pending_len(), 0);
1911        // Re-opening without a consumer is an explicit error again.
1912        assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1913        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1914    }
1915
1916    #[test]
1917    fn ui_dialog_duplicate_open_does_not_reenqueue() {
1918        let mailbox = UiDialogMailbox::new();
1919        mailbox.attach();
1920        mailbox.handle(open_args("r1", "c1")).unwrap();
1921        let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1922        assert_eq!(again["status"], "pending");
1923        assert_eq!(mailbox.pending_len(), 1);
1924        mailbox.take_pending().unwrap();
1925        assert!(mailbox.take_pending().is_none());
1926    }
1927
1928    /// The v2 trampoline must route action 17 into the session mailbox rather
1929    /// than rejecting it as unknown, and a detached mailbox must surface the
1930    /// explicit "requires an interactive UI" error (headless contract).
1931    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1932    async fn v2_dispatch_routes_ui_dialog_action_17() {
1933        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1934            prompt: String::new(),
1935            saw: Mutex::new(Vec::new()),
1936        });
1937        let bridge = ActionBridge::with_ui_dialog(
1938            tokio::runtime::Handle::current(),
1939            host,
1940            UiDialogMailbox::new(),
1941        );
1942        let user_data = bridge.user_data();
1943
1944        let mut out = StbString::empty();
1945        let rc = trampoline_runtime_action(
1946            17,
1947            StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1948            &mut out,
1949            user_data,
1950        );
1951        assert_eq!(rc, 1);
1952        let payload: serde_json::Value = serde_json::from_str(
1953            &unsafe {
1954                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1955            }
1956            .to_string_lossy(),
1957        )
1958        .expect("structured error JSON");
1959        assert!(
1960            payload["error"]
1961                .as_str()
1962                .is_some_and(|error| error.contains("interactive UI")),
1963            "unexpected payload: {payload}"
1964        );
1965
1966        // A genuinely unknown id is still rejected (no silent acceptance).
1967        // 26 is one past the highest defined action.
1968        let mut out = StbString::empty();
1969        assert_eq!(
1970            trampoline_runtime_action(26, StbStringRef::from_str("{}"), &mut out, user_data),
1971            2
1972        );
1973        // SAFETY: this action returned a live host allocation, not previously freed.
1974        drop(unsafe {
1975            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1976        });
1977    }
1978
1979    #[tokio::test(flavor = "current_thread")]
1980    async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1981        let host = Arc::new(MockHost {
1982            prompt: String::new(),
1983            saw: Mutex::new(Vec::new()),
1984        });
1985        let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1986        let a = root.for_plugin("a");
1987        let b = root.for_plugin("b");
1988        let mailbox = root.extension_status_mailbox();
1989        for plugin in [&a, &b] {
1990            let mut out = StbString::empty();
1991            assert_eq!(
1992                trampoline_runtime_action(
1993                    22,
1994                    StbStringRef::from_str(
1995                        r#"{"op":"register","id":"same","slot":"footer.after"}"#
1996                    ),
1997                    &mut out,
1998                    plugin.user_data()
1999                ),
2000                0
2001            );
2002            // SAFETY: this action returned a live host allocation, not previously freed.
2003            drop(unsafe {
2004                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2005            });
2006        }
2007        assert_eq!(mailbox.panels().len(), 2);
2008        drop(a);
2009        assert_eq!(mailbox.panels().len(), 1);
2010        root.invalidate();
2011        assert!(mailbox.panels().is_empty());
2012    }
2013
2014    /// Action 18 (`SetStatus`) is bridge-local, like `UiDialog`: it writes the
2015    /// shared registry the TUI polls and never touches the harness.
2016    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2017    async fn v2_dispatch_routes_set_status_action_18() {
2018        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2019            prompt: String::new(),
2020            saw: Mutex::new(Vec::new()),
2021        });
2022        let status = crate::status::ExtensionStatusMailbox::new();
2023        let bridge = ActionBridge::with_reload_and_ui(
2024            tokio::runtime::Handle::current(),
2025            host,
2026            Arc::new(|| Box::pin(async {})),
2027            UiDialogMailbox::new(),
2028            status.clone(),
2029            crate::editor_text::EditorTextMailbox::new(),
2030        );
2031        let user_data = bridge.user_data();
2032
2033        let mut out = StbString::empty();
2034        let rc = trampoline_runtime_action(
2035            18,
2036            StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2037            &mut out,
2038            user_data,
2039        );
2040        assert_eq!(rc, 0);
2041        assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2042        // SAFETY: this action returned a live host allocation, not previously freed.
2043        drop(unsafe {
2044            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2045        });
2046
2047        // Clearing through the same action empties the line again.
2048        let mut out = StbString::empty();
2049        assert_eq!(
2050            trampoline_runtime_action(
2051                18,
2052                StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2053                &mut out,
2054                user_data
2055            ),
2056            0
2057        );
2058        assert_eq!(status.text(), "");
2059        // SAFETY: this action returned a live host allocation, not previously freed.
2060        drop(unsafe {
2061            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2062        });
2063
2064        // A missing key is a host-level error (rc=1), not an unknown action.
2065        let mut out = StbString::empty();
2066        assert_eq!(
2067            trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2068            1
2069        );
2070        // SAFETY: this action returned a live host allocation, not previously freed.
2071        drop(unsafe {
2072            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2073        });
2074    }
2075}
2076
2077impl Drop for ActionBridge {
2078    fn drop(&mut self) {
2079        bridges()
2080            .lock()
2081            .unwrap_or_else(|e| e.into_inner())
2082            .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2083        if self.invocation.is_some() {
2084            return;
2085        }
2086        if let Some((owner, _)) = &self.plugin_identity {
2087            self.ext_status.remove_owner(*owner);
2088            self.host.cancel_child_agents(*owner);
2089            self.host.cancel_side_questions(*owner);
2090        }
2091    }
2092}
2093
2094#[cfg(test)]
2095mod runtime_tests;