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::RetryScheduled { .. } => None,
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::MessageEnd { message } => {
let stb = message_to_stb(message);
Some(StablePluginEvent::message(tag, stb))
}
AgentEvent::MessageUpdate {
assistant_message_event,
..
} => {
let payload = assistant_message_event_to_json(assistant_message_event);
Some(StablePluginEvent::message(
tag,
StbString::from_string(payload),
))
}
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)),
AgentEvent::RetryScheduled { .. } => None,
}
}
fn assistant_message_event_to_json(event: &rpi_ai::types::AssistantMessageEvent) -> String {
use rpi_ai::types::AssistantMessageEvent as E;
let value = match event {
E::TextStart { content_index, .. } => serde_json::json!({
"type": "text_start",
"contentIndex": content_index,
}),
E::TextDelta {
content_index,
delta,
..
} => serde_json::json!({
"type": "text_delta",
"contentIndex": content_index,
"delta": delta,
}),
E::TextEnd {
content_index,
content,
..
} => serde_json::json!({
"type": "text_end",
"contentIndex": content_index,
"content": content,
}),
E::ThinkingStart { content_index, .. } => serde_json::json!({
"type": "thinking_start",
"contentIndex": content_index,
}),
E::ThinkingDelta {
content_index,
delta,
..
} => serde_json::json!({
"type": "thinking_delta",
"contentIndex": content_index,
"delta": delta,
}),
E::ThinkingEnd {
content_index,
content,
..
} => serde_json::json!({
"type": "thinking_end",
"contentIndex": content_index,
"content": content,
}),
E::ToolCallStart { content_index, .. } => serde_json::json!({
"type": "toolcall_start",
"contentIndex": content_index,
}),
E::ToolCallDelta {
content_index,
delta,
..
} => serde_json::json!({
"type": "toolcall_delta",
"contentIndex": content_index,
"delta": delta,
}),
E::ToolCallEnd {
content_index,
tool_call,
..
} => serde_json::json!({
"type": "toolcall_end",
"contentIndex": content_index,
"toolCall": tool_call,
}),
E::Start { .. } | E::Done { .. } | E::Error { .. } => {
serde_json::json!({ "type": event_type_name(event) })
}
};
serde_json::json!({ "assistantMessageEvent": value }).to_string()
}
fn event_type_name(event: &rpi_ai::types::AssistantMessageEvent) -> &'static str {
use rpi_ai::types::AssistantMessageEvent as E;
match event {
E::Start { .. } => "start",
E::TextStart { .. } => "text_start",
E::TextDelta { .. } => "text_delta",
E::TextEnd { .. } => "text_end",
E::ThinkingStart { .. } => "thinking_start",
E::ThinkingDelta { .. } => "thinking_delta",
E::ThinkingEnd { .. } => "thinking_end",
E::ToolCallStart { .. } => "toolcall_start",
E::ToolCallDelta { .. } => "toolcall_delta",
E::ToolCallEnd { .. } => "toolcall_end",
E::Done { .. } => "done",
E::Error { .. } => "error",
}
}
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 {
if !crate::registry::platform_allows(&h.platforms) {
crate::event_log::log_handler_invocation(
event.tag,
&h.plugin,
"PlatformSkip",
0,
Some(format!("host={}", crate::registry::current_platform())),
);
continue;
}
let started = std::time::Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(*event, h.user_data)));
let duration_ms = started.elapsed().as_millis() as u64;
match outcome {
Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => {
crate::event_log::log_handler_invocation(
event.tag,
&h.plugin,
"Claimed",
duration_ms,
None,
);
}
Ok(rc) if rc != 0 => {
tracing::warn!(tag = ?event.tag, rc, "extension event handler returned nonzero");
crate::event_log::log_handler_invocation(
event.tag,
&h.plugin,
"Error",
duration_ms,
Some(format!("rc={rc}")),
);
}
Ok(_) => {
crate::event_log::log_handler_invocation(
event.tag,
&h.plugin,
"Continue",
duration_ms,
None,
);
}
Err(_) => {
tracing::error!(tag = ?event.tag, "extension event handler panicked — skipped");
crate::event_log::log_handler_invocation(
event.tag,
&h.plugin,
"Panic",
duration_ms,
None,
);
}
}
}
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
}
pub fn dispatch_empty_event(snapshot: &RegistrySnapshot, tag: EventTag) -> bool {
let handlers = snapshot.handlers_for(tag);
if handlers.is_empty() {
return false;
}
let event = StablePluginEvent::empty(tag);
dispatch_to_handlers(snapshot, &event);
true
}
pub const TOOL_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
pub async fn dispatch_tool_call_event(
session: &crate::ExtensionSession,
tool_call_id: &str,
tool_name: &str,
params: &serde_json::Value,
signal: &tokio_util::sync::CancellationToken,
ui_dialog: &crate::UiDialogMailbox,
) -> Option<String> {
dispatch_tool_call_event_with_timeout(
session,
tool_call_id,
tool_name,
params,
signal,
ui_dialog,
TOOL_CALL_TIMEOUT,
)
.await
}
async fn dispatch_tool_call_event_with_timeout(
session: &crate::ExtensionSession,
tool_call_id: &str,
tool_name: &str,
params: &serde_json::Value,
signal: &tokio_util::sync::CancellationToken,
ui_dialog: &crate::UiDialogMailbox,
timeout: std::time::Duration,
) -> Option<String> {
let snapshot = session.snapshot()?;
if !crate::registry::assert_active(snapshot.active_flag()) {
return Some(format!(
"tool `{tool_name}` approval session is stale; retry after reload"
));
}
for h in snapshot.handlers_for(EventTag::ToolCall).iter().cloned() {
if !crate::registry::platform_allows(&h.platforms) {
continue;
}
if signal.is_cancelled() {
ui_dialog.cancel_tool_call(tool_call_id);
return Some(format!("tool `{tool_name}` approval cancelled"));
}
if !crate::registry::assert_active(snapshot.active_flag()) {
return Some(format!(
"tool `{tool_name}` approval session is stale; retry after reload"
));
}
let plugin_name = h.plugin.clone();
let id = tool_call_id.to_string();
let name = tool_name.to_string();
let payload = params.to_string();
let keepalive = session.keepalive();
let started = std::time::Instant::now();
let mut task = tokio::task::spawn_blocking(move || {
let _keepalive = keepalive;
call_tool_call_handler(h, id, name, payload)
});
let outcome = tokio::select! {
biased;
_ = signal.cancelled() => {
task.abort(); ui_dialog.cancel_tool_call(tool_call_id);
crate::event_log::log_handler_invocation(
EventTag::ToolCall, &plugin_name, "Cancelled",
started.elapsed().as_millis() as u64, None,
);
return Some(format!("tool `{tool_name}` approval cancelled"));
}
outcome = tokio::time::timeout(timeout, &mut task) => outcome,
};
let duration_ms = started.elapsed().as_millis() as u64;
let (status, error) = match outcome {
Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_CONTINUE => ("Continue", None),
Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => (
"Abort",
Some(format!(
"extension `{plugin_name}` blocked tool `{tool_name}`"
)),
),
Ok(Ok(Ok(rc))) => (
"Error",
Some(format!(
"extension `{plugin_name}` tool `{tool_name}` approval failed: rc={rc}"
)),
),
Ok(Ok(Err(_))) => (
"Panic",
Some(format!(
"extension `{plugin_name}` tool `{tool_name}` approval panicked"
)),
),
Ok(Err(error)) => (
"JoinFailed",
Some(format!(
"extension `{plugin_name}` tool `{tool_name}` approval task failed: {error}"
)),
),
Err(_) => {
task.abort();
(
"Timeout",
Some(format!(
"extension `{plugin_name}` tool `{tool_name}` approval timed out"
)),
)
}
};
crate::event_log::log_handler_invocation(
EventTag::ToolCall,
&plugin_name,
status,
duration_ms,
error.clone(),
);
if let Some(reason) = error {
ui_dialog.cancel_tool_call(tool_call_id);
return Some(reason);
}
if !crate::registry::assert_active(snapshot.active_flag()) {
ui_dialog.cancel_tool_call(tool_call_id);
return Some(format!(
"tool `{tool_name}` approval session is stale; retry after reload"
));
}
}
None
}
pub fn dispatch_data_event_claiming(
snapshot: &RegistrySnapshot,
tag: EventTag,
data: &str,
) -> bool {
if !crate::registry::assert_active(snapshot.active_flag()) {
return false;
}
let handlers = snapshot.handlers_for(tag);
if handlers.is_empty() {
return false;
}
let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
let mut claimed = false;
for h in handlers {
if !crate::registry::platform_allows(&h.platforms) {
continue;
}
let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
match outcome {
Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => claimed = true,
Ok(rc) if rc != 0 => {
tracing::warn!(tag = ?tag, rc, "extension event handler returned nonzero");
}
Err(_) => {
tracing::error!(tag = ?tag, "extension event handler panicked — skipped");
}
Ok(_) => {}
}
}
free_dispatched_event(&event);
claimed
}
pub fn lifecycle_timeout_for(tag: EventTag) -> std::time::Duration {
use rpi_plugin_sdk::EventTag as T;
match tag {
T::BeforeTuiStart => std::time::Duration::from_secs(5),
T::SessionShutdown => std::time::Duration::from_secs(15),
_ => std::time::Duration::from_secs(10),
}
}
fn call_lifecycle_handler(
h: crate::registry::RegisteredHandler,
tag: EventTag,
) -> std::thread::Result<i32> {
let event = StablePluginEvent::empty(tag);
catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)))
}
fn call_tool_call_handler(
h: crate::registry::RegisteredHandler,
tool_call_id: String,
tool_name: String,
params: String,
) -> std::thread::Result<i32> {
let event = StablePluginEvent::tool_call(
EventTag::ToolCall,
StbString::from_string(tool_call_id),
StbString::from_string(tool_name),
StbString::from_string(params),
);
let rc = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
free_dispatched_event(&event);
rc
}
pub async fn dispatch_lifecycle_event(
snapshot: &RegistrySnapshot,
tag: EventTag,
) -> Option<String> {
if !crate::registry::assert_active(snapshot.active_flag()) {
return None;
}
let handlers = snapshot.handlers_for(tag).to_vec();
if handlers.is_empty() {
return None;
}
let timeout = lifecycle_timeout_for(tag);
for h in handlers {
if !crate::registry::platform_allows(&h.platforms) {
crate::event_log::log_handler_invocation(
tag,
&h.plugin,
"PlatformSkip",
0,
Some(format!("host={}", crate::registry::current_platform())),
);
continue;
}
let plugin_name = h.plugin.clone();
let h_task = h;
let tag_task = tag;
let started = std::time::Instant::now();
let outcome = tokio::time::timeout(
timeout,
tokio::task::spawn_blocking(move || call_lifecycle_handler(h_task, tag_task)),
)
.await;
let duration_ms = started.elapsed().as_millis() as u64;
match outcome {
Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => {
tracing::warn!(
tag = ?tag,
plugin = %plugin_name,
"extension handler vetoed lifecycle event"
);
crate::event_log::log_handler_invocation(
tag,
&plugin_name,
"Abort",
duration_ms,
None,
);
return Some(format!("extension `{plugin_name}` vetoed {tag:?}"));
}
Ok(Ok(Ok(rc))) => {
let result = if rc == 0 { "Continue" } else { "Error" };
crate::event_log::log_handler_invocation(
tag,
&plugin_name,
result,
duration_ms,
if rc == 0 {
None
} else {
Some(format!("rc={rc}"))
},
);
}
Ok(Ok(Err(_))) => {
tracing::error!(tag = ?tag, "extension handler panicked — skipped");
crate::event_log::log_handler_invocation(
tag,
&plugin_name,
"Panic",
duration_ms,
None,
);
}
Ok(Err(join_err)) => {
tracing::error!(
tag = ?tag,
error = %join_err,
"extension handler task join failed — skipped"
);
crate::event_log::log_handler_invocation(
tag,
&plugin_name,
"JoinFailed",
duration_ms,
Some(join_err.to_string()),
);
}
Err(_elapsed) => {
tracing::warn!(
tag = ?tag,
timeout_ms = timeout.as_millis(),
"extension handler timed out — skipped (still running on its blocking thread)"
);
crate::event_log::log_handler_invocation(
tag,
&plugin_name,
"Timeout",
duration_ms,
Some(format!("budget_ms={}", timeout.as_millis())),
);
}
}
}
None
}
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::BeforeTuiStart
| T::UiPromptStart
| T::UiPromptEnd => {
}
T::BeforeProviderRequest
| T::BeforeProviderHeaders
| T::AfterProviderResponse
| T::Input
| T::EditorChange => {
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, AssistantMessageEvent, 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: std::sync::Arc::new((*am.as_assistant().unwrap()).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)
);
assert!(event_tag_for(&AgentEvent::RetryScheduled {
attempt: 1,
max_retries: 10,
delay_ms: 2_000,
error: "503".into(),
})
.is_none());
}
#[test]
fn translate_message_update_forwards_native_text_deltas_without_snapshot() {
let partial = Arc::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("你好世界")],
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::Pending,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
});
let translate_delta = |delta: &str| {
let event = AgentEvent::MessageUpdate {
message: Arc::clone(&partial),
assistant_message_event: AssistantMessageEvent::TextDelta {
content_index: 0,
delta: delta.to_string(),
partial: Arc::clone(&partial),
},
};
let stable = translate(&event).expect("message_update maps");
let payload = unsafe { stable.payload.message.message.to_string_lossy() };
unsafe { host_free_string(stable.payload.message.message) };
serde_json::from_str::<serde_json::Value>(&payload).expect("valid payload")
};
let first = translate_delta("你好");
let second = translate_delta("世界");
assert_eq!(first["assistantMessageEvent"]["type"], "text_delta");
assert_eq!(first["assistantMessageEvent"]["contentIndex"], 0);
assert_eq!(first["assistantMessageEvent"]["delta"], "你好");
assert_eq!(second["assistantMessageEvent"]["delta"], "世界");
assert!(first.get("message").is_none());
assert!(first["assistantMessageEvent"].get("partial").is_none());
}
#[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(
"test-plugin".to_string(),
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(
"test-plugin".to_string(),
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"
);
}
#[test]
fn dispatch_empty_event_fans_out_to_subscribers() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
HANDLER_HITS.store(0, Ordering::SeqCst);
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"test-plugin".to_string(),
EventTag::SessionStart,
counting_handler,
std::ptr::null_mut(),
);
reg.register_event_handler(
"test-plugin".to_string(),
EventTag::BeforeTuiStart,
counting_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
assert!(!dispatch_empty_event(&snap, EventTag::SessionShutdown));
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 0);
assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
reg.invalidate();
assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
}
extern "C" fn aborting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
rpi_plugin_sdk::EVENT_HANDLER_ABORT
}
extern "C" fn continue_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
}
extern "C" fn error_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
rpi_plugin_sdk::EVENT_HANDLER_ERROR
}
#[test]
fn lifecycle_timeout_budget_matches_spec() {
assert_eq!(
lifecycle_timeout_for(EventTag::BeforeTuiStart),
std::time::Duration::from_secs(5)
);
assert_eq!(
lifecycle_timeout_for(EventTag::SessionShutdown),
std::time::Duration::from_secs(15)
);
assert_eq!(
lifecycle_timeout_for(EventTag::SessionStart),
std::time::Duration::from_secs(10)
);
}
#[tokio::test]
async fn dispatch_lifecycle_event_honors_veto() {
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"veto-ext".to_string(),
EventTag::BeforeTuiStart,
aborting_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
let result = dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart).await;
assert_eq!(
result.as_deref(),
Some("extension `veto-ext` vetoed BeforeTuiStart")
);
}
#[tokio::test]
async fn dispatch_lifecycle_event_continue_and_error_do_not_veto() {
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"ok-ext".to_string(),
EventTag::SessionStart,
continue_handler,
std::ptr::null_mut(),
);
reg.register_event_handler(
"err-ext".to_string(),
EventTag::SessionStart,
error_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
assert!(dispatch_lifecycle_event(&snap, EventTag::SessionStart)
.await
.is_none());
}
fn tool_call_session(reg: &crate::ExtensionRegistry) -> crate::ExtensionSession {
crate::ExtensionSession::from_parts(
Arc::new(reg.snapshot()),
crate::PluginKeepalive::empty(),
Vec::new(),
None,
)
}
#[tokio::test]
async fn tool_call_approval_error_and_stale_session_block_execution() {
let mut reg = crate::ExtensionRegistry::new();
reg.register_event_handler(
"broken-gate".into(),
EventTag::ToolCall,
error_handler,
std::ptr::null_mut(),
);
let session = tool_call_session(®);
let signal = tokio_util::sync::CancellationToken::new();
let ui = crate::UiDialogMailbox::new();
let reason =
dispatch_tool_call_event(&session, "c1", "bash", &serde_json::json!({}), &signal, &ui)
.await
.unwrap();
assert!(reason.contains("approval failed: rc="), "{reason}");
reg.invalidate();
let reason =
dispatch_tool_call_event(&session, "c2", "bash", &serde_json::json!({}), &signal, &ui)
.await
.unwrap();
assert!(reason.contains("stale"), "{reason}");
}
struct WaitingApproval {
ui: crate::UiDialogMailbox,
entered: tokio::sync::Notify,
finished: tokio::sync::Notify,
release: std::sync::atomic::AtomicBool,
}
extern "C" fn waiting_approval(event: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
let state = unsafe { Arc::from_raw(data as *const WaitingApproval) };
let call_id = unsafe { event.payload.tool_call.tool_call_id.to_string_lossy() };
state
.ui
.handle(serde_json::json!({
"op": "open", "requestId": "approval", "toolCallId": call_id,
"ui": {"question": "Allow?"},
}))
.unwrap();
state.entered.notify_one();
loop {
if state.release.load(Ordering::SeqCst) {
break;
}
if state.ui.poll("approval").unwrap()["status"] == "cancelled" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
while !state.release.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(2));
}
state.finished.notify_one();
rpi_plugin_sdk::EVENT_HANDLER_ABORT
}
async fn check_interrupted_approval(cancel: bool) {
let ui = crate::UiDialogMailbox::new();
ui.attach();
ui.handle(serde_json::json!({
"requestId": "other", "toolCallId": "other-call", "ui": {},
}))
.unwrap();
let state = Arc::new(WaitingApproval {
ui: ui.clone(),
entered: tokio::sync::Notify::new(),
finished: tokio::sync::Notify::new(),
release: std::sync::atomic::AtomicBool::new(false),
});
struct ReleaseWorker(Arc<WaitingApproval>);
impl Drop for ReleaseWorker {
fn drop(&mut self) {
self.0.release.store(true, Ordering::SeqCst);
}
}
let _release = ReleaseWorker(state.clone());
let mut reg = crate::ExtensionRegistry::new();
reg.register_event_handler(
"waiting-gate".into(),
EventTag::ToolCall,
waiting_approval,
Arc::into_raw(state.clone()) as *mut std::ffi::c_void,
);
let keepalive = crate::PluginKeepalive::empty();
let weak = Arc::downgrade(&keepalive);
let session = crate::ExtensionSession::from_parts(
Arc::new(reg.snapshot()),
keepalive,
Vec::new(),
None,
);
let signal = tokio_util::sync::CancellationToken::new();
let task_signal = signal.clone();
let task_ui = ui.clone();
let task = tokio::spawn(async move {
dispatch_tool_call_event_with_timeout(
&session,
"call",
"bash",
&serde_json::json!({}),
&task_signal,
&task_ui,
if cancel {
TOOL_CALL_TIMEOUT
} else {
std::time::Duration::from_millis(200)
},
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), state.entered.notified())
.await
.unwrap();
if cancel {
signal.cancel();
}
let reason = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(
reason.contains(if cancel { "cancelled" } else { "timed out" }),
"{reason}"
);
assert!(
weak.upgrade().is_some(),
"running callback must keep its library mapped"
);
assert_eq!(ui.poll("other").unwrap()["status"], "pending");
state.release.store(true, Ordering::SeqCst);
tokio::time::timeout(std::time::Duration::from_secs(2), state.finished.notified())
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while weak.upgrade().is_some() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
#[tokio::test]
async fn tool_call_approval_cancel_releases_waiter_and_retains_running_library() {
check_interrupted_approval(true).await;
}
#[tokio::test]
async fn tool_call_approval_timeout_blocks_and_retains_running_library() {
check_interrupted_approval(false).await;
}
#[tokio::test]
async fn dispatch_tool_call_event_honors_veto() {
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"gate-ext".to_string(),
EventTag::ToolCall,
aborting_handler,
std::ptr::null_mut(),
);
let session = tool_call_session(®);
let veto = dispatch_tool_call_event(
&session,
"call_1",
"bash",
&serde_json::json!({"command": "rm -rf /"}),
&tokio_util::sync::CancellationToken::new(),
&crate::UiDialogMailbox::new(),
)
.await;
assert_eq!(
veto.as_deref(),
Some("extension `gate-ext` blocked tool `bash`")
);
}
#[tokio::test]
async fn dispatch_tool_call_event_continue_and_empty_do_not_veto() {
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"ok-ext".to_string(),
EventTag::ToolCall,
continue_handler,
std::ptr::null_mut(),
);
let session = tool_call_session(®);
assert!(dispatch_tool_call_event(
&session,
"call_1",
"read",
&serde_json::json!({"path": "x"}),
&tokio_util::sync::CancellationToken::new(),
&crate::UiDialogMailbox::new(),
)
.await
.is_none());
let empty = crate::registry::ExtensionRegistry::new();
let empty_session = tool_call_session(&empty);
assert!(dispatch_tool_call_event(
&empty_session,
"call_2",
"read",
&serde_json::json!({}),
&tokio_util::sync::CancellationToken::new(),
&crate::UiDialogMailbox::new()
)
.await
.is_none());
}
#[tokio::test]
async fn dispatch_lifecycle_event_no_subscribers_is_none() {
let reg = crate::registry::ExtensionRegistry::new();
let snap = Arc::new(reg.snapshot());
assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
.await
.is_none());
}
#[tokio::test]
async fn dispatch_lifecycle_event_stale_is_none() {
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"veto-ext".to_string(),
EventTag::BeforeTuiStart,
aborting_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
reg.invalidate();
assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
.await
.is_none());
}
#[test]
fn dispatch_records_handler_invocation_to_event_log() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
HANDLER_HITS.store(0, Ordering::SeqCst);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("events.jsonl");
crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::open(
path.clone(),
));
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"log-ext".to_string(),
EventTag::MessageEnd,
counting_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
let event = StablePluginEvent::message(
EventTag::MessageEnd,
StbString::from_string("m".to_string()),
);
dispatch_to_handlers(&snap, &event);
crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::disabled());
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(r#""event":"MessageEnd""#), "log: {text}");
assert!(text.contains(r#""plugin":"log-ext""#), "log: {text}");
assert!(text.contains(r#""result":"Continue""#), "log: {text}");
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
}
static CLAIM_ROUTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
extern "C" fn claiming_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
let seen = unsafe { ev.payload.data.data.to_string_lossy() };
assert!(seen.contains("space"), "payload not delivered: {seen}");
if CLAIM_ROUTING.load(Ordering::SeqCst) {
rpi_plugin_sdk::EVENT_HANDLER_CLAIMED
} else {
rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
}
}
fn input_snapshot(with_handler: bool) -> Arc<crate::registry::RegistrySnapshot> {
let mut reg = crate::registry::ExtensionRegistry::new();
if with_handler {
reg.register_event_handler(
"voice".to_string(),
EventTag::Input,
claiming_handler,
std::ptr::null_mut(),
);
}
Arc::new(reg.snapshot())
}
#[test]
fn claiming_dispatch_reports_claim_and_passthrough() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
let snap = input_snapshot(true);
CLAIM_ROUTING.store(true, Ordering::SeqCst);
assert!(
dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
"a CLAIMED handler must swallow the key"
);
CLAIM_ROUTING.store(false, Ordering::SeqCst);
assert!(
!dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
"a CONTINUE handler must let the key reach the editor"
);
CLAIM_ROUTING.store(true, Ordering::SeqCst);
}
#[test]
fn claiming_dispatch_without_subscribers_is_false() {
let snap = input_snapshot(false);
assert!(!dispatch_data_event_claiming(
&snap,
EventTag::Input,
r#"{"key":"space"}"#
));
}
#[test]
fn editor_change_reaches_subscribers_with_its_payload() {
let _guard = HANDLER_TEST_LOCK.lock().unwrap();
let mut reg = crate::registry::ExtensionRegistry::new();
reg.register_event_handler(
"voice".to_string(),
EventTag::EditorChange,
editor_change_handler,
std::ptr::null_mut(),
);
let snap = Arc::new(reg.snapshot());
EDITOR_CHANGE_SEEN.store(false, Ordering::SeqCst);
assert!(
dispatch_data_event(
&snap,
EventTag::EditorChange,
r#"{"chars":4,"empty":false}"#
),
"a subscribed handler must be invoked"
);
assert!(
EDITOR_CHANGE_SEEN.load(Ordering::SeqCst),
"payload missing or handler not called"
);
}
static EDITOR_CHANGE_SEEN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
extern "C" fn editor_change_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
let seen = unsafe { ev.payload.data.data.to_string_lossy() };
if seen.contains("chars") && seen.contains("empty") {
EDITOR_CHANGE_SEEN.store(true, Ordering::SeqCst);
}
rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
}
}