use crate::{EventTag, StablePluginEvent};
pub struct EventView<'a> {
raw: &'a StablePluginEvent,
}
pub struct ToolCall {
pub tool_call_id: String,
pub tool_name: String,
pub params: String,
}
pub struct ToolResult {
pub tool_call_id: String,
pub tool_name: String,
pub result: String,
pub is_error: bool,
}
impl<'a> EventView<'a> {
pub unsafe fn from_raw(raw: &'a StablePluginEvent) -> Self {
Self { raw }
}
pub fn tag(&self) -> EventTag {
self.raw.tag
}
pub fn message_text(&self) -> Option<String> {
if !matches!(
self.tag(),
EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
) {
return None;
}
Some(unsafe { self.raw.payload.message.message }.to_string_lossy())
}
pub fn data_text(&self) -> Option<String> {
if !matches!(
self.tag(),
EventTag::SessionBeforeSwitch
| EventTag::SessionInfoChanged
| EventTag::SessionBeforeFork
| EventTag::SessionBeforeCompact
| EventTag::SessionCompact
| EventTag::SessionBeforeTree
| EventTag::SessionTree
| EventTag::Context
| EventTag::BeforeProviderRequest
| EventTag::BeforeProviderHeaders
| EventTag::AfterProviderResponse
| EventTag::BeforeAgentStart
| EventTag::ModelSelect
| EventTag::ThinkingLevelSelect
| EventTag::UserBash
| EventTag::Input
| EventTag::UiPromptStart
| EventTag::UiPromptEnd
| EventTag::EditorChange
) {
return None;
}
Some(unsafe { self.raw.payload.data.data }.to_string_lossy())
}
pub fn tool_call(&self) -> Option<ToolCall> {
if !matches!(
self.tag(),
EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
) {
return None;
}
let payload = unsafe { self.raw.payload.tool_call };
Some(ToolCall {
tool_call_id: payload.tool_call_id.to_string_lossy(),
tool_name: payload.tool_name.to_string_lossy(),
params: payload.params.to_string_lossy(),
})
}
pub fn tool_result(&self) -> Option<ToolResult> {
if !matches!(
self.tag(),
EventTag::ToolResult | EventTag::ToolExecutionEnd
) {
return None;
}
let payload = unsafe { self.raw.payload.tool_result };
Some(ToolResult {
tool_call_id: payload.tool_call_id.to_string_lossy(),
tool_name: payload.tool_name.to_string_lossy(),
result: payload.result.to_string_lossy(),
is_error: payload.is_error != 0,
})
}
}
#[macro_export]
macro_rules! event_handler {
($(#[$attr:meta])* fn $name:ident($event:ident, $data:ident) $body:block) => {
$(#[$attr])*
extern "C" fn $name(raw: $crate::StablePluginEvent, $data: *mut std::ffi::c_void) -> i32 {
$crate::guard_or($crate::EVENT_HANDLER_ERROR, || {
// SAFETY: this is the host-owned ABI callback; its event and
// buffers remain valid for the duration of this invocation.
let $event = unsafe { $crate::events::EventView::from_raw(&raw) };
$body
})
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StbStringRef;
fn borrowed_string(text: &str) -> crate::StbString {
let borrowed = StbStringRef::from_str(text);
crate::StbString {
ptr: borrowed.ptr as *mut _,
len: borrowed.len,
}
}
#[test]
fn wrong_accessors_do_not_read_inactive_union_arms() {
let raw = StablePluginEvent::empty(EventTag::AgentStart);
let view = unsafe { EventView::from_raw(&raw) };
assert!(view.message_text().is_none());
assert!(view.data_text().is_none());
assert!(view.tool_call().is_none());
assert!(view.tool_result().is_none());
}
#[test]
fn copied_message_outlives_the_borrowed_event_buffer() {
let copy = {
let text = String::from("消息");
let raw = StablePluginEvent::message(EventTag::MessageEnd, borrowed_string(&text));
let view = unsafe { EventView::from_raw(&raw) };
assert!(view.tool_result().is_none());
view.message_text().unwrap()
};
assert_eq!(copy, "消息");
}
#[test]
fn tool_payloads_are_copied_with_the_error_flag() {
let raw = StablePluginEvent::tool_result(
EventTag::ToolExecutionEnd,
borrowed_string("call"),
borrowed_string("tool"),
borrowed_string("{}"),
true,
);
let view = unsafe { EventView::from_raw(&raw) };
let result = view.tool_result().unwrap();
assert_eq!(result.tool_call_id, "call");
assert_eq!(result.tool_name, "tool");
assert_eq!(result.result, "{}");
assert!(result.is_error);
assert!(view.tool_call().is_none());
}
event_handler! { fn panicking(_event, _data) { panic!("handler panic") } }
#[test]
fn data_and_call_accessors_select_only_their_own_payloads() {
let raw = StablePluginEvent::data(EventTag::BeforeProviderRequest, borrowed_string("{}"));
let view = unsafe { EventView::from_raw(&raw) };
assert_eq!(view.data_text().as_deref(), Some("{}"));
assert!(view.message_text().is_none());
let raw = StablePluginEvent::tool_call(
EventTag::ToolCall,
borrowed_string("call"),
borrowed_string("tool"),
borrowed_string("{}"),
);
let view = unsafe { EventView::from_raw(&raw) };
let call = view.tool_call().unwrap();
assert_eq!(call.tool_call_id, "call");
assert_eq!(call.tool_name, "tool");
assert_eq!(call.params, "{}");
assert!(view.data_text().is_none());
}
#[test]
fn handler_panic_returns_error_without_crossing_abi() {
assert_eq!(
panicking(
StablePluginEvent::empty(EventTag::AgentStart),
std::ptr::null_mut()
),
crate::EVENT_HANDLER_ERROR
);
}
}