Skip to main content

rpi_plugin_sdk/
lib.rs

1//! Stable `#[repr(C)]` ABI contract for rpi **Rust-native (cdylib) plugins**.
2//!
3//! rpi loads extensions as compiled Rust cdylibs (`.dll`/`.so`/`.dylib`) via
4//! `libloading` — **not** TS/jiti. Because we control both sides, the plugin is
5//! Rust, but the *boundary* is still a hand-defined C ABI: the two sides may be
6//! compiled with different Rust versions / crate versions, so no Rust type with
7//! a non-`C` repr or a `Drop` impl may cross. This crate defines exactly those
8//! crossing types and the registration contract.
9//!
10//! ## Soundness rules (load-bearing — verified by an adversarial review)
11//!
12//! 1. **Every crossing type is `#[repr(C)]`.** Enums used in unions carry
13//!    `#[repr(u32)]` so the discriminant width is pinned.
14//! 2. **No `Drop` type crosses.** `Vec`/`String`/`serde_json::Value`/`Option` of
15//!    those / `Result` never appear in the ABI. Owned data crosses as
16//!    [`StbString`] (ptr+len) with an **explicit `free_string`** the producer
17//!    exports. [`StbString`] is `Copy` (raw pointers are `Copy`); copying
18//!    duplicates the *pointer*, not the allocation, so each allocation is freed
19//!    **exactly once** by the side that received it (see [`StbString`] docs).
20//! 3. **Owned → JSON round-trip.** Structured host data ([`StableJsonValue`],
21//!    tool params, `AgentToolResult`, events) crosses as a JSON string in a
22//!    [`StbString`]. `serde_json` with `preserve_order` + `arbitrary_precision`
23//!    must be enabled **consistently on host AND plugin** or integers >
24//!    `u64`/`i64` lose precision and object keys may reorder — documented as a
25//!    an ABI-wide limit. Tool args from the model rarely carry overflow ints, but it is
26//!    never silent.
27//! 4. **Unions are all-`Copy` payloads.** [`EventPayload`] / [`StepResultPayload`]
28//!    variants are `#[repr(C)]` structs of primitives or [`StbString`] only, so
29//!    the union is `Copy`-able and a wrong-variant read is `unsafe` (caller
30//!    discriminates by `tag`).
31//! 5. **Unwinding never crosses the ABI.** Every host→plugin and plugin→host
32//!    call is `extern "C"`; both sides wrap dispatch in `catch_unwind`
33//!    (abort-on-unwind / log-and-drop). A poisoned mutex or panicking emitter
34//!    cannot unwind into the other side.
35//!
36//! ## Lifetime: the 4-function handle
37//!
38//! A registered tool drives an execution through **four** plugin-exported
39//! functions (see [`ToolExecuteFn`] / [`ToolPollFn`] / [`ToolCancelFn`] /
40//! [`ToolDestroyFn`]) — `execute`→[`StepHandle`] (plugin-allocates), `poll`
41//! (non-blocking, **borrows** the handle, returns [`StepResult`]), `cancel`
42//! (sets an internal `AtomicBool` flag; **idempotent; does NOT free;
43//! thread-safe**), `destroy` (frees; **idempotent; called exactly once by the
44//! blocking driver**). `cancel` ≠ `destroy`: conflating them is a UAF /
45//! double-free. The adapter's blocking driver calls `poll` in a loop until
46//! `Done`/`Err`, forwards `Pending` partials, and calls `destroy` once on exit.
47//!
48//! `poll` is **non-blocking** and MUST observe the cancel flag and return
49//! `Done`/`Err` within a bounded number of polls; otherwise a cancelled call
50//! leaks a `spawn_blocking` thread forever (those tasks run to completion
51//! regardless of outer-future drop).
52//!
53//! The crate is `std` (not `no_std`): the ABI *types* are `#[repr(C)]` POD with
54//! no `Drop` — that is what makes the boundary sound — but plugins and the host
55//! are ordinary binaries with `std`, so the constructor/reader helpers and tests
56//! use `String`/`Vec`/`serde_json` directly. The `json` feature keeps
57//! `serde_json` optional for a plugin that wants to skip it.
58//!
59//! The optional `provider` feature adds the `provider` module and `export_provider!`
60//! for bridging Rust `rpi_ai::Provider` implementations to this ABI. It alone
61//! enables the AI, async and HTTP dependencies; the default ABI SDK stays light.
62
63#![deny(unsafe_op_in_unsafe_fn)]
64
65use core::ffi::c_char;
66use core::ffi::c_void;
67use core::ptr;
68
69pub mod callbacks;
70#[cfg(feature = "json")]
71pub mod dialogs;
72#[cfg(feature = "json")]
73pub mod events;
74pub mod handles;
75#[cfg(feature = "process")]
76pub mod process;
77#[cfg(feature = "provider")]
78pub mod provider;
79#[cfg(feature = "stdio")]
80pub mod stdio;
81
82// ---------------------------------------------------------------------------
83// StbString — owned UTF-8 crossing as ptr+len with an explicit free
84// ---------------------------------------------------------------------------
85
86/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
87///
88/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
89/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
90/// allocation and MUST free it **exactly once** by calling the producer's
91/// [`FreeStringFn`] (or [`StbString::free_with`]).
92/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
93/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
94/// is a no-op only if the producer guarantees the buffer outlives the call; in
95/// practice inputs cross as [`StbStringRef`] instead).
96///
97/// **Construction ownership contract:**
98/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
99///   `StbString` now owns it; `free` deallocates.
100/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
101///   over `from_owned` (host side, needs `alloc`).
102/// - [`StbString::empty`] — null/0; `free` is a no-op.
103///
104/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
105/// dereferenced.
106#[repr(C)]
107#[derive(Clone, Copy)]
108pub struct StbString {
109    /// UTF-8 bytes. Null when `len == 0` (empty string).
110    pub ptr: *mut c_char,
111    /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
112    pub len: usize,
113}
114
115// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
116// transferred across threads by the type itself, and lifetime/aliasing is the
117// caller's contract (documented above). It is `Send`+`Sync` so the host and the
118// blocking driver can pass it across threads; the *allocation* ownership rules
119// above still apply regardless of thread.
120unsafe impl Send for StbString {}
121unsafe impl Sync for StbString {}
122
123impl StbString {
124    /// An empty string: null pointer, zero length. `free` is a no-op.
125    pub const fn empty() -> Self {
126        Self {
127            ptr: ptr::null_mut(),
128            len: 0,
129        }
130    }
131
132    /// Whether this is the empty/null string.
133    pub const fn is_empty(&self) -> bool {
134        self.len == 0
135    }
136}
137
138// Constructors and the raw reader depend only on std, not serde_json. Keep
139// them available when the optional JSON feature is disabled: ABI trampolines
140// still allocate, copy and reclaim strings in that configuration.
141impl StbString {
142    /// Wrap an allocation the caller already boxed. The `StbString` takes
143    /// ownership: a later `free_with(free_fn)` will deallocate it via the
144    /// producer's `free_string`.
145    ///
146    /// The buffer MUST be UTF-8.
147    pub fn from_owned(buf: Box<[u8]>) -> Self {
148        let len = buf.len();
149        // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
150        // slice from ptr+len and drops it. Store the element pointer as
151        // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
152        // moves into the `StbString` (we do NOT drop here).
153        let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
154        let _ = len;
155        Self { ptr, len }
156    }
157
158    /// Convenience: from a `String`, transferring ownership. After this the
159    /// passed `String` is consumed and must not be reused.
160    pub fn from_string(s: String) -> Self {
161        Self::from_vec(s.into_bytes())
162    }
163
164    /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
165    pub fn from_vec(v: Vec<u8>) -> Self {
166        Self::from_owned(v.into_boxed_slice())
167    }
168
169    /// Convenience: from a boxed `str`.
170    pub fn from_boxed_str(s: Box<str>) -> Self {
171        let string: String = s.into();
172        Self::from_vec(string.into_bytes())
173    }
174
175    /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
176    /// the original — the caller still owns the original allocation). Use this
177    /// to *read* a received `StbString` into safe Rust; then `free` the original.
178    /// Invalid UTF-8 is replaced with the Unicode replacement character.
179    ///
180    /// # Safety
181    /// For a non-null pointer and nonzero length, `ptr` must be valid for
182    /// `len` initialized bytes in a single allocation, with `len <= isize::MAX`.
183    /// The bytes must remain live and unchanged during this call. A copied
184    /// `StbString` does not extend the allocation's lifetime.
185    ///
186    /// ```compile_fail
187    /// use rpi_plugin_sdk::StbString;
188    /// let raw = StbString::empty();
189    /// // Reading public ABI pointers requires an explicit validity contract.
190    /// let text = raw.to_string_lossy();
191    /// ```
192    pub unsafe fn to_string_lossy(&self) -> String {
193        if self.len == 0 || self.ptr.is_null() {
194            return String::new();
195        }
196        // SAFETY: the caller guarantees a live, readable, unchanged buffer.
197        // UTF-8 validity is not required by this lossy conversion.
198        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
199        String::from_utf8_lossy(slice).into_owned()
200    }
201}
202
203/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
204/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
205///
206/// Built from a `&str` on the calling side; the buffer is valid for the
207/// duration of the call (the caller's borrow).
208#[repr(C)]
209#[derive(Clone, Copy)]
210pub struct StbStringRef {
211    /// UTF-8 bytes (valid for the call; NUL-terminated not required).
212    pub ptr: *const c_char,
213    /// Byte length.
214    pub len: usize,
215}
216
217unsafe impl Send for StbStringRef {}
218unsafe impl Sync for StbStringRef {}
219
220impl StbStringRef {
221    /// Empty input.
222    pub const fn empty() -> Self {
223        Self {
224            ptr: ptr::null(),
225            len: 0,
226        }
227    }
228
229    /// Borrow a `&str` for the duration of a call. The caller must outlive the
230    /// call (normal borrow rules).
231    pub fn from_str(s: &str) -> Self {
232        Self {
233            ptr: s.as_ptr() as *const c_char,
234            len: s.len(),
235        }
236    }
237
238    /// Read into a safe `&str` for the callee's lifetime `'a`.
239    ///
240    /// Panics on invalid UTF-8; SDK callback trampolines contain that panic.
241    ///
242    /// # Safety
243    /// The caller guarantees `ptr` is valid for `len` initialized bytes in a
244    /// single allocation, with `len <= isize::MAX`, and those bytes remain live
245    /// and unchanged for the borrow's lifetime `'a`.
246    pub unsafe fn as_str<'a>(&self) -> &'a str {
247        if self.len == 0 || self.ptr.is_null() {
248            return "";
249        }
250        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
251        core::str::from_utf8(slice).expect("ABI string input must be valid UTF-8")
252    }
253}
254
255/// Function pointer a plugin exports to free a [`StbString`] it produced.
256/// Empty strings require no deallocation. Transfer each non-empty allocation
257/// exactly once; freeing another copy of an already-freed string is invalid.
258///
259/// The host calls this for every [`StbString`] it receives from the plugin; the
260/// plugin calls the **host's** `free_string` (from [`PluginApi`]) for every
261/// [`StbString`] it receives from the host.
262///
263/// # Safety
264/// The caller must transfer a live allocation made by this producer exactly
265/// once. Calling with a forged, borrowed or already-freed buffer is invalid.
266pub type FreeStringFn = unsafe extern "C" fn(s: StbString);
267
268impl StbString {
269    /// Free this `StbString` via the given `free_string` fn, if non-null and
270    /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
271    /// caller relinquishes the allocation).
272    ///
273    /// After this call the bytes are invalid; do not use the `StbString` again.
274    ///
275    /// # Safety
276    /// A non-empty string must be a live owning allocation created by the
277    /// supplied producer. This call transfers it exactly once; no other copy
278    /// may be read or freed afterward. Prefer [`callbacks::OwnedString`] for
279    /// Rust-side ownership and automatic cleanup.
280    ///
281    /// ```compile_fail
282    /// use rpi_plugin_sdk::{callbacks, StbString};
283    /// let raw = StbString::from_string("text".into());
284    /// raw.free_with(Some(callbacks::free_string));
285    /// ```
286    pub unsafe fn free_with(self, free_fn: Option<FreeStringFn>) {
287        if let Some(free_fn) = free_fn {
288            if !self.is_empty() {
289                // SAFETY: the caller supplies the producer and unique ownership.
290                unsafe { free_fn(self) };
291            }
292        }
293    }
294}
295
296// ---------------------------------------------------------------------------
297// StableJsonValue — JSON round-trip helpers (json feature)
298// ---------------------------------------------------------------------------
299
300/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
301/// for the precision/order limit.
302#[cfg(feature = "json")]
303pub mod json {
304    use super::StbString;
305    use serde_json::Value;
306
307    /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
308    /// must `free` via the producer's `free_string`.
309    pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
310        let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
311        let stb = StbString::from_string(s);
312        // `free_fn` is advisory metadata the receiver needs; the StbString
313        // itself carries only ptr+len. Stash nothing — the receiver must know
314        // which free fn to use (host's vs plugin's) by direction.
315        let _ = free_fn;
316        stb
317    }
318
319    /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
320    /// input — the caller still owns it.
321    ///
322    /// # Safety
323    /// The buffer must satisfy [`StbString::to_string_lossy`]'s validity and
324    /// lifetime requirements for the duration of this call.
325    ///
326    /// ```compile_fail
327    /// use rpi_plugin_sdk::{json, StbString};
328    /// let raw = StbString::empty();
329    /// let value = json::from_stable(&raw);
330    /// ```
331    pub unsafe fn from_stable(s: &StbString) -> Value {
332        // SAFETY: the caller supplies the raw string validity contract.
333        let text = unsafe { s.to_string_lossy() };
334        if text.is_empty() {
335            return Value::Null;
336        }
337        serde_json::from_str(&text).unwrap_or(Value::Null)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// StableToolSchema — the provider-facing tool definition
343// ---------------------------------------------------------------------------
344
345/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
346/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
347/// host parses it into its native `schemars::Schema`).
348///
349/// All three are owning [`StbString`]s the **plugin** produced; the **host**
350/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
351/// `register_tool`).
352#[repr(C)]
353#[derive(Clone, Copy)]
354pub struct StableToolSchema {
355    pub name: StbString,
356    pub description: StbString,
357    /// JSON-encoded JSON Schema for the tool's `parameters`.
358    pub parameters: StbString,
359}
360
361// ---------------------------------------------------------------------------
362// Host context injected into plugin tool arguments
363// ---------------------------------------------------------------------------
364
365/// Reserved key the host adds to a **plugin** tool call's arguments.
366///
367/// The model never writes this field and never sees it: the host injects it
368/// after the model's arguments are parsed and before the plugin's
369/// [`ToolExecuteFn`] runs, so a plugin can tell *which* project and *which*
370/// session it is serving without the model having to supply either. (Built-in
371/// tools do not need it — they receive the host's `ExecutionToolContext`
372/// directly. A plugin tool has no such channel.)
373///
374/// The value is an object; today:
375///
376/// ```json
377/// { "cwd": "D:\\Projects\\pi-rust",
378///   "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" }
379/// ```
380///
381/// Both fields are optional and either may be absent: `cwd` when the host
382/// cannot resolve one, `sessionId` for a session that has no id (ephemeral
383/// sessions) and on hosts that predate this field. The key itself may be absent
384/// entirely. Treat everything under it as best-effort, and prefer a
385/// project-scoped fallback when `sessionId` is missing rather than refusing to
386/// work.
387///
388/// Plugins using host-managed child actions additionally receive per-call
389/// `runtimeHandle` (opaque integer) and `runtimeKey` (capability string). Worker
390/// threads use that handle as runtime_action's opaque user_data and include
391/// `__rpiRuntimeKey` in ChildAgent arguments. Never log, persist, expose to the
392/// model, or reuse these values across tool calls. Missing values mean the
393/// host cannot support worker-originated ChildAgent safely; fail closed.
394///
395/// **Consequence for plugin parameter types:** a plugin must tolerate unknown
396/// top-level keys in its arguments. Reading them out of a `serde_json::Value`
397/// does that naturally; a `#[serde(deny_unknown_fields)]` parameter struct
398/// would reject this key, so do not use one for tool parameters.
399///
400/// The host's value wins if the model happens to send the same key: this is
401/// host truth, not a hint to be merged.
402pub const HOST_CONTEXT_KEY: &str = "__rpi";
403
404// ---------------------------------------------------------------------------
405// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
406// ---------------------------------------------------------------------------
407
408/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
409/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
410/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
411///
412/// The handle's interior layout is entirely plugin-private; the host treats it
413/// as an opaque pointer.
414pub type StepHandle = *mut c_void;
415
416/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
417/// payload is sound across compilers.
418#[repr(u32)]
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum StepResultTag {
421    /// `poll` has no terminal result yet; the partial payload may carry progress.
422    Pending = 0,
423    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
424    Done = 1,
425    /// Terminal failure; `err.message` is a UTF-8 error string.
426    Err = 2,
427}
428
429/// A partial/progress result emitted during `Pending`. `progress` is a JSON
430/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
431/// to the adapter's `on_update` callback. May be empty.
432#[repr(C)]
433#[derive(Clone, Copy)]
434pub struct StbPending {
435    pub progress: StbString,
436}
437
438/// Terminal success payload. `result` is a JSON `AgentToolResult`.
439#[repr(C)]
440#[derive(Clone, Copy)]
441pub struct StbDone {
442    pub result: StbString,
443}
444
445/// Terminal failure payload. `message` is a UTF-8 error string.
446#[repr(C)]
447#[derive(Clone, Copy)]
448pub struct StbErr {
449    pub message: StbString,
450}
451
452/// The `poll()` return value. Read the `payload` variant matching `tag`.
453///
454/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
455/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
456#[repr(C)]
457#[derive(Clone, Copy)]
458pub union StepResultPayload {
459    pub pending: StbPending,
460    pub done: StbDone,
461    pub err: StbErr,
462}
463
464/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
465/// the matching payload, and breaks the loop on `Done`/`Err`.
466#[repr(C)]
467#[derive(Clone, Copy)]
468pub struct StepResult {
469    pub tag: StepResultTag,
470    pub payload: StepResultPayload,
471}
472
473impl StepResult {
474    /// Build a `Pending` with a progress JSON string (may be empty).
475    pub fn pending(progress: StbString) -> Self {
476        Self {
477            tag: StepResultTag::Pending,
478            payload: StepResultPayload {
479                pending: StbPending { progress },
480            },
481        }
482    }
483
484    /// Build a `Done` with the terminal JSON `AgentToolResult`.
485    pub fn done(result: StbString) -> Self {
486        Self {
487            tag: StepResultTag::Done,
488            payload: StepResultPayload {
489                done: StbDone { result },
490            },
491        }
492    }
493
494    /// Build an `Err` with an error message.
495    pub fn err(message: StbString) -> Self {
496        Self {
497            tag: StepResultTag::Err,
498            payload: StepResultPayload {
499                err: StbErr { message },
500            },
501        }
502    }
503
504    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
505    ///
506    /// # Safety
507    /// Undefined behavior if `tag != StepResultTag::Pending`.
508    pub unsafe fn pending_payload(&self) -> &StbPending {
509        unsafe { &self.payload.pending }
510    }
511
512    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
513    ///
514    /// # Safety
515    /// Undefined behavior if `tag != StepResultTag::Done`.
516    pub unsafe fn done_payload(&self) -> &StbDone {
517        unsafe { &self.payload.done }
518    }
519
520    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
521    ///
522    /// # Safety
523    /// Undefined behavior if `tag != StepResultTag::Err`.
524    pub unsafe fn err_payload(&self) -> &StbErr {
525        unsafe { &self.payload.err }
526    }
527}
528
529// ---------------------------------------------------------------------------
530// The 4-function tool lifecycle fn-pointer types
531// ---------------------------------------------------------------------------
532
533/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
534/// `catch_unwind` on the host side. The plugin invokes it **synchronously
535/// inside `poll()`** when it has a `Pending` partial — never retained, never
536/// invoked after `Done`/`Err`.
537///
538/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
539/// host frees it through this tool's registered plugin `free_string`).
540pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
541
542/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
543/// handle and begins the work (non-blocking — the real progress comes via
544/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
545/// `params` is an owning JSON string of the tool-call arguments (the plugin
546/// frees it via the host's `free_string`). Returns null on allocation failure.
547pub type ToolExecuteFn = extern "C" fn(
548    tool_call_id: StbStringRef,
549    params: StbString,
550    free_params: Option<FreeStringFn>,
551) -> StepHandle;
552
553/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
554/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
555/// within a bounded number of polls. Borrows `handle` (does not free it).
556pub type ToolPollFn = extern "C" fn(
557    handle: StepHandle,
558    partial_cb: Option<ToolPartialCb>,
559    user_data: *mut c_void,
560) -> StepResult;
561
562/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
563/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
564pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
565
566/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
567/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
568/// propagated). Null handle is a no-op.
569pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
570
571/// Host notification that a wake-capable tool may make progress. May be called
572/// from any thread, including after unregistration; the opaque token is never
573/// dereferenced. Notifications coalesce and are retained until the next poll.
574pub type ToolWakeCb = extern "C" fn(callback_data: *mut c_void);
575
576/// Install or unregister a tool wake callback. Installing must notify if work
577/// is already ready. `None` unregisters; callbacks racing with removal are safe.
578/// The host serializes execute/install/poll/cancel/unregister/destroy per handle.
579/// After Pending, the host waits for a wake; plugins must wake for every new
580/// opportunity to poll. Partial callbacks remain synchronous within poll only.
581pub type ToolSetWakerFn =
582    extern "C" fn(handle: StepHandle, callback: Option<ToolWakeCb>, callback_data: *mut c_void);
583
584// ---------------------------------------------------------------------------
585// StablePluginEvent — 33 on() categories (explicit tag + union)
586// ---------------------------------------------------------------------------
587
588/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
589/// (33 total) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
590/// the discriminant width.
591///
592/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
593/// project_trust, resources_discover, session_start, session_info_changed,
594/// session_before_switch, session_before_fork, session_before_compact,
595/// session_compact, session_shutdown, session_before_tree, session_tree,
596/// context, before_provider_request, before_provider_headers,
597/// after_provider_response, before_agent_start, agent_start, agent_end,
598/// agent_settled, turn_start, turn_end, message_start, message_update,
599/// message_end, tool_execution_start, tool_execution_update,
600/// tool_execution_end, model_select, thinking_level_select, tool_call,
601/// tool_result, user_bash, input.
602///
603/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
604/// before the interactive TUI is initialized, so extensions can prepare
605/// (connect a server, load a resource) before the UI starts. Appended last so
606/// existing discriminants keep their values (ABI-safe for already-compiled
607/// plugins).
608#[repr(u32)]
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub enum EventTag {
611    ProjectTrust = 0,
612    ResourcesDiscover = 1,
613    SessionStart = 2,
614    SessionInfoChanged = 3,
615    SessionBeforeSwitch = 4,
616    SessionBeforeFork = 5,
617    SessionBeforeCompact = 6,
618    SessionCompact = 7,
619    SessionShutdown = 8,
620    SessionBeforeTree = 9,
621    SessionTree = 10,
622    Context = 11,
623    BeforeProviderRequest = 12,
624    BeforeProviderHeaders = 13,
625    AfterProviderResponse = 14,
626    /// The start of a run, carrying the user prompt (`{"prompt", "imageCount",
627    /// "sessionId", "host"}`). Fire-and-forget like every other tag: subscribe to
628    /// *observe* the turn. To **change** the system prompt for the run, register a
629    /// handler with `register_before_agent_start` — that slot carries an `out`
630    /// pointer, which this fire-and-forget signature cannot.
631    BeforeAgentStart = 15,
632    AgentStart = 16,
633    AgentEnd = 17,
634    AgentSettled = 18,
635    TurnStart = 19,
636    TurnEnd = 20,
637    MessageStart = 21,
638    MessageUpdate = 22,
639    MessageEnd = 23,
640    ToolExecutionStart = 24,
641    ToolExecutionUpdate = 25,
642    ToolExecutionEnd = 26,
643    ModelSelect = 27,
644    ThinkingLevelSelect = 28,
645    ToolCall = 29,
646    ToolResult = 30,
647    UserBash = 31,
648    Input = 32,
649    /// rpi-specific: dispatched before the interactive TUI initializes (not a
650    /// Pi `on()` category). Appended last so existing discriminants are stable.
651    BeforeTuiStart = 33,
652    /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
653    /// (selector, dialog, etc.) that blocks agent work.
654    UiPromptStart = 34,
655    /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
656    UiPromptEnd = 35,
657    /// rpi-specific (not a Pi `on()` category): dispatched whenever the
658    /// interactive prompt editor's **text changes** — typing, pasting, deleting,
659    /// or a programmatic rewrite. Appended last so existing discriminants are
660    /// stable. The payload is `{"chars":N,"empty":bool}`; the draft text itself
661    /// is deliberately not shipped, because subscribers (a voice extension
662    /// barging in on playback) only need to know *that* the user is composing.
663    EditorChange = 36,
664}
665
666/// Number of event categories — `37` (33 Pi `on()` categories + 1 rpi-specific
667/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events + 1 rpi-specific
668/// [`EventTag::EditorChange`]). A test asserts
669/// `EVENT_TAG_COUNT == 37` so a future edit that adds/removes a tag is caught.
670pub const EVENT_TAG_COUNT: usize = 37;
671
672/// The application name of the host that loads plugins: `"rpi"`.
673///
674/// This is the **contract's** copy of the host identity, and the host's
675/// `APP_NAME` is defined in terms of it, so the two cannot drift. An extension
676/// that labels its own output (a trace name, a tag, a `service.name`) should
677/// derive from this rather than hardcode a brand — it has no other way to learn
678/// what it is embedded in.
679///
680/// [`HOST_VERSION`] is the matching version. Both are also handed to plugins at
681/// runtime in the `BeforeAgentStart` payload's `host` object, which is what an
682/// extension should prefer: that value always describes the process actually
683/// running, whereas these constants describe the host the plugin was *built*
684/// against.
685pub const HOST_NAME: &str = "rpi";
686
687/// Version of the [`HOST_NAME`] host that this SDK ships with.
688///
689/// Because every workspace crate shares one version, this is the host's version
690/// as of this SDK release — good for a user agent or a fallback label, and
691/// superseded at runtime by the `host.version` field of the `BeforeAgentStart`
692/// payload.
693pub const HOST_VERSION: &str = env!("CARGO_PKG_VERSION");
694
695/// Environment variable carrying the id of the session this process serves.
696///
697/// The host **rewrites** it whenever a session becomes active — at startup and
698/// on every in-process swap (`/import`, fork, switch) — so the value always
699/// names the session currently being served. It is not an embedder override:
700/// a value inherited from a parent process would otherwise make a child `rpi`
701/// report its ancestor's session forever. Embedders that need to pin the id set
702/// it after the session is active rather than before.
703///
704/// The id is a plain UTF-8 string (no NUL), which is what makes writing it with
705/// `std::env::set_var` sound.
706pub const SESSION_ID_ENV: &str = "RPI_SESSION_ID";
707
708/// Environment variable a tracing extension publishes to name the trace a
709/// *nested* `rpi` should attach to, and reads back to decide it is a subagent.
710///
711/// The name is `rpi-langfuse`'s (it owns the `LANGFUSE_` prefix), but the
712/// variable is declared here because **three** parties have to agree on it and
713/// they live in different crates, two of them in different repositories:
714///
715/// - the extension writes it (so a launcher can attach a nested run),
716/// - the host's `bash`/`powershell` must *not* pass it on to a shell (or a plain
717///   `rpi` started from one mistakes itself for a subagent), and
718/// - a launcher may set it deliberately, which is the supported nesting path.
719///
720/// Declaring it once here is what keeps those three in step. The host builds its
721/// exclusion list from these declarations, so dropping or renaming the family
722/// ([`SUBAGENT_PARENT_ENV`]) is a compile error there rather than a silently
723/// re-opened leak, and a newly added member is excluded without the host having
724/// to be told. What the compiler still cannot check is the string itself, since
725/// `rpi-langfuse` lives in another repository and matches on the *value* — that
726/// is pinned by a test. See [`SUBAGENT_PARENT_ENV`] for the whole family.
727pub const SUBAGENT_PARENT_TRACE_ID_ENV: &str = "LANGFUSE_PI_PARENT_TRACE_ID";
728
729/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent turn's root observation id.
730pub const SUBAGENT_PARENT_SPAN_ID_ENV: &str = "LANGFUSE_PI_PARENT_SPAN_ID";
731
732/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent's session id.
733pub const SUBAGENT_PARENT_SESSION_ID_ENV: &str = "LANGFUSE_PI_PARENT_SESSION_ID";
734
735/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. Nesting depth, so a grandchild knows
736/// how far down it is.
737pub const SUBAGENT_PARENT_DEPTH_ENV: &str = "LANGFUSE_PI_PARENT_DEPTH";
738
739/// Every [`SUBAGENT_PARENT_TRACE_ID_ENV`] sibling, as one slice.
740///
741/// A host that excludes this channel from a spawned shell should cover the whole
742/// slice rather than restating the names, and assert it does — a new entry here
743/// that is not excluded is the failure mode this array exists to make testable.
744pub const SUBAGENT_PARENT_ENV: &[&str] = &[
745    SUBAGENT_PARENT_TRACE_ID_ENV,
746    SUBAGENT_PARENT_SPAN_ID_ENV,
747    SUBAGENT_PARENT_SESSION_ID_ENV,
748    SUBAGENT_PARENT_DEPTH_ENV,
749];
750
751/// No-payload marker for events that carry none (e.g. `session_shutdown`).
752/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
753/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
754#[repr(C)]
755#[derive(Clone, Copy)]
756pub struct EventEmpty {
757    _opaque: u8,
758}
759
760impl EventEmpty {
761    /// The one no-payload instance.
762    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
763}
764
765impl Default for EventEmpty {
766    fn default() -> Self {
767        Self::INSTANCE
768    }
769}
770
771/// A serialized message lifecycle payload (`message_start`/`message_update`/
772/// `message_end`). `message` is JSON. For `message_update`, it follows native
773/// Pi's envelope and carries `assistantMessageEvent`; streaming consumers must
774/// read its real `text_delta.delta` instead of diffing a cumulative snapshot.
775#[repr(C)]
776#[derive(Clone, Copy)]
777pub struct EventMessage {
778    pub message: StbString,
779}
780
781/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
782/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
783#[repr(C)]
784#[derive(Clone, Copy)]
785pub struct EventToolCall {
786    pub tool_call_id: StbString,
787    pub tool_name: StbString,
788    pub params: StbString,
789}
790
791/// A tool-result payload (`tool_result`, `tool_execution_end`).
792#[repr(C)]
793#[derive(Clone, Copy)]
794pub struct EventToolResult {
795    pub tool_call_id: StbString,
796    pub tool_name: StbString,
797    pub result: StbString,
798    pub is_error: u8,
799}
800
801/// An error/failure payload.
802#[repr(C)]
803#[derive(Clone, Copy)]
804pub struct EventError {
805    pub message: StbString,
806}
807
808/// A generic JSON-data payload for the long-tail events whose structured shape
809/// the host serializes wholesale (`context`, `before_provider_request`, model
810/// select, resources_discover response, etc.). The plugin reads the fields it
811/// needs.
812#[repr(C)]
813#[derive(Clone, Copy)]
814pub struct EventData {
815    pub data: StbString,
816}
817
818/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
819/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
820/// [`StablePluginEvent::tag`] before reading.
821#[repr(C)]
822#[derive(Clone, Copy)]
823pub union EventPayload {
824    pub empty: EventEmpty,
825    pub message: EventMessage,
826    pub tool_call: EventToolCall,
827    pub tool_result: EventToolResult,
828    pub error: EventError,
829    pub data: EventData,
830}
831
832/// One event dispatched to a plugin handler. The host translates its native
833/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
834/// for the `tag`. Event buffers are borrowed during fan-out: handlers must not
835/// free them or retain their pointers. The host frees them after all handlers
836/// return. Use `events::EventView` to retain owned payload copies.
837#[repr(C)]
838#[derive(Clone, Copy)]
839pub struct StablePluginEvent {
840    pub tag: EventTag,
841    pub payload: EventPayload,
842}
843
844impl StablePluginEvent {
845    /// Build a no-payload event.
846    pub fn empty(tag: EventTag) -> Self {
847        Self {
848            tag,
849            payload: EventPayload {
850                empty: EventEmpty::INSTANCE,
851            },
852        }
853    }
854
855    /// Build a message event.
856    pub fn message(tag: EventTag, message: StbString) -> Self {
857        debug_assert!(matches!(
858            tag,
859            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
860        ));
861        Self {
862            tag,
863            payload: EventPayload {
864                message: EventMessage { message },
865            },
866        }
867    }
868
869    /// Build a tool-call event.
870    pub fn tool_call(
871        tag: EventTag,
872        tool_call_id: StbString,
873        tool_name: StbString,
874        params: StbString,
875    ) -> Self {
876        debug_assert!(matches!(
877            tag,
878            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
879        ));
880        Self {
881            tag,
882            payload: EventPayload {
883                tool_call: EventToolCall {
884                    tool_call_id,
885                    tool_name,
886                    params,
887                },
888            },
889        }
890    }
891
892    /// Build a tool-result event.
893    pub fn tool_result(
894        tag: EventTag,
895        tool_call_id: StbString,
896        tool_name: StbString,
897        result: StbString,
898        is_error: bool,
899    ) -> Self {
900        debug_assert!(matches!(
901            tag,
902            EventTag::ToolResult | EventTag::ToolExecutionEnd
903        ));
904        Self {
905            tag,
906            payload: EventPayload {
907                tool_result: EventToolResult {
908                    tool_call_id,
909                    tool_name,
910                    result,
911                    is_error: is_error as u8,
912                },
913            },
914        }
915    }
916
917    /// Build an error event.
918    pub fn error(tag: EventTag, message: StbString) -> Self {
919        Self {
920            tag,
921            payload: EventPayload {
922                error: EventError { message },
923            },
924        }
925    }
926
927    /// Build a generic data event (JSON in `data`).
928    pub fn data(tag: EventTag, data: StbString) -> Self {
929        Self {
930            tag,
931            payload: EventPayload {
932                data: EventData { data },
933            },
934        }
935    }
936}
937
938/// Return codes an [`EventHandlerFn`] may return.
939///
940/// `0` means success; the host continues to the next handler. A **nonzero**
941/// return means the handler handled an error — the host logs it and (for the
942/// ordinary agent/observe fan-out) continues to other handlers (one handler's
943/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
944///
945/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
946/// current lifecycle phase. It is honored only by the host's lifecycle
947/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
948/// the ordinary observe fan-out treats it like any other nonzero (log +
949/// continue), because the agent loop must not be vetoed mid-run.
950pub const EVENT_HANDLER_CONTINUE: i32 = 0;
951/// The handler handled an error but the fan-out should continue (default
952/// nonzero semantics — equivalent to any other nonzero code).
953pub const EVENT_HANDLER_ERROR: i32 = 1;
954/// **Veto**: stop the current lifecycle phase. Honored only by the host's
955/// lifecycle dispatch; the observe fan-out logs + continues (see the module
956/// docs on [`EVENT_HANDLER_CONTINUE`]).
957pub const EVENT_HANDLER_ABORT: i32 = 2;
958/// **Claim**: the handler consumed this event and the host should not fall back
959/// to its own default handling. Honored only by dispatch paths that ask
960/// extensions to arbitrate (currently `EventTag::Input` key routing, via
961/// [`EVENT_HANDLER_CLAIMED`]); the ordinary observe fan-out treats it like any
962/// other nonzero (log + continue).
963///
964/// Lets a handler subscribe to a key yet decline it while its feature is off:
965/// return [`EVENT_HANDLER_CONTINUE`] to pass the key through to normal editor
966/// handling, or this to swallow it.
967pub const EVENT_HANDLER_CLAIMED: i32 = 3;
968
969/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
970/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
971/// signals a handled error (the host logs it; dispatch continues to other
972/// handlers — one handler's error does not abort the fan-out). The one
973/// exception: when the host dispatches a **lifecycle** event through its
974/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
975/// aborts that phase (see the constants above).
976pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
977
978/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
979/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
980/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
981/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
982/// and reclaimed via the `plugin_free_string` the host stored alongside the
983/// handler at registration. `user_data` is the plugin's opaque context. Returns
984/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
985/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
986pub type ResourcesDiscoverFn = extern "C" fn(
987    cwd: StbStringRef,
988    reason: StbStringRef,
989    out: *mut StbString,
990    user_data: *mut c_void,
991) -> i32;
992
993/// `before_agent_start` handler signature. Unlike [`EventHandlerFn`]
994/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin can
995/// hand back a system prompt to install for the run — pi's
996/// `BeforeAgentStartEventResult` (`extensions/types.ts:1394`), whose
997/// `systemPrompt` becomes `forceSystemPrompt` (`runner.ts:1347`).
998///
999/// `event_json` is a borrowed envelope with the same fields the
1000/// [`EventTag::BeforeAgentStart`] broadcast carries (`prompt`, `imageCount`,
1001/// `sessionId`, `host`) plus the live `systemPrompt` the run would otherwise
1002/// use. `out` is plugin-produced JSON `{"systemPrompt": "..."}` (or an empty
1003/// object for "no change"), reclaimed via the `plugin_free_string` the host
1004/// stored at registration.
1005///
1006/// Returns `0` on success (host reads `out`); nonzero on a handled error (host
1007/// logs the plugin's message and leaves the prompt untouched, then continues the
1008/// fan-out — mirrors pi, which isolates a throwing handler).
1009///
1010/// **Replacement, not append**: the returned text becomes the system prompt. A
1011/// plugin that wants to append reads `event_json.systemPrompt` and returns the
1012/// two concatenated, which is what pi requires of its extensions too.
1013/// SAFETY: the plugin warrants `user_data` is valid for the registry's lifetime
1014/// and the handler is callable from any thread.
1015pub type BeforeAgentStartFn =
1016    extern "C" fn(event_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1017
1018// ---------------------------------------------------------------------------
1019// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
1020// ---------------------------------------------------------------------------
1021
1022/// Identifier for a host runtime action the plugin may invoke via
1023/// `PluginApi::runtime_action`. One slot dispatches all actions; the numeric
1024/// id crosses the FFI boundary as a `u32` and the host validates it with
1025/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
1026/// strings.
1027#[repr(u32)]
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum RuntimeActionId {
1030    /// Send `message` (stored AgentMessage or Pi customType/content/display/
1031    /// details) or `text`. Options `triggerTurn` and `deliverAs` can be flat or
1032    /// nested under `options`. Default: idle append, active steer. Use explicit
1033    /// `triggerTurn: true` to start an idle run. `nextTurn` always defers.
1034    SendMessage = 0,
1035    SendUserMessage = 1,
1036    /// Append without starting a run. `{"customType": ..., "data": ...}` stores
1037    /// extension data; `{"message": ...}` queues context until the active turn
1038    /// settles. The returned `entryId` can be reserved before its entry exists.
1039    AppendEntry = 2,
1040    SetSessionName = 3,
1041    GetActiveTools = 4,
1042    SetActiveTools = 5,
1043    SetModel = 6,
1044    GetThinkingLevel = 7,
1045    SetThinkingLevel = 8,
1046    Compact = 9,
1047    GetSystemPrompt = 10,
1048    NewSession = 11,
1049    Fork = 12,
1050    NavigateTree = 13,
1051    SwitchSession = 14,
1052    Reload = 15,
1053    /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
1054    /// `{"value": <bool|string|null>}`.
1055    GetCliFlag = 16,
1056    /// Open/poll/cancel a host-provided interactive UI request. The payload is
1057    /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
1058    /// `requestId`; hosts without an attached TUI return an explicit error.
1059    UiDialog = 17,
1060    /// Publish a short status string for the host's UI (TUI footer).
1061    /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
1062    /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
1063    /// Headless hosts accept the write but have nothing to render.
1064    /// Legacy footer text (`key`, `value`). Use UiComponent for registered UI.
1065    SetStatus = 18,
1066    /// Put text into the interactive editor (the prompt input box) instead of
1067    /// sending it. Args: `{"text":"...", "mode":"append"|"replace",
1068    /// "autoSendMs": 3000}` (omit/`0` for manual send only). Result:
1069    /// `{"ok":true,"chars":N}`. A headless host has no editor and accepts the
1070    /// write without rendering it, mirroring [`RuntimeActionId::SetStatus`].
1071    SetEditorText = 19,
1072    /// Return the current session branch entries oldest-first as JSON.
1073    /// Args: `{}`; result: `{"entries":[...]}`.
1074    GetSessionBranch = 20,
1075    /// Inspect or finish a plugin-owned stdio server lifecycle. Args:
1076    /// `{"op":"status"}` or `{"op":"finish","exitCode":0}`. Only the
1077    /// declaring owner may finish; the host performs normal session shutdown.
1078    StdioLifecycle = 21,
1079    /// Plugin-owned text component lifecycle and content updates.
1080    UiComponent = 22,
1081    /// Safe live execution metadata (no credentials, endpoints or headers).
1082    /// Args: `{}`; result: model identity, thinkingLevel, tools, cwd, sessionId.
1083    GetExecutionContext = 23,
1084    /// Host-owned independent child run. `op`: start/poll/cancel/dispose.
1085    /// Start: task, optional role/maxTurns/timeoutSeconds/tools/toolCallId.
1086    /// Others: childId. Poll drains text deltas and returns status/result.
1087    /// Handles are scoped to the calling plugin and must be disposed.
1088    ChildAgent = 24,
1089    /// Read-only, host-managed side question. Registration-level commands may
1090    /// use start(question), status, cancel, hide and show. Never changes the
1091    /// main transcript or its queues. Presentation and execution stay in host.
1092    SideQuestion = 25,
1093}
1094
1095/// Error returned when a plugin passes a numeric runtime-action id that this
1096/// ABI does not define.
1097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1098pub struct UnknownRuntimeActionId(pub u32);
1099
1100impl core::fmt::Display for UnknownRuntimeActionId {
1101    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1102        write!(f, "unknown runtime action id {}", self.0)
1103    }
1104}
1105
1106impl std::error::Error for UnknownRuntimeActionId {}
1107
1108impl TryFrom<u32> for RuntimeActionId {
1109    type Error = UnknownRuntimeActionId;
1110
1111    fn try_from(value: u32) -> Result<Self, Self::Error> {
1112        match value {
1113            0 => Ok(Self::SendMessage),
1114            1 => Ok(Self::SendUserMessage),
1115            2 => Ok(Self::AppendEntry),
1116            3 => Ok(Self::SetSessionName),
1117            4 => Ok(Self::GetActiveTools),
1118            5 => Ok(Self::SetActiveTools),
1119            6 => Ok(Self::SetModel),
1120            7 => Ok(Self::GetThinkingLevel),
1121            8 => Ok(Self::SetThinkingLevel),
1122            9 => Ok(Self::Compact),
1123            10 => Ok(Self::GetSystemPrompt),
1124            11 => Ok(Self::NewSession),
1125            12 => Ok(Self::Fork),
1126            13 => Ok(Self::NavigateTree),
1127            14 => Ok(Self::SwitchSession),
1128            15 => Ok(Self::Reload),
1129            16 => Ok(Self::GetCliFlag),
1130            17 => Ok(Self::UiDialog),
1131            18 => Ok(Self::SetStatus),
1132            19 => Ok(Self::SetEditorText),
1133            20 => Ok(Self::GetSessionBranch),
1134            21 => Ok(Self::StdioLifecycle),
1135            22 => Ok(Self::UiComponent),
1136            23 => Ok(Self::GetExecutionContext),
1137            24 => Ok(Self::ChildAgent),
1138            25 => Ok(Self::SideQuestion),
1139            other => Err(UnknownRuntimeActionId(other)),
1140        }
1141    }
1142}
1143
1144impl From<RuntimeActionId> for u32 {
1145    fn from(value: RuntimeActionId) -> Self {
1146        value as u32
1147    }
1148}
1149
1150/// Runtime-action signature: `runtime_action(action_id, args_json, out,
1151/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
1152/// is an owning output ([`StbString`]) the host produces and the plugin frees
1153/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
1154/// Rust enum: an unknown value must be rejected as a normal protocol error
1155/// rather than materializing an invalid enum discriminant. Returns `0` on
1156/// success, nonzero on error.
1157pub type RuntimeActionFn = extern "C" fn(
1158    action_id: u32,
1159    args_json: StbStringRef,
1160    out: *mut StbString,
1161    user_data: *mut c_void,
1162) -> i32;
1163
1164// ---------------------------------------------------------------------------
1165// PluginApi — host-provided API struct the plugin calls
1166// ---------------------------------------------------------------------------
1167
1168/// A generic command-handler fn (for `register_command`). `args_json` is a
1169/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
1170/// JSON output reclaimed with the host `free_string`. The TUI understands
1171/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
1172/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
1173/// responses; selector/editor/input submissions call the same handler with an
1174/// `action` field in `args`. `user_data` is the registering plugin's opaque
1175/// runtime handle (or null when no action bridge exists), scoped to that
1176/// registration. Pass it to runtime_action; never cache it across registrations.
1177pub type CommandHandlerFn =
1178    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1179
1180/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
1181/// `out` is owning output the plugin frees via host `free_string`. Markdown
1182/// handlers return `{markdown:"..."}`; message/entry handlers return
1183/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
1184pub type RenderFn =
1185    extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1186
1187/// A provider-injection factory fn (for `register_provider`). `req_json` is a
1188/// borrowed request envelope; `out` is an owning response the plugin frees.
1189/// The host wraps this into a `Provider` impl (B4/B5).
1190///
1191/// **One-shot.** The plugin returns the complete assistant message and the host
1192/// emits it as a single terminal chunk (no progressive rendering). A provider
1193/// that can stream should use [`ProviderStreamRequestFn`] via
1194/// `register_streaming_provider` instead; both slots stay registered so an
1195/// existing plugin keeps its behavior unchanged.
1196pub type ProviderRequestFn =
1197    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1198
1199/// Chunk kind for [`ProviderStreamControl::is_cancelled`]. **Appended last** so a
1200/// plugin compiled against an older, shorter struct reads only the fields it
1201/// knows about.
1202#[repr(u32)]
1203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1204pub enum ProviderChunkKind {
1205    /// A chunk that carries model output (`text_delta`, `thinking_delta`, …).
1206    /// A cancelled request has no one left to deliver it to.
1207    Content = 0,
1208    /// A structural or accounting chunk (`start`, `*_start`, `*_end`, `usage`).
1209    /// Still acknowledged while the request winds down. A host that already
1210    /// emitted its aborted terminal discards these cleanup chunks.
1211    Structural = 1,
1212}
1213
1214/// The plugin's synchronous control surface over the host, passed to a **v2**
1215/// streaming request fn.
1216///
1217/// The fields are plain fn pointers plus an opaque pointer, so the struct is
1218/// `Copy` and needs no deallocation. The host owns it for the duration of the
1219/// request and it must not be retained.
1220///
1221/// `is_cancelled` is the cheap check a plugin's request loop makes between
1222/// chunks (poll it, do not block on it). `wait_cancelled_ms` is the blocking
1223/// variant for a plugin whose loop is not able to poll — an SSE frame read, a
1224/// sleep between retries — and it must return promptly on cancellation rather
1225/// than waiting out the full timeout.
1226///
1227/// ```ignore
1228/// // Inside a v2 request fn, before handing a delta to `emit`:
1229/// if control.is_cancelled(control.user_data, ProviderChunkKind::Content as u32) != 0 {
1230///     return 1; // stop producing; the host is already gone
1231/// }
1232/// ```
1233#[repr(C)]
1234#[derive(Clone, Copy)]
1235pub struct ProviderStreamControl {
1236    /// `is_cancelled(control_user_data, chunk_kind: u32) -> i32`. Nonzero when
1237    /// the caller has gone away. `chunk_kind` lets the host still admit the
1238    /// structural chunks a partial message needs: pass
1239    /// [`ProviderChunkKind::Content`] (0) for model output and
1240    /// [`ProviderChunkKind::Structural`] (1) otherwise. Never blocking, never
1241    /// frees anything.
1242    pub is_cancelled: extern "C" fn(user_data: *mut c_void, chunk_kind: u32) -> i32,
1243    /// `wait_cancelled_ms(control_user_data, timeout_ms) -> i32`. Blocks up to
1244    /// `timeout_ms` waiting for cancellation, returning nonzero the moment it is
1245    /// observed. `timeout_ms == 0` is a non-blocking poll (same as
1246    /// `is_cancelled` with [`ProviderChunkKind::Content`]). Safe to call from the
1247    /// request thread: it only touches the host's cancellation latch, which is
1248    /// always present in the snapshot and never holds another lock.
1249    pub wait_cancelled_ms: extern "C" fn(user_data: *mut c_void, timeout_ms: u32) -> i32,
1250    /// Host-owned opaque handle, passed back unchanged to both fields above.
1251    pub user_data: *mut c_void,
1252}
1253
1254/// Completion signal a **v2** streaming provider calls **after** its request fn
1255/// has returned.
1256///
1257/// Retained in the ABI for a future host that can keep the plugin's own stream
1258/// alive past the request fn. This host does **not** use it: the plugin receives
1259/// `None` and must therefore finish inside its request fn (which it can do
1260/// promptly on cancellation by polling [`ProviderStreamControl`]). Keeping the
1261/// type documented — rather than deleting it — is what makes a future host able
1262/// to add the capability without another signature change.
1263///
1264/// `message_json` is a **plugin-owned** owning [`StbString`] holding the final
1265/// assistant message.
1266pub type ProviderStreamCompletionFn =
1267    extern "C" fn(message_json: StbString, user_data: *mut c_void) -> i32;
1268
1269/// Per-chunk callback a **streaming** provider invokes while the response is
1270/// still being produced (for `register_streaming_provider`).
1271///
1272/// `event_json` is an **owning** [`StbString`] the plugin produces and transfers
1273/// to the host: the host reclaims it with the **producer's** `plugin_free_string`
1274/// — the one the plugin passed to `register_streaming_provider` — exactly like a
1275/// tool's partial-progress chunk. The host must not use its own `free_string`
1276/// for it: the allocation belongs to the plugin's SDK instance.
1277///
1278/// The callback is invoked **synchronously inside the request fn**, on the same
1279/// thread, only for the duration of that call — a plugin must never retain
1280/// `emit`, `emit_user_data`, or the struct they live in past the request.
1281/// Returns `0` on success; nonzero means the host rejected the chunk (a malformed
1282/// envelope), which a plugin may ignore or treat as a request failure.
1283pub type ProviderStreamEmitFn = extern "C" fn(event_json: StbString, user_data: *mut c_void) -> i32;
1284
1285/// A **streaming** provider-injection fn (for `register_streaming_provider`).
1286///
1287/// Same request envelope and final-response contract as [`ProviderRequestFn`],
1288/// plus a per-chunk channel and the cancellation protocol:
1289///
1290/// - `req_json` — borrowed `{ model, context, options }` envelope (never freed by
1291///   the plugin).
1292/// - `emit` + `emit_user_data` — the host's per-chunk callback. `emit` is `None`
1293///   only if the host cannot accept chunks; a plugin then degrades to buffering
1294///   and returning the message through `out`. While the caller is cancelled the
1295///   host acknowledges structural cleanup chunks and rejects content chunks
1296///   (nonzero rc). Cleanup chunks are discarded after the aborted terminal.
1297/// - `control` — pointer to the host's [`ProviderStreamControl`], present on
1298///   **every** call. It is `null` only against a host that cannot describe its
1299///   control surface; a plugin then treats the request as not cancellable and
1300///   behaves as if cancellation never happens. The plugin must not retain it: it
1301///   is valid only for the duration of the request fn.
1302/// - `completion` — reserved; always `None` on this host (see
1303///   [`ProviderStreamCompletionFn`]). A plugin must return its final message
1304///   through `out` and may not expect to keep streaming after returning.
1305/// - `out` — owning, plugin-owned, plugin-freed response holding the **final**
1306///   `AssistantMessage` JSON (identical to the one-shot contract).
1307/// - `user_data` — the opaque value the plugin registered with.
1308///
1309/// Call order: zero or more `emit` chunks, then exactly one return. The host
1310/// derives the terminal `Done`/`Error` event from `out` (or from a nonzero rc),
1311/// so a plugin that emits deltas still must fill `out`.
1312///
1313/// **Cancellation is opt-in per chunk, not per registration.** There is one
1314/// streaming registration and one signature; whether a plugin checks `control` is
1315/// up to it. A plugin that ignores `control` behaves exactly like the simpler
1316/// fire-and-forget shape (the host still stops forwarding content and terminates
1317/// the run on `Aborted`) — it just keeps generating until it finishes on its own.
1318///
1319/// Chunk envelope (JSON object with a `type` discriminator). `contentIndex` is the
1320/// index of the block inside the final message; each index may be started once,
1321/// but blocks may be opened in any order:
1322///
1323/// ```json
1324/// {"type":"start"}
1325/// {"type":"text_start","contentIndex":0}
1326/// {"type":"text_delta","contentIndex":0,"delta":"hello "}
1327/// {"type":"text_end","contentIndex":0,"content":"hello world"}
1328/// {"type":"thinking_start","contentIndex":1}
1329/// {"type":"thinking_delta","contentIndex":1,"delta":"considering"}
1330/// {"type":"thinking_end","contentIndex":1,"content":"considering..."}
1331/// {"type":"toolcall_start","contentIndex":2,"id":"call_1","name":"echo"}
1332/// {"type":"toolcall_delta","contentIndex":2,"delta":"{\"text\""}
1333/// {"type":"toolcall_end","contentIndex":2,"id":"call_1","name":"echo","arguments":{}}
1334/// {"type":"usage","usage":{"input":1,"output":2}}
1335/// ```
1336///
1337/// Unknown `type` values are ignored (forward compatibility). A `*_delta` may
1338/// carry an optional `partial` object (a full assistant message) to override the
1339/// host-side accumulated snapshot; otherwise the host accumulates deltas itself.
1340pub type ProviderStreamRequestFn = extern "C" fn(
1341    req_json: StbStringRef,
1342    emit: Option<ProviderStreamEmitFn>,
1343    emit_user_data: *mut c_void,
1344    control: *const ProviderStreamControl,
1345    completion: Option<ProviderStreamCompletionFn>,
1346    completion_user_data: *mut c_void,
1347    out: *mut StbString,
1348    user_data: *mut c_void,
1349) -> i32;
1350
1351/// OAuth provider callback. The host sends a JSON action envelope and the
1352/// plugin returns a JSON response. Actions are `begin`, `exchange`,
1353/// `refresh`, and `revoke`; token material remains plugin-owned and is never
1354/// placed in normal agent events.
1355pub type OAuthRequestFn =
1356    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1357
1358/// Register an OAuth provider using a JSON manifest plus one action callback.
1359/// The manifest is copied by the host and should include `id`, `displayName`,
1360/// `scopes`, `supportsPkce`, and optional `capabilities`.
1361pub type OAuthRegisterFn = OAuthRequestFn;
1362
1363// ---------------------------------------------------------------------------
1364// Unified ABI — single struct, version inside
1365// ---------------------------------------------------------------------------
1366
1367/// The unified ABI version.
1368pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1369
1370/// The unversioned symbol for the unified ABI.
1371pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1372
1373/// The host-provided API struct. The first two fields (`abi_version`
1374/// and `struct_size`) are contract-identity markers that survive future
1375/// signature changes: a plugin reads them from the pointer before anything
1376/// else, so even if the entrypoint arity changes, the version check remains
1377/// robust.
1378#[repr(C)]
1379#[derive(Clone, Copy)]
1380pub struct PluginApi {
1381    /// Contract identity. First field so a plugin can validate before reading
1382    /// anything else, independent of the entrypoint arity.
1383    pub abi_version: u32,
1384    /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1385    /// that predates slots it wants (host_struct_size < plugin's expected
1386    /// offset). Future hosts may grow this struct; old plugins check
1387    /// `struct_size` before accessing new fields.
1388    pub struct_size: u32,
1389
1390    // --- Host API ---
1391    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1392    /// *receives* from the host (outputs of actions, event payloads, inputs to
1393    /// execute). Never null.
1394    pub free_string: FreeStringFn,
1395
1396    // --- registrars (plugin → host "register X into the host") ---
1397    pub register_tool: Option<
1398        extern "C" fn(
1399            schema: *const StableToolSchema,
1400            execute_fn: ToolExecuteFn,
1401            poll_fn: ToolPollFn,
1402            cancel_fn: ToolCancelFn,
1403            destroy_fn: ToolDestroyFn,
1404            plugin_free_string: FreeStringFn,
1405        ) -> i32,
1406    >,
1407    pub register_command: Option<
1408        extern "C" fn(
1409            name: StbStringRef,
1410            description: StbStringRef,
1411            handler: CommandHandlerFn,
1412        ) -> i32,
1413    >,
1414    pub register_shortcut:
1415        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1416    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1417    pub register_provider: Option<
1418        extern "C" fn(
1419            provider_id: StbStringRef,
1420            base_url: StbStringRef,
1421            api_style: StbStringRef,
1422            request_fn: ProviderRequestFn,
1423            plugin_free_string: FreeStringFn,
1424            user_data: *mut c_void,
1425        ) -> i32,
1426    >,
1427    pub register_message_renderer: Option<
1428        extern "C" fn(
1429            name: StbStringRef,
1430            render_fn: RenderFn,
1431            plugin_free_string: FreeStringFn,
1432            user_data: *mut c_void,
1433        ) -> i32,
1434    >,
1435    pub register_markdown_transformer: Option<
1436        extern "C" fn(
1437            name: StbStringRef,
1438            render_fn: RenderFn,
1439            plugin_free_string: FreeStringFn,
1440            user_data: *mut c_void,
1441        ) -> i32,
1442    >,
1443    pub register_entry_renderer: Option<
1444        extern "C" fn(
1445            name: StbStringRef,
1446            render_fn: RenderFn,
1447            plugin_free_string: FreeStringFn,
1448            user_data: *mut c_void,
1449        ) -> i32,
1450    >,
1451    pub register_event_handler: Option<
1452        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1453    >,
1454    pub register_resources_discover: Option<
1455        extern "C" fn(
1456            handler: ResourcesDiscoverFn,
1457            plugin_free_string: FreeStringFn,
1458            user_data: *mut c_void,
1459        ) -> i32,
1460    >,
1461    pub runtime_action: RuntimeActionFn,
1462    pub dispatch_event:
1463        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1464    pub user_data: *mut c_void,
1465
1466    // --- declarations ---
1467    /// Declare this plugin's host-side preferences as a JSON object, e.g.
1468    /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1469    /// not support declarations yet (plugin skips the call; host applies
1470    /// defaults: priority `100`, all platforms).
1471    pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1472
1473    /// Register a system-prompt transformer for the run (pi's
1474    /// `before_agent_start` returning `systemPrompt`).
1475    ///
1476    /// **Appended last on purpose.** A plugin compiled against a shorter
1477    /// `PluginApi` indexes every earlier field by offset, so a slot inserted in
1478    /// the middle would make an old plugin read the new host's fields at the
1479    /// wrong offsets. Appending keeps every existing offset valid, which is what
1480    /// lets this ship without an ABI version bump; a plugin checks
1481    /// `struct_size` (or the slot for `None`) before using it.
1482    ///
1483    /// Nullable: a host that predates this slot leaves it `None`, the plugin
1484    /// skips the call, and the prompt is left alone — exactly that host's
1485    /// behavior before this slot existed.
1486    pub register_before_agent_start: Option<
1487        extern "C" fn(
1488            handler: BeforeAgentStartFn,
1489            plugin_free_string: FreeStringFn,
1490            user_data: *mut c_void,
1491        ) -> i32,
1492    >,
1493
1494    /// Register a provider-owned OAuth flow. Appended for ABI compatibility;
1495    /// older plugins compiled against a shorter struct remain valid.
1496    pub register_oauth_provider: Option<
1497        extern "C" fn(
1498            manifest_json: StbStringRef,
1499            request_fn: OAuthRequestFn,
1500            plugin_free_string: FreeStringFn,
1501            user_data: *mut c_void,
1502        ) -> i32,
1503    >,
1504
1505    /// Register a **streaming** custom provider (see
1506    /// [`ProviderStreamRequestFn`]). Appended after `register_oauth_provider` so
1507    /// every earlier field keeps its offset — a plugin compiled against a shorter
1508    /// [`PluginApi`] stays valid without an ABI version bump.
1509    ///
1510    /// Nullable: `register_entrypoint` fills slots absent from an older host's
1511    /// vtable with `None`; a plugin that cannot find the slot should fall back to
1512    /// `register_provider` (one-shot) rather than refuse to load.
1513    ///
1514    /// `provider_id`/`base_url`/`api_style` mirror `register_provider` and are
1515    /// copied by the host at registration. A streaming registration for an id
1516    /// that already has a one-shot registration **replaces** it: streaming is a
1517    /// strict superset of the one-shot contract, so a plugin registering both
1518    /// ends up on the richer path.
1519    pub register_streaming_provider: Option<
1520        extern "C" fn(
1521            provider_id: StbStringRef,
1522            base_url: StbStringRef,
1523            api_style: StbStringRef,
1524            request_fn: ProviderStreamRequestFn,
1525            plugin_free_string: FreeStringFn,
1526            user_data: *mut c_void,
1527        ) -> i32,
1528    >,
1529    /// Optional wake-driven tool registration, appended for ABI compatibility.
1530    /// Fall back to `register_tool` when an older host supplies None.
1531    pub register_tool_with_wake: Option<
1532        extern "C" fn(
1533            schema: *const StableToolSchema,
1534            execute_fn: ToolExecuteFn,
1535            poll_fn: ToolPollFn,
1536            cancel_fn: ToolCancelFn,
1537            destroy_fn: ToolDestroyFn,
1538            plugin_free_string: FreeStringFn,
1539            set_waker_fn: ToolSetWakerFn,
1540        ) -> i32,
1541    >,
1542}
1543unsafe impl Send for PluginApi {}
1544unsafe impl Sync for PluginApi {}
1545
1546/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1547/// `api->abi_version`.
1548pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1549
1550// ===========================================================================
1551// Panic containment
1552// ===========================================================================
1553
1554/// Status code a plugin's register entrypoint returns when its body panicked.
1555///
1556/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1557/// turns such an unwind into a **process abort** (`panic in a function that
1558/// cannot unwind`), which would take down the whole host. The SDK catches the
1559/// panic and returns this code instead, so the host can skip the plugin with a
1560/// diagnostic.
1561pub const REGISTER_PANIC_STATUS: i32 = 70;
1562
1563/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1564///
1565/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1566/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1567/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1568/// Unwinding out of an `extern "C"` function aborts the process.
1569pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1570    std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1571}
1572
1573/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1574/// must return a value rather than a `Result`.
1575pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1576    guard(body).unwrap_or(fallback)
1577}
1578
1579/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1580///
1581/// The unified ABI has no version suffix in the symbol name; the version lives
1582/// inside the [`PluginApi`] struct (first field `abi_version`).
1583///
1584/// The expression receives `&PluginApi` and returns the plugin-defined
1585/// registration status code.
1586///
1587/// ```ignore
1588/// rpi_plugin_sdk::export_plugin!(|api| {
1589///     if let Some(declare) = api.declare {
1590///         declare(rpi_plugin_sdk::StbStringRef::from_str(
1591///             r#"{"priority":60,"platforms":["linux"]}"#,
1592///         ));
1593///     }
1594///     // ... register tools and handlers through `api` ...
1595///     0
1596/// });
1597/// ```
1598#[macro_export]
1599macro_rules! export_plugin {
1600    ($body:expr) => {
1601        #[no_mangle]
1602        pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1603            // SAFETY: host guarantees `api` is valid for this call.
1604            unsafe { $crate::register_entrypoint(api, $body) }
1605        }
1606    };
1607}
1608
1609/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1610/// the struct pointer before dereferencing anything else. This survives future
1611/// signature changes because the pointer is always the first argument.
1612///
1613/// # Safety
1614///
1615/// `api` must point to a properly aligned, readable ABI header followed by
1616/// `struct_size` readable bytes of the host's initialized vtable. It may be a
1617/// shorter, older layout of [`PluginApi`]. The mandatory prefix through
1618/// `user_data` must be present, and optional trailing slots must be complete.
1619pub unsafe fn register_entrypoint(
1620    api: *const PluginApi,
1621    body: impl FnOnce(&PluginApi) -> i32,
1622) -> i32 {
1623    if api.is_null() {
1624        return 2;
1625    }
1626    // SAFETY: caller guarantees `api` is valid; we check null above.
1627    // Read the version and size fields first — they're at known offsets
1628    // regardless of future struct growth.
1629    let (abi_version, struct_size) = unsafe { ((*api).abi_version, (*api).struct_size) };
1630    if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1631        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1632        // and skips loading this plugin.
1633        return 1;
1634    }
1635    let host_size = struct_size as usize;
1636    let known_size = core::mem::size_of::<PluginApi>();
1637    let required_size = core::mem::offset_of!(PluginApi, declare);
1638    // All trailing fields are nullable function-pointer slots. Do not accept a
1639    // truncated mandatory prefix or a partial function pointer.
1640    if host_size < required_size
1641        || (host_size < known_size
1642            && (host_size - required_size) % core::mem::size_of::<*const ()>() != 0)
1643    {
1644        return 3;
1645    }
1646    let mut normalized = core::mem::MaybeUninit::<PluginApi>::zeroed();
1647    // SAFETY: copy only bytes the host advertises and this SDK knows. The
1648    // mandatory prefix contains every non-null function pointer; all missing
1649    // trailing fields are Option<extern "C" fn>, whose zero representation is
1650    // None. No reference to the larger PluginApi is formed over the old layout.
1651    let api = unsafe {
1652        core::ptr::copy_nonoverlapping(
1653            api.cast::<u8>(),
1654            normalized.as_mut_ptr().cast::<u8>(),
1655            host_size.min(known_size),
1656        );
1657        normalized.assume_init()
1658    };
1659    // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1660    // the process, so convert a panic into a status code the host can report.
1661    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(&api))) {
1662        Ok(code) => code,
1663        Err(_) => REGISTER_PANIC_STATUS,
1664    }
1665}
1666
1667// ===========================================================================
1668// Tests (need std + serde_json)
1669// ===========================================================================
1670
1671#[cfg(test)]
1672mod tests {
1673    use super::*;
1674
1675    /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1676    /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1677    /// plugin instead of the process aborting.
1678    #[test]
1679    fn register_entrypoint_contains_panics() {
1680        let mut api = std::mem::MaybeUninit::<PluginApi>::uninit();
1681        let api_ptr = api.as_mut_ptr();
1682        // Set the version/size fields so the entrypoint gets past the checks
1683        // and into the body (which panics).
1684        unsafe {
1685            (*api_ptr).abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
1686            (*api_ptr).struct_size = core::mem::size_of::<PluginApi>() as u32;
1687        }
1688        let rc = unsafe {
1689            register_entrypoint(api_ptr, |_api| {
1690                panic!("plugin register exploded");
1691            })
1692        };
1693        assert_eq!(rc, REGISTER_PANIC_STATUS);
1694    }
1695
1696    #[test]
1697    fn guard_returns_value_and_contains_panic() {
1698        assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1699        assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1700        assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1701    }
1702
1703    // A test allocator + free fn so we can verify the own/free contract
1704    // without a real plugin's free_string.
1705    std::thread_local! {
1706        static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1707    }
1708
1709    unsafe extern "C" fn test_free(s: StbString) {
1710        if s.is_empty() || s.ptr.is_null() {
1711            return;
1712        }
1713        // SAFETY: the test transfers a live SDK allocation exactly once.
1714        unsafe { callbacks::free_string(s) };
1715        FREED.with(|freed| freed.set(freed.get() + 1));
1716    }
1717
1718    fn reset_freed() -> usize {
1719        FREED.with(|freed| freed.replace(0))
1720    }
1721
1722    fn freed_count() -> usize {
1723        FREED.with(std::cell::Cell::get)
1724    }
1725
1726    // A no-op `runtime_action` impl for the vtable-construction tests (closures
1727    // can't coerce to `extern "C" fn`, so we use a real fn).
1728    extern "C" fn noop_runtime_action(
1729        _action_id: u32,
1730        _args: StbStringRef,
1731        _out: *mut StbString,
1732        _user_data: *mut c_void,
1733    ) -> i32 {
1734        0
1735    }
1736
1737    #[test]
1738    fn stbstring_round_trip_and_free_once() {
1739        let prev = reset_freed();
1740        let _ = prev;
1741        let s = StbString::from_string("hello, pi".to_string());
1742        assert_eq!(s.len, 9);
1743        // SAFETY: s owns a live allocation, freed below.
1744        assert_eq!(unsafe { s.to_string_lossy() }, "hello, pi");
1745        // SAFETY: this test transfers its live SDK allocation exactly once.
1746        unsafe { s.free_with(Some(test_free)) };
1747        assert_eq!(freed_count(), 1);
1748    }
1749
1750    #[test]
1751    fn lossy_copy_accepts_non_utf8_and_does_not_free_or_modify_the_buffer() {
1752        let _ = reset_freed();
1753        let s = StbString::from_vec(vec![b'a', 0xff, b'b']);
1754        // SAFETY: s owns three initialized, unchanged bytes until free below.
1755        let first = unsafe { s.to_string_lossy() };
1756        // SAFETY: copying did not consume or free the original allocation.
1757        let second = unsafe { s.to_string_lossy() };
1758        assert_eq!(first, "a\u{fffd}b");
1759        assert_eq!(first, second);
1760        assert_eq!(freed_count(), 0);
1761        // SAFETY: this test transfers its live SDK allocation exactly once.
1762        unsafe { s.free_with(Some(test_free)) };
1763        assert_eq!(freed_count(), 1);
1764        assert_eq!(first, "a\u{fffd}b");
1765    }
1766
1767    #[test]
1768    fn empty_stbstring_free_is_noop() {
1769        let _ = reset_freed();
1770        // SAFETY: this test transfers its live SDK allocation exactly once.
1771        unsafe { StbString::empty().free_with(Some(test_free)) };
1772        assert_eq!(freed_count(), 0);
1773    }
1774
1775    #[cfg(feature = "json")]
1776    #[test]
1777    fn json_round_trip_preserves_structure() {
1778        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1779        let stb = json::to_stable(&val, None);
1780        // SAFETY: stb owns a live allocation, freed below.
1781        let back = unsafe { json::from_stable(&stb) };
1782        assert_eq!(val, back);
1783        // SAFETY: this test transfers its live SDK allocation exactly once.
1784        unsafe { stb.free_with(Some(test_free)) };
1785        let _ = reset_freed();
1786    }
1787
1788    #[test]
1789    fn step_result_done_round_trip() {
1790        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1791        let sr = StepResult::done(result_json);
1792        assert_eq!(sr.tag, StepResultTag::Done);
1793        // SAFETY: tag == Done.
1794        let done = unsafe { sr.done_payload() };
1795        assert_eq!(
1796            // SAFETY: the initialized Done result is live until test_free below.
1797            unsafe { done.result.to_string_lossy() },
1798            r#"{"content":[{"text":"hi"}]}"#
1799        );
1800        // SAFETY: this test transfers its live SDK allocation exactly once.
1801        unsafe { done.result.free_with(Some(test_free)) };
1802        let _ = reset_freed();
1803    }
1804
1805    #[test]
1806    fn step_result_pending_and_err() {
1807        let prog = StbString::from_string("...".to_string());
1808        let srp = StepResult::pending(prog);
1809        assert_eq!(srp.tag, StepResultTag::Pending);
1810        // SAFETY: tag == Pending.
1811        unsafe {
1812            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1813        }
1814        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1815
1816        let msg = StbString::from_string("boom".to_string());
1817        let sre = StepResult::err(msg);
1818        assert_eq!(sre.tag, StepResultTag::Err);
1819        // SAFETY: tag == Err.
1820        unsafe {
1821            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1822            sre.err_payload().message.free_with(Some(test_free));
1823        }
1824        let _ = reset_freed();
1825    }
1826
1827    #[test]
1828    fn event_tag_count_is_37() {
1829        // Enumerate every tag; a compile-time + runtime guarantee that the
1830        // 37-category surface is intact (33 Pi on() categories + the
1831        // rpi-specific BeforeTuiStart + 2 UI prompt events + EditorChange).
1832        let tags = [
1833            EventTag::ProjectTrust,
1834            EventTag::ResourcesDiscover,
1835            EventTag::SessionStart,
1836            EventTag::SessionInfoChanged,
1837            EventTag::SessionBeforeSwitch,
1838            EventTag::SessionBeforeFork,
1839            EventTag::SessionBeforeCompact,
1840            EventTag::SessionCompact,
1841            EventTag::SessionShutdown,
1842            EventTag::SessionBeforeTree,
1843            EventTag::SessionTree,
1844            EventTag::Context,
1845            EventTag::BeforeProviderRequest,
1846            EventTag::BeforeProviderHeaders,
1847            EventTag::AfterProviderResponse,
1848            EventTag::BeforeAgentStart,
1849            EventTag::AgentStart,
1850            EventTag::AgentEnd,
1851            EventTag::AgentSettled,
1852            EventTag::TurnStart,
1853            EventTag::TurnEnd,
1854            EventTag::MessageStart,
1855            EventTag::MessageUpdate,
1856            EventTag::MessageEnd,
1857            EventTag::ToolExecutionStart,
1858            EventTag::ToolExecutionUpdate,
1859            EventTag::ToolExecutionEnd,
1860            EventTag::ModelSelect,
1861            EventTag::ThinkingLevelSelect,
1862            EventTag::ToolCall,
1863            EventTag::ToolResult,
1864            EventTag::UserBash,
1865            EventTag::Input,
1866            EventTag::BeforeTuiStart,
1867            EventTag::UiPromptStart,
1868            EventTag::UiPromptEnd,
1869            EventTag::EditorChange,
1870        ];
1871        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1872        assert_eq!(EVENT_TAG_COUNT, 37);
1873        // Distinct discriminants 0..36.
1874        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1875        discs.sort();
1876        assert_eq!(discs, (0..37).collect::<Vec<u32>>());
1877    }
1878
1879    #[test]
1880    fn event_payloads_construct_and_free() {
1881        let m = StbString::from_string("msg".to_string());
1882        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1883        assert_eq!(ev.tag, EventTag::MessageEnd);
1884        // SAFETY: tag == MessageEnd (message variant).
1885        unsafe {
1886            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1887            ev.payload.message.message.free_with(Some(test_free));
1888        }
1889
1890        let tc = StablePluginEvent::tool_call(
1891            EventTag::ToolCall,
1892            StbString::from_string("call_1".to_string()),
1893            StbString::from_string("echo".to_string()),
1894            StbString::from_string("{}".to_string()),
1895        );
1896        // SAFETY: tag == ToolCall (tool_call variant).
1897        unsafe {
1898            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1899            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1900            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1901            tc.payload.tool_call.params.free_with(Some(test_free));
1902        }
1903        let _ = reset_freed();
1904    }
1905
1906    #[test]
1907    fn plugin_api_is_pod_and_sized() {
1908        // The API struct is a plain old data struct: every fn pointer is
1909        // non-Drop, the struct has no Drop impl.
1910        let api = PluginApi {
1911            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
1912            struct_size: core::mem::size_of::<PluginApi>() as u32,
1913            free_string: test_free,
1914            register_tool: None,
1915            register_command: None,
1916            register_shortcut: None,
1917            register_flag: None,
1918            register_provider: None,
1919            register_message_renderer: None,
1920            register_markdown_transformer: None,
1921            register_entry_renderer: None,
1922            register_event_handler: None,
1923            register_resources_discover: None,
1924            runtime_action: noop_runtime_action,
1925            dispatch_event: None,
1926            user_data: core::ptr::null_mut(),
1927            declare: None,
1928            register_before_agent_start: None,
1929            register_oauth_provider: None,
1930            register_streaming_provider: None,
1931            register_tool_with_wake: None,
1932        };
1933        // All optional slots are null → plugin must degrade.
1934        assert!(api.register_tool.is_none());
1935        assert!(api.register_event_handler.is_none());
1936        assert!(api.register_resources_discover.is_none());
1937        assert!(api.register_before_agent_start.is_none());
1938        // Copy (POD) — no UB from a plain copy.
1939        let _copy = api;
1940        assert!(!core::mem::needs_drop::<PluginApi>());
1941        assert!(!core::mem::needs_drop::<StbString>());
1942        assert!(!core::mem::needs_drop::<StepResult>());
1943        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1944        assert!(!core::mem::needs_drop::<StableToolSchema>());
1945        assert!(
1946            !core::mem::needs_drop::<ProviderStreamControl>(),
1947            "the control struct is fn pointers + a u32: it must stay Copy/POD"
1948        );
1949    }
1950
1951    /// New vtable slots must be **appended**, never inserted.
1952    ///
1953    /// A plugin compiled against a shorter `PluginApi` reads every field by
1954    /// offset. Inserting a slot in the middle would make that plugin read the
1955    /// new host's fields at the wrong offsets — silently, since both sides just
1956    /// see pointers. Appending is what lets a slot ship without an ABI version
1957    /// bump. This pins the three fields at the end and the identity markers at
1958    /// the front, so a future edit that inserts in the middle fails here.
1959    #[test]
1960    fn vtable_grows_only_at_the_end() {
1961        use core::mem::{align_of, size_of};
1962
1963        // Identity markers stay first: a plugin reads these before anything
1964        // else, independent of the entrypoint arity.
1965        assert_eq!(core::mem::offset_of!(PluginApi, abi_version), 0);
1966        assert_eq!(
1967            core::mem::offset_of!(PluginApi, struct_size),
1968            size_of::<u32>()
1969        );
1970
1971        // The most recently added slot is last, and the ones before it are
1972        // adjacent — i.e. nothing was wedged in between them.
1973        let declare = core::mem::offset_of!(PluginApi, declare);
1974        let prompt_slot = core::mem::offset_of!(PluginApi, register_before_agent_start);
1975        assert_eq!(
1976            prompt_slot,
1977            declare + size_of::<Option<extern "C" fn(StbStringRef) -> i32>>(),
1978            "register_before_agent_start must be appended right after `declare`; \
1979             inserting a slot earlier shifts every later offset and breaks \
1980             plugins built against the previous layout"
1981        );
1982        let oauth_slot = core::mem::offset_of!(PluginApi, register_oauth_provider);
1983        assert_eq!(
1984            oauth_slot,
1985            prompt_slot
1986                + size_of::<
1987                    Option<extern "C" fn(BeforeAgentStartFn, FreeStringFn, *mut c_void) -> i32>,
1988                >(),
1989            "register_oauth_provider must stay adjacent to register_before_agent_start"
1990        );
1991        let streaming_slot = core::mem::offset_of!(PluginApi, register_streaming_provider);
1992        assert_eq!(
1993            streaming_slot,
1994            oauth_slot
1995                + size_of::<
1996                    Option<
1997                        extern "C" fn(
1998                            StbStringRef,
1999                            OAuthRequestFn,
2000                            FreeStringFn,
2001                            *mut c_void,
2002                        ) -> i32,
2003                    >,
2004                >(),
2005            "register_streaming_provider must be appended after register_oauth_provider \
2006             so an older plugin's every offset stays valid"
2007        );
2008        assert_eq!(
2009            streaming_slot
2010                + size_of::<
2011                    Option<
2012                        extern "C" fn(
2013                            StbStringRef,
2014                            StbStringRef,
2015                            StbStringRef,
2016                            ProviderStreamRequestFn,
2017                            FreeStringFn,
2018                            *mut c_void,
2019                        ) -> i32,
2020                    >,
2021                >(),
2022            core::mem::offset_of!(PluginApi, register_tool_with_wake),
2023            "wake registration must follow streaming registration"
2024        );
2025        assert_eq!(
2026            size_of::<PluginApi>(),
2027            core::mem::offset_of!(PluginApi, register_tool_with_wake) + size_of::<usize>()
2028        );
2029        assert_eq!(align_of::<PluginApi>(), align_of::<*const ()>());
2030    }
2031
2032    #[test]
2033    fn runtime_action_ids_are_explicitly_validated() {
2034        let ids = [
2035            RuntimeActionId::SendMessage,
2036            RuntimeActionId::SendUserMessage,
2037            RuntimeActionId::AppendEntry,
2038            RuntimeActionId::SetSessionName,
2039            RuntimeActionId::GetActiveTools,
2040            RuntimeActionId::SetActiveTools,
2041            RuntimeActionId::SetModel,
2042            RuntimeActionId::GetThinkingLevel,
2043            RuntimeActionId::SetThinkingLevel,
2044            RuntimeActionId::Compact,
2045            RuntimeActionId::GetSystemPrompt,
2046            RuntimeActionId::NewSession,
2047            RuntimeActionId::Fork,
2048            RuntimeActionId::NavigateTree,
2049            RuntimeActionId::SwitchSession,
2050            RuntimeActionId::Reload,
2051            RuntimeActionId::GetCliFlag,
2052            RuntimeActionId::UiDialog,
2053            RuntimeActionId::SetStatus,
2054            RuntimeActionId::SetEditorText,
2055            RuntimeActionId::GetSessionBranch,
2056            RuntimeActionId::StdioLifecycle,
2057            RuntimeActionId::UiComponent,
2058            RuntimeActionId::GetExecutionContext,
2059            RuntimeActionId::ChildAgent,
2060            RuntimeActionId::SideQuestion,
2061        ];
2062
2063        for (raw, expected) in ids.into_iter().enumerate() {
2064            assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
2065            assert_eq!(u32::from(expected), raw as u32);
2066        }
2067        // One past the highest defined id is rejected (nothing is silently
2068        // accepted), and so is the u32 ceiling.
2069        assert_eq!(
2070            RuntimeActionId::try_from(26),
2071            Err(UnknownRuntimeActionId(26))
2072        );
2073        assert_eq!(
2074            RuntimeActionId::try_from(u32::MAX),
2075            Err(UnknownRuntimeActionId(u32::MAX))
2076        );
2077    }
2078
2079    fn entrypoint_test_api() -> PluginApi {
2080        PluginApi {
2081            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
2082            struct_size: core::mem::size_of::<PluginApi>() as u32,
2083            free_string: test_free,
2084            register_tool: None,
2085            register_command: None,
2086            register_shortcut: None,
2087            register_flag: None,
2088            register_provider: None,
2089            register_message_renderer: None,
2090            register_markdown_transformer: None,
2091            register_entry_renderer: None,
2092            register_event_handler: None,
2093            register_resources_discover: None,
2094            runtime_action: noop_runtime_action,
2095            dispatch_event: None,
2096            user_data: core::ptr::null_mut(),
2097            declare: None,
2098            register_before_agent_start: None,
2099            register_oauth_provider: None,
2100            register_streaming_provider: None,
2101            register_tool_with_wake: None,
2102        }
2103    }
2104
2105    #[test]
2106    fn register_entrypoint_version_mismatch_refuses() {
2107        let api = entrypoint_test_api();
2108        assert_eq!(RPI_PLUGIN_ABI_VERSION_UNIFIED, 4);
2109
2110        // A future version is rejected by the pre-dereference check.
2111        let mut future_api = api;
2112        future_api.abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED + 1;
2113        let rc = unsafe {
2114            register_entrypoint(&future_api, |_| {
2115                panic!("body must not run on version mismatch");
2116            })
2117        };
2118        assert_eq!(rc, 1);
2119
2120        // Right version → body runs, rc propagated.
2121        let rc = unsafe { register_entrypoint(&api, |_| 0) };
2122        assert_eq!(rc, 0);
2123        let rc = unsafe { register_entrypoint(&api, |_| 42) };
2124        assert_eq!(rc, 42);
2125
2126        // Null api → refuse.
2127        let rc = unsafe { register_entrypoint(core::ptr::null(), |_| 0) };
2128        assert_ne!(rc, 0);
2129        let _ = reset_freed();
2130    }
2131
2132    #[test]
2133    fn register_entrypoint_accepts_short_vtables_without_reading_missing_slots() {
2134        extern "C" fn declare(_: StbStringRef) -> i32 {
2135            7
2136        }
2137        extern "C" fn provider(
2138            _: StbStringRef,
2139            _: StbStringRef,
2140            _: StbStringRef,
2141            _: ProviderRequestFn,
2142            _: FreeStringFn,
2143            _: *mut c_void,
2144        ) -> i32 {
2145            0
2146        }
2147        for size in [
2148            core::mem::offset_of!(PluginApi, declare),
2149            core::mem::offset_of!(PluginApi, register_before_agent_start),
2150            core::mem::offset_of!(PluginApi, register_oauth_provider),
2151            core::mem::offset_of!(PluginApi, register_streaming_provider),
2152            core::mem::offset_of!(PluginApi, register_tool_with_wake),
2153        ] {
2154            let mut api = entrypoint_test_api();
2155            api.struct_size = size as u32;
2156            api.register_provider = Some(provider);
2157            api.declare = Some(declare);
2158            // Allocate exactly the old layout's size, with pointer alignment.
2159            let mut old = vec![0usize; size / core::mem::size_of::<usize>()];
2160            // SAFETY: both buffers are valid for size bytes; the old layout is
2161            // a fully initialized prefix ending at a complete field boundary.
2162            unsafe {
2163                core::ptr::copy_nonoverlapping(
2164                    (&api as *const PluginApi).cast::<u8>(),
2165                    old.as_mut_ptr().cast::<u8>(),
2166                    size,
2167                );
2168            }
2169            let mut called = false;
2170            let rc = unsafe {
2171                register_entrypoint(old.as_ptr().cast(), |api| {
2172                    called = true;
2173                    assert_eq!(api.struct_size, size as u32);
2174                    assert!(api.register_streaming_provider.is_none());
2175                    assert!(api.register_tool_with_wake.is_none());
2176                    assert!(
2177                        api.register_provider.is_some(),
2178                        "one-shot fallback remains available"
2179                    );
2180                    if size > core::mem::offset_of!(PluginApi, declare) {
2181                        assert_eq!((api.declare.unwrap())(StbStringRef::from_str("{}")), 7);
2182                    } else {
2183                        assert!(api.declare.is_none());
2184                    }
2185                    0
2186                })
2187            };
2188            assert_eq!(rc, 0);
2189            assert!(
2190                called,
2191                "the plugin registration body must run on an older host"
2192            );
2193        }
2194    }
2195
2196    #[test]
2197    fn register_entrypoint_rejects_incomplete_mandatory_fields_or_slots() {
2198        for size in [
2199            core::mem::offset_of!(PluginApi, declare) - 1,
2200            core::mem::offset_of!(PluginApi, register_streaming_provider) + 1,
2201        ] {
2202            let mut api = entrypoint_test_api();
2203            api.struct_size = size as u32;
2204            let rc = unsafe { register_entrypoint(&api, |_| panic!("incomplete ABI layout")) };
2205            assert_eq!(rc, 3);
2206        }
2207    }
2208}