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