Skip to main content

rpi_plugin_sdk/
events.rs

1//! Borrowed ABI event views with tag-checked, owned payload copies.
2use crate::{EventTag, StablePluginEvent};
3
4pub struct EventView<'a> {
5    raw: &'a StablePluginEvent,
6}
7
8pub struct ToolCall {
9    pub tool_call_id: String,
10    pub tool_name: String,
11    pub params: String,
12}
13
14pub struct ToolResult {
15    pub tool_call_id: String,
16    pub tool_name: String,
17    pub result: String,
18    pub is_error: bool,
19}
20
21impl<'a> EventView<'a> {
22    /// Create a view at an ABI callback boundary.
23    ///
24    /// # Safety
25    /// The host must initialize the payload variant specified by `tag` and
26    /// keep every payload buffer valid until the callback returns. The view
27    /// borrows the event; accessors copy strings and never free host memory.
28    pub unsafe fn from_raw(raw: &'a StablePluginEvent) -> Self {
29        Self { raw }
30    }
31
32    pub fn tag(&self) -> EventTag {
33        self.raw.tag
34    }
35
36    pub fn message_text(&self) -> Option<String> {
37        if !matches!(
38            self.tag(),
39            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
40        ) {
41            return None;
42        }
43        // SAFETY: from_raw's invariant and the tag check select the live arm.
44        Some(unsafe { self.raw.payload.message.message }.to_string_lossy())
45    }
46
47    pub fn data_text(&self) -> Option<String> {
48        if !matches!(
49            self.tag(),
50            EventTag::SessionBeforeSwitch
51                | EventTag::SessionInfoChanged
52                | EventTag::SessionBeforeFork
53                | EventTag::SessionBeforeCompact
54                | EventTag::SessionCompact
55                | EventTag::SessionBeforeTree
56                | EventTag::SessionTree
57                | EventTag::Context
58                | EventTag::BeforeProviderRequest
59                | EventTag::BeforeProviderHeaders
60                | EventTag::AfterProviderResponse
61                | EventTag::BeforeAgentStart
62                | EventTag::ModelSelect
63                | EventTag::ThinkingLevelSelect
64                | EventTag::UserBash
65                | EventTag::Input
66                | EventTag::UiPromptStart
67                | EventTag::UiPromptEnd
68                | EventTag::EditorChange
69        ) {
70            return None;
71        }
72        // SAFETY: from_raw's invariant and the tag check select the live arm.
73        Some(unsafe { self.raw.payload.data.data }.to_string_lossy())
74    }
75
76    pub fn tool_call(&self) -> Option<ToolCall> {
77        if !matches!(
78            self.tag(),
79            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
80        ) {
81            return None;
82        }
83        // SAFETY: from_raw's invariant and the tag check select the live arm.
84        let payload = unsafe { self.raw.payload.tool_call };
85        Some(ToolCall {
86            tool_call_id: payload.tool_call_id.to_string_lossy(),
87            tool_name: payload.tool_name.to_string_lossy(),
88            params: payload.params.to_string_lossy(),
89        })
90    }
91
92    pub fn tool_result(&self) -> Option<ToolResult> {
93        if !matches!(
94            self.tag(),
95            EventTag::ToolResult | EventTag::ToolExecutionEnd
96        ) {
97            return None;
98        }
99        // SAFETY: from_raw's invariant and the tag check select the live arm.
100        let payload = unsafe { self.raw.payload.tool_result };
101        Some(ToolResult {
102            tool_call_id: payload.tool_call_id.to_string_lossy(),
103            tool_name: payload.tool_name.to_string_lossy(),
104            result: payload.result.to_string_lossy(),
105            is_error: payload.is_error != 0,
106        })
107    }
108}
109
110/// Define an ABI event handler whose body uses an `EventView`.
111///
112/// The generated trampoline contains the host's ABI validity assumption and
113/// catches panics before they can unwind across `extern "C"`. Payload strings
114/// copied by the view may safely be retained after the callback returns.
115#[macro_export]
116macro_rules! event_handler {
117    ($(#[$attr:meta])* fn $name:ident($event:ident, $data:ident) $body:block) => {
118        $(#[$attr])*
119        extern "C" fn $name(raw: $crate::StablePluginEvent, $data: *mut std::ffi::c_void) -> i32 {
120            $crate::guard_or($crate::EVENT_HANDLER_ERROR, || {
121                // SAFETY: this is the host-owned ABI callback; its event and
122                // buffers remain valid for the duration of this invocation.
123                let $event = unsafe { $crate::events::EventView::from_raw(&raw) };
124                $body
125            })
126        }
127    };
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::StbStringRef;
134
135    // Borrowing a Rust string gives this test a live buffer without allocating
136    // or leaking an ABI string that would need an unsafe deallocator.
137    fn borrowed_string(text: &str) -> crate::StbString {
138        let borrowed = StbStringRef::from_str(text);
139        crate::StbString {
140            ptr: borrowed.ptr as *mut _,
141            len: borrowed.len,
142        }
143    }
144
145    #[test]
146    fn wrong_accessors_do_not_read_inactive_union_arms() {
147        let raw = StablePluginEvent::empty(EventTag::AgentStart);
148        let view = unsafe { EventView::from_raw(&raw) };
149        assert!(view.message_text().is_none());
150        assert!(view.data_text().is_none());
151        assert!(view.tool_call().is_none());
152        assert!(view.tool_result().is_none());
153    }
154
155    #[test]
156    fn copied_message_outlives_the_borrowed_event_buffer() {
157        let copy = {
158            let text = String::from("消息");
159            let raw = StablePluginEvent::message(EventTag::MessageEnd, borrowed_string(&text));
160            let view = unsafe { EventView::from_raw(&raw) };
161            assert!(view.tool_result().is_none());
162            view.message_text().unwrap()
163        };
164        assert_eq!(copy, "消息");
165    }
166
167    #[test]
168    fn tool_payloads_are_copied_with_the_error_flag() {
169        let raw = StablePluginEvent::tool_result(
170            EventTag::ToolExecutionEnd,
171            borrowed_string("call"),
172            borrowed_string("tool"),
173            borrowed_string("{}"),
174            true,
175        );
176        let view = unsafe { EventView::from_raw(&raw) };
177        let result = view.tool_result().unwrap();
178        assert_eq!(result.tool_call_id, "call");
179        assert_eq!(result.tool_name, "tool");
180        assert_eq!(result.result, "{}");
181        assert!(result.is_error);
182        assert!(view.tool_call().is_none());
183    }
184
185    event_handler! { fn panicking(_event, _data) { panic!("handler panic") } }
186
187    #[test]
188    fn data_and_call_accessors_select_only_their_own_payloads() {
189        let raw = StablePluginEvent::data(EventTag::BeforeProviderRequest, borrowed_string("{}"));
190        let view = unsafe { EventView::from_raw(&raw) };
191        assert_eq!(view.data_text().as_deref(), Some("{}"));
192        assert!(view.message_text().is_none());
193        let raw = StablePluginEvent::tool_call(
194            EventTag::ToolCall,
195            borrowed_string("call"),
196            borrowed_string("tool"),
197            borrowed_string("{}"),
198        );
199        let view = unsafe { EventView::from_raw(&raw) };
200        let call = view.tool_call().unwrap();
201        assert_eq!(call.tool_call_id, "call");
202        assert_eq!(call.tool_name, "tool");
203        assert_eq!(call.params, "{}");
204        assert!(view.data_text().is_none());
205    }
206
207    #[test]
208    fn handler_panic_returns_error_without_crossing_abi() {
209        assert_eq!(
210            panicking(
211                StablePluginEvent::empty(EventTag::AgentStart),
212                std::ptr::null_mut()
213            ),
214            crate::EVENT_HANDLER_ERROR
215        );
216    }
217}