rpi_plugin_sdk/lib.rs
1//! Stable `#[repr(C)]` ABI contract for rpi **Rust-native (cdylib) plugins**.
2//!
3//! rpi loads extensions as compiled Rust cdylibs (`.dll`/`.so`/`.dylib`) via
4//! `libloading` — **not** TS/jiti. Because we control both sides, the plugin is
5//! Rust, but the *boundary* is still a hand-defined C ABI: the two sides may be
6//! compiled with different Rust versions / crate versions, so no Rust type with
7//! a non-`C` repr or a `Drop` impl may cross. This crate defines exactly those
8//! crossing types and the registration contract.
9//!
10//! ## Soundness rules (load-bearing — verified by an adversarial review)
11//!
12//! 1. **Every crossing type is `#[repr(C)]`.** Enums used in unions carry
13//! `#[repr(u32)]` so the discriminant width is pinned.
14//! 2. **No `Drop` type crosses.** `Vec`/`String`/`serde_json::Value`/`Option` of
15//! those / `Result` never appear in the ABI. Owned data crosses as
16//! [`StbString`] (ptr+len) with an **explicit `free_string`** the producer
17//! exports. [`StbString`] is `Copy` (raw pointers are `Copy`); copying
18//! duplicates the *pointer*, not the allocation, so each allocation is freed
19//! **exactly once** by the side that received it (see [`StbString`] docs).
20//! 3. **Owned → JSON round-trip.** Structured host data ([`StableJsonValue`],
21//! tool params, `AgentToolResult`, events) crosses as a JSON string in a
22//! [`StbString`]. `serde_json` with `preserve_order` + `arbitrary_precision`
23//! must be enabled **consistently on host AND plugin** or integers >
24//! `u64`/`i64` lose precision and object keys may reorder — documented as a
25//! an ABI-wide limit. Tool args from the model rarely carry overflow ints, but it is
26//! never silent.
27//! 4. **Unions are all-`Copy` payloads.** [`EventPayload`] / [`StepResultPayload`]
28//! variants are `#[repr(C)]` structs of primitives or [`StbString`] only, so
29//! the union is `Copy`-able and a wrong-variant read is `unsafe` (caller
30//! discriminates by `tag`).
31//! 5. **Unwinding never crosses the ABI.** Every host→plugin and plugin→host
32//! call is `extern "C"`; both sides wrap dispatch in `catch_unwind`
33//! (abort-on-unwind / log-and-drop). A poisoned mutex or panicking emitter
34//! cannot unwind into the other side.
35//!
36//! ## Lifetime: the 4-function handle
37//!
38//! A registered tool drives an execution through **four** plugin-exported
39//! functions (see [`ToolExecuteFn`] / [`ToolPollFn`] / [`ToolCancelFn`] /
40//! [`ToolDestroyFn`]) — `execute`→[`StepHandle`] (plugin-allocates), `poll`
41//! (non-blocking, **borrows** the handle, returns [`StepResult`]), `cancel`
42//! (sets an internal `AtomicBool` flag; **idempotent; does NOT free;
43//! thread-safe**), `destroy` (frees; **idempotent; called exactly once by the
44//! blocking driver**). `cancel` ≠ `destroy`: conflating them is a UAF /
45//! double-free. The adapter's blocking driver calls `poll` in a loop until
46//! `Done`/`Err`, forwards `Pending` partials, and calls `destroy` once on exit.
47//!
48//! `poll` is **non-blocking** and MUST observe the cancel flag and return
49//! `Done`/`Err` within a bounded number of polls; otherwise a cancelled call
50//! leaks a `spawn_blocking` thread forever (those tasks run to completion
51//! regardless of outer-future drop).
52//!
53//! The crate is `std` (not `no_std`): the ABI *types* are `#[repr(C)]` POD with
54//! no `Drop` — that is what makes the boundary sound — but plugins and the host
55//! are ordinary binaries with `std`, so the constructor/reader helpers and tests
56//! use `String`/`Vec`/`serde_json` directly. The `json` feature keeps
57//! `serde_json` optional for a plugin that wants to skip it.
58
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) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
475/// the discriminant width.
476///
477/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
478/// project_trust, resources_discover, session_start, session_info_changed,
479/// session_before_switch, session_before_fork, session_before_compact,
480/// session_compact, session_shutdown, session_before_tree, session_tree,
481/// context, before_provider_request, before_provider_headers,
482/// after_provider_response, before_agent_start, agent_start, agent_end,
483/// agent_settled, turn_start, turn_end, message_start, message_update,
484/// message_end, tool_execution_start, tool_execution_update,
485/// tool_execution_end, model_select, thinking_level_select, tool_call,
486/// tool_result, user_bash, input.
487///
488/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
489/// before the interactive TUI is initialized, so extensions can prepare
490/// (connect a server, load a resource) before the UI starts. Appended last so
491/// existing discriminants keep their values (ABI-safe for already-compiled
492/// plugins).
493#[repr(u32)]
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum EventTag {
496 ProjectTrust = 0,
497 ResourcesDiscover = 1,
498 SessionStart = 2,
499 SessionInfoChanged = 3,
500 SessionBeforeSwitch = 4,
501 SessionBeforeFork = 5,
502 SessionBeforeCompact = 6,
503 SessionCompact = 7,
504 SessionShutdown = 8,
505 SessionBeforeTree = 9,
506 SessionTree = 10,
507 Context = 11,
508 BeforeProviderRequest = 12,
509 BeforeProviderHeaders = 13,
510 AfterProviderResponse = 14,
511 BeforeAgentStart = 15,
512 AgentStart = 16,
513 AgentEnd = 17,
514 AgentSettled = 18,
515 TurnStart = 19,
516 TurnEnd = 20,
517 MessageStart = 21,
518 MessageUpdate = 22,
519 MessageEnd = 23,
520 ToolExecutionStart = 24,
521 ToolExecutionUpdate = 25,
522 ToolExecutionEnd = 26,
523 ModelSelect = 27,
524 ThinkingLevelSelect = 28,
525 ToolCall = 29,
526 ToolResult = 30,
527 UserBash = 31,
528 Input = 32,
529 /// rpi-specific: dispatched before the interactive TUI initializes (not a
530 /// Pi `on()` category). Appended last so existing discriminants are stable.
531 BeforeTuiStart = 33,
532 /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
533 /// (selector, dialog, etc.) that blocks agent work.
534 UiPromptStart = 34,
535 /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
536 UiPromptEnd = 35,
537}
538
539/// Number of event categories — `36` (33 Pi `on()` categories + 1 rpi-specific
540/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events). A test asserts
541/// `EVENT_TAG_COUNT == 36` so a future edit that adds/removes a tag is caught.
542pub const EVENT_TAG_COUNT: usize = 36;
543
544/// No-payload marker for events that carry none (e.g. `session_shutdown`).
545/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
546/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
547#[repr(C)]
548#[derive(Clone, Copy)]
549pub struct EventEmpty {
550 _opaque: u8,
551}
552
553impl EventEmpty {
554 /// The one no-payload instance.
555 pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
556}
557
558impl Default for EventEmpty {
559 fn default() -> Self {
560 Self::INSTANCE
561 }
562}
563
564/// A serialized message payload (`message_start`/`update`/`end`, tool-result
565/// messages). `message` is a JSON `AgentMessage`.
566#[repr(C)]
567#[derive(Clone, Copy)]
568pub struct EventMessage {
569 pub message: StbString,
570}
571
572/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
573/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
574#[repr(C)]
575#[derive(Clone, Copy)]
576pub struct EventToolCall {
577 pub tool_call_id: StbString,
578 pub tool_name: StbString,
579 pub params: StbString,
580}
581
582/// A tool-result payload (`tool_result`, `tool_execution_end`).
583#[repr(C)]
584#[derive(Clone, Copy)]
585pub struct EventToolResult {
586 pub tool_call_id: StbString,
587 pub tool_name: StbString,
588 pub result: StbString,
589 pub is_error: u8,
590}
591
592/// An error/failure payload.
593#[repr(C)]
594#[derive(Clone, Copy)]
595pub struct EventError {
596 pub message: StbString,
597}
598
599/// A generic JSON-data payload for the long-tail events whose structured shape
600/// the host serializes wholesale (`context`, `before_provider_request`, model
601/// select, resources_discover response, etc.). The plugin reads the fields it
602/// needs.
603#[repr(C)]
604#[derive(Clone, Copy)]
605pub struct EventData {
606 pub data: StbString,
607}
608
609/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
610/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
611/// [`StablePluginEvent::tag`] before reading.
612#[repr(C)]
613#[derive(Clone, Copy)]
614pub union EventPayload {
615 pub empty: EventEmpty,
616 pub message: EventMessage,
617 pub tool_call: EventToolCall,
618 pub tool_result: EventToolResult,
619 pub error: EventError,
620 pub data: EventData,
621}
622
623/// One event dispatched to a plugin handler. The host translates its native
624/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
625/// for the `tag` (dispatch wrapped in `catch_unwind`). Ownership of the
626/// [`StbString`]s passes to the handler; the handler frees them via the host's
627/// `free_string`.
628#[repr(C)]
629#[derive(Clone, Copy)]
630pub struct StablePluginEvent {
631 pub tag: EventTag,
632 pub payload: EventPayload,
633}
634
635impl StablePluginEvent {
636 /// Build a no-payload event.
637 pub fn empty(tag: EventTag) -> Self {
638 Self {
639 tag,
640 payload: EventPayload {
641 empty: EventEmpty::INSTANCE,
642 },
643 }
644 }
645
646 /// Build a message event.
647 pub fn message(tag: EventTag, message: StbString) -> Self {
648 debug_assert!(matches!(
649 tag,
650 EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
651 ));
652 Self {
653 tag,
654 payload: EventPayload {
655 message: EventMessage { message },
656 },
657 }
658 }
659
660 /// Build a tool-call event.
661 pub fn tool_call(
662 tag: EventTag,
663 tool_call_id: StbString,
664 tool_name: StbString,
665 params: StbString,
666 ) -> Self {
667 debug_assert!(matches!(
668 tag,
669 EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
670 ));
671 Self {
672 tag,
673 payload: EventPayload {
674 tool_call: EventToolCall {
675 tool_call_id,
676 tool_name,
677 params,
678 },
679 },
680 }
681 }
682
683 /// Build a tool-result event.
684 pub fn tool_result(
685 tag: EventTag,
686 tool_call_id: StbString,
687 tool_name: StbString,
688 result: StbString,
689 is_error: bool,
690 ) -> Self {
691 debug_assert!(matches!(
692 tag,
693 EventTag::ToolResult | EventTag::ToolExecutionEnd
694 ));
695 Self {
696 tag,
697 payload: EventPayload {
698 tool_result: EventToolResult {
699 tool_call_id,
700 tool_name,
701 result,
702 is_error: is_error as u8,
703 },
704 },
705 }
706 }
707
708 /// Build an error event.
709 pub fn error(tag: EventTag, message: StbString) -> Self {
710 Self {
711 tag,
712 payload: EventPayload {
713 error: EventError { message },
714 },
715 }
716 }
717
718 /// Build a generic data event (JSON in `data`).
719 pub fn data(tag: EventTag, data: StbString) -> Self {
720 Self {
721 tag,
722 payload: EventPayload {
723 data: EventData { data },
724 },
725 }
726 }
727}
728
729/// Return codes an [`EventHandlerFn`] may return.
730///
731/// `0` means success; the host continues to the next handler. A **nonzero**
732/// return means the handler handled an error — the host logs it and (for the
733/// ordinary agent/observe fan-out) continues to other handlers (one handler's
734/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
735///
736/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
737/// current lifecycle phase. It is honored only by the host's lifecycle
738/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
739/// the ordinary observe fan-out treats it like any other nonzero (log +
740/// continue), because the agent loop must not be vetoed mid-run.
741pub const EVENT_HANDLER_CONTINUE: i32 = 0;
742/// The handler handled an error but the fan-out should continue (default
743/// nonzero semantics — equivalent to any other nonzero code).
744pub const EVENT_HANDLER_ERROR: i32 = 1;
745/// **Veto**: stop the current lifecycle phase. Honored only by the host's
746/// lifecycle dispatch; the observe fan-out logs + continues (see the module
747/// docs on [`EVENT_HANDLER_CONTINUE`]).
748pub const EVENT_HANDLER_ABORT: i32 = 2;
749
750/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
751/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
752/// signals a handled error (the host logs it; dispatch continues to other
753/// handlers — one handler's error does not abort the fan-out). The one
754/// exception: when the host dispatches a **lifecycle** event through its
755/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
756/// aborts that phase (see the constants above).
757pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
758
759/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
760/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
761/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
762/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
763/// and reclaimed via the `plugin_free_string` the host stored alongside the
764/// handler at registration. `user_data` is the plugin's opaque context. Returns
765/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
766/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
767pub type ResourcesDiscoverFn = extern "C" fn(
768 cwd: StbStringRef,
769 reason: StbStringRef,
770 out: *mut StbString,
771 user_data: *mut c_void,
772) -> i32;
773
774// ---------------------------------------------------------------------------
775// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
776// ---------------------------------------------------------------------------
777
778/// Identifier for a host runtime action the plugin may invoke via
779/// `PluginApiVt::runtime_action`. One slot dispatches all actions; the numeric
780/// id crosses the FFI boundary as a `u32` and the host validates it with
781/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
782/// strings.
783#[repr(u32)]
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
785pub enum RuntimeActionId {
786 SendMessage = 0,
787 SendUserMessage = 1,
788 AppendEntry = 2,
789 SetSessionName = 3,
790 GetActiveTools = 4,
791 SetActiveTools = 5,
792 SetModel = 6,
793 GetThinkingLevel = 7,
794 SetThinkingLevel = 8,
795 Compact = 9,
796 GetSystemPrompt = 10,
797 NewSession = 11,
798 Fork = 12,
799 NavigateTree = 13,
800 SwitchSession = 14,
801 Reload = 15,
802 /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
803 /// `{"value": <bool|string|null>}`.
804 GetCliFlag = 16,
805 /// Open/poll/cancel a host-provided interactive UI request. The payload is
806 /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
807 /// `requestId`; hosts without an attached TUI return an explicit error.
808 UiDialog = 17,
809 /// Publish a short status string for the host's UI (TUI footer).
810 /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
811 /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
812 /// Headless hosts accept the write but have nothing to render.
813 SetStatus = 18,
814}
815
816/// Error returned when a plugin passes a numeric runtime-action id that this
817/// ABI does not define.
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub struct UnknownRuntimeActionId(pub u32);
820
821impl core::fmt::Display for UnknownRuntimeActionId {
822 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
823 write!(f, "unknown runtime action id {}", self.0)
824 }
825}
826
827impl std::error::Error for UnknownRuntimeActionId {}
828
829impl TryFrom<u32> for RuntimeActionId {
830 type Error = UnknownRuntimeActionId;
831
832 fn try_from(value: u32) -> Result<Self, Self::Error> {
833 match value {
834 0 => Ok(Self::SendMessage),
835 1 => Ok(Self::SendUserMessage),
836 2 => Ok(Self::AppendEntry),
837 3 => Ok(Self::SetSessionName),
838 4 => Ok(Self::GetActiveTools),
839 5 => Ok(Self::SetActiveTools),
840 6 => Ok(Self::SetModel),
841 7 => Ok(Self::GetThinkingLevel),
842 8 => Ok(Self::SetThinkingLevel),
843 9 => Ok(Self::Compact),
844 10 => Ok(Self::GetSystemPrompt),
845 11 => Ok(Self::NewSession),
846 12 => Ok(Self::Fork),
847 13 => Ok(Self::NavigateTree),
848 14 => Ok(Self::SwitchSession),
849 15 => Ok(Self::Reload),
850 16 => Ok(Self::GetCliFlag),
851 17 => Ok(Self::UiDialog),
852 18 => Ok(Self::SetStatus),
853 other => Err(UnknownRuntimeActionId(other)),
854 }
855 }
856}
857
858impl From<RuntimeActionId> for u32 {
859 fn from(value: RuntimeActionId) -> Self {
860 value as u32
861 }
862}
863
864/// Runtime-action signature: `runtime_action(action_id, args_json, out,
865/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
866/// is an owning output ([`StbString`]) the host produces and the plugin frees
867/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
868/// Rust enum: an unknown value must be rejected as a normal protocol error
869/// rather than materializing an invalid enum discriminant. Returns `0` on
870/// success, nonzero on error.
871pub type RuntimeActionFn = extern "C" fn(
872 action_id: u32,
873 args_json: StbStringRef,
874 out: *mut StbString,
875 user_data: *mut c_void,
876) -> i32;
877
878// ---------------------------------------------------------------------------
879// PluginApiVt — host-provided vtable of fn pointers the plugin calls
880// ---------------------------------------------------------------------------
881
882/// A generic command-handler fn (for `register_command`). `args_json` is a
883/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
884/// JSON output reclaimed with the host `free_string`. The TUI understands
885/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
886/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
887/// responses; selector/editor/input submissions call the same handler with an
888/// `action` field in `args`.
889pub type CommandHandlerFn =
890 extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
891
892/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
893/// `out` is owning output the plugin frees via host `free_string`. Markdown
894/// handlers return `{markdown:"..."}`; message/entry handlers return
895/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
896pub type RenderFn =
897 extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
898
899/// A provider-injection factory fn (for `register_provider`). `req_json` is a
900/// borrowed request envelope; `out` is an owning response the plugin frees.
901/// The host wraps this into a `Provider` impl (B4/B5).
902pub type ProviderRequestFn =
903 extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
904
905/// ABI v1 runtime-action signature. The original SDK exposed a `#[repr(u32)]`
906/// enum at this position; the legacy host view uses the ABI-equivalent raw
907/// integer so it can reject unknown values before constructing an enum.
908pub type LegacyRuntimeActionFn = extern "C" fn(
909 action_id: u32,
910 args_json: StbStringRef,
911 out: *mut StbString,
912 user_data: *mut c_void,
913) -> i32;
914
915/// Frozen host vtable layout used by plugins exporting
916/// [`LEGACY_REGISTER_SYMBOL`]. Do not add, remove, reorder, or retype fields in
917/// this struct. New plugins use [`PluginApiVt`] and [`REGISTER_SYMBOL_V2`].
918///
919/// The v1 action slot accepts only the historical ids `0..=15`. Hosts must
920/// validate the raw `u32` before dispatching it.
921#[repr(C)]
922pub struct LegacyPluginApiV1 {
923 pub free_string: FreeStringFn,
924 pub register_tool: Option<
925 extern "C" fn(
926 schema: *const StableToolSchema,
927 execute_fn: ToolExecuteFn,
928 poll_fn: ToolPollFn,
929 cancel_fn: ToolCancelFn,
930 destroy_fn: ToolDestroyFn,
931 plugin_free_string: FreeStringFn,
932 ) -> i32,
933 >,
934 pub register_command: Option<
935 extern "C" fn(
936 name: StbStringRef,
937 description: StbStringRef,
938 handler: CommandHandlerFn,
939 ) -> i32,
940 >,
941 pub register_shortcut:
942 Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
943 pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
944 pub register_provider: Option<
945 extern "C" fn(
946 provider_id: StbStringRef,
947 base_url: StbStringRef,
948 api_style: StbStringRef,
949 request_fn: ProviderRequestFn,
950 plugin_free_string: FreeStringFn,
951 user_data: *mut c_void,
952 ) -> i32,
953 >,
954 pub register_message_renderer: Option<
955 extern "C" fn(
956 name: StbStringRef,
957 render_fn: RenderFn,
958 plugin_free_string: FreeStringFn,
959 user_data: *mut c_void,
960 ) -> i32,
961 >,
962 pub register_markdown_transformer: Option<
963 extern "C" fn(
964 name: StbStringRef,
965 render_fn: RenderFn,
966 plugin_free_string: FreeStringFn,
967 user_data: *mut c_void,
968 ) -> i32,
969 >,
970 pub register_entry_renderer: Option<
971 extern "C" fn(
972 name: StbStringRef,
973 render_fn: RenderFn,
974 plugin_free_string: FreeStringFn,
975 user_data: *mut c_void,
976 ) -> i32,
977 >,
978 pub register_event_handler: Option<
979 extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
980 >,
981 pub register_resources_discover: Option<
982 extern "C" fn(
983 handler: ResourcesDiscoverFn,
984 plugin_free_string: FreeStringFn,
985 user_data: *mut c_void,
986 ) -> i32,
987 >,
988 pub runtime_action: LegacyRuntimeActionFn,
989 pub dispatch_event:
990 Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
991 pub user_data: *mut c_void,
992}
993
994unsafe impl Send for LegacyPluginApiV1 {}
995unsafe impl Sync for LegacyPluginApiV1 {}
996
997/// The ABI v2 host-provided vtable, passed to `rpi_plugin_register_v2` as a
998/// `*const`.
999///
1000/// The plugin reads it during `register` and may copy fn pointers it needs (the
1001/// struct is POD/Copy). **Every slot is nullable**: a null fn pointer means the
1002/// host does not support that capability yet — the plugin MUST null-check
1003/// before calling and degrade gracefully. This keeps the vtable forward-
1004/// compatible across rpi versions without re-ABI bumps within one
1005/// `RPI_PLUGIN_ABI_VERSION`.
1006///
1007/// `user_data` is the host's opaque context, passed back to every host-provided
1008/// fn (so the host can recover its session/harness state). The plugin stores it
1009/// and passes it through unchanged.
1010#[repr(C)]
1011pub struct PluginApiVt {
1012 /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1013 /// *receives* from the host (outputs of actions, event payloads, inputs to
1014 /// execute). Never null.
1015 pub free_string: FreeStringFn,
1016
1017 // --- 8 registrars (plugin → host "register X into the host") ---
1018 /// Register a tool. `schema` + the four lifecycle fns + the plugin's own
1019 /// `free_string` (for the [`StbString`]s in `schema`). Returns `0` on
1020 /// success. Nullable: host not yet wired for tool registration.
1021 pub register_tool: Option<
1022 extern "C" fn(
1023 schema: *const StableToolSchema,
1024 execute_fn: ToolExecuteFn,
1025 poll_fn: ToolPollFn,
1026 cancel_fn: ToolCancelFn,
1027 destroy_fn: ToolDestroyFn,
1028 plugin_free_string: FreeStringFn,
1029 ) -> i32,
1030 >,
1031
1032 /// Register a slash command. Nullable.
1033 pub register_command: Option<
1034 extern "C" fn(
1035 name: StbStringRef,
1036 description: StbStringRef,
1037 handler: CommandHandlerFn,
1038 ) -> i32,
1039 >,
1040
1041 /// Register a keyboard shortcut. Nullable.
1042 pub register_shortcut:
1043 Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1044
1045 /// Register a CLI flag using its bare name (without leading `--`). The
1046 /// parsed value is read later with [`RuntimeActionId::GetCliFlag`].
1047 /// Nullable.
1048 pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1049
1050 /// Register a custom provider. The host stores `provider_id`/`base_url`/
1051 /// `api_style` + the plugin's `request_fn` + the plugin's own
1052 /// `plugin_free_string` (the `out` [`StbString`] `request_fn` *produces* is
1053 /// plugin-owned and the host must reclaim it — same ownership rule as
1054 /// `register_resources_discover`) + the plugin's `user_data` (which
1055 /// `request_fn` receives back unmodified on every call). Nullable (B5c).
1056 pub register_provider: Option<
1057 extern "C" fn(
1058 provider_id: StbStringRef,
1059 base_url: StbStringRef,
1060 api_style: StbStringRef,
1061 request_fn: ProviderRequestFn,
1062 plugin_free_string: FreeStringFn,
1063 user_data: *mut c_void,
1064 ) -> i32,
1065 >,
1066
1067 /// Register a message renderer. `plugin_free_string` reclaims the `out`
1068 /// [`StbString`] `render_fn` produces; `user_data` is passed back to it on
1069 /// every render call. Nullable; interactive TUI consumption accepts the
1070 /// host JSON component envelope (`text`/`lines`).
1071 pub register_message_renderer: Option<
1072 extern "C" fn(
1073 name: StbStringRef,
1074 render_fn: RenderFn,
1075 plugin_free_string: FreeStringFn,
1076 user_data: *mut c_void,
1077 ) -> i32,
1078 >,
1079
1080 /// Register a markdown transformer. Same ownership shape as
1081 /// `register_message_renderer`. Nullable; markdown output is chained in
1082 /// assistant rendering.
1083 pub register_markdown_transformer: Option<
1084 extern "C" fn(
1085 name: StbStringRef,
1086 render_fn: RenderFn,
1087 plugin_free_string: FreeStringFn,
1088 user_data: *mut c_void,
1089 ) -> i32,
1090 >,
1091
1092 /// Register an entry renderer. Same ownership shape as
1093 /// `register_message_renderer`. Nullable; interactive TUI consumption
1094 /// accepts the host JSON component envelope (`text`/`lines`).
1095 pub register_entry_renderer: Option<
1096 extern "C" fn(
1097 name: StbStringRef,
1098 render_fn: RenderFn,
1099 plugin_free_string: FreeStringFn,
1100 user_data: *mut c_void,
1101 ) -> i32,
1102 >,
1103
1104 // --- on() event handler registration (the 33-category subscription) ---
1105 /// Subscribe a handler to one event `tag`. Nullable: host not yet wiring
1106 /// events. The host dispatches [`StablePluginEvent`]s of that tag to the
1107 /// handler (`catch_unwind`-wrapped).
1108 pub register_event_handler: Option<
1109 extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1110 >,
1111
1112 /// Register a `resources_discover` handler (B5b). The host stores `handler`
1113 /// + the plugin's own `plugin_free_string` (the `out` [`StbString`] the
1114 /// handler produces is plugin-owned and the host must reclaim it) +
1115 /// `user_data`. On discovery (`startup`/`reload`) the host fans the event to
1116 /// every registered handler in order, concatenating their returned
1117 /// `{skillPaths, promptPaths, themePaths}` (errors per-handler do NOT abort
1118 /// the fan-out). Nullable: a host without the resources-discover path leaves
1119 /// this null and the plugin must degrade (no dynamic resource contribution).
1120 pub register_resources_discover: Option<
1121 extern "C" fn(
1122 handler: ResourcesDiscoverFn,
1123 plugin_free_string: FreeStringFn,
1124 user_data: *mut c_void,
1125 ) -> i32,
1126 >,
1127
1128 // --- runtime actions (17, uniform dispatch) ---
1129 /// Invoke a host runtime action. See [`RuntimeActionId`] / [`RuntimeActionFn`].
1130 /// Always present; a host without an action bridge installs a nonzero-returning
1131 /// stub so plugins can degrade gracefully.
1132 pub runtime_action: RuntimeActionFn,
1133
1134 // --- event dispatch (plugin → host "emit an event upstream") ---
1135 /// Emit an event upstream (e.g. a tool announcing a custom UI event). The
1136 /// host forwards to interested subscribers. Ownership of the event's
1137 /// [`StbString`]s passes to the host (freed via `free_string`). Nullable.
1138 pub dispatch_event:
1139 Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1140
1141 /// The host's opaque context, passed through to every host-provided fn.
1142 /// The plugin stores this and hands it back unmodified on each call.
1143 pub user_data: *mut c_void,
1144}
1145
1146// SAFETY: the vtable is a POD struct of fn pointers + one raw `user_data`
1147// pointer. It is `Send`+`Sync` so the host can hand it to the plugin's register
1148// thread and the plugin can call its fns from the blocking driver thread; the
1149// host guarantees the `user_data` is valid across those calls.
1150unsafe impl Send for PluginApiVt {}
1151unsafe impl Sync for PluginApiVt {}
1152
1153// ---------------------------------------------------------------------------
1154// Register contract
1155// ---------------------------------------------------------------------------
1156
1157/// The ABI version this SDK publishes. ABI v2 adds the `GetCliFlag` runtime
1158/// action and makes [`RuntimeActionFn`]'s action parameter an explicitly
1159/// validated `u32`.
1160///
1161/// A host and plugin built for different ABI versions must never use each
1162/// other's [`PluginApiVt`]. [`register_entrypoint`] compares the scalar version
1163/// before dereferencing `api`, so an ABI v1 plugin presented to a v2 host (or a
1164/// v2 plugin presented to a v1 host) returns nonzero and is safely skipped
1165/// rather than reading a differently defined vtable.
1166pub const RPI_PLUGIN_ABI_VERSION: u32 = 2;
1167
1168/// ABI version passed to the legacy `rpi_plugin_register` entrypoint.
1169pub const LEGACY_PLUGIN_ABI_VERSION: u32 = 1;
1170
1171/// The ABI v2 symbol the host looks up first in each cdylib.
1172/// Must be an `extern "C" fn(*const PluginApiVt, u32) -> i32`.
1173pub const REGISTER_SYMBOL_V2: &[u8] = b"rpi_plugin_register_v2\0";
1174
1175/// Alias for the current SDK's register symbol.
1176pub const REGISTER_SYMBOL: &[u8] = REGISTER_SYMBOL_V2;
1177
1178/// The ABI v1 symbol used only when [`REGISTER_SYMBOL_V2`] is absent.
1179pub const LEGACY_REGISTER_SYMBOL: &[u8] = b"rpi_plugin_register\0";
1180
1181/// Plugin entrypoint signature. The host loads the cdylib, looks up
1182/// `rpi_plugin_register_v2`, and calls it with the host `PluginApiVt` and the
1183/// host's current `RPI_PLUGIN_ABI_VERSION`.
1184///
1185/// Return `0` on successful registration; nonzero is a plugin-defined error
1186/// code (the host logs it and skips the plugin). The plugin must compare
1187/// `abi_version` before reading `api`; [`register_entrypoint`] implements this
1188/// ordering and safely rejects old/new ABI mixtures. A plugin should copy any
1189/// needed function pointers only inside the callback this helper invokes.
1190pub type RpiPluginRegister = extern "C" fn(api: *const PluginApiVt, abi_version: u32) -> i32;
1191
1192/// ABI v1 plugin entrypoint signature.
1193pub type LegacyRpiPluginRegister =
1194 extern "C" fn(api: *const LegacyPluginApiV1, abi_version: u32) -> i32;
1195
1196// ---------------------------------------------------------------------------
1197// ABI v3 — plugin declarations (extension block beside the frozen v2 vtable)
1198// ---------------------------------------------------------------------------
1199
1200/// The ABI version the v3 entrypoint advertises.
1201pub const RPI_PLUGIN_ABI_VERSION_V3: u32 = 3;
1202
1203/// The ABI v3 symbol the host looks up before [`REGISTER_SYMBOL_V2`].
1204pub const REGISTER_SYMBOL_V3: &[u8] = b"rpi_plugin_register_v3\0";
1205
1206/// The ABI v3 **extension block**, passed to `rpi_plugin_register_v3` *in
1207/// addition to* the frozen v2 [`PluginApiVt`]. Keeping v3 as a separate side
1208/// struct means the v2 vtable layout never changes: a v2 plugin is unaffected,
1209/// a v3 plugin reads both.
1210///
1211/// Every slot is nullable. A null slot means the host does not support that
1212/// declaration yet — the plugin must null-check and degrade (skip the
1213/// declaration; the host applies defaults: priority `100`, all platforms).
1214#[repr(C)]
1215pub struct PluginApiVt3Ext {
1216 /// Declare this plugin's host-side preferences as a JSON object, e.g.
1217 /// `{"priority":60,"platforms":["linux","macos"]}`.
1218 ///
1219 /// - `priority` (integer, default `100`): dispatch order for this plugin's
1220 /// handlers — **smaller runs first** (stable; registration order breaks
1221 /// ties). Suggested bands: `0..=49` infrastructure, `50..=99`
1222 /// connection, `100..=199` normal, `200+` post/cleanup.
1223 /// - `platforms` (array of strings, default all): platforms this plugin
1224 /// supports; the host skips the plugin's handlers on any other platform.
1225 /// Recognised: `"linux"`, `"macos"`, `"windows"`, `"android"`.
1226 ///
1227 /// Called during `register`. Returns `0` on success, nonzero on a handled
1228 /// error (host logs it and keeps the defaults).
1229 pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1230 /// Reserved for future v3 declarations. MUST be null-filled today; the host
1231 /// ignores these slots. Zeroing them lets a future host start using them
1232 /// without another ABI bump.
1233 pub _reserved: [*mut c_void; 3],
1234}
1235unsafe impl Send for PluginApiVt3Ext {}
1236unsafe impl Sync for PluginApiVt3Ext {}
1237
1238impl PluginApiVt3Ext {
1239 /// A v3 ext block with every slot null (no declarations). Useful for tests
1240 /// and hosts that support no declarations yet.
1241 pub const fn empty() -> Self {
1242 Self {
1243 declare: None,
1244 _reserved: [ptr::null_mut(); 3],
1245 }
1246 }
1247}
1248
1249/// ABI v3 plugin entrypoint signature: `(api, ext, abi_version)`.
1250pub type RpiPluginRegisterV3 =
1251 extern "C" fn(api: *const PluginApiVt, ext: *const PluginApiVt3Ext, abi_version: u32) -> i32;
1252
1253// ---------------------------------------------------------------------------
1254// Unified ABI — single unversioned symbol, version in struct
1255// ---------------------------------------------------------------------------
1256
1257/// The unified ABI version. This is the *only* ABI going forward; the version
1258/// lives inside the struct (first field) so future breaks can be detected by
1259/// reading the pointer, independent of entrypoint arity.
1260pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1261
1262/// The unversioned symbol for the unified ABI. Old v1 plugins also exported
1263/// `rpi_plugin_register` with a different signature; repurposing the name
1264/// means leftover v1 `.dll`s will be mis-called (accepted risk).
1265pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1266
1267/// The unified host-provided API struct. The first two fields (`abi_version`
1268/// and `struct_size`) are contract-identity markers that survive future
1269/// signature changes: a plugin reads them from the pointer before anything
1270/// else, so even if the entrypoint arity changes, the version check remains
1271/// robust.
1272///
1273/// This struct folds in v3's `declare` slot (previously in `PluginApiVt3Ext`),
1274/// so there's no side block — one struct, one entrypoint, forever.
1275#[repr(C)]
1276pub struct PluginApi {
1277 /// Contract identity. First field so a plugin can validate before reading
1278 /// anything else, independent of the entrypoint arity.
1279 pub abi_version: u32,
1280 /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1281 /// that predates slots it wants (host_struct_size < plugin's expected
1282 /// offset). Future hosts may grow this struct; old plugins check
1283 /// `struct_size` before accessing new fields.
1284 pub struct_size: u32,
1285
1286 // --- All fields from PluginApiVt (same order, same types) ---
1287 /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1288 /// *receives* from the host (outputs of actions, event payloads, inputs to
1289 /// execute). Never null.
1290 pub free_string: FreeStringFn,
1291
1292 // --- 8 registrars (plugin → host "register X into the host") ---
1293 pub register_tool: Option<
1294 extern "C" fn(
1295 schema: *const StableToolSchema,
1296 execute_fn: ToolExecuteFn,
1297 poll_fn: ToolPollFn,
1298 cancel_fn: ToolCancelFn,
1299 destroy_fn: ToolDestroyFn,
1300 plugin_free_string: FreeStringFn,
1301 ) -> i32,
1302 >,
1303 pub register_command: Option<
1304 extern "C" fn(
1305 name: StbStringRef,
1306 description: StbStringRef,
1307 handler: CommandHandlerFn,
1308 ) -> i32,
1309 >,
1310 pub register_shortcut:
1311 Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1312 pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1313 pub register_provider: Option<
1314 extern "C" fn(
1315 provider_id: StbStringRef,
1316 base_url: StbStringRef,
1317 api_style: StbStringRef,
1318 request_fn: ProviderRequestFn,
1319 plugin_free_string: FreeStringFn,
1320 user_data: *mut c_void,
1321 ) -> i32,
1322 >,
1323 pub register_message_renderer: Option<
1324 extern "C" fn(
1325 name: StbStringRef,
1326 render_fn: RenderFn,
1327 plugin_free_string: FreeStringFn,
1328 user_data: *mut c_void,
1329 ) -> i32,
1330 >,
1331 pub register_markdown_transformer: Option<
1332 extern "C" fn(
1333 name: StbStringRef,
1334 render_fn: RenderFn,
1335 plugin_free_string: FreeStringFn,
1336 user_data: *mut c_void,
1337 ) -> i32,
1338 >,
1339 pub register_entry_renderer: Option<
1340 extern "C" fn(
1341 name: StbStringRef,
1342 render_fn: RenderFn,
1343 plugin_free_string: FreeStringFn,
1344 user_data: *mut c_void,
1345 ) -> i32,
1346 >,
1347 pub register_event_handler: Option<
1348 extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1349 >,
1350 pub register_resources_discover: Option<
1351 extern "C" fn(
1352 handler: ResourcesDiscoverFn,
1353 plugin_free_string: FreeStringFn,
1354 user_data: *mut c_void,
1355 ) -> i32,
1356 >,
1357 pub runtime_action: RuntimeActionFn,
1358 pub dispatch_event:
1359 Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1360 pub user_data: *mut c_void,
1361
1362 // --- v3's declare slot, folded in ---
1363 /// Declare this plugin's host-side preferences as a JSON object, e.g.
1364 /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1365 /// not support declarations yet (plugin skips the call; host applies
1366 /// defaults: priority `100`, all platforms).
1367 pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1368}
1369unsafe impl Send for PluginApi {}
1370unsafe impl Sync for PluginApi {}
1371
1372/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1373/// `api->abi_version`; no positional arg.
1374pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1375
1376// ===========================================================================
1377// Panic containment
1378// ===========================================================================
1379
1380/// Status code a plugin's register entrypoint returns when its body panicked.
1381///
1382/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1383/// turns such an unwind into a **process abort** (`panic in a function that
1384/// cannot unwind`), which would take down the whole host. The SDK catches the
1385/// panic and returns this code instead, so the host can skip the plugin with a
1386/// diagnostic.
1387pub const REGISTER_PANIC_STATUS: i32 = 70;
1388
1389/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1390///
1391/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1392/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1393/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1394/// Unwinding out of an `extern "C"` function aborts the process.
1395pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1396 std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1397}
1398
1399/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1400/// must return a value rather than a `Result`.
1401pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1402 guard(body).unwrap_or(fallback)
1403}
1404
1405/// Export an ABI v3 plugin entrypoint under `rpi_plugin_register_v3`.
1406///
1407/// The expression receives `(&PluginApiVt, &PluginApiVt3Ext)` and returns the
1408/// plugin-defined registration status code. Use this when the plugin wants to
1409/// declare a priority / supported platforms through [`PluginApiVt3Ext::declare`];
1410/// otherwise prefer `export_plugin_v2!`.
1411///
1412/// ```ignore
1413/// rpi_plugin_sdk::export_plugin_v3!(|api, ext| {
1414/// if let Some(declare) = ext.declare {
1415/// declare(rpi_plugin_sdk::StbStringRef::from_str(
1416/// r#"{"priority":60,"platforms":["linux"]}"#,
1417/// ));
1418/// }
1419/// // ... register tools and handlers through `api` ...
1420/// 0
1421/// });
1422/// ```
1423#[macro_export]
1424macro_rules! export_plugin_v3 {
1425 ($body:expr) => {
1426 #[no_mangle]
1427 pub extern "C" fn rpi_plugin_register_v3(
1428 api: *const $crate::PluginApiVt,
1429 ext: *const $crate::PluginApiVt3Ext,
1430 abi_version: u32,
1431 ) -> i32 {
1432 // SAFETY: host guarantees `api`/`ext` are valid for this call.
1433 unsafe { $crate::register_entrypoint_v3(api, ext, abi_version, $body) }
1434 }
1435 };
1436}
1437
1438/// v3 entrypoint helper: compares `abi_version` against
1439/// [`RPI_PLUGIN_ABI_VERSION_V3`] before dereferencing `api`/`ext`.
1440///
1441/// # Safety
1442///
1443/// `api` and `ext` must be valid, properly aligned pointers that remain valid
1444/// for the duration of `body`.
1445pub unsafe fn register_entrypoint_v3(
1446 api: *const PluginApiVt,
1447 ext: *const PluginApiVt3Ext,
1448 abi_version: u32,
1449 body: impl FnOnce(&PluginApiVt, &PluginApiVt3Ext) -> i32,
1450) -> i32 {
1451 if abi_version != RPI_PLUGIN_ABI_VERSION_V3 {
1452 return 1;
1453 }
1454 if api.is_null() {
1455 return 2;
1456 }
1457 if ext.is_null() {
1458 return 3;
1459 }
1460 // SAFETY: caller guarantees both pointers are valid; we checked null.
1461 let api = &*api;
1462 let ext = &*ext;
1463 // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1464 // the process, so convert a panic into a status code the host can report.
1465 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api, ext))) {
1466 Ok(code) => code,
1467 Err(_) => REGISTER_PANIC_STATUS,
1468 }
1469}
1470
1471/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1472///
1473/// The unified ABI has no version suffix in the symbol name; the version lives
1474/// inside the [`PluginApi`] struct (first field `abi_version`). This is the
1475/// *only* ABI going forward; old versioned symbols (`rpi_plugin_register_v2`,
1476/// `_v3`) are kept temporarily for migration but are deprecated.
1477///
1478/// The expression receives `&PluginApi` and returns the plugin-defined
1479/// registration status code.
1480///
1481/// ```ignore
1482/// rpi_plugin_sdk::export_plugin!(|api| {
1483/// if let Some(declare) = api.declare {
1484/// declare(rpi_plugin_sdk::StbStringRef::from_str(
1485/// r#"{"priority":60,"platforms":["linux"]}"#,
1486/// ));
1487/// }
1488/// // ... register tools and handlers through `api` ...
1489/// 0
1490/// });
1491/// ```
1492#[macro_export]
1493macro_rules! export_plugin {
1494 ($body:expr) => {
1495 #[no_mangle]
1496 pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1497 // SAFETY: host guarantees `api` is valid for this call.
1498 unsafe { $crate::register_entrypoint_unified(api, $body) }
1499 }
1500 };
1501}
1502
1503/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1504/// the struct pointer before dereferencing anything else. This survives future
1505/// signature changes because the pointer is always the first argument.
1506///
1507/// # Safety
1508///
1509/// `api` must be a valid, properly aligned pointer to a [`PluginApi`] that
1510/// remains valid for the duration of `body`.
1511pub unsafe fn register_entrypoint_unified(
1512 api: *const PluginApi,
1513 body: impl FnOnce(&PluginApi) -> i32,
1514) -> i32 {
1515 if api.is_null() {
1516 return 2;
1517 }
1518 // SAFETY: caller guarantees `api` is valid; we check null above.
1519 // Read the version and size fields first — they're at known offsets
1520 // regardless of future struct growth.
1521 let abi_version = (*api).abi_version;
1522 let struct_size = (*api).struct_size;
1523 if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1524 // Mismatch: refuse to register. The host logs "ABI version mismatch"
1525 // and skips loading this plugin.
1526 return 1;
1527 }
1528 if (struct_size as usize) < core::mem::size_of::<PluginApi>() {
1529 // Host predates a field the plugin expects; degrade gracefully.
1530 return 3;
1531 }
1532 let api = &*api;
1533 // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1534 // the process, so convert a panic into a status code the host can report.
1535 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
1536 Ok(code) => code,
1537 Err(_) => REGISTER_PANIC_STATUS,
1538 }
1539}
1540
1541/// Export an ABI v2 plugin entrypoint under `rpi_plugin_register_v2`.
1542///
1543/// The expression receives `&PluginApiVt` and returns the plugin-defined
1544/// registration status code.
1545///
1546/// ```ignore
1547/// rpi_plugin_sdk::export_plugin_v2!(|api| {
1548/// // register tools and handlers through `api`
1549/// 0
1550/// });
1551/// ```
1552#[macro_export]
1553macro_rules! export_plugin_v2 {
1554 ($body:expr) => {
1555 #[no_mangle]
1556 pub extern "C" fn rpi_plugin_register_v2(
1557 api: *const $crate::PluginApiVt,
1558 abi_version: u32,
1559 ) -> i32 {
1560 // SAFETY: host guarantees `api` is valid for this call.
1561 unsafe { $crate::register_entrypoint(api, abi_version, $body) }
1562 }
1563 };
1564}
1565
1566/// Convenience for host + plugin: declare the register entrypoint.
1567///
1568/// A plugin crate writes:
1569/// ```ignore
1570/// #[no_mangle]
1571/// pub extern "C" fn rpi_plugin_register_v2(api: *const PluginApiVt, abi_version: u32) -> i32 {
1572/// rpi_plugin_sdk::register_entrypoint(api, abi_version, |api| {
1573/// // ... register tools / handlers using `api` ...
1574/// 0
1575/// })
1576/// }
1577/// ```
1578/// The helper performs the version check (return nonzero on mismatch) and
1579/// null-checks `api` before invoking the plugin body.
1580/// # Safety
1581///
1582/// `api` must be a valid, properly aligned pointer to a `PluginApiVt` that
1583/// remains valid for the duration of the `body` call. The host is responsible
1584/// for ensuring this invariant.
1585pub unsafe fn register_entrypoint(
1586 api: *const PluginApiVt,
1587 abi_version: u32,
1588 body: impl FnOnce(&PluginApiVt) -> i32,
1589) -> i32 {
1590 if abi_version != RPI_PLUGIN_ABI_VERSION {
1591 // Mismatch: refuse to register. The host logs "ABI version mismatch"
1592 // and skips loading this plugin.
1593 return 1;
1594 }
1595 if api.is_null() {
1596 return 2;
1597 }
1598 // SAFETY: caller guarantees `api` is valid; we additionally check for null.
1599 let api = &*api;
1600 // Contain panics (see `REGISTER_PANIC_STATUS`): a panic in the plugin body
1601 // must not unwind out of the `extern "C"` entrypoint.
1602 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
1603 Ok(code) => code,
1604 Err(_) => REGISTER_PANIC_STATUS,
1605 }
1606}
1607
1608// ===========================================================================
1609// Tests (need std + serde_json)
1610// ===========================================================================
1611
1612#[cfg(test)]
1613mod tests {
1614 use super::*;
1615
1616 /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1617 /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1618 /// plugin instead of the process aborting.
1619 #[test]
1620 fn register_entrypoint_contains_panics() {
1621 // The body panics before touching the vtable, so an uninitialized (but
1622 // aligned, non-null) pointer is sufficient — it is only dereferenced to
1623 // build `&PluginApiVt`, never read.
1624 let mut vt = std::mem::MaybeUninit::<PluginApiVt>::uninit();
1625 let vt_ptr = vt.as_mut_ptr();
1626 let rc = unsafe {
1627 register_entrypoint(vt_ptr, RPI_PLUGIN_ABI_VERSION, |_api| {
1628 panic!("plugin register exploded");
1629 })
1630 };
1631 assert_eq!(rc, REGISTER_PANIC_STATUS);
1632 }
1633
1634 #[test]
1635 fn guard_returns_value_and_contains_panic() {
1636 assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1637 assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1638 assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1639 }
1640
1641 // A test allocator + free fn so we can verify the own/free contract
1642 // without a real plugin's free_string.
1643 std::thread_local! {
1644 static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1645 }
1646
1647 extern "C" fn test_free(s: StbString) {
1648 if s.is_empty() || s.ptr.is_null() {
1649 return;
1650 }
1651 // Reconstruct the boxed slice and drop it.
1652 unsafe {
1653 let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
1654 let _ = Box::from_raw(slice as *mut [u8]);
1655 }
1656 FREED.with(|freed| freed.set(freed.get() + 1));
1657 }
1658
1659 fn reset_freed() -> usize {
1660 FREED.with(|freed| freed.replace(0))
1661 }
1662
1663 fn freed_count() -> usize {
1664 FREED.with(std::cell::Cell::get)
1665 }
1666
1667 // A no-op `runtime_action` impl for the vtable-construction tests (closures
1668 // can't coerce to `extern "C" fn`, so we use a real fn).
1669 extern "C" fn noop_runtime_action(
1670 _action_id: u32,
1671 _args: StbStringRef,
1672 _out: *mut StbString,
1673 _user_data: *mut c_void,
1674 ) -> i32 {
1675 0
1676 }
1677
1678 #[test]
1679 fn stbstring_round_trip_and_free_once() {
1680 let prev = reset_freed();
1681 let _ = prev;
1682 let s = StbString::from_string("hello, pi".to_string());
1683 assert_eq!(s.len, 9);
1684 assert_eq!(s.to_string_lossy(), "hello, pi");
1685 s.free_with(Some(test_free));
1686 assert_eq!(freed_count(), 1);
1687 }
1688
1689 #[test]
1690 fn empty_stbstring_free_is_noop() {
1691 let _ = reset_freed();
1692 StbString::empty().free_with(Some(test_free));
1693 assert_eq!(freed_count(), 0);
1694 }
1695
1696 #[test]
1697 fn json_round_trip_preserves_structure() {
1698 let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1699 let stb = json::to_stable(&val, None);
1700 let back = json::from_stable(&stb);
1701 assert_eq!(val, back);
1702 stb.free_with(Some(test_free));
1703 let _ = reset_freed();
1704 }
1705
1706 #[test]
1707 fn step_result_done_round_trip() {
1708 let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1709 let sr = StepResult::done(result_json);
1710 assert_eq!(sr.tag, StepResultTag::Done);
1711 // SAFETY: tag == Done.
1712 let done = unsafe { sr.done_payload() };
1713 assert_eq!(
1714 done.result.to_string_lossy(),
1715 r#"{"content":[{"text":"hi"}]}"#
1716 );
1717 done.result.free_with(Some(test_free));
1718 let _ = reset_freed();
1719 }
1720
1721 #[test]
1722 fn step_result_pending_and_err() {
1723 let prog = StbString::from_string("...".to_string());
1724 let srp = StepResult::pending(prog);
1725 assert_eq!(srp.tag, StepResultTag::Pending);
1726 // SAFETY: tag == Pending.
1727 unsafe {
1728 assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1729 }
1730 unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1731
1732 let msg = StbString::from_string("boom".to_string());
1733 let sre = StepResult::err(msg);
1734 assert_eq!(sre.tag, StepResultTag::Err);
1735 // SAFETY: tag == Err.
1736 unsafe {
1737 assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1738 sre.err_payload().message.free_with(Some(test_free));
1739 }
1740 let _ = reset_freed();
1741 }
1742
1743 #[test]
1744 fn event_tag_count_is_36() {
1745 // Enumerate every tag; a compile-time + runtime guarantee that the
1746 // 36-category surface is intact (33 Pi on() categories + the
1747 // rpi-specific BeforeTuiStart + 2 UI prompt events).
1748 let tags = [
1749 EventTag::ProjectTrust,
1750 EventTag::ResourcesDiscover,
1751 EventTag::SessionStart,
1752 EventTag::SessionInfoChanged,
1753 EventTag::SessionBeforeSwitch,
1754 EventTag::SessionBeforeFork,
1755 EventTag::SessionBeforeCompact,
1756 EventTag::SessionCompact,
1757 EventTag::SessionShutdown,
1758 EventTag::SessionBeforeTree,
1759 EventTag::SessionTree,
1760 EventTag::Context,
1761 EventTag::BeforeProviderRequest,
1762 EventTag::BeforeProviderHeaders,
1763 EventTag::AfterProviderResponse,
1764 EventTag::BeforeAgentStart,
1765 EventTag::AgentStart,
1766 EventTag::AgentEnd,
1767 EventTag::AgentSettled,
1768 EventTag::TurnStart,
1769 EventTag::TurnEnd,
1770 EventTag::MessageStart,
1771 EventTag::MessageUpdate,
1772 EventTag::MessageEnd,
1773 EventTag::ToolExecutionStart,
1774 EventTag::ToolExecutionUpdate,
1775 EventTag::ToolExecutionEnd,
1776 EventTag::ModelSelect,
1777 EventTag::ThinkingLevelSelect,
1778 EventTag::ToolCall,
1779 EventTag::ToolResult,
1780 EventTag::UserBash,
1781 EventTag::Input,
1782 EventTag::BeforeTuiStart,
1783 EventTag::UiPromptStart,
1784 EventTag::UiPromptEnd,
1785 ];
1786 assert_eq!(tags.len(), EVENT_TAG_COUNT);
1787 assert_eq!(EVENT_TAG_COUNT, 36);
1788 // Distinct discriminants 0..35.
1789 let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1790 discs.sort();
1791 assert_eq!(discs, (0..36).collect::<Vec<u32>>());
1792 }
1793
1794 #[test]
1795 fn event_payloads_construct_and_free() {
1796 let m = StbString::from_string("msg".to_string());
1797 let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1798 assert_eq!(ev.tag, EventTag::MessageEnd);
1799 // SAFETY: tag == MessageEnd (message variant).
1800 unsafe {
1801 assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1802 ev.payload.message.message.free_with(Some(test_free));
1803 }
1804
1805 let tc = StablePluginEvent::tool_call(
1806 EventTag::ToolCall,
1807 StbString::from_string("call_1".to_string()),
1808 StbString::from_string("echo".to_string()),
1809 StbString::from_string("{}".to_string()),
1810 );
1811 // SAFETY: tag == ToolCall (tool_call variant).
1812 unsafe {
1813 assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1814 tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1815 tc.payload.tool_call.tool_name.free_with(Some(test_free));
1816 tc.payload.tool_call.params.free_with(Some(test_free));
1817 }
1818 let _ = reset_freed();
1819 }
1820
1821 #[test]
1822 fn plugin_api_vt_is_pod_and_sized() {
1823 // The vtable must be a plain old data struct: every fn pointer is
1824 // non-Drop, the struct has no Drop impl. We exercise that it can be
1825 // zeroed and read without UB.
1826 let vt = PluginApiVt {
1827 free_string: test_free,
1828 register_tool: None,
1829 register_command: None,
1830 register_shortcut: None,
1831 register_flag: None,
1832 register_provider: None,
1833 register_message_renderer: None,
1834 register_markdown_transformer: None,
1835 register_entry_renderer: None,
1836 register_event_handler: None,
1837 register_resources_discover: None,
1838 runtime_action: noop_runtime_action,
1839 dispatch_event: None,
1840 user_data: core::ptr::null_mut(),
1841 };
1842 // All optional slots are null → plugin must degrade.
1843 assert!(vt.register_tool.is_none());
1844 assert!(vt.register_event_handler.is_none());
1845 assert!(vt.register_resources_discover.is_none());
1846 // Copy (POD) — no UB from a plain copy.
1847 let _copy = vt;
1848 // `assert!(core::mem::needs_drop::<PluginApiVt>() == false)` — verified
1849 // by the absence of a Drop impl + all-Copy fields.
1850 assert!(!core::mem::needs_drop::<PluginApiVt>());
1851 assert!(!core::mem::needs_drop::<StbString>());
1852 assert!(!core::mem::needs_drop::<StepResult>());
1853 assert!(!core::mem::needs_drop::<StablePluginEvent>());
1854 assert!(!core::mem::needs_drop::<StableToolSchema>());
1855 }
1856
1857 #[test]
1858 fn legacy_v1_layout_is_frozen_and_matches_v2_shared_slots() {
1859 use core::mem::{align_of, offset_of, size_of};
1860
1861 let pointer_size = size_of::<*const ()>();
1862 assert_eq!(size_of::<LegacyPluginApiV1>(), 14 * pointer_size);
1863 assert_eq!(align_of::<LegacyPluginApiV1>(), align_of::<*const ()>());
1864 assert_eq!(size_of::<PluginApiVt>(), size_of::<LegacyPluginApiV1>());
1865 assert_eq!(align_of::<PluginApiVt>(), align_of::<LegacyPluginApiV1>());
1866
1867 macro_rules! assert_same_offset {
1868 ($field:ident, $index:expr) => {
1869 assert_eq!(
1870 offset_of!(LegacyPluginApiV1, $field),
1871 $index * pointer_size,
1872 concat!("unexpected ABI v1 offset for ", stringify!($field))
1873 );
1874 assert_eq!(
1875 offset_of!(PluginApiVt, $field),
1876 offset_of!(LegacyPluginApiV1, $field),
1877 concat!("v1/v2 shared field moved: ", stringify!($field))
1878 );
1879 };
1880 }
1881
1882 assert_same_offset!(free_string, 0);
1883 assert_same_offset!(register_tool, 1);
1884 assert_same_offset!(register_command, 2);
1885 assert_same_offset!(register_shortcut, 3);
1886 assert_same_offset!(register_flag, 4);
1887 assert_same_offset!(register_provider, 5);
1888 assert_same_offset!(register_message_renderer, 6);
1889 assert_same_offset!(register_markdown_transformer, 7);
1890 assert_same_offset!(register_entry_renderer, 8);
1891 assert_same_offset!(register_event_handler, 9);
1892 assert_same_offset!(register_resources_discover, 10);
1893 assert_same_offset!(runtime_action, 11);
1894 assert_same_offset!(dispatch_event, 12);
1895 assert_same_offset!(user_data, 13);
1896 assert!(!core::mem::needs_drop::<LegacyPluginApiV1>());
1897 }
1898
1899 #[test]
1900 fn runtime_action_ids_are_explicitly_validated() {
1901 let ids = [
1902 RuntimeActionId::SendMessage,
1903 RuntimeActionId::SendUserMessage,
1904 RuntimeActionId::AppendEntry,
1905 RuntimeActionId::SetSessionName,
1906 RuntimeActionId::GetActiveTools,
1907 RuntimeActionId::SetActiveTools,
1908 RuntimeActionId::SetModel,
1909 RuntimeActionId::GetThinkingLevel,
1910 RuntimeActionId::SetThinkingLevel,
1911 RuntimeActionId::Compact,
1912 RuntimeActionId::GetSystemPrompt,
1913 RuntimeActionId::NewSession,
1914 RuntimeActionId::Fork,
1915 RuntimeActionId::NavigateTree,
1916 RuntimeActionId::SwitchSession,
1917 RuntimeActionId::Reload,
1918 RuntimeActionId::GetCliFlag,
1919 RuntimeActionId::UiDialog,
1920 RuntimeActionId::SetStatus,
1921 ];
1922
1923 for (raw, expected) in ids.into_iter().enumerate() {
1924 assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
1925 assert_eq!(u32::from(expected), raw as u32);
1926 }
1927 // One past the highest defined id is rejected (nothing is silently
1928 // accepted), and so is the u32 ceiling.
1929 assert_eq!(
1930 RuntimeActionId::try_from(19),
1931 Err(UnknownRuntimeActionId(19))
1932 );
1933 assert_eq!(
1934 RuntimeActionId::try_from(u32::MAX),
1935 Err(UnknownRuntimeActionId(u32::MAX))
1936 );
1937 }
1938
1939 #[test]
1940 fn register_entrypoint_version_mismatch_refuses() {
1941 let vt = PluginApiVt {
1942 free_string: test_free,
1943 register_tool: None,
1944 register_command: None,
1945 register_shortcut: None,
1946 register_flag: None,
1947 register_provider: None,
1948 register_message_renderer: None,
1949 register_markdown_transformer: None,
1950 register_entry_renderer: None,
1951 register_event_handler: None,
1952 register_resources_discover: None,
1953 runtime_action: noop_runtime_action,
1954 dispatch_event: None,
1955 user_data: core::ptr::null_mut(),
1956 };
1957 assert_eq!(RPI_PLUGIN_ABI_VERSION, 2);
1958 assert_eq!(LEGACY_PLUGIN_ABI_VERSION, 1);
1959 assert_eq!(REGISTER_SYMBOL, REGISTER_SYMBOL_V2);
1960 assert_ne!(REGISTER_SYMBOL_V2, LEGACY_REGISTER_SYMBOL);
1961
1962 // An ABI v1 plugin/host mixture is refused before the v2 vtable is read.
1963 // A null pointer makes the ordering observable: checking `api` first
1964 // would return 2, while the required version-first path returns 1.
1965 let rc = unsafe {
1966 register_entrypoint(core::ptr::null(), 1, |_| {
1967 panic!("body must not run on version mismatch");
1968 })
1969 };
1970 assert_eq!(rc, 1);
1971
1972 // A future version is rejected by the same pre-dereference check.
1973 let rc = unsafe {
1974 register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION + 1, |_| {
1975 panic!("body must not run on version mismatch");
1976 })
1977 };
1978 assert_ne!(rc, 0);
1979
1980 // Right version → body runs, rc propagated.
1981 let rc = unsafe { register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 0) };
1982 assert_eq!(rc, 0);
1983 let rc = unsafe { register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 42) };
1984 assert_eq!(rc, 42);
1985
1986 // Null api → refuse.
1987 let rc = unsafe { register_entrypoint(core::ptr::null(), RPI_PLUGIN_ABI_VERSION, |_| 0) };
1988 assert_ne!(rc, 0);
1989 let _ = reset_freed();
1990 }
1991
1992 #[test]
1993 fn v3_ext_is_pod_and_entrypoint_validates() {
1994 // The v3 ext block is POD (fn pointer + reserved raw pointers, no Drop).
1995 assert!(!core::mem::needs_drop::<PluginApiVt3Ext>());
1996 assert_eq!(RPI_PLUGIN_ABI_VERSION_V3, 3);
1997 assert_ne!(REGISTER_SYMBOL_V3, REGISTER_SYMBOL_V2);
1998 let empty = PluginApiVt3Ext::empty();
1999 assert!(empty.declare.is_none());
2000 assert!(empty._reserved.iter().all(|p| p.is_null()));
2001
2002 let vt = PluginApiVt {
2003 free_string: test_free,
2004 register_tool: None,
2005 register_command: None,
2006 register_shortcut: None,
2007 register_flag: None,
2008 register_provider: None,
2009 register_message_renderer: None,
2010 register_markdown_transformer: None,
2011 register_entry_renderer: None,
2012 register_event_handler: None,
2013 register_resources_discover: None,
2014 runtime_action: noop_runtime_action,
2015 dispatch_event: None,
2016 user_data: core::ptr::null_mut(),
2017 };
2018 let ext = PluginApiVt3Ext::empty();
2019
2020 // Version mismatch → refused before the body runs (null api makes the
2021 // ordering observable: version-first returns 1, not the null-api code).
2022 let rc = unsafe {
2023 register_entrypoint_v3(core::ptr::null(), &ext, 2, |_, _| {
2024 panic!("body must not run on version mismatch");
2025 })
2026 };
2027 assert_eq!(rc, 1);
2028
2029 // Null api / null ext → distinct refusals.
2030 assert_eq!(
2031 unsafe { register_entrypoint_v3(core::ptr::null(), &ext, 3, |_, _| 0) },
2032 2
2033 );
2034 assert_eq!(
2035 unsafe { register_entrypoint_v3(&vt, core::ptr::null(), 3, |_, _| 0) },
2036 3
2037 );
2038
2039 // Right version + non-null → body runs, rc propagated.
2040 let rc = unsafe { register_entrypoint_v3(&vt, &ext, 3, |_, _| 0) };
2041 assert_eq!(rc, 0);
2042 let rc = unsafe { register_entrypoint_v3(&vt, &ext, 3, |_, _| 7) };
2043 assert_eq!(rc, 7);
2044 let _ = reset_freed();
2045 }
2046}