use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::Arc;
use futures::future::BoxFuture;
use rpi_agent::events::{AgentEmitter, AgentEvent};
use rpi_plugin_sdk::{EventTag, StablePluginEvent, StbString};
use crate::host_free_string;
use crate::registry::RegistrySnapshot;
pub fn event_tag_for(event: &AgentEvent) -> Option<EventTag> {
match event {
AgentEvent::AgentStart => Some(EventTag::AgentStart),
AgentEvent::AgentEnd { .. } => Some(EventTag::AgentEnd),
AgentEvent::TurnStart => Some(EventTag::TurnStart),
AgentEvent::TurnEnd { .. } => Some(EventTag::TurnEnd),
AgentEvent::MessageStart { .. } => Some(EventTag::MessageStart),
AgentEvent::MessageUpdate { .. } => Some(EventTag::MessageUpdate),
AgentEvent::MessageEnd { .. } => Some(EventTag::MessageEnd),
AgentEvent::ToolExecutionStart { .. } => Some(EventTag::ToolExecutionStart),
AgentEvent::ToolExecutionUpdate { .. } => Some(EventTag::ToolExecutionUpdate),
AgentEvent::ToolExecutionEnd { .. } => Some(EventTag::ToolExecutionEnd),
}
}
fn message_to_stb(message: &rpi_agent::message::AgentMessage) -> StbString {
let text = serde_json::to_string(message).unwrap_or_else(|_| "null".to_string());
StbString::from_string(text)
}
pub fn translate(event: &AgentEvent) -> Option<StablePluginEvent> {
let tag = event_tag_for(event)?;
match event {
AgentEvent::MessageStart { message }
| AgentEvent::MessageUpdate { message, .. }
| AgentEvent::MessageEnd { message } => {
let stb = message_to_stb(message);
Some(StablePluginEvent::message(tag, stb))
}
AgentEvent::ToolExecutionStart {
tool_call_id,
tool_name,
args,
}
| AgentEvent::ToolExecutionUpdate {
tool_call_id,
tool_name,
args,
..
} => Some(StablePluginEvent::tool_call(
tag,
StbString::from_string(tool_call_id.clone()),
StbString::from_string(tool_name.clone()),
StbString::from_string(serde_json::to_string(args).unwrap_or_else(|_| "null".into())),
)),
AgentEvent::ToolExecutionEnd {
tool_call_id,
tool_name,
result,
is_error,
} => {
let result_json = agent_tool_result_to_json(result);
Some(StablePluginEvent::tool_result(
tag,
StbString::from_string(tool_call_id.clone()),
StbString::from_string(tool_name.clone()),
StbString::from_string(result_json),
*is_error,
))
}
AgentEvent::AgentStart
| AgentEvent::TurnStart
| AgentEvent::AgentEnd { .. }
| AgentEvent::TurnEnd { .. } => Some(StablePluginEvent::empty(tag)),
}
}
fn agent_tool_result_to_json(result: &rpi_agent::types::AgentToolResult) -> String {
let mut txt = String::new();
txt.push('{');
txt.push_str("\"content\":[");
for (i, c) in result.content.iter().enumerate() {
if i > 0 {
txt.push(',');
}
match c {
rpi_agent::types::TextContentOrImage::Text(t) => {
txt.push_str(
&serde_json::to_string(&serde_json::json!({ "type": "text", "text": t.text }))
.unwrap_or_else(|_| "\"\"".into()),
);
}
rpi_agent::types::TextContentOrImage::Image(img) => {
txt.push_str(
&serde_json::to_string(&serde_json::json!({
"type": "image",
"data": img.data,
"mimeType": img.mime_type,
}))
.unwrap_or_else(|_| "\"\"".into()),
);
}
}
}
txt.push(']');
txt.push_str(",\"details\":");
txt.push_str(&serde_json::to_string(&result.details).unwrap_or_else(|_| "null".into()));
txt.push_str(",\"terminate\":");
txt.push_str(if result.terminate { "true" } else { "false" });
txt.push_str(",\"addedToolNames\":");
txt.push_str(&serde_json::to_string(&result.added_tool_names).unwrap_or_else(|_| "[]".into()));
txt.push('}');
txt
}
#[allow(dead_code)]
fn free_event_strings(_event: &StablePluginEvent) {
}
pub struct ExtensionEmitter {
snapshot: Arc<RegistrySnapshot>,
#[allow(dead_code)]
keepalive: Arc<crate::PluginKeepalive>,
}
impl ExtensionEmitter {
pub fn new(snapshot: Arc<RegistrySnapshot>, keepalive: Arc<crate::PluginKeepalive>) -> Self {
Self {
snapshot,
keepalive,
}
}
fn dispatch(&self, event: &StablePluginEvent) {
dispatch_to_handlers(&self.snapshot, event);
}
}
pub fn dispatch_to_handlers(snapshot: &RegistrySnapshot, event: &StablePluginEvent) {
if !crate::registry::assert_active(snapshot.active_flag()) {
return;
}
let handlers = snapshot.handlers_for(event.tag);
if handlers.is_empty() {
free_dispatched_event(event);
return;
}
for h in handlers {
let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(*event, h.user_data)));
match outcome {
Ok(rc) if rc != 0 => {
tracing::warn!(tag = ?event.tag, rc, "extension event handler returned nonzero");
}
Ok(_) => {}
Err(_) => {
tracing::error!(tag = ?event.tag, "extension event handler panicked — skipped");
}
}
}
free_dispatched_event(event);
}
pub fn dispatch_data_event(snapshot: &RegistrySnapshot, tag: EventTag, data: &str) -> bool {
let handlers = snapshot.handlers_for(tag);
if handlers.is_empty() {
return false;
}
let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
dispatch_to_handlers(snapshot, &event);
true
}
impl AgentEmitter for ExtensionEmitter {
fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
if let Some(stable) = translate(&event) {
self.dispatch(&stable);
}
Box::pin(async {})
}
fn try_emit(&self, event: AgentEvent) {
if let Some(stable) = translate(&event) {
self.dispatch(&stable);
}
}
}
pub struct TeeEmitter {
emitters: Vec<Arc<dyn AgentEmitter>>,
}
impl TeeEmitter {
pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
Self { emitters }
}
}
impl AgentEmitter for TeeEmitter {
fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
let emitters = self.emitters.clone();
Box::pin(async move {
for e in &emitters {
e.emit(event.clone()).await;
}
})
}
fn try_emit(&self, event: AgentEvent) {
for e in &self.emitters {
e.try_emit(event.clone());
}
}
}
fn free_dispatched_event(event: &StablePluginEvent) {
use rpi_plugin_sdk::EventTag as T;
match event.tag {
T::MessageStart | T::MessageUpdate | T::MessageEnd => {
unsafe { host_free_string(event.payload.message.message) };
}
T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
unsafe {
let tc = &event.payload.tool_call;
host_free_string(tc.tool_call_id);
host_free_string(tc.tool_name);
host_free_string(tc.params);
}
}
T::ToolResult | T::ToolExecutionEnd => {
unsafe {
let tr = &event.payload.tool_result;
host_free_string(tr.tool_call_id);
host_free_string(tr.tool_name);
host_free_string(tr.result);
}
}
T::ProjectTrust
| T::ResourcesDiscover
| T::SessionStart
| T::SessionInfoChanged
| T::SessionBeforeSwitch
| T::SessionBeforeFork
| T::SessionBeforeCompact
| T::SessionCompact
| T::SessionShutdown
| T::SessionBeforeTree
| T::SessionTree
| T::Context
| T::BeforeAgentStart
| T::AgentStart
| T::AgentEnd
| T::AgentSettled
| T::TurnStart
| T::TurnEnd
| T::ModelSelect
| T::ThinkingLevelSelect
| T::UserBash
| T::Input => {
}
T::BeforeProviderRequest | T::BeforeProviderHeaders | T::AfterProviderResponse => {
unsafe { host_free_string(event.payload.data.data) };
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rpi_agent::message::AgentMessage;
use rpi_agent::types::AgentToolResult;
use rpi_ai::types::{AssistantMessage, Usage};
use rpi_plugin_sdk::EventTag;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn all_ten_agent_events_map_to_a_tag() {
let am = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("hi")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
let events = vec![
AgentEvent::AgentStart,
AgentEvent::AgentEnd { messages: vec![] },
AgentEvent::TurnStart,
AgentEvent::TurnEnd {
message: am.clone(),
tool_results: vec![],
},
AgentEvent::MessageStart {
message: am.clone(),
},
AgentEvent::MessageUpdate {
message: am.clone(),
assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
},
},
AgentEvent::MessageEnd { message: am },
AgentEvent::ToolExecutionStart {
tool_call_id: "c1".into(),
tool_name: "echo".into(),
args: serde_json::json!({}),
},
AgentEvent::ToolExecutionUpdate {
tool_call_id: "c1".into(),
tool_name: "echo".into(),
args: serde_json::json!({}),
partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
},
AgentEvent::ToolExecutionEnd {
tool_call_id: "c1".into(),
tool_name: "echo".into(),
result: AgentToolResult::text("done"),
is_error: false,
},
];
for e in &events {
assert!(
event_tag_for(e).is_some(),
"event {:?} should map",
e.type_tag()
);
}
assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
assert_eq!(
event_tag_for(&events[7]),
Some(EventTag::ToolExecutionStart)
);
}
#[test]
fn translate_message_end_produces_message_payload() {
let am = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("hi")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
let ev = AgentEvent::MessageEnd { message: am };
let stable = translate(&ev).expect("maps");
assert_eq!(stable.tag, EventTag::MessageEnd);
unsafe { host_free_string(stable.payload.message.message) };
}
static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
0
}
#[test]
fn emitter_dispatches_to_registered_handlers() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
HANDLER_HITS.store(0, Ordering::SeqCst);
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(EventTag::MessageEnd, counting_handler, std::ptr::null_mut());
let snap = Arc::new(reg.snapshot());
let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
let am = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("hi")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
emitter.try_emit(AgentEvent::MessageEnd { message: am });
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
reg.invalidate();
let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("hi")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
assert_eq!(
HANDLER_HITS.load(Ordering::SeqCst),
1,
"stale registry must not dispatch"
);
}
#[tokio::test]
async fn tee_emitter_fans_out_to_every_child() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
use rpi_agent::events::{AgentEmitter, CollectorEmitter};
let (collector_a, events_a) = CollectorEmitter::new();
let (collector_b, events_b) = CollectorEmitter::new();
HANDLER_HITS.store(0, Ordering::SeqCst);
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(EventTag::MessageEnd, counting_handler, std::ptr::null_mut());
let snap = Arc::new(reg.snapshot());
let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
let tee = TeeEmitter::new(vec![
Arc::new(collector_a),
Arc::new(collector_b),
Arc::new(ext),
]);
let am = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("hi")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
tee.emit(AgentEvent::MessageEnd { message: am }).await;
assert_eq!(
events_a.lock().unwrap().len(),
1,
"collector A got the event"
);
assert_eq!(
events_b.lock().unwrap().len(),
1,
"collector B got the event"
);
assert_eq!(
HANDLER_HITS.load(Ordering::SeqCst),
1,
"plugin handler fired once"
);
}
}