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//!    v1 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
59use core::ffi::c_char;
60use core::ffi::c_void;
61use core::ptr;
62
63// ---------------------------------------------------------------------------
64// StbString — owned UTF-8 crossing as ptr+len with an explicit free
65// ---------------------------------------------------------------------------
66
67/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
68///
69/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
70/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
71/// allocation and MUST free it **exactly once** by calling the producer's
72/// [`FreeStringFn`] (or [`StbString::free_with`] / [`StbString::free_host`]).
73/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
74/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
75/// is a no-op only if the producer guarantees the buffer outlives the call; in
76/// practice inputs cross as [`StbStringRef`] instead).
77///
78/// **Construction ownership contract:**
79/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
80///   `StbString` now owns it; `free` deallocates.
81/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
82///   over `from_owned` (host side, needs `alloc`).
83/// - [`StbString::empty`] — null/0; `free` is a no-op.
84///
85/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
86/// dereferenced.
87#[repr(C)]
88#[derive(Clone, Copy)]
89pub struct StbString {
90    /// UTF-8 bytes. Null when `len == 0` (empty string).
91    pub ptr: *mut c_char,
92    /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
93    pub len: usize,
94}
95
96// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
97// transferred across threads by the type itself, and lifetime/aliasing is the
98// caller's contract (documented above). It is `Send`+`Sync` so the host and the
99// blocking driver can pass it across threads; the *allocation* ownership rules
100// above still apply regardless of thread.
101unsafe impl Send for StbString {}
102unsafe impl Sync for StbString {}
103
104impl StbString {
105    /// An empty string: null pointer, zero length. `free` is a no-op.
106    pub const fn empty() -> Self {
107        Self { ptr: ptr::null_mut(), len: 0 }
108    }
109
110    /// Whether this is the empty/null string.
111    pub const fn is_empty(&self) -> bool {
112        self.len == 0
113    }
114}
115
116// Allocating constructors + safe reader need `std` (Box/Vec/String). The ABI
117// type itself (`StbString { ptr, len }`) is POD with no `Drop` — that is what
118// makes the boundary sound. These helpers are available whenever `std` is (the
119// crate is std-using). When the `json` feature is off a plugin still gets these
120// because the crate links std; the gate here keeps `serde_json` truly optional.
121#[cfg(any(feature = "json", test))]
122impl StbString {
123    /// Wrap an allocation the caller already boxed. The `StbString` takes
124    /// ownership: a later `free_with(free_fn)` will deallocate it via the
125    /// producer's `free_string`.
126    ///
127    /// The buffer MUST be UTF-8.
128    pub fn from_owned(buf: Box<[u8]>) -> Self {
129        let len = buf.len();
130        // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
131        // slice from ptr+len and drops it. Store the element pointer as
132        // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
133        // moves into the `StbString` (we do NOT drop here).
134        let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
135        let _ = len;
136        Self { ptr, len }
137    }
138
139    /// Convenience: from a `String`, transferring ownership. After this the
140    /// passed `String` is consumed and must not be reused.
141    pub fn from_string(s: String) -> Self {
142        Self::from_vec(s.into_bytes())
143    }
144
145    /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
146    pub fn from_vec(v: Vec<u8>) -> Self {
147        Self::from_owned(v.into_boxed_slice())
148    }
149
150    /// Convenience: from a boxed `str`.
151    pub fn from_boxed_str(s: Box<str>) -> Self {
152        let string: String = s.into();
153        Self::from_vec(string.into_bytes())
154    }
155
156    /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
157    /// the original — the caller still owns the original allocation). Use this
158    /// to *read* a received `StbString` into safe Rust; then `free` the original.
159    pub fn to_string_lossy(&self) -> String {
160        if self.len == 0 || self.ptr.is_null() {
161            return String::new();
162        }
163        // SAFETY: the producer guarantees `ptr` is valid for `len` bytes and the
164        // bytes are UTF-8. We only read (no free) here.
165        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
166        String::from_utf8_lossy(slice).into_owned()
167    }
168}
169
170/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
171/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
172///
173/// Built from a `&str` on the calling side; the buffer is valid for the
174/// duration of the call (the caller's borrow).
175#[repr(C)]
176#[derive(Clone, Copy)]
177pub struct StbStringRef {
178    /// UTF-8 bytes (valid for the call; NUL-terminated not required).
179    pub ptr: *const c_char,
180    /// Byte length.
181    pub len: usize,
182}
183
184unsafe impl Send for StbStringRef {}
185unsafe impl Sync for StbStringRef {}
186
187impl StbStringRef {
188    /// Empty input.
189    pub const fn empty() -> Self {
190        Self { ptr: ptr::null(), len: 0 }
191    }
192
193    /// Borrow a `&str` for the duration of a call. The caller must outlive the
194    /// call (normal borrow rules).
195    pub fn from_str(s: &str) -> Self {
196        Self { ptr: s.as_ptr() as *const c_char, len: s.len() }
197    }
198
199    /// Read into a safe `&str` for the callee's lifetime `'a`.
200    ///
201    /// # Safety
202    /// The caller guarantees `ptr` is valid for `len` bytes and they are UTF-8,
203    /// and the borrow survives `'a`.
204    pub unsafe fn as_str<'a>(&self) -> &'a str {
205        if self.len == 0 || self.ptr.is_null() {
206            return "";
207        }
208        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
209        unsafe { core::str::from_utf8_unchecked(slice) }
210    }
211}
212
213/// Function pointer a plugin exports to free a [`StbString`] it produced.
214/// Idempotent: freeing an already-freed or empty `StbString` is a no-op.
215///
216/// The host calls this for every [`StbString`] it receives from the plugin; the
217/// plugin calls the **host's** `free_string` (from [`PluginApiVt`]) for every
218/// [`StbString`] it receives from the host.
219pub type FreeStringFn = extern "C" fn(s: StbString);
220
221impl StbString {
222    /// Free this `StbString` via the given `free_string` fn, if non-null and
223    /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
224    /// caller relinquishes the allocation).
225    ///
226    /// After this call the bytes are invalid; do not use the `StbString` again.
227    pub fn free_with(self, free_fn: Option<FreeStringFn>) {
228        if let Some(free_fn) = free_fn {
229            if !self.is_empty() {
230                free_fn(self);
231            }
232        }
233    }
234}
235
236// ---------------------------------------------------------------------------
237// StableJsonValue — JSON round-trip helpers (json feature)
238// ---------------------------------------------------------------------------
239
240/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
241/// for the precision/order limit.
242#[cfg(any(feature = "json", doc))]
243pub mod json {
244    use super::StbString;
245    use serde_json::Value;
246
247    /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
248    /// must `free` via the producer's `free_string`.
249    pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
250        let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
251        let stb = StbString::from_string(s);
252        // `free_fn` is advisory metadata the receiver needs; the StbString
253        // itself carries only ptr+len. Stash nothing — the receiver must know
254        // which free fn to use (host's vs plugin's) by direction.
255        let _ = free_fn;
256        stb
257    }
258
259    /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
260    /// input — the caller still owns it.
261    pub fn from_stable(s: &StbString) -> Value {
262        let text = s.to_string_lossy();
263        if text.is_empty() {
264            return Value::Null;
265        }
266        serde_json::from_str(&text).unwrap_or(Value::Null)
267    }
268}
269
270// ---------------------------------------------------------------------------
271// StableToolSchema — the provider-facing tool definition
272// ---------------------------------------------------------------------------
273
274/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
275/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
276/// host parses it into its native `schemars::Schema`).
277///
278/// All three are owning [`StbString`]s the **plugin** produced; the **host**
279/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
280/// `register_tool`).
281#[repr(C)]
282#[derive(Clone, Copy)]
283pub struct StableToolSchema {
284    pub name: StbString,
285    pub description: StbString,
286    /// JSON-encoded JSON Schema for the tool's `parameters`.
287    pub parameters: StbString,
288}
289
290unsafe impl Send for StableToolSchema {}
291unsafe impl Sync for StableToolSchema {}
292
293// ---------------------------------------------------------------------------
294// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
295// ---------------------------------------------------------------------------
296
297/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
298/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
299/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
300///
301/// The handle's interior layout is entirely plugin-private; the host treats it
302/// as an opaque pointer.
303pub type StepHandle = *mut c_void;
304
305/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
306/// payload is sound across compilers.
307#[repr(u32)]
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum StepResultTag {
310    /// `poll` has no terminal result yet; the partial payload may carry progress.
311    Pending = 0,
312    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
313    Done = 1,
314    /// Terminal failure; `err.message` is a UTF-8 error string.
315    Err = 2,
316}
317
318/// A partial/progress result emitted during `Pending`. `progress` is a JSON
319/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
320/// to the adapter's `on_update` callback. May be empty.
321#[repr(C)]
322#[derive(Clone, Copy)]
323pub struct StbPending {
324    pub progress: StbString,
325}
326
327/// Terminal success payload. `result` is a JSON `AgentToolResult`.
328#[repr(C)]
329#[derive(Clone, Copy)]
330pub struct StbDone {
331    pub result: StbString,
332}
333
334/// Terminal failure payload. `message` is a UTF-8 error string.
335#[repr(C)]
336#[derive(Clone, Copy)]
337pub struct StbErr {
338    pub message: StbString,
339}
340
341/// The `poll()` return value. Read the `payload` variant matching `tag`.
342///
343/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
344/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
345#[repr(C)]
346#[derive(Clone, Copy)]
347pub union StepResultPayload {
348    pub pending: StbPending,
349    pub done: StbDone,
350    pub err: StbErr,
351}
352
353/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
354/// the matching payload, and breaks the loop on `Done`/`Err`.
355#[repr(C)]
356#[derive(Clone, Copy)]
357pub struct StepResult {
358    pub tag: StepResultTag,
359    pub payload: StepResultPayload,
360}
361
362impl StepResult {
363    /// Build a `Pending` with a progress JSON string (may be empty).
364    pub fn pending(progress: StbString) -> Self {
365        Self { tag: StepResultTag::Pending, payload: StepResultPayload { pending: StbPending { progress } } }
366    }
367
368    /// Build a `Done` with the terminal JSON `AgentToolResult`.
369    pub fn done(result: StbString) -> Self {
370        Self { tag: StepResultTag::Done, payload: StepResultPayload { done: StbDone { result } } }
371    }
372
373    /// Build an `Err` with an error message.
374    pub fn err(message: StbString) -> Self {
375        Self { tag: StepResultTag::Err, payload: StepResultPayload { err: StbErr { message } } }
376    }
377
378    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
379    ///
380    /// # Safety
381    /// Undefined behavior if `tag != StepResultTag::Pending`.
382    pub unsafe fn pending_payload(&self) -> &StbPending {
383        unsafe { &self.payload.pending }
384    }
385
386    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
387    ///
388    /// # Safety
389    /// Undefined behavior if `tag != StepResultTag::Done`.
390    pub unsafe fn done_payload(&self) -> &StbDone {
391        unsafe { &self.payload.done }
392    }
393
394    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
395    ///
396    /// # Safety
397    /// Undefined behavior if `tag != StepResultTag::Err`.
398    pub unsafe fn err_payload(&self) -> &StbErr {
399        unsafe { &self.payload.err }
400    }
401}
402
403// ---------------------------------------------------------------------------
404// The 4-function tool lifecycle fn-pointer types
405// ---------------------------------------------------------------------------
406
407/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
408/// `catch_unwind` on the host side. The plugin invokes it **synchronously
409/// inside `poll()`** when it has a `Pending` partial — never retained, never
410/// invoked after `Done`/`Err`.
411///
412/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
413/// host frees it via the host's `free_string`).
414pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
415
416/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
417/// handle and begins the work (non-blocking — the real progress comes via
418/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
419/// `params` is an owning JSON string of the tool-call arguments (the plugin
420/// frees it via the host's `free_string`). Returns null on allocation failure.
421pub type ToolExecuteFn = extern "C" fn(
422    tool_call_id: StbStringRef,
423    params: StbString,
424    free_params: Option<FreeStringFn>,
425) -> StepHandle;
426
427/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
428/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
429/// within a bounded number of polls. Borrows `handle` (does not free it).
430pub type ToolPollFn =
431    extern "C" fn(handle: StepHandle, partial_cb: Option<ToolPartialCb>, user_data: *mut c_void) -> StepResult;
432
433/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
434/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
435pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
436
437/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
438/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
439/// propagated). Null handle is a no-op.
440pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
441
442// ---------------------------------------------------------------------------
443// StablePluginEvent — 33 on() categories (explicit tag + union)
444// ---------------------------------------------------------------------------
445
446/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
447/// (33 total). `#[repr(u32)]` pins the discriminant width.
448///
449/// The 33 categories (verified against `extensions/types.ts:1203-1244`):
450/// project_trust, resources_discover, session_start, session_info_changed,
451/// session_before_switch, session_before_fork, session_before_compact,
452/// session_compact, session_shutdown, session_before_tree, session_tree,
453/// context, before_provider_request, before_provider_headers,
454/// after_provider_response, before_agent_start, agent_start, agent_end,
455/// agent_settled, turn_start, turn_end, message_start, message_update,
456/// message_end, tool_execution_start, tool_execution_update,
457/// tool_execution_end, model_select, thinking_level_select, tool_call,
458/// tool_result, user_bash, input.
459#[repr(u32)]
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum EventTag {
462    ProjectTrust = 0,
463    ResourcesDiscover = 1,
464    SessionStart = 2,
465    SessionInfoChanged = 3,
466    SessionBeforeSwitch = 4,
467    SessionBeforeFork = 5,
468    SessionBeforeCompact = 6,
469    SessionCompact = 7,
470    SessionShutdown = 8,
471    SessionBeforeTree = 9,
472    SessionTree = 10,
473    Context = 11,
474    BeforeProviderRequest = 12,
475    BeforeProviderHeaders = 13,
476    AfterProviderResponse = 14,
477    BeforeAgentStart = 15,
478    AgentStart = 16,
479    AgentEnd = 17,
480    AgentSettled = 18,
481    TurnStart = 19,
482    TurnEnd = 20,
483    MessageStart = 21,
484    MessageUpdate = 22,
485    MessageEnd = 23,
486    ToolExecutionStart = 24,
487    ToolExecutionUpdate = 25,
488    ToolExecutionEnd = 26,
489    ModelSelect = 27,
490    ThinkingLevelSelect = 28,
491    ToolCall = 29,
492    ToolResult = 30,
493    UserBash = 31,
494    Input = 32,
495}
496
497/// Number of `on()` event categories — `33`. A test asserts
498/// `EVENT_TAG_COUNT == 33` so a future edit that adds/removes a tag is caught.
499pub const EVENT_TAG_COUNT: usize = 33;
500
501/// No-payload marker for events that carry none (e.g. `session_shutdown`).
502/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
503/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
504#[repr(C)]
505#[derive(Clone, Copy)]
506pub struct EventEmpty {
507    _opaque: u8,
508}
509
510impl EventEmpty {
511    /// The one no-payload instance.
512    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
513}
514
515impl Default for EventEmpty {
516    fn default() -> Self {
517        Self::INSTANCE
518    }
519}
520
521/// A serialized message payload (`message_start`/`update`/`end`, tool-result
522/// messages). `message` is a JSON `AgentMessage`.
523#[repr(C)]
524#[derive(Clone, Copy)]
525pub struct EventMessage {
526    pub message: StbString,
527}
528
529/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
530/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
531#[repr(C)]
532#[derive(Clone, Copy)]
533pub struct EventToolCall {
534    pub tool_call_id: StbString,
535    pub tool_name: StbString,
536    pub params: StbString,
537}
538
539/// A tool-result payload (`tool_result`, `tool_execution_end`).
540#[repr(C)]
541#[derive(Clone, Copy)]
542pub struct EventToolResult {
543    pub tool_call_id: StbString,
544    pub tool_name: StbString,
545    pub result: StbString,
546    pub is_error: u8,
547}
548
549/// An error/failure payload.
550#[repr(C)]
551#[derive(Clone, Copy)]
552pub struct EventError {
553    pub message: StbString,
554}
555
556/// A generic JSON-data payload for the long-tail events whose structured shape
557/// the host serializes wholesale (`context`, `before_provider_request`, model
558/// select, resources_discover response, etc.). The plugin reads the fields it
559/// needs.
560#[repr(C)]
561#[derive(Clone, Copy)]
562pub struct EventData {
563    pub data: StbString,
564}
565
566/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
567/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
568/// [`StablePluginEvent::tag`] before reading.
569#[repr(C)]
570#[derive(Clone, Copy)]
571pub union EventPayload {
572    pub empty: EventEmpty,
573    pub message: EventMessage,
574    pub tool_call: EventToolCall,
575    pub tool_result: EventToolResult,
576    pub error: EventError,
577    pub data: EventData,
578}
579
580/// One event dispatched to a plugin handler. The host translates its native
581/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
582/// for the `tag` (dispatch wrapped in `catch_unwind`). Ownership of the
583/// [`StbString`]s passes to the handler; the handler frees them via the host's
584/// `free_string`.
585#[repr(C)]
586#[derive(Clone, Copy)]
587pub struct StablePluginEvent {
588    pub tag: EventTag,
589    pub payload: EventPayload,
590}
591
592impl StablePluginEvent {
593    /// Build a no-payload event.
594    pub fn empty(tag: EventTag) -> Self {
595        Self { tag, payload: EventPayload { empty: EventEmpty::INSTANCE } }
596    }
597
598    /// Build a message event.
599    pub fn message(tag: EventTag, message: StbString) -> Self {
600        debug_assert!(matches!(
601            tag,
602            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
603        ));
604        Self { tag, payload: EventPayload { message: EventMessage { message } } }
605    }
606
607    /// Build a tool-call event.
608    pub fn tool_call(tag: EventTag, tool_call_id: StbString, tool_name: StbString, params: StbString) -> Self {
609        debug_assert!(matches!(
610            tag,
611            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
612        ));
613        Self { tag, payload: EventPayload { tool_call: EventToolCall { tool_call_id, tool_name, params } } }
614    }
615
616    /// Build a tool-result event.
617    pub fn tool_result(
618        tag: EventTag,
619        tool_call_id: StbString,
620        tool_name: StbString,
621        result: StbString,
622        is_error: bool,
623    ) -> Self {
624        debug_assert!(matches!(tag, EventTag::ToolResult | EventTag::ToolExecutionEnd));
625        Self {
626            tag,
627            payload: EventPayload {
628                tool_result: EventToolResult { tool_call_id, tool_name, result, is_error: is_error as u8 },
629            },
630        }
631    }
632
633    /// Build an error event.
634    pub fn error(tag: EventTag, message: StbString) -> Self {
635        Self { tag, payload: EventPayload { error: EventError { message } } }
636    }
637
638    /// Build a generic data event (JSON in `data`).
639    pub fn data(tag: EventTag, data: StbString) -> Self {
640        Self { tag, payload: EventPayload { data: EventData { data } } }
641    }
642}
643
644/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
645/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
646/// signals a handled error (the host logs it; dispatch continues to other
647/// handlers — one handler's error does not abort the fan-out).
648pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
649
650/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
651/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
652/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
653/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
654/// and reclaimed via the `plugin_free_string` the host stored alongside the
655/// handler at registration. `user_data` is the plugin's opaque context. Returns
656/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
657/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
658pub type ResourcesDiscoverFn = extern "C" fn(
659    cwd: StbStringRef,
660    reason: StbStringRef,
661    out: *mut StbString,
662    user_data: *mut c_void,
663) -> i32;
664
665// ---------------------------------------------------------------------------
666// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
667// ---------------------------------------------------------------------------
668
669/// Identifier for a host runtime action the plugin may invoke via
670/// `PluginApiVt::runtime_action`. One slot dispatches all actions — forward-
671/// compatible (new actions add ids, not vtable slots). Args/results cross as
672/// JSON strings.
673#[repr(u32)]
674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675pub enum RuntimeActionId {
676    SendMessage = 0,
677    SendUserMessage = 1,
678    AppendEntry = 2,
679    SetSessionName = 3,
680    GetActiveTools = 4,
681    SetActiveTools = 5,
682    SetModel = 6,
683    GetThinkingLevel = 7,
684    SetThinkingLevel = 8,
685    Compact = 9,
686    GetSystemPrompt = 10,
687    NewSession = 11,
688    Fork = 12,
689    NavigateTree = 13,
690    SwitchSession = 14,
691    Reload = 15,
692}
693
694/// Runtime-action signature: `runtime_action(action_id, args_json, out,
695/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
696/// is an owning output ([`StbString`]) the host produces and the plugin frees
697/// via the host's `free_string`. Returns `0` on success, nonzero on error.
698pub type RuntimeActionFn = extern "C" fn(
699    action: RuntimeActionId,
700    args_json: StbStringRef,
701    out: *mut StbString,
702    user_data: *mut c_void,
703) -> i32;
704
705// ---------------------------------------------------------------------------
706// PluginApiVt — host-provided vtable of fn pointers the plugin calls
707// ---------------------------------------------------------------------------
708
709/// A generic command-handler fn (for `register_command`). `args_json` is
710/// borrowed input; `out` is owning output the plugin frees via host `free_string`.
711pub type CommandHandlerFn =
712    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
713
714/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
715/// `out` is owning output the plugin frees via host `free_string`.
716pub type RenderFn = extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
717
718/// A provider-injection factory fn (for `register_provider`). `req_json` is a
719/// borrowed request envelope; `out` is an owning response the plugin frees.
720/// The host wraps this into a `Provider` impl (B4/B5).
721pub type ProviderRequestFn =
722    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
723
724/// The host-provided vtable, passed to [`rpi_plugin_register`] as a `*const`.
725///
726/// The plugin reads it during `register` and may copy fn pointers it needs (the
727/// struct is POD/Copy). **Every slot is nullable**: a null fn pointer means the
728/// host does not support that capability yet — the plugin MUST null-check
729/// before calling and degrade gracefully. This keeps the vtable forward-
730/// compatible across rpi versions without re-ABI bumps within one
731/// `RPI_PLUGIN_ABI_VERSION`.
732///
733/// `user_data` is the host's opaque context, passed back to every host-provided
734/// fn (so the host can recover its session/harness state). The plugin stores it
735/// and passes it through unchanged.
736#[repr(C)]
737pub struct PluginApiVt {
738    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
739    /// *receives* from the host (outputs of actions, event payloads, inputs to
740    /// execute). Never null.
741    pub free_string: FreeStringFn,
742
743    // --- 8 registrars (plugin → host "register X into the host") ---
744
745    /// Register a tool. `schema` + the four lifecycle fns + the plugin's own
746    /// `free_string` (for the [`StbString`]s in `schema`). Returns `0` on
747    /// success. Nullable: host not yet wired for tool registration.
748    pub register_tool: Option<
749        extern "C" fn(
750            schema: *const StableToolSchema,
751            execute_fn: ToolExecuteFn,
752            poll_fn: ToolPollFn,
753            cancel_fn: ToolCancelFn,
754            destroy_fn: ToolDestroyFn,
755            plugin_free_string: FreeStringFn,
756        ) -> i32,
757    >,
758
759    /// Register a slash command. Nullable.
760    pub register_command: Option<extern "C" fn(name: StbStringRef, description: StbStringRef, handler: CommandHandlerFn) -> i32>,
761
762    /// Register a keyboard shortcut. Nullable.
763    pub register_shortcut: Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
764
765    /// Register a CLI flag. Nullable.
766    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
767
768    /// Register a custom provider. The host stores `provider_id`/`base_url`/
769    /// `api_style` + the plugin's `request_fn` + the plugin's own
770    /// `plugin_free_string` (the `out` [`StbString`] `request_fn` *produces* is
771    /// plugin-owned and the host must reclaim it — same ownership rule as
772    /// `register_resources_discover`) + the plugin's `user_data` (which
773    /// `request_fn` receives back unmodified on every call). Nullable (B5c).
774    pub register_provider: Option<
775        extern "C" fn(
776            provider_id: StbStringRef,
777            base_url: StbStringRef,
778            api_style: StbStringRef,
779            request_fn: ProviderRequestFn,
780            plugin_free_string: FreeStringFn,
781            user_data: *mut c_void,
782        ) -> i32,
783    >,
784
785    /// Register a message renderer. `plugin_free_string` reclaims the `out`
786    /// [`StbString`] `render_fn` produces; `user_data` is passed back to it on
787    /// every render call. Nullable (B5c; TUI consumption deferred).
788    pub register_message_renderer: Option<
789        extern "C" fn(
790            name: StbStringRef,
791            render_fn: RenderFn,
792            plugin_free_string: FreeStringFn,
793            user_data: *mut c_void,
794        ) -> i32,
795    >,
796
797    /// Register a markdown transformer. Same ownership shape as
798    /// `register_message_renderer`. Nullable (B5c; TUI wiring in B5e).
799    pub register_markdown_transformer: Option<
800        extern "C" fn(
801            name: StbStringRef,
802            render_fn: RenderFn,
803            plugin_free_string: FreeStringFn,
804            user_data: *mut c_void,
805        ) -> i32,
806    >,
807
808    /// Register an entry renderer. Same ownership shape as
809    /// `register_message_renderer`. Nullable (B5c; TUI consumption deferred).
810    pub register_entry_renderer: Option<
811        extern "C" fn(
812            name: StbStringRef,
813            render_fn: RenderFn,
814            plugin_free_string: FreeStringFn,
815            user_data: *mut c_void,
816        ) -> i32,
817    >,
818
819    // --- on() event handler registration (the 33-category subscription) ---
820
821    /// Subscribe a handler to one event `tag`. Nullable: host not yet wiring
822    /// events. The host dispatches [`StablePluginEvent`]s of that tag to the
823    /// handler (`catch_unwind`-wrapped).
824    pub register_event_handler: Option<extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32>,
825
826    /// Register a `resources_discover` handler (B5b). The host stores `handler`
827    /// + the plugin's own `plugin_free_string` (the `out` [`StbString`] the
828    /// handler produces is plugin-owned and the host must reclaim it) +
829    /// `user_data`. On discovery (`startup`/`reload`) the host fans the event to
830    /// every registered handler in order, concatenating their returned
831    /// `{skillPaths, promptPaths, themePaths}` (errors per-handler do NOT abort
832    /// the fan-out). Nullable: a host without the resources-discover path leaves
833    /// this null and the plugin must degrade (no dynamic resource contribution).
834    pub register_resources_discover: Option<
835        extern "C" fn(handler: ResourcesDiscoverFn, plugin_free_string: FreeStringFn, user_data: *mut c_void) -> i32,
836    >,
837
838    // --- runtime actions (~14, uniform dispatch) ---
839
840    /// Invoke a host runtime action. See [`RuntimeActionId`] / [`RuntimeActionFn`].
841    /// Nullable: host not yet exposing actions.
842    pub runtime_action: RuntimeActionFn,
843
844    // --- event dispatch (plugin → host "emit an event upstream") ---
845
846    /// Emit an event upstream (e.g. a tool announcing a custom UI event). The
847    /// host forwards to interested subscribers. Ownership of the event's
848    /// [`StbString`]s passes to the host (freed via `free_string`). Nullable.
849    pub dispatch_event: Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
850
851    /// The host's opaque context, passed through to every host-provided fn.
852    /// The plugin stores this and hands it back unmodified on each call.
853    pub user_data: *mut c_void,
854}
855
856// SAFETY: the vtable is a POD struct of fn pointers + one raw `user_data`
857// pointer. It is `Send`+`Sync` so the host can hand it to the plugin's register
858// thread and the plugin can call its fns from the blocking driver thread; the
859// host guarantees the `user_data` is valid across those calls.
860unsafe impl Send for PluginApiVt {}
861unsafe impl Sync for PluginApiVt {}
862
863// ---------------------------------------------------------------------------
864// Register contract
865// ---------------------------------------------------------------------------
866
867/// The ABI version this SDK publishes. The host refuses to load a plugin whose
868/// declared `RPI_PLUGIN_ABI_VERSION` differs from its own (skip + diagnostic,
869/// never load — no half-compatible call surface). Bump only on a breaking ABI
870/// change (reorder/retype a vtable slot, change a crossing struct layout);
871/// adding a nullable vtable slot **or widening an existing nullable slot**'s
872/// parameter list within a version is *not* a bump — the plugin and host are
873/// both recompiled from this same SDK, and a nullable slot a plugin never calls
874/// is unaffected by a wider callee signature. (B5c widens the four
875/// renderer/provider registrar slots within ABI v1 on this basis.)
876pub const RPI_PLUGIN_ABI_VERSION: u32 = 1;
877
878/// The symbol the host looks up in each cdylib via `libloading::Library::get`.
879/// Must be an `extern "C" fn(*const PluginApiVt, u32) -> i32`.
880pub const REGISTER_SYMBOL: &[u8] = b"rpi_plugin_register\0";
881
882/// Plugin entrypoint signature. The host loads the cdylib, looks up
883/// `rpi_plugin_register`, and calls it with the host `PluginApiVt` and the
884/// host's current `RPI_PLUGIN_ABI_VERSION`.
885///
886/// Return `0` on successful registration; nonzero is a plugin-defined error
887/// code (the host logs it and skips the plugin). The host checks
888/// `abi_version` **before** calling — if the plugin was compiled against a
889/// different `RPI_PLUGIN_ABI_VERSION` it must itself refuse (return nonzero) if
890/// it sees an unrecognized version; idiomatically the plugin stores the passed
891/// `api` only when `abi_version == RPI_PLUGIN_ABI_VERSION`.
892pub type RpiPluginRegister = extern "C" fn(api: *const PluginApiVt, abi_version: u32) -> i32;
893
894/// Convenience for host + plugin: declare the register entrypoint.
895///
896/// A plugin crate writes:
897/// ```ignore
898/// #[no_mangle]
899/// pub extern "C" fn rpi_plugin_register(api: *const PluginApiVt, abi_version: u32) -> i32 {
900///     rpi_plugin_sdk::register_entrypoint(api, abi_version, |api| {
901///         // ... register tools / handlers using `api` ...
902///         0
903///     })
904/// }
905/// ```
906/// The helper performs the version check (return nonzero on mismatch) and
907/// null-checks `api` before invoking the plugin body.
908pub fn register_entrypoint(
909    api: *const PluginApiVt,
910    abi_version: u32,
911    body: impl FnOnce(&PluginApiVt) -> i32,
912) -> i32 {
913    if abi_version != RPI_PLUGIN_ABI_VERSION {
914        // Mismatch: refuse to register. The host logs "ABI version mismatch"
915        // and skips loading this plugin.
916        return 1;
917    }
918    if api.is_null() {
919        return 2;
920    }
921    // SAFETY: the host guarantees `api` is valid for the register call and the
922    // plugin does not retain the borrow past `body` (it copies the fn pointers
923    // it needs).
924    let api = unsafe { &*api };
925    body(api)
926}
927
928// ===========================================================================
929// Tests (need std + serde_json)
930// ===========================================================================
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    // A test allocator + free fn so we can verify the own/free contract
937    // without a real plugin's free_string.
938    static FREED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
939
940    extern "C" fn test_free(s: StbString) {
941        if s.is_empty() || s.ptr.is_null() {
942            return;
943        }
944        // Reconstruct the boxed slice and drop it.
945        unsafe {
946            let slice = core::slice::from_raw_parts(s.ptr as *const u8, s.len);
947            let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
948        }
949        FREED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
950    }
951
952    fn reset_freed() -> usize {
953        FREED.swap(0, std::sync::atomic::Ordering::SeqCst)
954    }
955
956    // A no-op `runtime_action` impl for the vtable-construction tests (closures
957    // can't coerce to `extern "C" fn`, so we use a real fn).
958    extern "C" fn noop_runtime_action(
959        _action: RuntimeActionId,
960        _args: StbStringRef,
961        _out: *mut StbString,
962        _user_data: *mut c_void,
963    ) -> i32 {
964        0
965    }
966
967    #[test]
968    fn stbstring_round_trip_and_free_once() {
969        let prev = reset_freed();
970        let _ = prev;
971        let s = StbString::from_string("hello, pi".to_string());
972        assert_eq!(s.len, 9);
973        assert_eq!(s.to_string_lossy(), "hello, pi");
974        s.free_with(Some(test_free));
975        assert_eq!(FREED.load(std::sync::atomic::Ordering::SeqCst), 1);
976    }
977
978    #[test]
979    fn empty_stbstring_free_is_noop() {
980        let _ = reset_freed();
981        StbString::empty().free_with(Some(test_free));
982        assert_eq!(FREED.load(std::sync::atomic::Ordering::SeqCst), 0);
983    }
984
985    #[test]
986    fn json_round_trip_preserves_structure() {
987        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
988        let stb = json::to_stable(&val, None);
989        let back = json::from_stable(&stb);
990        assert_eq!(val, back);
991        stb.free_with(Some(test_free));
992        let _ = reset_freed();
993    }
994
995    #[test]
996    fn step_result_done_round_trip() {
997        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
998        let sr = StepResult::done(result_json);
999        assert_eq!(sr.tag, StepResultTag::Done);
1000        // SAFETY: tag == Done.
1001        let done = unsafe { sr.done_payload() };
1002        assert_eq!(done.result.to_string_lossy(), r#"{"content":[{"text":"hi"}]}"#);
1003        done.result.free_with(Some(test_free));
1004        let _ = reset_freed();
1005    }
1006
1007    #[test]
1008    fn step_result_pending_and_err() {
1009        let prog = StbString::from_string("...".to_string());
1010        let srp = StepResult::pending(prog);
1011        assert_eq!(srp.tag, StepResultTag::Pending);
1012        // SAFETY: tag == Pending.
1013        unsafe {
1014            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1015        }
1016        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1017
1018        let msg = StbString::from_string("boom".to_string());
1019        let sre = StepResult::err(msg);
1020        assert_eq!(sre.tag, StepResultTag::Err);
1021        // SAFETY: tag == Err.
1022        unsafe {
1023            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1024            sre.err_payload().message.free_with(Some(test_free));
1025        }
1026        let _ = reset_freed();
1027    }
1028
1029    #[test]
1030    fn event_tag_count_is_33() {
1031        // Enumerate every tag; a compile-time + runtime guarantee that the
1032        // 33-category surface is intact.
1033        let tags = [
1034            EventTag::ProjectTrust,
1035            EventTag::ResourcesDiscover,
1036            EventTag::SessionStart,
1037            EventTag::SessionInfoChanged,
1038            EventTag::SessionBeforeSwitch,
1039            EventTag::SessionBeforeFork,
1040            EventTag::SessionBeforeCompact,
1041            EventTag::SessionCompact,
1042            EventTag::SessionShutdown,
1043            EventTag::SessionBeforeTree,
1044            EventTag::SessionTree,
1045            EventTag::Context,
1046            EventTag::BeforeProviderRequest,
1047            EventTag::BeforeProviderHeaders,
1048            EventTag::AfterProviderResponse,
1049            EventTag::BeforeAgentStart,
1050            EventTag::AgentStart,
1051            EventTag::AgentEnd,
1052            EventTag::AgentSettled,
1053            EventTag::TurnStart,
1054            EventTag::TurnEnd,
1055            EventTag::MessageStart,
1056            EventTag::MessageUpdate,
1057            EventTag::MessageEnd,
1058            EventTag::ToolExecutionStart,
1059            EventTag::ToolExecutionUpdate,
1060            EventTag::ToolExecutionEnd,
1061            EventTag::ModelSelect,
1062            EventTag::ThinkingLevelSelect,
1063            EventTag::ToolCall,
1064            EventTag::ToolResult,
1065            EventTag::UserBash,
1066            EventTag::Input,
1067        ];
1068        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1069        assert_eq!(EVENT_TAG_COUNT, 33);
1070        // Distinct discriminants 0..32.
1071        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1072        discs.sort();
1073        assert_eq!(discs, (0..33).collect::<Vec<u32>>());
1074    }
1075
1076    #[test]
1077    fn event_payloads_construct_and_free() {
1078        let m = StbString::from_string("msg".to_string());
1079        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1080        assert_eq!(ev.tag, EventTag::MessageEnd);
1081        // SAFETY: tag == MessageEnd (message variant).
1082        unsafe {
1083            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1084            ev.payload.message.message.free_with(Some(test_free));
1085        }
1086
1087        let tc = StablePluginEvent::tool_call(
1088            EventTag::ToolCall,
1089            StbString::from_string("call_1".to_string()),
1090            StbString::from_string("echo".to_string()),
1091            StbString::from_string("{}".to_string()),
1092        );
1093        // SAFETY: tag == ToolCall (tool_call variant).
1094        unsafe {
1095            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1096            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1097            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1098            tc.payload.tool_call.params.free_with(Some(test_free));
1099        }
1100        let _ = reset_freed();
1101    }
1102
1103    #[test]
1104    fn plugin_api_vt_is_pod_and_sized() {
1105        // The vtable must be a plain old data struct: every fn pointer is
1106        // non-Drop, the struct has no Drop impl. We exercise that it can be
1107        // zeroed and read without UB.
1108        let vt = PluginApiVt {
1109            free_string: test_free,
1110            register_tool: None,
1111            register_command: None,
1112            register_shortcut: None,
1113            register_flag: None,
1114            register_provider: None,
1115            register_message_renderer: None,
1116            register_markdown_transformer: None,
1117            register_entry_renderer: None,
1118            register_event_handler: None,
1119            register_resources_discover: None,
1120            runtime_action: noop_runtime_action,
1121            dispatch_event: None,
1122            user_data: core::ptr::null_mut(),
1123        };
1124        // All optional slots are null → plugin must degrade.
1125        assert!(vt.register_tool.is_none());
1126        assert!(vt.register_event_handler.is_none());
1127        assert!(vt.register_resources_discover.is_none());
1128        // Copy (POD) — no UB from a plain copy.
1129        let _copy = vt;
1130        // `assert!(core::mem::needs_drop::<PluginApiVt>() == false)` — verified
1131        // by the absence of a Drop impl + all-Copy fields.
1132        assert!(!core::mem::needs_drop::<PluginApiVt>());
1133        assert!(!core::mem::needs_drop::<StbString>());
1134        assert!(!core::mem::needs_drop::<StepResult>());
1135        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1136        assert!(!core::mem::needs_drop::<StableToolSchema>());
1137    }
1138
1139    #[test]
1140    fn register_entrypoint_version_mismatch_refuses() {
1141        let vt = PluginApiVt {
1142            free_string: test_free,
1143            register_tool: None,
1144            register_command: None,
1145            register_shortcut: None,
1146            register_flag: None,
1147            register_provider: None,
1148            register_message_renderer: None,
1149            register_markdown_transformer: None,
1150            register_entry_renderer: None,
1151            register_event_handler: None,
1152            register_resources_discover: None,
1153            runtime_action: noop_runtime_action,
1154            dispatch_event: None,
1155            user_data: core::ptr::null_mut(),
1156        };
1157        // Wrong version → refuse (nonzero), body never runs.
1158        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION.wrapping_add(1), |_| {
1159            panic!("body must not run on version mismatch");
1160        });
1161        assert_ne!(rc, 0);
1162
1163        // Right version → body runs, rc propagated.
1164        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 0);
1165        assert_eq!(rc, 0);
1166        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 42);
1167        assert_eq!(rc, 42);
1168
1169        // Null api → refuse.
1170        let rc = register_entrypoint(core::ptr::null(), RPI_PLUGIN_ABI_VERSION, |_| 0);
1171        assert_ne!(rc, 0);
1172        let _ = reset_freed();
1173    }
1174}