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` — current main branch entries, oldest-first.
493    async fn get_session_branch(
494        &self,
495        _args: serde_json::Value,
496    ) -> Result<serde_json::Value, String> {
497        Err("session branch lookup is not configured".to_string())
498    }
499}
500
501/// The host-side bridge carried in [`PluginApi::user_data`] so
502/// [`trampoline_runtime_action`] can recover the harness state from any thread.
503///
504/// `runtime: Handle` is captured at build time (the host is on the runtime when
505/// it constructs the bridge) — this is the foreign-thread fix. `host` is the
506/// `rpi-cli` impl over the harness. `reload` is a CLI-owned callback that
507/// re-runs extension discovery + Part-A loaders (a CLI concern, NOT a harness
508/// op) — wired by `rpi-cli` in B5d via [`reload_callback_from_mailbox`].
509///
510/// ## Staleness (B5d)
511///
512/// `active: Arc<AtomicBool>` is shared with a [`ReloadMailbox`]-driven swap
513/// site. A `/reload` (TUI command OR a plugin's `runtime_action(Reload)`) builds
514/// a fresh `ExtensionSession` + a fresh `ActionBridge`, calls
515/// [`invalidate`](Self::invalidate) on the old bridge, and swaps the new one in.
516/// In-flight `runtime_action` calls that recovered the OLD bridge from
517/// `user_data` (the pointer a plugin stored during the prior `register`) then
518/// hit the staleness guard in [`run_action`] and fail with a structured error
519/// instead of driving a half-swapped harness. (Plugins load fresh on reload,
520/// handing them the NEW bridge pointer; the guard only catches the race window
521/// where an old call is still parked on `rx.recv()`.)
522///
523/// Held behind `Arc`. FFI callers carry only an opaque registry handle; each
524/// dispatch upgrades its weak entry before accessing the bridge. A tool driver
525/// owns its invocation bridge through its last foreign callback. Failed reloads
526/// and dropped candidate sessions cannot leave dereferenceable dangling pointers.
527pub struct ActionBridge {
528    /// Some(child) only for a live host-created tool invocation.
529    invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
530    execution_context: Option<serde_json::Value>,
531    stdio: crate::StdioLifecycle,
532    plugin_identity: Option<(u64, String)>,
533    component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
534    /// Captured at build time from a thread running the target runtime.
535    pub(crate) runtime: Handle,
536    /// The host impl (`HarnessActionHost` in rpi-cli).
537    pub(crate) host: Arc<dyn RuntimeActionHost>,
538    /// B5d reload callback; `None` until the TUI wires `/reload`.
539    pub(crate) reload:
540        Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
541    /// Session-scoped UI mailbox. It is cloned into reload-created bridges so
542    /// a TUI attachment and in-flight requests survive extension reloads.
543    ui_dialog: UiDialogMailbox,
544    /// Session-scoped extension status registry (`SetStatus` runtime action).
545    /// Shared with the TUI, which polls [`ExtensionStatusMailbox::revision`]
546    /// and repaints the footer only when an extension actually wrote.
547    ext_status: crate::status::ExtensionStatusMailbox,
548    /// Session-scoped editor-text queue (`SetEditorText` runtime action). Shared
549    /// with the TUI, which drains it once per tick and writes the draft into the
550    /// prompt editor.
551    editor_text: crate::editor_text::EditorTextMailbox,
552    /// B5d staleness flag. Shared so [`invalidate`] flips it for every clone.
553    /// `true` while this bridge is the live session's bridge.
554    active: Arc<AtomicBool>,
555}
556
557impl ActionBridge {
558    /// Stable opaque context, valid only while this bridge is retained.
559    pub fn user_data(self: &Arc<Self>) -> *mut c_void {
560        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
561        let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
562        if let Some((&id, _)) = registry
563            .iter()
564            .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
565        {
566            return id as *mut c_void;
567        }
568        let mut id = NEXT.load(Ordering::Relaxed);
569        loop {
570            let next = id.checked_add(1).expect("runtime handle space exhausted");
571            match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
572                Ok(_) => break,
573                Err(current) => id = current,
574            }
575        }
576        registry.insert(id, Arc::downgrade(self));
577        id as *mut c_void
578    }
579    pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
580        bridges()
581            .lock()
582            .unwrap_or_else(|e| e.into_inner())
583            .get(&id)
584            .and_then(Weak::upgrade)
585    }
586    pub(crate) fn for_invocation(
587        &self,
588        child: bool,
589        signal: tokio_util::sync::CancellationToken,
590    ) -> Arc<Self> {
591        Arc::new(Self {
592            invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
593            execution_context: rpi_agent::tool_execution_scope::execution_context(),
594            stdio: self.stdio.clone(),
595            plugin_identity: self.plugin_identity.clone(),
596            component_owners: self.component_owners.clone(),
597            runtime: self.runtime.clone(),
598            host: self.host.clone(),
599            reload: self.reload.clone(),
600            ui_dialog: self.ui_dialog.clone(),
601            ext_status: self.ext_status.clone(),
602            editor_text: self.editor_text.clone(),
603            active: self.active.clone(),
604        })
605    }
606    pub(crate) fn invocation_key(&self) -> Option<&str> {
607        self.invocation.as_ref().map(|(_, key, _)| key.as_str())
608    }
609    pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
610        InvocationGuard {
611            previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
612            _thread_bound: std::marker::PhantomData,
613        }
614    }
615    /// Build a bridge. The `Handle` MUST be captured from a thread running the
616    /// target runtime (pi-cli builds the bridge on the async main thread).
617    pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
618        Arc::new(Self {
619            invocation: None,
620            execution_context: None,
621            stdio: crate::StdioLifecycle::default(),
622            plugin_identity: None,
623            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
624            runtime,
625            host,
626            reload: None,
627            ui_dialog: UiDialogMailbox::new(),
628            ext_status: crate::status::ExtensionStatusMailbox::new(),
629            editor_text: crate::editor_text::EditorTextMailbox::new(),
630            active: Arc::new(AtomicBool::new(true)),
631        })
632    }
633
634    /// Same as [`new`](Self::new) with a reload callback (B5d wires this via
635    /// [`reload_callback_from_mailbox`]).
636    pub fn with_reload(
637        runtime: Handle,
638        host: Arc<dyn RuntimeActionHost>,
639        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
640    ) -> Arc<Self> {
641        Self::with_reload_and_ui(
642            runtime,
643            host,
644            reload,
645            UiDialogMailbox::new(),
646            crate::status::ExtensionStatusMailbox::new(),
647            crate::editor_text::EditorTextMailbox::new(),
648        )
649    }
650
651    /// Build a bridge with an explicit session-scoped UI mailbox.
652    pub fn with_ui_dialog(
653        runtime: Handle,
654        host: Arc<dyn RuntimeActionHost>,
655        ui_dialog: UiDialogMailbox,
656    ) -> Arc<Self> {
657        Arc::new(Self {
658            invocation: None,
659            execution_context: None,
660            stdio: crate::StdioLifecycle::default(),
661            plugin_identity: None,
662            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
663            runtime,
664            host,
665            reload: None,
666            ui_dialog,
667            ext_status: crate::status::ExtensionStatusMailbox::new(),
668            editor_text: crate::editor_text::EditorTextMailbox::new(),
669            active: Arc::new(AtomicBool::new(true)),
670        })
671    }
672
673    /// `with_reload` variant that preserves a mailbox across bridge swaps.
674    pub fn with_reload_and_ui(
675        runtime: Handle,
676        host: Arc<dyn RuntimeActionHost>,
677        reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
678        ui_dialog: UiDialogMailbox,
679        ext_status: crate::status::ExtensionStatusMailbox,
680        editor_text: crate::editor_text::EditorTextMailbox,
681    ) -> Arc<Self> {
682        Arc::new(Self {
683            invocation: None,
684            execution_context: None,
685            stdio: crate::StdioLifecycle::default(),
686            plugin_identity: None,
687            component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
688            runtime,
689            host,
690            reload: Some(reload),
691            ui_dialog,
692            ext_status,
693            editor_text,
694            active: Arc::new(AtomicBool::new(true)),
695        })
696    }
697
698    pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
699        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
700        let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
701        self.component_owners
702            .lock()
703            .unwrap_or_else(|e| e.into_inner())
704            .push(owner);
705        Arc::new(Self {
706            invocation: None,
707            execution_context: None,
708            component_owners: self.component_owners.clone(),
709            stdio: self.stdio.clone(),
710            plugin_identity: Some((owner, name.to_owned())),
711            runtime: self.runtime.clone(),
712            host: self.host.clone(),
713            reload: self.reload.clone(),
714            ui_dialog: self.ui_dialog.clone(),
715            ext_status: self.ext_status.clone(),
716            editor_text: self.editor_text.clone(),
717            active: self.active.clone(),
718        })
719    }
720
721    pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
722        self.stdio.clone()
723    }
724
725    pub(crate) fn claim_stdio(&self) -> Result<(), String> {
726        let (id, name) = self
727            .plugin_identity
728            .as_ref()
729            .ok_or("missing plugin identity")?;
730        self.stdio.claim(*id, name)
731    }
732
733    pub(crate) fn release_stdio_claim(&self) {
734        if let Some((id, _)) = &self.plugin_identity {
735            self.stdio.release_failed_registration(*id);
736            self.ext_status.remove_owner(*id);
737        }
738    }
739
740    /// Clone the session mailbox for TUI attachment or a reload-created bridge.
741    pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
742        self.ui_dialog.clone()
743    }
744
745    /// Clone the session status registry (TUI rendering + reload-created bridges).
746    pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
747        self.ext_status.clone()
748    }
749
750    /// Clone the session editor-text queue (TUI drain + reload-created bridges).
751    pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
752        self.editor_text.clone()
753    }
754
755    /// Mark this bridge stale (B5d). A `/reload` that swaps in a fresh bridge
756    /// calls this on the old one so in-flight `runtime_action` calls parked on
757    /// the old `user_data` pointer fail fast with a staleness error instead of
758    /// driving the swapped-out session. Idempotent.
759    pub fn invalidate(&self) {
760        let owners = {
761            let owners = self
762                .component_owners
763                .lock()
764                .unwrap_or_else(|e| e.into_inner());
765            self.active.store(false, Ordering::SeqCst);
766            for owner in owners.iter() {
767                self.ext_status.remove_owner(*owner);
768            }
769            owners.clone()
770        };
771        // Host cancellation may contend with a job publishing through its
772        // sink. Never invoke it while holding the sink's lifecycle gate.
773        for owner in owners {
774            self.host.cancel_child_agents(owner);
775            self.host.cancel_side_questions(owner);
776        }
777    }
778
779    /// Whether this bridge is still the live session's bridge.
780    pub fn is_active(&self) -> bool {
781        self.active.load(Ordering::SeqCst)
782    }
783
784    /// B5d: clone the host impl so a `/reload` can build a FRESH `ActionBridge`
785    /// over the SAME `RuntimeActionHost` (the host's harness cell already points
786    /// at the live harness — the harness is NOT rebuilt on reload — so the host
787    /// is reusable across reloads; only the bridge's staleness flag + reload
788    /// callback differ). The fresh bridge gets a fresh `active` flag (true) +
789    /// the reload callback the TUI installed; the old bridge is `invalidate`d.
790    pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
791        Arc::clone(&self.host)
792    }
793}
794
795/// A reload-signal mail slot (B5d). The reload callback (built by
796/// [`reload_callback_from_mailbox`]) captures a clone; the TUI installs a
797/// `tokio` unbounded sender after it starts. When a plugin calls
798/// `runtime_action(Reload)`, the callback signals `()` (if a TUI is installed)
799/// and the TUI performs the reload **asynchronously** — the plugin's call
800/// returns `Ok(null)` immediately, so the calling plugin's cdylib is NOT
801/// unmapped while its `runtime_action` frame is still on the stack (the reload,
802/// which drops the old keepalive, happens after the call returns). This breaks
803/// the self-unmapping race a synchronous plugin-initiated reload would have.
804///
805/// rpi-extensions carries only `()` (no pi-cli `TuiMessage` type) — preserving
806/// the leaf DAG. The TUI owns the receiver + the actual reload routine.
807#[derive(Clone)]
808pub struct ReloadMailbox {
809    tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
810}
811
812impl Default for ReloadMailbox {
813    fn default() -> Self {
814        Self {
815            tx: Arc::new(std::sync::Mutex::new(None)),
816        }
817    }
818}
819
820impl ReloadMailbox {
821    /// A fresh empty mail slot (no TUI installed yet).
822    pub fn new() -> Self {
823        Self::default()
824    }
825
826    /// Install the TUI's reload-signal sender (after the TUI starts). Replaces
827    /// any prior sender. The TUI drops the receiver on shutdown; a sender held
828    /// here keeps the channel half-open, so [`clear`] on shutdown is advised.
829    pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
830        *self.tx.lock().unwrap() = Some(tx);
831    }
832
833    /// Signal a reload (plugin-initiated via `runtime_action(Reload)`).
834    /// `Ok(())` if a TUI is installed (the signal was enqueued; the TUI may
835    /// still be mid-reload). `Err(())` if no TUI is installed (the host returns
836    /// a "reload not available" error to the plugin).
837    pub fn signal(&self) -> Result<(), ()> {
838        let g = self.tx.lock().unwrap();
839        match &*g {
840            Some(tx) => {
841                let _ = tx.send(());
842                Ok(())
843            }
844            None => Err(()),
845        }
846    }
847
848    /// Drop the installed sender (TUI shutdown). Idempotent.
849    pub fn clear(&self) {
850        *self.tx.lock().unwrap() = None;
851    }
852}
853
854/// Build the reload callback the bridge carries, backed by a [`ReloadMailbox`].
855/// When a plugin calls `runtime_action(Reload)`, the bridge's spawn site awaits
856/// this callback, which signals the TUI (if installed) and returns; the plugin
857/// receives `Ok(null)` and the TUI performs the reload asynchronously. If no
858/// TUI is installed, the callback returns without signalling and the host's
859/// [`RuntimeActionHost::reload`] fallback surfaces the "not configured" error.
860pub fn reload_callback_from_mailbox(
861    mailbox: ReloadMailbox,
862) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
863    Arc::new(move || {
864        let m = mailbox.clone();
865        Box::pin(async move {
866            let _ = m.signal();
867        })
868    })
869}
870
871// `Handle` is Send+Sync, `Arc<dyn RuntimeActionHost>` (with `Send + Sync` bound)
872// is Send+Sync, and the reload closure is `Send + Sync` — so `ActionBridge` is
873// naturally Send+Sync; no manual unsafe impl needed.
874
875/// Dispatch one action to the host. Async — runs on the bridge's runtime.
876/// Handles all 19 ids; `Reload` delegates to [`RuntimeActionHost::reload`]
877/// (the "no callback configured" fallback). When the bridge has a reload
878/// callback, the spawn site intercepts `Reload` and awaits the callback
879/// instead (a CLI concern, not a harness op) — this helper is the plain
880/// host-only path.
881async fn dispatch(
882    host: &Arc<dyn RuntimeActionHost>,
883    ui_dialog: &UiDialogMailbox,
884    ext_status: &crate::status::ExtensionStatusMailbox,
885    editor_text: &crate::editor_text::EditorTextMailbox,
886    action: RuntimeActionId,
887    args: serde_json::Value,
888) -> Result<serde_json::Value, String> {
889    match action {
890        RuntimeActionId::SendMessage => host.send_message(args).await,
891        RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
892        RuntimeActionId::AppendEntry => host.append_entry(args).await,
893        RuntimeActionId::SetSessionName => host.set_session_name(args).await,
894        RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
895        RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
896        RuntimeActionId::SetModel => host.set_model(args).await,
897        RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
898        RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
899        RuntimeActionId::Compact => host.compact(args).await,
900        RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
901        RuntimeActionId::NewSession => host.new_session(args).await,
902        RuntimeActionId::Fork => host.fork(args).await,
903        RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
904        RuntimeActionId::SwitchSession => host.switch_session(args).await,
905        RuntimeActionId::Reload => host.reload(args).await,
906        RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
907        RuntimeActionId::UiDialog => ui_dialog.handle(args),
908        // UI-only action: handled by the bridge (no harness call), exactly like
909        // `UiDialog`. Written straight into the shared registry the TUI polls.
910        RuntimeActionId::SetStatus => ext_status.handle(args),
911        RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
912        // Editor-only action: served by the bridge (no harness call), exactly
913        // like `SetStatus`/`UiDialog`. The TUI drains the queue each tick.
914        RuntimeActionId::SetEditorText => editor_text.handle(args),
915        RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
916        RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
917        RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
918        RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
919        RuntimeActionId::StdioLifecycle => {
920            Err("stdio lifecycle requires a plugin context".to_owned())
921        }
922    }
923}
924
925/// The real `runtime_action` trampoline — replaces `stub_runtime_action` when a
926/// bridge is present (see [`HostApi::build_vtable`](crate::HostApi)).
927///
928/// Resolves an `Arc<ActionBridge>` from an opaque handle (or the active tool
929/// invocation), parses `args_json`, drives the host's async dispatch via
930/// `Handle::spawn`, and parks
931/// the plugin thread on a std `mpsc` `recv` (works from ANY thread — no ambient
932/// runtime needed, which is the load-bearing property for foreign plugin
933/// threads).
934///
935/// ## Return codes
936/// - `0` — success; `*out` written with the result JSON (host-owned
937///   [`StbString`]; the plugin frees it via the host `free_string` from the
938///   vtable).
939/// - `1` — host-level error; `*out` written with `{"error": msg}` JSON.
940/// - `2` — unknown numeric action id; `*out` contains a structured error and
941///   no host method is dispatched.
942/// - `-1` — no bridge present (`user_data` null; should not happen when wired).
943/// - `-2` — the spawned task dropped its sender without sending (runtime
944///   shutdown / dispatch panic); no result available.
945///
946/// The whole body is `catch_unwind`-wrapped — a panic across FFI ⇒ abort (same
947/// policy as the tool partial callback, `tool.rs:206`).
948pub extern "C" fn trampoline_runtime_action(
949    action_id: u32,
950    args_json: StbStringRef,
951    out: *mut StbString,
952    user_data: *mut c_void,
953) -> i32 {
954    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
955        run_action(
956            action_id,
957            // Highest id this ABI defines — bump when adding an action.
958            u32::from(RuntimeActionId::SideQuestion),
959            args_json,
960            out,
961            user_data,
962        )
963    }));
964    match outcome {
965        Ok(rc) => rc,
966        Err(_) => {
967            // A panic in the host-side dispatch was caught. Do not abort the
968            // process: report a host-level error to the caller (documented
969            // code `1`) with a structured payload when `out` is writable.
970            tracing::error!("runtime_action trampoline panicked — reporting host error");
971            if !out.is_null() {
972                unsafe {
973                    *out = StbString::from_string(
974                        r#"{"error":"runtime_action panicked"}"#.to_string(),
975                    );
976                }
977            }
978            1
979        }
980    }
981}
982
983/// Inner synchronous driver (split out so the `catch_unwind` wrapper is clean).
984fn run_action(
985    action_id: u32,
986    max_action_id: u32,
987    args_json: StbStringRef,
988    out: *mut StbString,
989    user_data: *mut c_void,
990) -> i32 {
991    // Validate the raw FFI integer before dereferencing host state or entering
992    // the enum-based dispatch. Unknown values are ordinary protocol errors;
993    // they never become invalid Rust enum discriminants.
994    let action = match RuntimeActionId::try_from(action_id) {
995        Ok(action) if action_id <= max_action_id => action,
996        Ok(_) | Err(_) => {
997            if !out.is_null() {
998                let json = serde_json::json!({
999                    "error": format!("unknown runtime action id {action_id}")
1000                })
1001                .to_string();
1002                // SAFETY: `out` is non-null and points to the caller-provided
1003                // output slot. The plugin reclaims this host allocation via
1004                // `host_free_string` from the vtable.
1005                unsafe {
1006                    *out = StbString::from_string(json);
1007                }
1008            }
1009            return 2;
1010        }
1011    };
1012
1013    if user_data.is_null() {
1014        return -1;
1015    }
1016    // Synchronous callbacks inherit the actual driver's invocation, not a
1017    // plugin-global registration pointer. Worker threads pass its opaque ID.
1018    let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1019    let invocation_thread = invocation.is_some();
1020    let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1021    let Some(bridge) = bridge else {
1022        if !out.is_null() {
1023            // SAFETY: the ABI caller supplies a writable output slot.
1024            unsafe {
1025                *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1026            }
1027        }
1028        return 1;
1029    };
1030
1031    // B5d staleness guard: a `/reload` that swapped in a fresh bridge calls
1032    // `invalidate` on the old one. A plugin that still holds the old pointer
1033    // (stored during the prior `register`) must not drive the swapped-out
1034    // session. Surface a structured "stale bridge" error so the plugin's
1035    // `runtime_action` returns nonzero + `{"error": ...}` instead of racing
1036    // the swap. (The new bridge's pointer was handed to the reloaded plugins;
1037    // this guard only catches the race window where an old call is still parked.)
1038    if !bridge.is_active() {
1039        if out.is_null() {
1040            return 1;
1041        }
1042        let json = serde_json::json!({
1043            "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1044        })
1045        .to_string();
1046        unsafe {
1047            *out = StbString::from_string(json);
1048        }
1049        return 1;
1050    }
1051
1052    // Parse args. An empty/invalid JSON blob collapses to `{}` — getters ignore
1053    // args; setters that require a field surface a clear error string.
1054    let args_str = unsafe { args_json.as_str() };
1055    let mut args: serde_json::Value = if args_str.is_empty() {
1056        serde_json::Value::Object(serde_json::Map::new())
1057    } else {
1058        serde_json::from_str(args_str)
1059            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1060    };
1061
1062    let runtime_key = args
1063        .as_object_mut()
1064        .and_then(|args| args.remove("__rpiRuntimeKey"));
1065
1066    // Drive the host's async dispatch on the bridge's runtime. STD mpsc so the
1067    // plugin thread can recv from any thread (no ambient runtime). sync_channel
1068    // (1): bounded; the send completes once the value is delivered. rx.recv()
1069    // parks the plugin thread until the spawn finishes (or its sender drops).
1070    let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1071    // Clone the bridge's host + reload callback into the spawned task. We pass
1072    // an owned `Arc<dyn RuntimeActionHost>` plus a reload-option snapshot so the
1073    // `dispatch` helper has everything it needs without borrowing `bridge`.
1074    let host = Arc::clone(&bridge.host);
1075    let ui_dialog = bridge.ui_dialog.clone();
1076    let ext_status = bridge.ext_status.clone();
1077    let editor_text = bridge.editor_text.clone();
1078    let reload_cb = bridge.reload.clone();
1079    let execution_context = bridge.execution_context.clone();
1080    if action == RuntimeActionId::StdioLifecycle {
1081        let result = bridge.stdio.handle(
1082            bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1083            args,
1084        );
1085        let _ = tx.send(result);
1086    } else if action == RuntimeActionId::UiComponent {
1087        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1088        let _owners = bridge
1089            .component_owners
1090            .lock()
1091            .unwrap_or_else(|e| e.into_inner());
1092        let result = if bridge.is_active() {
1093            ext_status.component(owner, args)
1094        } else {
1095            Err("stale plugin bridge".into())
1096        };
1097        let _ = tx.send(result);
1098    } else if action == RuntimeActionId::SideQuestion {
1099        let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1100        if owner == 0 || bridge.invocation.is_some() {
1101            let _ = tx.send(Err(
1102                "SideQuestion requires a plugin command, not a tool invocation".into(),
1103            ));
1104        } else {
1105            // Serialize writes with invalidate(): no late publication can
1106            // resurrect components after owner retirement. Keep only weak state.
1107            let weak = Arc::downgrade(&bridge);
1108            let sink: SideQuestionSink = Arc::new(move |args| {
1109                let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1110                let _owners = bridge
1111                    .component_owners
1112                    .lock()
1113                    .unwrap_or_else(|e| e.into_inner());
1114                if !bridge.is_active() {
1115                    return Err("stale side question bridge".into());
1116                }
1117                if args["op"] == "check" {
1118                    return Ok(serde_json::json!({"ok":true}));
1119                }
1120                bridge.ext_status.component(owner, args)
1121            });
1122            bridge.runtime.spawn(async move {
1123                let _ = tx.send(host.side_question(owner, args, sink).await);
1124            });
1125        }
1126    } else if action == RuntimeActionId::ChildAgent {
1127        // A registration handle cannot assert that it is a parent invocation.
1128        // All child-originated invocations fail closed, regardless of tool name.
1129        // Cancellation stops admission, not authenticated reclamation. The
1130        // plugin's cancel/destroy path must still dispose its retained handles.
1131        let cleanup = matches!(
1132            args.get("op").and_then(serde_json::Value::as_str),
1133            Some("poll" | "cancel" | "dispose")
1134        );
1135        let allowed = bridge
1136            .invocation
1137            .as_ref()
1138            .is_some_and(|(child, key, signal)| {
1139                !child
1140                    && (cleanup || !signal.is_cancelled())
1141                    && (invocation_thread
1142                        || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1143                            == Some(key.as_str()))
1144            });
1145        if !allowed {
1146            let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1147        } else {
1148            let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1149            let signal = bridge
1150                .invocation
1151                .as_ref()
1152                .expect("authorized invocation")
1153                .2
1154                .clone();
1155            bridge.runtime.spawn(async move {
1156                let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1157                    signal,
1158                    host.child_agent(owner, args),
1159                )
1160                .await;
1161                let _ = tx.send(result);
1162            });
1163        }
1164    } else if action == RuntimeActionId::SetStatus {
1165        // Same reasoning as `UiDialog`: this action never touches the harness,
1166        // so it must not need a Tokio worker. A plugin calling it from inside a
1167        // single-threaded runtime (a tool poll callback, say) would otherwise
1168        // park that executor and deadlock.
1169        std::thread::spawn(move || {
1170            let _ = tx.send(ext_status.handle(args));
1171        });
1172    } else if action == RuntimeActionId::SetEditorText {
1173        // Editor-only, like `SetStatus`: no harness call, so keep it off the
1174        // Tokio worker for the same single-thread-runtime reason.
1175        std::thread::spawn(move || {
1176            let _ = tx.send(editor_text.handle(args));
1177        });
1178    } else if action == RuntimeActionId::UiDialog {
1179        // UiDialog is intentionally synchronous at the plugin ABI boundary:
1180        // `open` waits until the TUI answers. Never park that wait inside the
1181        // Tokio worker that services the bridge. A SEARCH/extension tool that
1182        // invokes `ui_request/open` from a single-thread runtime would
1183        // otherwise block the executor before the TUI can deliver the answer.
1184        std::thread::spawn(move || {
1185            let _ = tx.send(ui_dialog.handle(args));
1186        });
1187    } else {
1188        bridge.runtime.spawn(async move {
1189            let mut r = if action == RuntimeActionId::Reload {
1190                if let Some(cb) = reload_cb {
1191                    cb().await;
1192                    Ok(serde_json::Value::Null)
1193                } else {
1194                    host.reload(args).await
1195                }
1196            } else {
1197                dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1198            };
1199            if action == RuntimeActionId::GetExecutionContext {
1200                if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1201                    (&mut r, execution_context)
1202                {
1203                    if let Some(object) = context.as_object_mut() {
1204                        object.extend(fields);
1205                    }
1206                }
1207            }
1208            // If the plugin thread already moved on (dropped rx), discard — a
1209            // send error is NOT a host fault.
1210            let _ = tx.send(r);
1211        });
1212    }
1213
1214    // Event observers can call back synchronously from a Tokio worker. A task
1215    // just spawned above may still occupy that worker's non-stealable LIFO
1216    // slot. Yield the scheduler before blocking so dispatch can actually run.
1217    // Foreign/plugin workers and current-thread mailbox-only calls keep the
1218    // ordinary blocking receive.
1219    let received = if Handle::try_current()
1220        .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1221    {
1222        tokio::task::block_in_place(|| rx.recv())
1223    } else {
1224        rx.recv()
1225    };
1226    let result = match received {
1227        Ok(r) => r,
1228        Err(_) => {
1229            // Spawned task dropped the sender without sending: runtime shutdown
1230            // or the dispatch future panicked (caught inside dispatch? no —
1231            // dispatch is plain async, a panic would propagate to the spawn and
1232            // drop the sender). No result to return.
1233            return -2;
1234        }
1235    };
1236
1237    // Write the result (or error) into `*out` as a host-owned StbString. The
1238    // plugin frees it via the vtable's `free_string` (= `host_free_string`).
1239    if out.is_null() {
1240        // Nothing to write to; still report the outcome via the return code.
1241        return match result {
1242            Ok(_) => 0,
1243            Err(_) => 1,
1244        };
1245    }
1246
1247    let (rc, payload) = match result {
1248        Ok(value) => {
1249            let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1250            (0, json)
1251        }
1252        Err(msg) => {
1253            let json = serde_json::json!({ "error": msg }).to_string();
1254            (1, json)
1255        }
1256    };
1257    // SAFETY: `out` is a valid `*mut StbString` the plugin provided for this
1258    // call (null checked above). `from_string` allocates a `Box<[u8]>` the host
1259    // owns; the plugin reclaims it via `host_free_string` (which reconstructs
1260    // the Box from ptr+len — matches `from_string`'s allocation, same pattern
1261    // used by translate.rs/tool.rs).
1262    unsafe {
1263        *out = StbString::from_string(payload);
1264    }
1265    rc
1266}
1267
1268// ===========================================================================
1269// Tests — round-trip the real trampoline + dispatch + Handle::spawn + mpsc
1270// against a mock host. This is the B5a unit proof: plugin→host `runtime_action`
1271// recovers the `ActionBridge` via `user_data`, drives the host's async method on
1272// the runtime, parks the caller on `rx.recv()`, and writes the result JSON back
1273// through `*out` (freed via `host_free_string`). No cdylib needed — the trampoline
1274// is the same `extern "C" fn` a plugin's vtable carries.
1275// ===========================================================================
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279    use std::sync::Mutex;
1280
1281    #[tokio::test(flavor = "current_thread")]
1282    async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1283        let master = ActionBridge::new(
1284            Handle::current(),
1285            Arc::new(MockHost {
1286                prompt: String::new(),
1287                saw: Mutex::new(Vec::new()),
1288            }),
1289        );
1290        let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1291        let owner = crate::HostApi::with_action_bridge(
1292            "owner",
1293            crate::ExtensionRegistry::new(),
1294            diag.clone(),
1295            master.clone(),
1296        );
1297        let other = crate::HostApi::with_action_bridge(
1298            "other",
1299            crate::ExtensionRegistry::new(),
1300            diag,
1301            master.clone(),
1302        );
1303        let vtable = owner.build_vtable();
1304        let claim = r#"{"ownsStdio":true}"#;
1305        {
1306            let _registration = crate::enter_current_api(&owner);
1307            assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1308        }
1309        let other_vtable = other.build_vtable();
1310        let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1311        let mut out = StbString::empty();
1312        // Direct, on a single-thread executor: this action must not park on Tokio.
1313        assert_eq!(
1314            (other_vtable.runtime_action)(
1315                RuntimeActionId::StdioLifecycle as u32,
1316                finish,
1317                &mut out,
1318                other_vtable.user_data
1319            ),
1320            1
1321        );
1322        // SAFETY: the action returned a live allocation from this vtable.
1323        unsafe { (other_vtable.free_string)(out) };
1324        assert_eq!(
1325            (vtable.runtime_action)(
1326                RuntimeActionId::StdioLifecycle as u32,
1327                finish,
1328                &mut out,
1329                vtable.user_data
1330            ),
1331            0
1332        );
1333        // SAFETY: the action returned a live allocation from this vtable.
1334        unsafe { (vtable.free_string)(out) };
1335        assert_eq!(master.stdio_lifecycle().wait().await, 7);
1336        {
1337            let _registration = crate::enter_current_api(&other);
1338            assert_ne!(
1339                other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1340                0
1341            );
1342        }
1343        assert!(master
1344            .stdio_lifecycle()
1345            .validate()
1346            .unwrap_err()
1347            .contains("owner"));
1348    }
1349
1350    /// A minimal `RuntimeActionHost` that answers `get_system_prompt` with a
1351    /// canned string and `reload` with Ok(null); every other action returns an
1352    /// "unimplemented" error. Enough to prove the dispatch switch + the async
1353    /// spawn + the mpsc round-trip + the StbString write.
1354    struct MockHost {
1355        prompt: String,
1356        saw: Mutex<Vec<RuntimeActionId>>,
1357    }
1358
1359    #[test]
1360    fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1361        const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1362        if std::env::var_os(CHILD).is_some() {
1363            let runtime = tokio::runtime::Builder::new_multi_thread()
1364                .worker_threads(1)
1365                .enable_all()
1366                .build()
1367                .unwrap();
1368            runtime.block_on(async {
1369                // Lifecycle observers run directly on a worker. Their runtime
1370                // action spawns back onto that runtime, including its LIFO slot.
1371                tokio::spawn(async {
1372                    let host = Arc::new(MockHost {
1373                        prompt: "worker callback".into(),
1374                        saw: Mutex::new(Vec::new()),
1375                    });
1376                    let bridge = ActionBridge::new(Handle::current(), host);
1377                    let mut out = StbString::empty();
1378                    assert_eq!(
1379                        trampoline_runtime_action(
1380                            RuntimeActionId::GetSystemPrompt.into(),
1381                            StbStringRef::from_str("{}"),
1382                            &mut out,
1383                            bridge.user_data(),
1384                        ),
1385                        0
1386                    );
1387                    // SAFETY: the action returned a live host-owned string.
1388                    let text = unsafe {
1389                        rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1390                            out,
1391                            Some(crate::host_free_string),
1392                        )
1393                    }
1394                    .to_string_lossy();
1395                    assert_eq!(
1396                        serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1397                        "worker callback"
1398                    );
1399                })
1400                .await
1401                .unwrap();
1402            });
1403            return;
1404        }
1405        // Isolate the old self-deadlock so a regression fails promptly instead
1406        // of hanging the test runner or leaving a blocked runtime thread behind.
1407        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1408            .args([
1409                "--exact",
1410                "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1411                "--nocapture",
1412            ])
1413            .env(CHILD, "1")
1414            .stdout(std::process::Stdio::piped())
1415            .stderr(std::process::Stdio::piped())
1416            .spawn()
1417            .unwrap();
1418        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1419        while child.try_wait().unwrap().is_none() {
1420            if std::time::Instant::now() >= deadline {
1421                let _ = child.kill();
1422                let _ = child.wait();
1423                panic!("runtime action blocked the worker needed by its own dispatch");
1424            }
1425            std::thread::sleep(std::time::Duration::from_millis(10));
1426        }
1427        let output = child.wait_with_output().unwrap();
1428        assert!(
1429            output.status.success(),
1430            "{}\n{}",
1431            String::from_utf8_lossy(&output.stdout),
1432            String::from_utf8_lossy(&output.stderr)
1433        );
1434    }
1435
1436    #[async_trait::async_trait]
1437    impl RuntimeActionHost for MockHost {
1438        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1439            unreachable!("not under test")
1440        }
1441        async fn send_user_message(
1442            &self,
1443            _: serde_json::Value,
1444        ) -> Result<serde_json::Value, String> {
1445            unreachable!("not under test")
1446        }
1447        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1448            unreachable!("not under test")
1449        }
1450        async fn set_session_name(
1451            &self,
1452            _: serde_json::Value,
1453        ) -> Result<serde_json::Value, String> {
1454            unreachable!("not under test")
1455        }
1456        async fn get_active_tools(
1457            &self,
1458            _: serde_json::Value,
1459        ) -> Result<serde_json::Value, String> {
1460            unreachable!("not under test")
1461        }
1462        async fn set_active_tools(
1463            &self,
1464            _: serde_json::Value,
1465        ) -> Result<serde_json::Value, String> {
1466            unreachable!("not under test")
1467        }
1468        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1469            unreachable!("not under test")
1470        }
1471        async fn get_thinking_level(
1472            &self,
1473            _: serde_json::Value,
1474        ) -> Result<serde_json::Value, String> {
1475            unreachable!("not under test")
1476        }
1477        async fn set_thinking_level(
1478            &self,
1479            _: serde_json::Value,
1480        ) -> Result<serde_json::Value, String> {
1481            unreachable!("not under test")
1482        }
1483        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1484            unreachable!("not under test")
1485        }
1486        async fn get_system_prompt(
1487            &self,
1488            _: serde_json::Value,
1489        ) -> Result<serde_json::Value, String> {
1490            self.saw
1491                .lock()
1492                .unwrap()
1493                .push(RuntimeActionId::GetSystemPrompt);
1494            Ok(serde_json::json!({ "prompt": self.prompt }))
1495        }
1496        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1497            unreachable!("not under test")
1498        }
1499        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1500            unreachable!("not under test")
1501        }
1502        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1503            unreachable!("not under test")
1504        }
1505        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1506            unreachable!("not under test")
1507        }
1508        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1509            self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1510            Ok(serde_json::Value::Null)
1511        }
1512        async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1513            self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1514            Ok(serde_json::json!({ "value": true }))
1515        }
1516    }
1517
1518    /// Async host actions need a multi-thread runtime when called synchronously
1519    /// from inside it. The trampoline yields that worker with block_in_place;
1520    /// foreign/plugin threads instead park on the ordinary channel receive.
1521    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1522    async fn trampoline_round_trips_get_system_prompt() {
1523        let host = Arc::new(MockHost {
1524            prompt: "hello from host".to_string(),
1525            saw: Mutex::new(Vec::new()),
1526        });
1527        let host_for_assert = Arc::clone(&host);
1528        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1529        let runtime = tokio::runtime::Handle::current();
1530        let bridge = ActionBridge::new(runtime, host_dyn);
1531        let user_data = bridge.user_data();
1532
1533        // Build a StbStringRef for the args. `{}` — getters ignore it.
1534        let args_str = "{}";
1535        let args_ref = StbStringRef::from_str(args_str);
1536
1537        let mut out = StbString::empty();
1538        let rc = trampoline_runtime_action(
1539            RuntimeActionId::GetSystemPrompt.into(),
1540            args_ref,
1541            &mut out as *mut StbString,
1542            user_data,
1543        );
1544        assert_eq!(rc, 0, "success return code");
1545
1546        // Read the result JSON back + reclaim the host-owned StbString.
1547        // SAFETY: the action wrote a live host-owned output, freed below.
1548        let json_text = unsafe {
1549            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1550        }
1551        .to_string_lossy();
1552        let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1553        assert_eq!(parsed["prompt"], "hello from host");
1554
1555        // The host saw exactly the one action. `host_for_assert` is a clone of
1556        // the `Arc<MockHost>` kept before it was coerced to the trait object.
1557        let saw = host_for_assert.saw.lock().unwrap().clone();
1558        assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1559    }
1560
1561    /// The `SetEditorText` runtime action must reach the bridge's editor-text
1562    /// mailbox through the ABI trampoline. This is the wiring a plugin relies on
1563    /// (a voice extension writes a transcription draft here); a broken action id
1564    /// or a dropped dispatch would be invisible to the plugin — it only sees
1565    /// `ok: true` — so pin it here.
1566    #[tokio::test]
1567    async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1568        let host = Arc::new(MockHost {
1569            prompt: String::new(),
1570            saw: Mutex::new(Vec::new()),
1571        });
1572        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1573        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1574        let mailbox = bridge.editor_text_mailbox();
1575        let user_data = bridge.user_data();
1576
1577        let args_ref =
1578            StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1579        let mut out = StbString::empty();
1580        let rc = trampoline_runtime_action(
1581            RuntimeActionId::SetEditorText.into(),
1582            args_ref,
1583            &mut out as *mut StbString,
1584            user_data,
1585        );
1586        assert_eq!(rc, 0, "success return code");
1587
1588        // SAFETY: the action wrote a live host-owned output, freed below.
1589        let parsed: serde_json::Value = serde_json::from_str(
1590            &unsafe {
1591                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1592            }
1593            .to_string_lossy(),
1594        )
1595        .expect("valid json");
1596        assert_eq!(parsed["ok"], serde_json::json!(true));
1597        assert_eq!(parsed["chars"], serde_json::json!(10));
1598
1599        // The queued edit is what the host will apply to the prompt editor.
1600        let edit = mailbox.take_pending().expect("queued edit");
1601        assert_eq!(edit.text, "from voice");
1602        assert_eq!(edit.mode, crate::EditorTextMode::Append);
1603        assert_eq!(edit.auto_send_ms, Some(2000));
1604        assert!(mailbox.take_pending().is_none());
1605    }
1606
1607    #[tokio::test]
1608    async fn trampoline_null_user_data_returns_minus_one() {
1609        let args_ref = StbStringRef::from_str("{}");
1610        let mut out = StbString::empty();
1611        let rc = trampoline_runtime_action(
1612            RuntimeActionId::GetSystemPrompt.into(),
1613            args_ref,
1614            &mut out as *mut StbString,
1615            std::ptr::null_mut(),
1616        );
1617        assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1618    }
1619
1620    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1621    async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1622        // A bridge with a reload callback: the spawn site intercepts Reload and
1623        // runs the callback instead of the host's `reload` method. Proves the
1624        // bridge-level special-casing (the host impl is the fallback only).
1625        use std::sync::atomic::{AtomicUsize, Ordering};
1626        let reload_calls = Arc::new(AtomicUsize::new(0));
1627        let reload_calls_for_cb = Arc::clone(&reload_calls);
1628        let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1629            Arc::new(move || {
1630                let c = Arc::clone(&reload_calls_for_cb);
1631                Box::pin(async move {
1632                    c.fetch_add(1, Ordering::SeqCst);
1633                })
1634            });
1635        let host = Arc::new(MockHost {
1636            prompt: String::new(),
1637            saw: Mutex::new(Vec::new()),
1638        });
1639        let host_for_assert = Arc::clone(&host);
1640        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1641        let runtime = tokio::runtime::Handle::current();
1642        let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1643        let user_data = bridge.user_data();
1644
1645        let args_ref = StbStringRef::from_str("{}");
1646        let mut out = StbString::empty();
1647        let rc = trampoline_runtime_action(
1648            RuntimeActionId::Reload.into(),
1649            args_ref,
1650            &mut out as *mut StbString,
1651            user_data,
1652        );
1653        assert_eq!(rc, 0);
1654        // The callback ran once; the host's `reload` did NOT.
1655        assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1656        assert!(host_for_assert.saw.lock().unwrap().is_empty());
1657        // SAFETY: this action returned a live host allocation, not previously freed.
1658        drop(unsafe {
1659            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1660        });
1661    }
1662
1663    /// B5d: an invalidated bridge rejects `runtime_action` with a stale-bridge
1664    /// error instead of dispatching. A `/reload` calls `invalidate` on the old
1665    /// bridge; an in-flight call that still holds the old pointer must fail
1666    /// fast rather than drive the swapped-out session.
1667    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1668    async fn trampoline_rejects_stale_bridge_with_invalidate() {
1669        let host = Arc::new(MockHost {
1670            prompt: String::new(),
1671            saw: Mutex::new(Vec::new()),
1672        });
1673        let host_for_assert = Arc::clone(&host);
1674        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1675        let runtime = tokio::runtime::Handle::current();
1676        let bridge = ActionBridge::new(runtime, host_dyn);
1677        let user_data = bridge.user_data();
1678
1679        // Invalidate (as a `/reload` would on the old bridge).
1680        bridge.invalidate();
1681        assert!(!bridge.is_active());
1682
1683        let args_ref = StbStringRef::from_str("{}");
1684        let mut out = StbString::empty();
1685        let rc = trampoline_runtime_action(
1686            RuntimeActionId::GetSystemPrompt.into(),
1687            args_ref,
1688            &mut out as *mut StbString,
1689            user_data,
1690        );
1691        // Nonzero (error), and the host method never ran (no dispatch).
1692        assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1693        // SAFETY: the action wrote a live host-owned output, freed below.
1694        let json_text = unsafe {
1695            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1696        }
1697        .to_string_lossy();
1698        assert!(
1699            json_text.contains("stale"),
1700            "stale-bridge error payload: {json_text}"
1701        );
1702
1703        assert!(
1704            host_for_assert.saw.lock().unwrap().is_empty(),
1705            "host dispatch must NOT run on a stale bridge"
1706        );
1707    }
1708
1709    #[tokio::test]
1710    async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1711        let host = Arc::new(MockHost {
1712            prompt: String::new(),
1713            saw: Mutex::new(Vec::new()),
1714        });
1715        let host_for_assert = Arc::clone(&host);
1716        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1717        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1718        let user_data = bridge.user_data();
1719
1720        let mut out = StbString::empty();
1721        let rc = trampoline_runtime_action(
1722            0xFFFF_FFFE,
1723            StbStringRef::from_str("{}"),
1724            &mut out,
1725            user_data,
1726        );
1727
1728        assert_eq!(rc, 2, "unknown action id must be a protocol error");
1729        let payload: serde_json::Value = serde_json::from_str(
1730            &unsafe {
1731                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1732            }
1733            .to_string_lossy(),
1734        )
1735        .expect("structured error JSON");
1736        assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1737
1738        assert!(
1739            host_for_assert.saw.lock().unwrap().is_empty(),
1740            "unknown action id must not reach host dispatch"
1741        );
1742    }
1743
1744    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1745    async fn v2_trampoline_accepts_new_action() {
1746        let host = Arc::new(MockHost {
1747            prompt: "v2".to_string(),
1748            saw: Mutex::new(Vec::new()),
1749        });
1750        let host_for_assert = Arc::clone(&host);
1751        let host_dyn: Arc<dyn RuntimeActionHost> = host;
1752        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1753        let user_data = bridge.user_data();
1754
1755        let mut v2_out = StbString::empty();
1756        assert_eq!(
1757            trampoline_runtime_action(
1758                RuntimeActionId::GetCliFlag.into(),
1759                StbStringRef::from_str(r#"{"name":"flag"}"#),
1760                &mut v2_out,
1761                user_data,
1762            ),
1763            0
1764        );
1765        // SAFETY: this action returned a live host allocation, not previously freed.
1766        drop(unsafe {
1767            rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1768        });
1769
1770        assert_eq!(
1771            *host_for_assert.saw.lock().unwrap(),
1772            vec![RuntimeActionId::GetCliFlag]
1773        );
1774    }
1775
1776    /// `ReloadMailbox` + `reload_callback_from_mailbox` round-trip: signalling
1777    /// fires the installed receiver; an uninstalled mailbox yields `Err` (the
1778    /// host's "not configured" fallback). Exercises the B5d reload-signal path
1779    /// the TUI installs.
1780    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1781    async fn reload_mailbox_signals_installed_receiver() {
1782        let mailbox = ReloadMailbox::new();
1783        // No receiver installed yet ⇒ signal fails.
1784        assert!(matches!(mailbox.signal(), Err(())));
1785
1786        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1787        mailbox.install(tx);
1788        let cb = reload_callback_from_mailbox(mailbox.clone());
1789        cb().await;
1790        assert_eq!(
1791            rx.recv().await,
1792            Some(()),
1793            "installed receiver saw the signal"
1794        );
1795        mailbox.clear();
1796    }
1797
1798    // ---- UiDialogMailbox (runtime action 17 / ask_user) ----
1799
1800    fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1801        serde_json::json!({
1802            "op": "open",
1803            "requestId": id,
1804            "toolCallId": tool_call,
1805            "ui": {"kind": "selector", "questions": [
1806                {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1807            ]},
1808        })
1809    }
1810
1811    /// Headless hosts (nothing attached) must fail loudly rather than fabricate
1812    /// a user answer.
1813    #[test]
1814    fn ui_dialog_open_requires_an_attached_ui() {
1815        let mailbox = UiDialogMailbox::new();
1816        let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1817        assert!(error.contains("interactive UI"), "got: {error}");
1818    }
1819
1820    #[test]
1821    fn ui_dialog_open_poll_answer_round_trip() {
1822        let mailbox = UiDialogMailbox::new();
1823        mailbox.attach();
1824
1825        let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1826        assert_eq!(opened["status"], "pending");
1827        assert_eq!(opened["toolCallId"], "c1");
1828
1829        let request = mailbox.take_pending().expect("request visible to TUI");
1830        assert_eq!(request.request_id, "r1");
1831        assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1832        // A pending poll is non-destructive and remains available.
1833        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1834        assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1835
1836        mailbox
1837            .respond("r1", serde_json::json!("7890"))
1838            .expect("respond");
1839        let answered = mailbox.poll("r1").unwrap();
1840        assert_eq!(answered["status"], "answered");
1841        assert_eq!(answered["answer"], "7890");
1842        // Polling consumes the terminal entry.
1843        assert!(mailbox.poll("r1").is_err());
1844    }
1845
1846    #[test]
1847    fn ui_dialog_cancel_is_reported_once() {
1848        let mailbox = UiDialogMailbox::new();
1849        mailbox.attach();
1850        mailbox.handle(open_args("r1", "c1")).unwrap();
1851        mailbox.cancel("r1").unwrap();
1852        let cancelled = mailbox.poll("r1").unwrap();
1853        assert_eq!(cancelled["status"], "cancelled");
1854        assert!(mailbox.poll("r1").is_err());
1855    }
1856
1857    #[test]
1858    fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1859        let mailbox = UiDialogMailbox::new();
1860        mailbox.attach();
1861        mailbox.handle(open_args("r1", "c1")).unwrap();
1862        mailbox.handle(open_args("r2", "c2")).unwrap();
1863        mailbox.cancel_tool_call("c1");
1864        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1865        assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1866        assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1867        assert_eq!(
1868            mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1869            "cancelled"
1870        );
1871        assert!(mailbox.take_pending().is_none());
1872    }
1873
1874    #[test]
1875    fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1876        let mailbox = UiDialogMailbox::new();
1877        mailbox.attach();
1878        mailbox.handle(open_args("r1", "c1")).unwrap();
1879        mailbox.handle(open_args("r2", "c2")).unwrap();
1880
1881        let first = mailbox.take_pending().unwrap().request_id;
1882        let second = mailbox.take_pending().unwrap().request_id;
1883        assert_ne!(first, second);
1884
1885        // Answer the SECOND request first; the first must stay pending.
1886        mailbox
1887            .respond(&second, serde_json::json!("second"))
1888            .unwrap();
1889        assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1890        let second_answer = mailbox.poll(&second).unwrap();
1891        assert_eq!(second_answer["answer"], "second");
1892
1893        mailbox.respond(&first, serde_json::json!("first")).unwrap();
1894        assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1895    }
1896
1897    #[test]
1898    fn ui_dialog_detach_cancels_outstanding() {
1899        let mailbox = UiDialogMailbox::new();
1900        mailbox.attach();
1901        mailbox.handle(open_args("r1", "c1")).unwrap();
1902        mailbox.detach();
1903        assert!(!mailbox.is_attached());
1904        assert_eq!(mailbox.pending_len(), 0);
1905        // Re-opening without a consumer is an explicit error again.
1906        assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1907        assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1908    }
1909
1910    #[test]
1911    fn ui_dialog_duplicate_open_does_not_reenqueue() {
1912        let mailbox = UiDialogMailbox::new();
1913        mailbox.attach();
1914        mailbox.handle(open_args("r1", "c1")).unwrap();
1915        let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1916        assert_eq!(again["status"], "pending");
1917        assert_eq!(mailbox.pending_len(), 1);
1918        mailbox.take_pending().unwrap();
1919        assert!(mailbox.take_pending().is_none());
1920    }
1921
1922    /// The v2 trampoline must route action 17 into the session mailbox rather
1923    /// than rejecting it as unknown, and a detached mailbox must surface the
1924    /// explicit "requires an interactive UI" error (headless contract).
1925    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1926    async fn v2_dispatch_routes_ui_dialog_action_17() {
1927        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1928            prompt: String::new(),
1929            saw: Mutex::new(Vec::new()),
1930        });
1931        let bridge = ActionBridge::with_ui_dialog(
1932            tokio::runtime::Handle::current(),
1933            host,
1934            UiDialogMailbox::new(),
1935        );
1936        let user_data = bridge.user_data();
1937
1938        let mut out = StbString::empty();
1939        let rc = trampoline_runtime_action(
1940            17,
1941            StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1942            &mut out,
1943            user_data,
1944        );
1945        assert_eq!(rc, 1);
1946        let payload: serde_json::Value = serde_json::from_str(
1947            &unsafe {
1948                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1949            }
1950            .to_string_lossy(),
1951        )
1952        .expect("structured error JSON");
1953        assert!(
1954            payload["error"]
1955                .as_str()
1956                .is_some_and(|error| error.contains("interactive UI")),
1957            "unexpected payload: {payload}"
1958        );
1959
1960        // A genuinely unknown id is still rejected (no silent acceptance).
1961        // 26 is one past the highest defined action.
1962        let mut out = StbString::empty();
1963        assert_eq!(
1964            trampoline_runtime_action(26, StbStringRef::from_str("{}"), &mut out, user_data),
1965            2
1966        );
1967        // SAFETY: this action returned a live host allocation, not previously freed.
1968        drop(unsafe {
1969            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1970        });
1971    }
1972
1973    #[tokio::test(flavor = "current_thread")]
1974    async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1975        let host = Arc::new(MockHost {
1976            prompt: String::new(),
1977            saw: Mutex::new(Vec::new()),
1978        });
1979        let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1980        let a = root.for_plugin("a");
1981        let b = root.for_plugin("b");
1982        let mailbox = root.extension_status_mailbox();
1983        for plugin in [&a, &b] {
1984            let mut out = StbString::empty();
1985            assert_eq!(
1986                trampoline_runtime_action(
1987                    22,
1988                    StbStringRef::from_str(
1989                        r#"{"op":"register","id":"same","slot":"footer.after"}"#
1990                    ),
1991                    &mut out,
1992                    plugin.user_data()
1993                ),
1994                0
1995            );
1996            // SAFETY: this action returned a live host allocation, not previously freed.
1997            drop(unsafe {
1998                rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1999            });
2000        }
2001        assert_eq!(mailbox.panels().len(), 2);
2002        drop(a);
2003        assert_eq!(mailbox.panels().len(), 1);
2004        root.invalidate();
2005        assert!(mailbox.panels().is_empty());
2006    }
2007
2008    /// Action 18 (`SetStatus`) is bridge-local, like `UiDialog`: it writes the
2009    /// shared registry the TUI polls and never touches the harness.
2010    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2011    async fn v2_dispatch_routes_set_status_action_18() {
2012        let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2013            prompt: String::new(),
2014            saw: Mutex::new(Vec::new()),
2015        });
2016        let status = crate::status::ExtensionStatusMailbox::new();
2017        let bridge = ActionBridge::with_reload_and_ui(
2018            tokio::runtime::Handle::current(),
2019            host,
2020            Arc::new(|| Box::pin(async {})),
2021            UiDialogMailbox::new(),
2022            status.clone(),
2023            crate::editor_text::EditorTextMailbox::new(),
2024        );
2025        let user_data = bridge.user_data();
2026
2027        let mut out = StbString::empty();
2028        let rc = trampoline_runtime_action(
2029            18,
2030            StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2031            &mut out,
2032            user_data,
2033        );
2034        assert_eq!(rc, 0);
2035        assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2036        // SAFETY: this action returned a live host allocation, not previously freed.
2037        drop(unsafe {
2038            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2039        });
2040
2041        // Clearing through the same action empties the line again.
2042        let mut out = StbString::empty();
2043        assert_eq!(
2044            trampoline_runtime_action(
2045                18,
2046                StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2047                &mut out,
2048                user_data
2049            ),
2050            0
2051        );
2052        assert_eq!(status.text(), "");
2053        // SAFETY: this action returned a live host allocation, not previously freed.
2054        drop(unsafe {
2055            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2056        });
2057
2058        // A missing key is a host-level error (rc=1), not an unknown action.
2059        let mut out = StbString::empty();
2060        assert_eq!(
2061            trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2062            1
2063        );
2064        // SAFETY: this action returned a live host allocation, not previously freed.
2065        drop(unsafe {
2066            rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2067        });
2068    }
2069}
2070
2071impl Drop for ActionBridge {
2072    fn drop(&mut self) {
2073        bridges()
2074            .lock()
2075            .unwrap_or_else(|e| e.into_inner())
2076            .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2077        if self.invocation.is_some() {
2078            return;
2079        }
2080        if let Some((owner, _)) = &self.plugin_identity {
2081            self.ext_status.remove_owner(*owner);
2082            self.host.cancel_child_agents(*owner);
2083            self.host.cancel_side_questions(*owner);
2084        }
2085    }
2086}
2087
2088#[cfg(test)]
2089mod runtime_tests;