1use std::panic::{catch_unwind, AssertUnwindSafe};
13use std::sync::Arc;
14
15use futures::future::BoxFuture;
16use rpi_agent::events::{AgentEmitter, AgentEvent};
17use rpi_plugin_sdk::{EventTag, StablePluginEvent, StbString};
18
19use crate::host_free_string;
20use crate::registry::RegistrySnapshot;
21
22pub fn event_tag_for(event: &AgentEvent) -> Option<EventTag> {
28 match event {
29 AgentEvent::AgentStart => Some(EventTag::AgentStart),
30 AgentEvent::AgentEnd { .. } => Some(EventTag::AgentEnd),
31 AgentEvent::RetryScheduled { .. } => None,
32 AgentEvent::TurnStart => Some(EventTag::TurnStart),
33 AgentEvent::TurnEnd { .. } => Some(EventTag::TurnEnd),
34 AgentEvent::MessageStart { .. } => Some(EventTag::MessageStart),
35 AgentEvent::MessageUpdate { .. } => Some(EventTag::MessageUpdate),
36 AgentEvent::MessageEnd { .. } => Some(EventTag::MessageEnd),
37 AgentEvent::ToolExecutionStart { .. } => Some(EventTag::ToolExecutionStart),
38 AgentEvent::ToolExecutionUpdate { .. } => Some(EventTag::ToolExecutionUpdate),
39 AgentEvent::ToolExecutionEnd { .. } => Some(EventTag::ToolExecutionEnd),
40 }
41}
42
43fn message_to_stb(message: &rpi_agent::message::AgentMessage) -> StbString {
47 let text = serde_json::to_string(message).unwrap_or_else(|_| "null".to_string());
49 StbString::from_string(text)
50}
51
52pub fn translate(event: &AgentEvent) -> Option<StablePluginEvent> {
57 let tag = event_tag_for(event)?;
58 match event {
59 AgentEvent::MessageStart { message } | AgentEvent::MessageEnd { message } => {
60 let stb = message_to_stb(message);
61 Some(StablePluginEvent::message(tag, stb))
62 }
63 AgentEvent::MessageUpdate {
64 assistant_message_event,
65 ..
66 } => {
67 let payload = assistant_message_event_to_json(assistant_message_event);
71 Some(StablePluginEvent::message(
72 tag,
73 StbString::from_string(payload),
74 ))
75 }
76
77 AgentEvent::ToolExecutionStart {
78 tool_call_id,
79 tool_name,
80 args,
81 }
82 | AgentEvent::ToolExecutionUpdate {
83 tool_call_id,
84 tool_name,
85 args,
86 ..
87 } => Some(StablePluginEvent::tool_call(
88 tag,
89 StbString::from_string(tool_call_id.clone()),
90 StbString::from_string(tool_name.clone()),
91 StbString::from_string(serde_json::to_string(args).unwrap_or_else(|_| "null".into())),
92 )),
93
94 AgentEvent::ToolExecutionEnd {
95 tool_call_id,
96 tool_name,
97 result,
98 is_error,
99 } => {
100 let result_json = agent_tool_result_to_json(result);
102 Some(StablePluginEvent::tool_result(
103 tag,
104 StbString::from_string(tool_call_id.clone()),
105 StbString::from_string(tool_name.clone()),
106 StbString::from_string(result_json),
107 *is_error,
108 ))
109 }
110
111 AgentEvent::AgentStart
113 | AgentEvent::TurnStart
114 | AgentEvent::AgentEnd { .. }
115 | AgentEvent::TurnEnd { .. } => Some(StablePluginEvent::empty(tag)),
116
117 AgentEvent::RetryScheduled { .. } => None,
118 }
119}
120
121fn assistant_message_event_to_json(event: &rpi_ai::types::AssistantMessageEvent) -> String {
127 use rpi_ai::types::AssistantMessageEvent as E;
128
129 let value = match event {
130 E::TextStart { content_index, .. } => serde_json::json!({
131 "type": "text_start",
132 "contentIndex": content_index,
133 }),
134 E::TextDelta {
135 content_index,
136 delta,
137 ..
138 } => serde_json::json!({
139 "type": "text_delta",
140 "contentIndex": content_index,
141 "delta": delta,
142 }),
143 E::TextEnd {
144 content_index,
145 content,
146 ..
147 } => serde_json::json!({
148 "type": "text_end",
149 "contentIndex": content_index,
150 "content": content,
151 }),
152 E::ThinkingStart { content_index, .. } => serde_json::json!({
153 "type": "thinking_start",
154 "contentIndex": content_index,
155 }),
156 E::ThinkingDelta {
157 content_index,
158 delta,
159 ..
160 } => serde_json::json!({
161 "type": "thinking_delta",
162 "contentIndex": content_index,
163 "delta": delta,
164 }),
165 E::ThinkingEnd {
166 content_index,
167 content,
168 ..
169 } => serde_json::json!({
170 "type": "thinking_end",
171 "contentIndex": content_index,
172 "content": content,
173 }),
174 E::ToolCallStart { content_index, .. } => serde_json::json!({
175 "type": "toolcall_start",
176 "contentIndex": content_index,
177 }),
178 E::ToolCallDelta {
179 content_index,
180 delta,
181 ..
182 } => serde_json::json!({
183 "type": "toolcall_delta",
184 "contentIndex": content_index,
185 "delta": delta,
186 }),
187 E::ToolCallEnd {
188 content_index,
189 tool_call,
190 ..
191 } => serde_json::json!({
192 "type": "toolcall_end",
193 "contentIndex": content_index,
194 "toolCall": tool_call,
195 }),
196 E::Start { .. } | E::Done { .. } | E::Error { .. } => {
197 serde_json::json!({ "type": event_type_name(event) })
198 }
199 };
200
201 serde_json::json!({ "assistantMessageEvent": value }).to_string()
202}
203
204fn event_type_name(event: &rpi_ai::types::AssistantMessageEvent) -> &'static str {
205 use rpi_ai::types::AssistantMessageEvent as E;
206 match event {
207 E::Start { .. } => "start",
208 E::TextStart { .. } => "text_start",
209 E::TextDelta { .. } => "text_delta",
210 E::TextEnd { .. } => "text_end",
211 E::ThinkingStart { .. } => "thinking_start",
212 E::ThinkingDelta { .. } => "thinking_delta",
213 E::ThinkingEnd { .. } => "thinking_end",
214 E::ToolCallStart { .. } => "toolcall_start",
215 E::ToolCallDelta { .. } => "toolcall_delta",
216 E::ToolCallEnd { .. } => "toolcall_end",
217 E::Done { .. } => "done",
218 E::Error { .. } => "error",
219 }
220}
221
222fn agent_tool_result_to_json(result: &rpi_agent::types::AgentToolResult) -> String {
227 let mut txt = String::new();
228 txt.push('{');
229 txt.push_str("\"content\":[");
230 for (i, c) in result.content.iter().enumerate() {
231 if i > 0 {
232 txt.push(',');
233 }
234 match c {
235 rpi_agent::types::TextContentOrImage::Text(t) => {
236 txt.push_str(
237 &serde_json::to_string(&serde_json::json!({ "type": "text", "text": t.text }))
238 .unwrap_or_else(|_| "\"\"".into()),
239 );
240 }
241 rpi_agent::types::TextContentOrImage::Image(img) => {
242 txt.push_str(
243 &serde_json::to_string(&serde_json::json!({
244 "type": "image",
245 "data": img.data,
246 "mimeType": img.mime_type,
247 }))
248 .unwrap_or_else(|_| "\"\"".into()),
249 );
250 }
251 }
252 }
253 txt.push(']');
254 txt.push_str(",\"details\":");
255 txt.push_str(&serde_json::to_string(&result.details).unwrap_or_else(|_| "null".into()));
256 txt.push_str(",\"terminate\":");
257 txt.push_str(if result.terminate { "true" } else { "false" });
258 txt.push_str(",\"addedToolNames\":");
259 txt.push_str(&serde_json::to_string(&result.added_tool_names).unwrap_or_else(|_| "[]".into()));
260 txt.push('}');
261 txt
262}
263
264#[allow(dead_code)]
281fn free_event_strings(_event: &StablePluginEvent) {
282 }
284
285pub struct ExtensionEmitter {
307 snapshot: Arc<RegistrySnapshot>,
308 #[allow(dead_code)]
313 keepalive: Arc<crate::PluginKeepalive>,
314}
315
316impl ExtensionEmitter {
317 pub fn new(snapshot: Arc<RegistrySnapshot>, keepalive: Arc<crate::PluginKeepalive>) -> Self {
320 Self {
321 snapshot,
322 keepalive,
323 }
324 }
325
326 fn dispatch(&self, event: &StablePluginEvent) {
331 dispatch_to_handlers(&self.snapshot, event);
332 }
333}
334
335pub fn dispatch_to_handlers(snapshot: &RegistrySnapshot, event: &StablePluginEvent) {
340 if !crate::registry::assert_active(snapshot.active_flag()) {
342 return;
343 }
344 let handlers = snapshot.handlers_for(event.tag);
345 if handlers.is_empty() {
346 free_dispatched_event(event);
349 return;
350 }
351 for h in handlers {
352 if !crate::registry::platform_allows(&h.platforms) {
354 crate::event_log::log_handler_invocation(
355 event.tag,
356 &h.plugin,
357 "PlatformSkip",
358 0,
359 Some(format!("host={}", crate::registry::current_platform())),
360 );
361 continue;
362 }
363 let started = std::time::Instant::now();
366 let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(*event, h.user_data)));
367 let duration_ms = started.elapsed().as_millis() as u64;
368 match outcome {
369 Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => {
373 crate::event_log::log_handler_invocation(
374 event.tag,
375 &h.plugin,
376 "Claimed",
377 duration_ms,
378 None,
379 );
380 }
381 Ok(rc) if rc != 0 => {
382 tracing::warn!(tag = ?event.tag, rc, "extension event handler returned nonzero");
383 crate::event_log::log_handler_invocation(
384 event.tag,
385 &h.plugin,
386 "Error",
387 duration_ms,
388 Some(format!("rc={rc}")),
389 );
390 }
391 Ok(_) => {
392 crate::event_log::log_handler_invocation(
393 event.tag,
394 &h.plugin,
395 "Continue",
396 duration_ms,
397 None,
398 );
399 }
400 Err(_) => {
401 tracing::error!(tag = ?event.tag, "extension event handler panicked — skipped");
402 crate::event_log::log_handler_invocation(
403 event.tag,
404 &h.plugin,
405 "Panic",
406 duration_ms,
407 None,
408 );
409 }
410 }
411 }
412 free_dispatched_event(event);
414}
415
416pub fn dispatch_data_event(snapshot: &RegistrySnapshot, tag: EventTag, data: &str) -> bool {
421 let handlers = snapshot.handlers_for(tag);
422 if handlers.is_empty() {
423 return false;
424 }
425 let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
426 dispatch_to_handlers(snapshot, &event);
427 true
428}
429
430pub fn dispatch_empty_event(snapshot: &RegistrySnapshot, tag: EventTag) -> bool {
436 let handlers = snapshot.handlers_for(tag);
437 if handlers.is_empty() {
438 return false;
439 }
440 let event = StablePluginEvent::empty(tag);
441 dispatch_to_handlers(snapshot, &event);
442 true
443}
444
445pub fn dispatch_data_event_claiming(
458 snapshot: &RegistrySnapshot,
459 tag: EventTag,
460 data: &str,
461) -> bool {
462 if !crate::registry::assert_active(snapshot.active_flag()) {
463 return false;
464 }
465 let handlers = snapshot.handlers_for(tag);
466 if handlers.is_empty() {
467 return false;
468 }
469 let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
470 let mut claimed = false;
471 for h in handlers {
472 if !crate::registry::platform_allows(&h.platforms) {
473 continue;
474 }
475 let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
478 match outcome {
479 Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => claimed = true,
480 Ok(rc) if rc != 0 => {
481 tracing::warn!(tag = ?tag, rc, "extension event handler returned nonzero");
482 }
483 Err(_) => {
484 tracing::error!(tag = ?tag, "extension event handler panicked — skipped");
485 }
486 Ok(_) => {}
487 }
488 }
489 free_dispatched_event(&event);
490 claimed
491}
492
493pub fn lifecycle_timeout_for(tag: EventTag) -> std::time::Duration {
498 use rpi_plugin_sdk::EventTag as T;
499 match tag {
500 T::BeforeTuiStart => std::time::Duration::from_secs(5),
501 T::SessionShutdown => std::time::Duration::from_secs(15),
502 _ => std::time::Duration::from_secs(10),
503 }
504}
505
506fn call_lifecycle_handler(
515 h: crate::registry::RegisteredHandler,
516 tag: EventTag,
517) -> std::thread::Result<i32> {
518 let event = StablePluginEvent::empty(tag);
519 catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)))
522}
523
524pub async fn dispatch_lifecycle_event(
540 snapshot: &RegistrySnapshot,
541 tag: EventTag,
542) -> Option<String> {
543 if !crate::registry::assert_active(snapshot.active_flag()) {
545 return None;
546 }
547 let handlers = snapshot.handlers_for(tag).to_vec();
548 if handlers.is_empty() {
549 return None;
550 }
551 let timeout = lifecycle_timeout_for(tag);
555 for h in handlers {
556 if !crate::registry::platform_allows(&h.platforms) {
558 crate::event_log::log_handler_invocation(
559 tag,
560 &h.plugin,
561 "PlatformSkip",
562 0,
563 Some(format!("host={}", crate::registry::current_platform())),
564 );
565 continue;
566 }
567 let plugin_name = h.plugin.clone();
570 let h_task = h;
571 let tag_task = tag;
572 let started = std::time::Instant::now();
573 let outcome = tokio::time::timeout(
574 timeout,
575 tokio::task::spawn_blocking(move || call_lifecycle_handler(h_task, tag_task)),
576 )
577 .await;
578 let duration_ms = started.elapsed().as_millis() as u64;
579 match outcome {
580 Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => {
581 tracing::warn!(
582 tag = ?tag,
583 plugin = %plugin_name,
584 "extension handler vetoed lifecycle event"
585 );
586 crate::event_log::log_handler_invocation(
587 tag,
588 &plugin_name,
589 "Abort",
590 duration_ms,
591 None,
592 );
593 return Some(format!("extension `{plugin_name}` vetoed {tag:?}"));
594 }
595 Ok(Ok(Ok(rc))) => {
596 let result = if rc == 0 { "Continue" } else { "Error" };
597 crate::event_log::log_handler_invocation(
598 tag,
599 &plugin_name,
600 result,
601 duration_ms,
602 if rc == 0 {
603 None
604 } else {
605 Some(format!("rc={rc}"))
606 },
607 );
608 }
609 Ok(Ok(Err(_))) => {
610 tracing::error!(tag = ?tag, "extension handler panicked — skipped");
611 crate::event_log::log_handler_invocation(
612 tag,
613 &plugin_name,
614 "Panic",
615 duration_ms,
616 None,
617 );
618 }
619 Ok(Err(join_err)) => {
620 tracing::error!(
621 tag = ?tag,
622 error = %join_err,
623 "extension handler task join failed — skipped"
624 );
625 crate::event_log::log_handler_invocation(
626 tag,
627 &plugin_name,
628 "JoinFailed",
629 duration_ms,
630 Some(join_err.to_string()),
631 );
632 }
633 Err(_elapsed) => {
634 tracing::warn!(
635 tag = ?tag,
636 timeout_ms = timeout.as_millis(),
637 "extension handler timed out — skipped (still running on its blocking thread)"
638 );
639 crate::event_log::log_handler_invocation(
640 tag,
641 &plugin_name,
642 "Timeout",
643 duration_ms,
644 Some(format!("budget_ms={}", timeout.as_millis())),
645 );
646 }
647 }
648 }
649 None
650}
651
652impl AgentEmitter for ExtensionEmitter {
653 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
654 if let Some(stable) = translate(&event) {
658 self.dispatch(&stable);
659 }
660 Box::pin(async {})
661 }
662
663 fn try_emit(&self, event: AgentEvent) {
664 if let Some(stable) = translate(&event) {
665 self.dispatch(&stable);
666 }
667 }
668}
669
670pub struct TeeEmitter {
689 emitters: Vec<Arc<dyn AgentEmitter>>,
690}
691
692impl TeeEmitter {
693 pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
698 Self { emitters }
699 }
700}
701
702impl AgentEmitter for TeeEmitter {
703 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
704 let emitters = self.emitters.clone();
710 Box::pin(async move {
711 for e in &emitters {
712 e.emit(event.clone()).await;
713 }
714 })
715 }
716
717 fn try_emit(&self, event: AgentEvent) {
718 for e in &self.emitters {
719 e.try_emit(event.clone());
720 }
721 }
722}
723
724fn free_dispatched_event(event: &StablePluginEvent) {
728 use rpi_plugin_sdk::EventTag as T;
729 match event.tag {
730 T::MessageStart | T::MessageUpdate | T::MessageEnd => {
731 unsafe { host_free_string(event.payload.message.message) };
733 }
734 T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
735 unsafe {
737 let tc = &event.payload.tool_call;
738 host_free_string(tc.tool_call_id);
739 host_free_string(tc.tool_name);
740 host_free_string(tc.params);
741 }
742 }
743 T::ToolResult | T::ToolExecutionEnd => {
744 unsafe {
746 let tr = &event.payload.tool_result;
747 host_free_string(tr.tool_call_id);
748 host_free_string(tr.tool_name);
749 host_free_string(tr.result);
750 }
751 }
752 T::ProjectTrust
753 | T::ResourcesDiscover
754 | T::SessionStart
755 | T::SessionInfoChanged
756 | T::SessionBeforeSwitch
757 | T::SessionBeforeFork
758 | T::SessionBeforeCompact
759 | T::SessionCompact
760 | T::SessionShutdown
761 | T::SessionBeforeTree
762 | T::SessionTree
763 | T::Context
764 | T::BeforeAgentStart
765 | T::AgentStart
766 | T::AgentEnd
767 | T::AgentSettled
768 | T::TurnStart
769 | T::TurnEnd
770 | T::ModelSelect
771 | T::ThinkingLevelSelect
772 | T::UserBash
773 | T::BeforeTuiStart
774 | T::UiPromptStart
775 | T::UiPromptEnd => {
776 }
778 T::BeforeProviderRequest
782 | T::BeforeProviderHeaders
783 | T::AfterProviderResponse
784 | T::Input
785 | T::EditorChange => {
786 unsafe { host_free_string(event.payload.data.data) };
788 }
789 }
790}
791
792#[cfg(test)]
800mod tests {
801 use super::*;
802 use rpi_agent::message::AgentMessage;
803 use rpi_agent::types::AgentToolResult;
804 use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, Usage};
805 use rpi_plugin_sdk::EventTag;
806 use std::sync::atomic::{AtomicUsize, Ordering};
807 use std::sync::Mutex;
808
809 static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
817
818 #[test]
819 fn all_ten_agent_events_map_to_a_tag() {
820 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
821 role: rpi_ai::types::AssistantRole,
822 content: vec![rpi_ai::types::Content::text("hi")],
823 api: rpi_ai::types::Api::AnthropicMessages,
824 provider: "anthropic".to_string(),
825 model: "m".into(),
826 response_model: None,
827 response_id: None,
828 usage: Usage::zero(),
829 stop_reason: rpi_ai::types::StopReason::Stop,
830 deferred: None,
831 error_message: None,
832 raw_stop_reason: None,
833 end_turn: None,
834 timestamp: 0,
835 }));
836 let events = vec![
837 AgentEvent::AgentStart,
838 AgentEvent::AgentEnd { messages: vec![] },
839 AgentEvent::TurnStart,
840 AgentEvent::TurnEnd {
841 message: am.clone(),
842 tool_results: vec![],
843 },
844 AgentEvent::MessageStart {
845 message: am.clone(),
846 },
847 AgentEvent::MessageUpdate {
848 message: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
851 assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
852 partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
853 },
854 },
855 AgentEvent::MessageEnd { message: am },
856 AgentEvent::ToolExecutionStart {
857 tool_call_id: "c1".into(),
858 tool_name: "echo".into(),
859 args: serde_json::json!({}),
860 },
861 AgentEvent::ToolExecutionUpdate {
862 tool_call_id: "c1".into(),
863 tool_name: "echo".into(),
864 args: serde_json::json!({}),
865 partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
866 },
867 AgentEvent::ToolExecutionEnd {
868 tool_call_id: "c1".into(),
869 tool_name: "echo".into(),
870 result: AgentToolResult::text("done"),
871 is_error: false,
872 },
873 ];
874 for e in &events {
875 assert!(
876 event_tag_for(e).is_some(),
877 "event {:?} should map",
878 e.type_tag()
879 );
880 }
881 assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
883 assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
884 assert_eq!(
885 event_tag_for(&events[7]),
886 Some(EventTag::ToolExecutionStart)
887 );
888 assert!(event_tag_for(&AgentEvent::RetryScheduled {
889 attempt: 1,
890 max_retries: 10,
891 delay_ms: 2_000,
892 error: "503".into(),
893 })
894 .is_none());
895 }
896
897 #[test]
898 fn translate_message_update_forwards_native_text_deltas_without_snapshot() {
899 let partial = Arc::new(AssistantMessage {
900 role: rpi_ai::types::AssistantRole,
901 content: vec![rpi_ai::types::Content::text("你好世界")],
902 api: rpi_ai::types::Api::AnthropicMessages,
903 provider: "anthropic".to_string(),
904 model: "m".into(),
905 response_model: None,
906 response_id: None,
907 usage: Usage::zero(),
908 stop_reason: rpi_ai::types::StopReason::Pending,
909 deferred: None,
910 error_message: None,
911 raw_stop_reason: None,
912 end_turn: None,
913 timestamp: 0,
914 });
915 let translate_delta = |delta: &str| {
916 let event = AgentEvent::MessageUpdate {
917 message: Arc::clone(&partial),
918 assistant_message_event: AssistantMessageEvent::TextDelta {
919 content_index: 0,
920 delta: delta.to_string(),
921 partial: Arc::clone(&partial),
922 },
923 };
924 let stable = translate(&event).expect("message_update maps");
925 let payload = unsafe { stable.payload.message.message.to_string_lossy() };
926 unsafe { host_free_string(stable.payload.message.message) };
927 serde_json::from_str::<serde_json::Value>(&payload).expect("valid payload")
928 };
929
930 let first = translate_delta("你好");
931 let second = translate_delta("世界");
932 assert_eq!(first["assistantMessageEvent"]["type"], "text_delta");
933 assert_eq!(first["assistantMessageEvent"]["contentIndex"], 0);
934 assert_eq!(first["assistantMessageEvent"]["delta"], "你好");
935 assert_eq!(second["assistantMessageEvent"]["delta"], "世界");
936 assert!(first.get("message").is_none());
937 assert!(first["assistantMessageEvent"].get("partial").is_none());
938 }
939
940 #[test]
941 fn translate_message_end_produces_message_payload() {
942 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
943 role: rpi_ai::types::AssistantRole,
944 content: vec![rpi_ai::types::Content::text("hi")],
945 api: rpi_ai::types::Api::AnthropicMessages,
946 provider: "anthropic".to_string(),
947 model: "m".into(),
948 response_model: None,
949 response_id: None,
950 usage: Usage::zero(),
951 stop_reason: rpi_ai::types::StopReason::Stop,
952 deferred: None,
953 error_message: None,
954 raw_stop_reason: None,
955 end_turn: None,
956 timestamp: 0,
957 }));
958 let ev = AgentEvent::MessageEnd { message: am };
959 let stable = translate(&ev).expect("maps");
960 assert_eq!(stable.tag, EventTag::MessageEnd);
961 unsafe { host_free_string(stable.payload.message.message) };
964 }
965
966 static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
969
970 extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
971 HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
972 0
973 }
974
975 #[test]
976 fn emitter_dispatches_to_registered_handlers() {
977 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
978 HANDLER_HITS.store(0, Ordering::SeqCst);
979 let mut reg = crate::registry::ExtensionRegistry::new();
980 reg.register_event_handler(
981 "test-plugin".to_string(),
982 EventTag::MessageEnd,
983 counting_handler,
984 std::ptr::null_mut(),
985 );
986 let snap = Arc::new(reg.snapshot());
987 let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
988
989 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
990 role: rpi_ai::types::AssistantRole,
991 content: vec![rpi_ai::types::Content::text("hi")],
992 api: rpi_ai::types::Api::AnthropicMessages,
993 provider: "anthropic".to_string(),
994 model: "m".into(),
995 response_model: None,
996 response_id: None,
997 usage: Usage::zero(),
998 stop_reason: rpi_ai::types::StopReason::Stop,
999 deferred: None,
1000 error_message: None,
1001 raw_stop_reason: None,
1002 end_turn: None,
1003 timestamp: 0,
1004 }));
1005 emitter.try_emit(AgentEvent::MessageEnd { message: am });
1007 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1008
1009 reg.invalidate();
1011 let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
1012 role: rpi_ai::types::AssistantRole,
1013 content: vec![rpi_ai::types::Content::text("hi")],
1014 api: rpi_ai::types::Api::AnthropicMessages,
1015 provider: "anthropic".to_string(),
1016 model: "m".into(),
1017 response_model: None,
1018 response_id: None,
1019 usage: Usage::zero(),
1020 stop_reason: rpi_ai::types::StopReason::Stop,
1021 deferred: None,
1022 error_message: None,
1023 raw_stop_reason: None,
1024 end_turn: None,
1025 timestamp: 0,
1026 }));
1027 emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
1028 assert_eq!(
1029 HANDLER_HITS.load(Ordering::SeqCst),
1030 1,
1031 "stale registry must not dispatch"
1032 );
1033 }
1034
1035 #[tokio::test]
1038 async fn tee_emitter_fans_out_to_every_child() {
1039 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1040 use rpi_agent::events::{AgentEmitter, CollectorEmitter};
1041
1042 let (collector_a, events_a) = CollectorEmitter::new();
1044 let (collector_b, events_b) = CollectorEmitter::new();
1045 HANDLER_HITS.store(0, Ordering::SeqCst);
1046 let mut reg = crate::registry::ExtensionRegistry::new();
1047 reg.register_event_handler(
1048 "test-plugin".to_string(),
1049 EventTag::MessageEnd,
1050 counting_handler,
1051 std::ptr::null_mut(),
1052 );
1053 let snap = Arc::new(reg.snapshot());
1054 let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1055
1056 let tee = TeeEmitter::new(vec![
1057 Arc::new(collector_a),
1058 Arc::new(collector_b),
1059 Arc::new(ext),
1060 ]);
1061
1062 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1063 role: rpi_ai::types::AssistantRole,
1064 content: vec![rpi_ai::types::Content::text("hi")],
1065 api: rpi_ai::types::Api::AnthropicMessages,
1066 provider: "anthropic".to_string(),
1067 model: "m".into(),
1068 response_model: None,
1069 response_id: None,
1070 usage: Usage::zero(),
1071 stop_reason: rpi_ai::types::StopReason::Stop,
1072 deferred: None,
1073 error_message: None,
1074 raw_stop_reason: None,
1075 end_turn: None,
1076 timestamp: 0,
1077 }));
1078 tee.emit(AgentEvent::MessageEnd { message: am }).await;
1081 assert_eq!(
1082 events_a.lock().unwrap().len(),
1083 1,
1084 "collector A got the event"
1085 );
1086 assert_eq!(
1087 events_b.lock().unwrap().len(),
1088 1,
1089 "collector B got the event"
1090 );
1091 assert_eq!(
1092 HANDLER_HITS.load(Ordering::SeqCst),
1093 1,
1094 "plugin handler fired once"
1095 );
1096 }
1097
1098 #[test]
1104 fn dispatch_empty_event_fans_out_to_subscribers() {
1105 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1106 HANDLER_HITS.store(0, Ordering::SeqCst);
1107 let mut reg = crate::registry::ExtensionRegistry::new();
1108 reg.register_event_handler(
1109 "test-plugin".to_string(),
1110 EventTag::SessionStart,
1111 counting_handler,
1112 std::ptr::null_mut(),
1113 );
1114 reg.register_event_handler(
1115 "test-plugin".to_string(),
1116 EventTag::BeforeTuiStart,
1117 counting_handler,
1118 std::ptr::null_mut(),
1119 );
1120 let snap = Arc::new(reg.snapshot());
1121
1122 assert!(!dispatch_empty_event(&snap, EventTag::SessionShutdown));
1124 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 0);
1125
1126 assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1128 assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1129 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1130
1131 reg.invalidate();
1135 assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1136 assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1137 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1138 }
1139
1140 extern "C" fn aborting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1144 rpi_plugin_sdk::EVENT_HANDLER_ABORT
1145 }
1146
1147 extern "C" fn continue_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1149 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1150 }
1151
1152 extern "C" fn error_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1154 rpi_plugin_sdk::EVENT_HANDLER_ERROR
1155 }
1156
1157 #[test]
1158 fn lifecycle_timeout_budget_matches_spec() {
1159 assert_eq!(
1160 lifecycle_timeout_for(EventTag::BeforeTuiStart),
1161 std::time::Duration::from_secs(5)
1162 );
1163 assert_eq!(
1164 lifecycle_timeout_for(EventTag::SessionShutdown),
1165 std::time::Duration::from_secs(15)
1166 );
1167 assert_eq!(
1168 lifecycle_timeout_for(EventTag::SessionStart),
1169 std::time::Duration::from_secs(10)
1170 );
1171 }
1172
1173 #[tokio::test]
1176 async fn dispatch_lifecycle_event_honors_veto() {
1177 let mut reg = crate::registry::ExtensionRegistry::new();
1178 reg.register_event_handler(
1179 "veto-ext".to_string(),
1180 EventTag::BeforeTuiStart,
1181 aborting_handler,
1182 std::ptr::null_mut(),
1183 );
1184 let snap = Arc::new(reg.snapshot());
1185 let result = dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart).await;
1186 assert_eq!(
1187 result.as_deref(),
1188 Some("extension `veto-ext` vetoed BeforeTuiStart")
1189 );
1190 }
1191
1192 #[tokio::test]
1195 async fn dispatch_lifecycle_event_continue_and_error_do_not_veto() {
1196 let mut reg = crate::registry::ExtensionRegistry::new();
1197 reg.register_event_handler(
1198 "ok-ext".to_string(),
1199 EventTag::SessionStart,
1200 continue_handler,
1201 std::ptr::null_mut(),
1202 );
1203 reg.register_event_handler(
1204 "err-ext".to_string(),
1205 EventTag::SessionStart,
1206 error_handler,
1207 std::ptr::null_mut(),
1208 );
1209 let snap = Arc::new(reg.snapshot());
1210 assert!(dispatch_lifecycle_event(&snap, EventTag::SessionStart)
1211 .await
1212 .is_none());
1213 }
1214
1215 #[tokio::test]
1217 async fn dispatch_lifecycle_event_no_subscribers_is_none() {
1218 let reg = crate::registry::ExtensionRegistry::new();
1219 let snap = Arc::new(reg.snapshot());
1220 assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1221 .await
1222 .is_none());
1223 }
1224
1225 #[tokio::test]
1228 async fn dispatch_lifecycle_event_stale_is_none() {
1229 let mut reg = crate::registry::ExtensionRegistry::new();
1230 reg.register_event_handler(
1231 "veto-ext".to_string(),
1232 EventTag::BeforeTuiStart,
1233 aborting_handler,
1234 std::ptr::null_mut(),
1235 );
1236 let snap = Arc::new(reg.snapshot());
1237 reg.invalidate();
1238 assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1239 .await
1240 .is_none());
1241 }
1242
1243 #[test]
1246 fn dispatch_records_handler_invocation_to_event_log() {
1247 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1248 HANDLER_HITS.store(0, Ordering::SeqCst);
1249 let dir = tempfile::tempdir().unwrap();
1250 let path = dir.path().join("events.jsonl");
1251 crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::open(
1252 path.clone(),
1253 ));
1254
1255 let mut reg = crate::registry::ExtensionRegistry::new();
1256 reg.register_event_handler(
1257 "log-ext".to_string(),
1258 EventTag::MessageEnd,
1259 counting_handler,
1260 std::ptr::null_mut(),
1261 );
1262 let snap = Arc::new(reg.snapshot());
1263 let event = StablePluginEvent::message(
1264 EventTag::MessageEnd,
1265 StbString::from_string("m".to_string()),
1266 );
1267 dispatch_to_handlers(&snap, &event);
1268
1269 crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::disabled());
1272
1273 let text = std::fs::read_to_string(&path).unwrap();
1274 assert!(text.contains(r#""event":"MessageEnd""#), "log: {text}");
1275 assert!(text.contains(r#""plugin":"log-ext""#), "log: {text}");
1276 assert!(text.contains(r#""result":"Continue""#), "log: {text}");
1277 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1278 }
1279
1280 static CLAIM_ROUTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1285
1286 extern "C" fn claiming_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1287 let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1290 assert!(seen.contains("space"), "payload not delivered: {seen}");
1291 if CLAIM_ROUTING.load(Ordering::SeqCst) {
1292 rpi_plugin_sdk::EVENT_HANDLER_CLAIMED
1293 } else {
1294 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1295 }
1296 }
1297
1298 fn input_snapshot(with_handler: bool) -> Arc<crate::registry::RegistrySnapshot> {
1299 let mut reg = crate::registry::ExtensionRegistry::new();
1300 if with_handler {
1301 reg.register_event_handler(
1302 "voice".to_string(),
1303 EventTag::Input,
1304 claiming_handler,
1305 std::ptr::null_mut(),
1306 );
1307 }
1308 Arc::new(reg.snapshot())
1309 }
1310
1311 #[test]
1312 fn claiming_dispatch_reports_claim_and_passthrough() {
1313 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1314 let snap = input_snapshot(true);
1315
1316 CLAIM_ROUTING.store(true, Ordering::SeqCst);
1317 assert!(
1318 dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1319 "a CLAIMED handler must swallow the key"
1320 );
1321
1322 CLAIM_ROUTING.store(false, Ordering::SeqCst);
1323 assert!(
1324 !dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1325 "a CONTINUE handler must let the key reach the editor"
1326 );
1327 CLAIM_ROUTING.store(true, Ordering::SeqCst);
1328 }
1329
1330 #[test]
1331 fn claiming_dispatch_without_subscribers_is_false() {
1332 let snap = input_snapshot(false);
1333 assert!(!dispatch_data_event_claiming(
1334 &snap,
1335 EventTag::Input,
1336 r#"{"key":"space"}"#
1337 ));
1338 }
1339
1340 #[test]
1346 fn editor_change_reaches_subscribers_with_its_payload() {
1347 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1348 let mut reg = crate::registry::ExtensionRegistry::new();
1349 reg.register_event_handler(
1350 "voice".to_string(),
1351 EventTag::EditorChange,
1352 editor_change_handler,
1353 std::ptr::null_mut(),
1354 );
1355 let snap = Arc::new(reg.snapshot());
1356
1357 EDITOR_CHANGE_SEEN.store(false, Ordering::SeqCst);
1358 assert!(
1359 dispatch_data_event(
1360 &snap,
1361 EventTag::EditorChange,
1362 r#"{"chars":4,"empty":false}"#
1363 ),
1364 "a subscribed handler must be invoked"
1365 );
1366 assert!(
1367 EDITOR_CHANGE_SEEN.load(Ordering::SeqCst),
1368 "payload missing or handler not called"
1369 );
1370 }
1371
1372 static EDITOR_CHANGE_SEEN: std::sync::atomic::AtomicBool =
1375 std::sync::atomic::AtomicBool::new(false);
1376
1377 extern "C" fn editor_change_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1378 let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1381 if seen.contains("chars") && seen.contains("empty") {
1382 EDITOR_CHANGE_SEEN.store(true, Ordering::SeqCst);
1383 }
1384 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1385 }
1386}