use core::ffi::c_char;
use core::ffi::c_void;
use core::ptr;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbString {
pub ptr: *mut c_char,
pub len: usize,
}
unsafe impl Send for StbString {}
unsafe impl Sync for StbString {}
impl StbString {
pub const fn empty() -> Self {
Self {
ptr: ptr::null_mut(),
len: 0,
}
}
pub const fn is_empty(&self) -> bool {
self.len == 0
}
}
#[cfg(any(feature = "json", test))]
impl StbString {
pub fn from_owned(buf: Box<[u8]>) -> Self {
let len = buf.len();
let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
let _ = len;
Self { ptr, len }
}
pub fn from_string(s: String) -> Self {
Self::from_vec(s.into_bytes())
}
pub fn from_vec(v: Vec<u8>) -> Self {
Self::from_owned(v.into_boxed_slice())
}
pub fn from_boxed_str(s: Box<str>) -> Self {
let string: String = s.into();
Self::from_vec(string.into_bytes())
}
pub fn to_string_lossy(&self) -> String {
if self.len == 0 || self.ptr.is_null() {
return String::new();
}
let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
String::from_utf8_lossy(slice).into_owned()
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbStringRef {
pub ptr: *const c_char,
pub len: usize,
}
unsafe impl Send for StbStringRef {}
unsafe impl Sync for StbStringRef {}
impl StbStringRef {
pub const fn empty() -> Self {
Self {
ptr: ptr::null(),
len: 0,
}
}
pub fn from_str(s: &str) -> Self {
Self {
ptr: s.as_ptr() as *const c_char,
len: s.len(),
}
}
pub unsafe fn as_str<'a>(&self) -> &'a str {
if self.len == 0 || self.ptr.is_null() {
return "";
}
let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
unsafe { core::str::from_utf8_unchecked(slice) }
}
}
pub type FreeStringFn = extern "C" fn(s: StbString);
impl StbString {
pub fn free_with(self, free_fn: Option<FreeStringFn>) {
if let Some(free_fn) = free_fn {
if !self.is_empty() {
free_fn(self);
}
}
}
}
#[cfg(any(feature = "json", doc))]
pub mod json {
use super::StbString;
use serde_json::Value;
pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
let stb = StbString::from_string(s);
let _ = free_fn;
stb
}
pub fn from_stable(s: &StbString) -> Value {
let text = s.to_string_lossy();
if text.is_empty() {
return Value::Null;
}
serde_json::from_str(&text).unwrap_or(Value::Null)
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StableToolSchema {
pub name: StbString,
pub description: StbString,
pub parameters: StbString,
}
unsafe impl Send for StableToolSchema {}
unsafe impl Sync for StableToolSchema {}
pub const HOST_CONTEXT_KEY: &str = "__rpi";
pub type StepHandle = *mut c_void;
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepResultTag {
Pending = 0,
Done = 1,
Err = 2,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbPending {
pub progress: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbDone {
pub result: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbErr {
pub message: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub union StepResultPayload {
pub pending: StbPending,
pub done: StbDone,
pub err: StbErr,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StepResult {
pub tag: StepResultTag,
pub payload: StepResultPayload,
}
impl StepResult {
pub fn pending(progress: StbString) -> Self {
Self {
tag: StepResultTag::Pending,
payload: StepResultPayload {
pending: StbPending { progress },
},
}
}
pub fn done(result: StbString) -> Self {
Self {
tag: StepResultTag::Done,
payload: StepResultPayload {
done: StbDone { result },
},
}
}
pub fn err(message: StbString) -> Self {
Self {
tag: StepResultTag::Err,
payload: StepResultPayload {
err: StbErr { message },
},
}
}
pub unsafe fn pending_payload(&self) -> &StbPending {
unsafe { &self.payload.pending }
}
pub unsafe fn done_payload(&self) -> &StbDone {
unsafe { &self.payload.done }
}
pub unsafe fn err_payload(&self) -> &StbErr {
unsafe { &self.payload.err }
}
}
pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
pub type ToolExecuteFn = extern "C" fn(
tool_call_id: StbStringRef,
params: StbString,
free_params: Option<FreeStringFn>,
) -> StepHandle;
pub type ToolPollFn = extern "C" fn(
handle: StepHandle,
partial_cb: Option<ToolPartialCb>,
user_data: *mut c_void,
) -> StepResult;
pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventTag {
ProjectTrust = 0,
ResourcesDiscover = 1,
SessionStart = 2,
SessionInfoChanged = 3,
SessionBeforeSwitch = 4,
SessionBeforeFork = 5,
SessionBeforeCompact = 6,
SessionCompact = 7,
SessionShutdown = 8,
SessionBeforeTree = 9,
SessionTree = 10,
Context = 11,
BeforeProviderRequest = 12,
BeforeProviderHeaders = 13,
AfterProviderResponse = 14,
BeforeAgentStart = 15,
AgentStart = 16,
AgentEnd = 17,
AgentSettled = 18,
TurnStart = 19,
TurnEnd = 20,
MessageStart = 21,
MessageUpdate = 22,
MessageEnd = 23,
ToolExecutionStart = 24,
ToolExecutionUpdate = 25,
ToolExecutionEnd = 26,
ModelSelect = 27,
ThinkingLevelSelect = 28,
ToolCall = 29,
ToolResult = 30,
UserBash = 31,
Input = 32,
BeforeTuiStart = 33,
UiPromptStart = 34,
UiPromptEnd = 35,
EditorChange = 36,
}
pub const EVENT_TAG_COUNT: usize = 37;
pub const HOST_NAME: &str = "rpi";
pub const HOST_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const SESSION_ID_ENV: &str = "RPI_SESSION_ID";
pub const SUBAGENT_PARENT_TRACE_ID_ENV: &str = "LANGFUSE_PI_PARENT_TRACE_ID";
pub const SUBAGENT_PARENT_SPAN_ID_ENV: &str = "LANGFUSE_PI_PARENT_SPAN_ID";
pub const SUBAGENT_PARENT_SESSION_ID_ENV: &str = "LANGFUSE_PI_PARENT_SESSION_ID";
pub const SUBAGENT_PARENT_DEPTH_ENV: &str = "LANGFUSE_PI_PARENT_DEPTH";
pub const SUBAGENT_PARENT_ENV: &[&str] = &[
SUBAGENT_PARENT_TRACE_ID_ENV,
SUBAGENT_PARENT_SPAN_ID_ENV,
SUBAGENT_PARENT_SESSION_ID_ENV,
SUBAGENT_PARENT_DEPTH_ENV,
];
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventEmpty {
_opaque: u8,
}
impl EventEmpty {
pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
}
impl Default for EventEmpty {
fn default() -> Self {
Self::INSTANCE
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventMessage {
pub message: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventToolCall {
pub tool_call_id: StbString,
pub tool_name: StbString,
pub params: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventToolResult {
pub tool_call_id: StbString,
pub tool_name: StbString,
pub result: StbString,
pub is_error: u8,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventError {
pub message: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventData {
pub data: StbString,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub union EventPayload {
pub empty: EventEmpty,
pub message: EventMessage,
pub tool_call: EventToolCall,
pub tool_result: EventToolResult,
pub error: EventError,
pub data: EventData,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StablePluginEvent {
pub tag: EventTag,
pub payload: EventPayload,
}
impl StablePluginEvent {
pub fn empty(tag: EventTag) -> Self {
Self {
tag,
payload: EventPayload {
empty: EventEmpty::INSTANCE,
},
}
}
pub fn message(tag: EventTag, message: StbString) -> Self {
debug_assert!(matches!(
tag,
EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
));
Self {
tag,
payload: EventPayload {
message: EventMessage { message },
},
}
}
pub fn tool_call(
tag: EventTag,
tool_call_id: StbString,
tool_name: StbString,
params: StbString,
) -> Self {
debug_assert!(matches!(
tag,
EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
));
Self {
tag,
payload: EventPayload {
tool_call: EventToolCall {
tool_call_id,
tool_name,
params,
},
},
}
}
pub fn tool_result(
tag: EventTag,
tool_call_id: StbString,
tool_name: StbString,
result: StbString,
is_error: bool,
) -> Self {
debug_assert!(matches!(
tag,
EventTag::ToolResult | EventTag::ToolExecutionEnd
));
Self {
tag,
payload: EventPayload {
tool_result: EventToolResult {
tool_call_id,
tool_name,
result,
is_error: is_error as u8,
},
},
}
}
pub fn error(tag: EventTag, message: StbString) -> Self {
Self {
tag,
payload: EventPayload {
error: EventError { message },
},
}
}
pub fn data(tag: EventTag, data: StbString) -> Self {
Self {
tag,
payload: EventPayload {
data: EventData { data },
},
}
}
}
pub const EVENT_HANDLER_CONTINUE: i32 = 0;
pub const EVENT_HANDLER_ERROR: i32 = 1;
pub const EVENT_HANDLER_ABORT: i32 = 2;
pub const EVENT_HANDLER_CLAIMED: i32 = 3;
pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
pub type ResourcesDiscoverFn = extern "C" fn(
cwd: StbStringRef,
reason: StbStringRef,
out: *mut StbString,
user_data: *mut c_void,
) -> i32;
pub type BeforeAgentStartFn =
extern "C" fn(event_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeActionId {
SendMessage = 0,
SendUserMessage = 1,
AppendEntry = 2,
SetSessionName = 3,
GetActiveTools = 4,
SetActiveTools = 5,
SetModel = 6,
GetThinkingLevel = 7,
SetThinkingLevel = 8,
Compact = 9,
GetSystemPrompt = 10,
NewSession = 11,
Fork = 12,
NavigateTree = 13,
SwitchSession = 14,
Reload = 15,
GetCliFlag = 16,
UiDialog = 17,
SetStatus = 18,
SetEditorText = 19,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownRuntimeActionId(pub u32);
impl core::fmt::Display for UnknownRuntimeActionId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "unknown runtime action id {}", self.0)
}
}
impl std::error::Error for UnknownRuntimeActionId {}
impl TryFrom<u32> for RuntimeActionId {
type Error = UnknownRuntimeActionId;
fn try_from(value: u32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::SendMessage),
1 => Ok(Self::SendUserMessage),
2 => Ok(Self::AppendEntry),
3 => Ok(Self::SetSessionName),
4 => Ok(Self::GetActiveTools),
5 => Ok(Self::SetActiveTools),
6 => Ok(Self::SetModel),
7 => Ok(Self::GetThinkingLevel),
8 => Ok(Self::SetThinkingLevel),
9 => Ok(Self::Compact),
10 => Ok(Self::GetSystemPrompt),
11 => Ok(Self::NewSession),
12 => Ok(Self::Fork),
13 => Ok(Self::NavigateTree),
14 => Ok(Self::SwitchSession),
15 => Ok(Self::Reload),
16 => Ok(Self::GetCliFlag),
17 => Ok(Self::UiDialog),
18 => Ok(Self::SetStatus),
19 => Ok(Self::SetEditorText),
other => Err(UnknownRuntimeActionId(other)),
}
}
}
impl From<RuntimeActionId> for u32 {
fn from(value: RuntimeActionId) -> Self {
value as u32
}
}
pub type RuntimeActionFn = extern "C" fn(
action_id: u32,
args_json: StbStringRef,
out: *mut StbString,
user_data: *mut c_void,
) -> i32;
pub type CommandHandlerFn =
extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
pub type RenderFn =
extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
pub type ProviderRequestFn =
extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PluginApi {
pub abi_version: u32,
pub struct_size: u32,
pub free_string: FreeStringFn,
pub register_tool: Option<
extern "C" fn(
schema: *const StableToolSchema,
execute_fn: ToolExecuteFn,
poll_fn: ToolPollFn,
cancel_fn: ToolCancelFn,
destroy_fn: ToolDestroyFn,
plugin_free_string: FreeStringFn,
) -> i32,
>,
pub register_command: Option<
extern "C" fn(
name: StbStringRef,
description: StbStringRef,
handler: CommandHandlerFn,
) -> i32,
>,
pub register_shortcut:
Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
pub register_provider: Option<
extern "C" fn(
provider_id: StbStringRef,
base_url: StbStringRef,
api_style: StbStringRef,
request_fn: ProviderRequestFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
pub register_message_renderer: Option<
extern "C" fn(
name: StbStringRef,
render_fn: RenderFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
pub register_markdown_transformer: Option<
extern "C" fn(
name: StbStringRef,
render_fn: RenderFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
pub register_entry_renderer: Option<
extern "C" fn(
name: StbStringRef,
render_fn: RenderFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
pub register_event_handler: Option<
extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
>,
pub register_resources_discover: Option<
extern "C" fn(
handler: ResourcesDiscoverFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
pub runtime_action: RuntimeActionFn,
pub dispatch_event:
Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
pub user_data: *mut c_void,
pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
pub register_before_agent_start: Option<
extern "C" fn(
handler: BeforeAgentStartFn,
plugin_free_string: FreeStringFn,
user_data: *mut c_void,
) -> i32,
>,
}
unsafe impl Send for PluginApi {}
unsafe impl Sync for PluginApi {}
pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
pub const REGISTER_PANIC_STATUS: i32 = 70;
pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
}
pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
guard(body).unwrap_or(fallback)
}
#[macro_export]
macro_rules! export_plugin {
($body:expr) => {
#[no_mangle]
pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
unsafe { $crate::register_entrypoint(api, $body) }
}
};
}
pub unsafe fn register_entrypoint(
api: *const PluginApi,
body: impl FnOnce(&PluginApi) -> i32,
) -> i32 {
if api.is_null() {
return 2;
}
let abi_version = (*api).abi_version;
let struct_size = (*api).struct_size;
if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
return 1;
}
if (struct_size as usize) < core::mem::size_of::<PluginApi>() {
return 3;
}
let api = &*api;
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
Ok(code) => code,
Err(_) => REGISTER_PANIC_STATUS,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_entrypoint_contains_panics() {
let mut api = std::mem::MaybeUninit::<PluginApi>::uninit();
let api_ptr = api.as_mut_ptr();
unsafe {
(*api_ptr).abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
(*api_ptr).struct_size = core::mem::size_of::<PluginApi>() as u32;
}
let rc = unsafe {
register_entrypoint(api_ptr, |_api| {
panic!("plugin register exploded");
})
};
assert_eq!(rc, REGISTER_PANIC_STATUS);
}
#[test]
fn guard_returns_value_and_contains_panic() {
assert_eq!(guard(|| 41 + 1).ok(), Some(42));
assert!(guard(|| -> i32 { panic!("nope") }).is_err());
assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
}
std::thread_local! {
static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
}
extern "C" fn test_free(s: StbString) {
if s.is_empty() || s.ptr.is_null() {
return;
}
unsafe {
let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
let _ = Box::from_raw(slice as *mut [u8]);
}
FREED.with(|freed| freed.set(freed.get() + 1));
}
fn reset_freed() -> usize {
FREED.with(|freed| freed.replace(0))
}
fn freed_count() -> usize {
FREED.with(std::cell::Cell::get)
}
extern "C" fn noop_runtime_action(
_action_id: u32,
_args: StbStringRef,
_out: *mut StbString,
_user_data: *mut c_void,
) -> i32 {
0
}
#[test]
fn stbstring_round_trip_and_free_once() {
let prev = reset_freed();
let _ = prev;
let s = StbString::from_string("hello, pi".to_string());
assert_eq!(s.len, 9);
assert_eq!(s.to_string_lossy(), "hello, pi");
s.free_with(Some(test_free));
assert_eq!(freed_count(), 1);
}
#[test]
fn empty_stbstring_free_is_noop() {
let _ = reset_freed();
StbString::empty().free_with(Some(test_free));
assert_eq!(freed_count(), 0);
}
#[test]
fn json_round_trip_preserves_structure() {
let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
let stb = json::to_stable(&val, None);
let back = json::from_stable(&stb);
assert_eq!(val, back);
stb.free_with(Some(test_free));
let _ = reset_freed();
}
#[test]
fn step_result_done_round_trip() {
let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
let sr = StepResult::done(result_json);
assert_eq!(sr.tag, StepResultTag::Done);
let done = unsafe { sr.done_payload() };
assert_eq!(
done.result.to_string_lossy(),
r#"{"content":[{"text":"hi"}]}"#
);
done.result.free_with(Some(test_free));
let _ = reset_freed();
}
#[test]
fn step_result_pending_and_err() {
let prog = StbString::from_string("...".to_string());
let srp = StepResult::pending(prog);
assert_eq!(srp.tag, StepResultTag::Pending);
unsafe {
assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
}
unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
let msg = StbString::from_string("boom".to_string());
let sre = StepResult::err(msg);
assert_eq!(sre.tag, StepResultTag::Err);
unsafe {
assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
sre.err_payload().message.free_with(Some(test_free));
}
let _ = reset_freed();
}
#[test]
fn event_tag_count_is_37() {
let tags = [
EventTag::ProjectTrust,
EventTag::ResourcesDiscover,
EventTag::SessionStart,
EventTag::SessionInfoChanged,
EventTag::SessionBeforeSwitch,
EventTag::SessionBeforeFork,
EventTag::SessionBeforeCompact,
EventTag::SessionCompact,
EventTag::SessionShutdown,
EventTag::SessionBeforeTree,
EventTag::SessionTree,
EventTag::Context,
EventTag::BeforeProviderRequest,
EventTag::BeforeProviderHeaders,
EventTag::AfterProviderResponse,
EventTag::BeforeAgentStart,
EventTag::AgentStart,
EventTag::AgentEnd,
EventTag::AgentSettled,
EventTag::TurnStart,
EventTag::TurnEnd,
EventTag::MessageStart,
EventTag::MessageUpdate,
EventTag::MessageEnd,
EventTag::ToolExecutionStart,
EventTag::ToolExecutionUpdate,
EventTag::ToolExecutionEnd,
EventTag::ModelSelect,
EventTag::ThinkingLevelSelect,
EventTag::ToolCall,
EventTag::ToolResult,
EventTag::UserBash,
EventTag::Input,
EventTag::BeforeTuiStart,
EventTag::UiPromptStart,
EventTag::UiPromptEnd,
EventTag::EditorChange,
];
assert_eq!(tags.len(), EVENT_TAG_COUNT);
assert_eq!(EVENT_TAG_COUNT, 37);
let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
discs.sort();
assert_eq!(discs, (0..37).collect::<Vec<u32>>());
}
#[test]
fn event_payloads_construct_and_free() {
let m = StbString::from_string("msg".to_string());
let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
assert_eq!(ev.tag, EventTag::MessageEnd);
unsafe {
assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
ev.payload.message.message.free_with(Some(test_free));
}
let tc = StablePluginEvent::tool_call(
EventTag::ToolCall,
StbString::from_string("call_1".to_string()),
StbString::from_string("echo".to_string()),
StbString::from_string("{}".to_string()),
);
unsafe {
assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
tc.payload.tool_call.tool_name.free_with(Some(test_free));
tc.payload.tool_call.params.free_with(Some(test_free));
}
let _ = reset_freed();
}
#[test]
fn plugin_api_is_pod_and_sized() {
let api = PluginApi {
abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
struct_size: core::mem::size_of::<PluginApi>() as u32,
free_string: test_free,
register_tool: None,
register_command: None,
register_shortcut: None,
register_flag: None,
register_provider: None,
register_message_renderer: None,
register_markdown_transformer: None,
register_entry_renderer: None,
register_event_handler: None,
register_resources_discover: None,
runtime_action: noop_runtime_action,
dispatch_event: None,
user_data: core::ptr::null_mut(),
declare: None,
register_before_agent_start: None,
};
assert!(api.register_tool.is_none());
assert!(api.register_event_handler.is_none());
assert!(api.register_resources_discover.is_none());
assert!(api.register_before_agent_start.is_none());
let _copy = api;
assert!(!core::mem::needs_drop::<PluginApi>());
assert!(!core::mem::needs_drop::<StbString>());
assert!(!core::mem::needs_drop::<StepResult>());
assert!(!core::mem::needs_drop::<StablePluginEvent>());
assert!(!core::mem::needs_drop::<StableToolSchema>());
}
#[test]
fn vtable_grows_only_at_the_end() {
use core::mem::{align_of, size_of};
assert_eq!(core::mem::offset_of!(PluginApi, abi_version), 0);
assert_eq!(
core::mem::offset_of!(PluginApi, struct_size),
size_of::<u32>()
);
let declare = core::mem::offset_of!(PluginApi, declare);
let new_slot = core::mem::offset_of!(PluginApi, register_before_agent_start);
assert_eq!(
new_slot,
declare + size_of::<Option<extern "C" fn(StbStringRef) -> i32>>(),
"register_before_agent_start must be appended right after `declare`; \
inserting a slot earlier shifts every later offset and breaks \
plugins built against the previous layout"
);
assert_eq!(align_of::<PluginApi>(), align_of::<*const ()>());
}
#[test]
fn runtime_action_ids_are_explicitly_validated() {
let ids = [
RuntimeActionId::SendMessage,
RuntimeActionId::SendUserMessage,
RuntimeActionId::AppendEntry,
RuntimeActionId::SetSessionName,
RuntimeActionId::GetActiveTools,
RuntimeActionId::SetActiveTools,
RuntimeActionId::SetModel,
RuntimeActionId::GetThinkingLevel,
RuntimeActionId::SetThinkingLevel,
RuntimeActionId::Compact,
RuntimeActionId::GetSystemPrompt,
RuntimeActionId::NewSession,
RuntimeActionId::Fork,
RuntimeActionId::NavigateTree,
RuntimeActionId::SwitchSession,
RuntimeActionId::Reload,
RuntimeActionId::GetCliFlag,
RuntimeActionId::UiDialog,
RuntimeActionId::SetStatus,
RuntimeActionId::SetEditorText,
];
for (raw, expected) in ids.into_iter().enumerate() {
assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
assert_eq!(u32::from(expected), raw as u32);
}
assert_eq!(
RuntimeActionId::try_from(20),
Err(UnknownRuntimeActionId(20))
);
assert_eq!(
RuntimeActionId::try_from(u32::MAX),
Err(UnknownRuntimeActionId(u32::MAX))
);
}
#[test]
fn register_entrypoint_version_mismatch_refuses() {
let api = PluginApi {
abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
struct_size: core::mem::size_of::<PluginApi>() as u32,
free_string: test_free,
register_tool: None,
register_command: None,
register_shortcut: None,
register_flag: None,
register_provider: None,
register_message_renderer: None,
register_markdown_transformer: None,
register_entry_renderer: None,
register_event_handler: None,
register_resources_discover: None,
runtime_action: noop_runtime_action,
dispatch_event: None,
user_data: core::ptr::null_mut(),
declare: None,
register_before_agent_start: None,
};
assert_eq!(RPI_PLUGIN_ABI_VERSION_UNIFIED, 4);
let mut future_api = api;
future_api.abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED + 1;
let rc = unsafe {
register_entrypoint(&future_api, |_| {
panic!("body must not run on version mismatch");
})
};
assert_eq!(rc, 1);
let rc = unsafe { register_entrypoint(&api, |_| 0) };
assert_eq!(rc, 0);
let rc = unsafe { register_entrypoint(&api, |_| 42) };
assert_eq!(rc, 42);
let rc = unsafe { register_entrypoint(core::ptr::null(), |_| 0) };
assert_ne!(rc, 0);
let _ = reset_freed();
}
}