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 const TOOL_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
448
449pub async fn dispatch_tool_call_event(
454 session: &crate::ExtensionSession,
455 tool_call_id: &str,
456 tool_name: &str,
457 params: &serde_json::Value,
458 signal: &tokio_util::sync::CancellationToken,
459 ui_dialog: &crate::UiDialogMailbox,
460) -> Option<String> {
461 dispatch_tool_call_event_with_timeout(
462 session,
463 tool_call_id,
464 tool_name,
465 params,
466 signal,
467 ui_dialog,
468 TOOL_CALL_TIMEOUT,
469 )
470 .await
471}
472
473async fn dispatch_tool_call_event_with_timeout(
474 session: &crate::ExtensionSession,
475 tool_call_id: &str,
476 tool_name: &str,
477 params: &serde_json::Value,
478 signal: &tokio_util::sync::CancellationToken,
479 ui_dialog: &crate::UiDialogMailbox,
480 timeout: std::time::Duration,
481) -> Option<String> {
482 let snapshot = session.snapshot()?;
483 if !crate::registry::assert_active(snapshot.active_flag()) {
484 return Some(format!(
485 "tool `{tool_name}` approval session is stale; retry after reload"
486 ));
487 }
488 for h in snapshot.handlers_for(EventTag::ToolCall).iter().cloned() {
489 if !crate::registry::platform_allows(&h.platforms) {
490 continue;
491 }
492 if signal.is_cancelled() {
493 ui_dialog.cancel_tool_call(tool_call_id);
494 return Some(format!("tool `{tool_name}` approval cancelled"));
495 }
496 if !crate::registry::assert_active(snapshot.active_flag()) {
497 return Some(format!(
498 "tool `{tool_name}` approval session is stale; retry after reload"
499 ));
500 }
501 let plugin_name = h.plugin.clone();
502 let id = tool_call_id.to_string();
503 let name = tool_name.to_string();
504 let payload = params.to_string();
505 let keepalive = session.keepalive();
506 let started = std::time::Instant::now();
507 let mut task = tokio::task::spawn_blocking(move || {
510 let _keepalive = keepalive;
511 call_tool_call_handler(h, id, name, payload)
512 });
513 let outcome = tokio::select! {
514 biased;
515 _ = signal.cancelled() => {
516 task.abort(); ui_dialog.cancel_tool_call(tool_call_id);
518 crate::event_log::log_handler_invocation(
519 EventTag::ToolCall, &plugin_name, "Cancelled",
520 started.elapsed().as_millis() as u64, None,
521 );
522 return Some(format!("tool `{tool_name}` approval cancelled"));
523 }
524 outcome = tokio::time::timeout(timeout, &mut task) => outcome,
525 };
526 let duration_ms = started.elapsed().as_millis() as u64;
527 let (status, error) = match outcome {
528 Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_CONTINUE => ("Continue", None),
529 Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => (
530 "Abort",
531 Some(format!(
532 "extension `{plugin_name}` blocked tool `{tool_name}`"
533 )),
534 ),
535 Ok(Ok(Ok(rc))) => (
536 "Error",
537 Some(format!(
538 "extension `{plugin_name}` tool `{tool_name}` approval failed: rc={rc}"
539 )),
540 ),
541 Ok(Ok(Err(_))) => (
542 "Panic",
543 Some(format!(
544 "extension `{plugin_name}` tool `{tool_name}` approval panicked"
545 )),
546 ),
547 Ok(Err(error)) => (
548 "JoinFailed",
549 Some(format!(
550 "extension `{plugin_name}` tool `{tool_name}` approval task failed: {error}"
551 )),
552 ),
553 Err(_) => {
554 task.abort();
555 (
556 "Timeout",
557 Some(format!(
558 "extension `{plugin_name}` tool `{tool_name}` approval timed out"
559 )),
560 )
561 }
562 };
563 crate::event_log::log_handler_invocation(
564 EventTag::ToolCall,
565 &plugin_name,
566 status,
567 duration_ms,
568 error.clone(),
569 );
570 if let Some(reason) = error {
571 ui_dialog.cancel_tool_call(tool_call_id);
572 return Some(reason);
573 }
574 if !crate::registry::assert_active(snapshot.active_flag()) {
576 ui_dialog.cancel_tool_call(tool_call_id);
577 return Some(format!(
578 "tool `{tool_name}` approval session is stale; retry after reload"
579 ));
580 }
581 }
582 None
583}
584
585pub fn dispatch_data_event_claiming(
598 snapshot: &RegistrySnapshot,
599 tag: EventTag,
600 data: &str,
601) -> bool {
602 if !crate::registry::assert_active(snapshot.active_flag()) {
603 return false;
604 }
605 let handlers = snapshot.handlers_for(tag);
606 if handlers.is_empty() {
607 return false;
608 }
609 let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
610 let mut claimed = false;
611 for h in handlers {
612 if !crate::registry::platform_allows(&h.platforms) {
613 continue;
614 }
615 let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
618 match outcome {
619 Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => claimed = true,
620 Ok(rc) if rc != 0 => {
621 tracing::warn!(tag = ?tag, rc, "extension event handler returned nonzero");
622 }
623 Err(_) => {
624 tracing::error!(tag = ?tag, "extension event handler panicked — skipped");
625 }
626 Ok(_) => {}
627 }
628 }
629 free_dispatched_event(&event);
630 claimed
631}
632
633pub fn lifecycle_timeout_for(tag: EventTag) -> std::time::Duration {
638 use rpi_plugin_sdk::EventTag as T;
639 match tag {
640 T::BeforeTuiStart => std::time::Duration::from_secs(5),
641 T::SessionShutdown => std::time::Duration::from_secs(15),
642 _ => std::time::Duration::from_secs(10),
643 }
644}
645
646fn call_lifecycle_handler(
655 h: crate::registry::RegisteredHandler,
656 tag: EventTag,
657) -> std::thread::Result<i32> {
658 let event = StablePluginEvent::empty(tag);
659 catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)))
662}
663
664fn call_tool_call_handler(
670 h: crate::registry::RegisteredHandler,
671 tool_call_id: String,
672 tool_name: String,
673 params: String,
674) -> std::thread::Result<i32> {
675 let event = StablePluginEvent::tool_call(
676 EventTag::ToolCall,
677 StbString::from_string(tool_call_id),
678 StbString::from_string(tool_name),
679 StbString::from_string(params),
680 );
681 let rc = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
684 free_dispatched_event(&event);
685 rc
686}
687
688pub async fn dispatch_lifecycle_event(
704 snapshot: &RegistrySnapshot,
705 tag: EventTag,
706) -> Option<String> {
707 if !crate::registry::assert_active(snapshot.active_flag()) {
709 return None;
710 }
711 let handlers = snapshot.handlers_for(tag).to_vec();
712 if handlers.is_empty() {
713 return None;
714 }
715 let timeout = lifecycle_timeout_for(tag);
719 for h in handlers {
720 if !crate::registry::platform_allows(&h.platforms) {
722 crate::event_log::log_handler_invocation(
723 tag,
724 &h.plugin,
725 "PlatformSkip",
726 0,
727 Some(format!("host={}", crate::registry::current_platform())),
728 );
729 continue;
730 }
731 let plugin_name = h.plugin.clone();
734 let h_task = h;
735 let tag_task = tag;
736 let started = std::time::Instant::now();
737 let outcome = tokio::time::timeout(
738 timeout,
739 tokio::task::spawn_blocking(move || call_lifecycle_handler(h_task, tag_task)),
740 )
741 .await;
742 let duration_ms = started.elapsed().as_millis() as u64;
743 match outcome {
744 Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => {
745 tracing::warn!(
746 tag = ?tag,
747 plugin = %plugin_name,
748 "extension handler vetoed lifecycle event"
749 );
750 crate::event_log::log_handler_invocation(
751 tag,
752 &plugin_name,
753 "Abort",
754 duration_ms,
755 None,
756 );
757 return Some(format!("extension `{plugin_name}` vetoed {tag:?}"));
758 }
759 Ok(Ok(Ok(rc))) => {
760 let result = if rc == 0 { "Continue" } else { "Error" };
761 crate::event_log::log_handler_invocation(
762 tag,
763 &plugin_name,
764 result,
765 duration_ms,
766 if rc == 0 {
767 None
768 } else {
769 Some(format!("rc={rc}"))
770 },
771 );
772 }
773 Ok(Ok(Err(_))) => {
774 tracing::error!(tag = ?tag, "extension handler panicked — skipped");
775 crate::event_log::log_handler_invocation(
776 tag,
777 &plugin_name,
778 "Panic",
779 duration_ms,
780 None,
781 );
782 }
783 Ok(Err(join_err)) => {
784 tracing::error!(
785 tag = ?tag,
786 error = %join_err,
787 "extension handler task join failed — skipped"
788 );
789 crate::event_log::log_handler_invocation(
790 tag,
791 &plugin_name,
792 "JoinFailed",
793 duration_ms,
794 Some(join_err.to_string()),
795 );
796 }
797 Err(_elapsed) => {
798 tracing::warn!(
799 tag = ?tag,
800 timeout_ms = timeout.as_millis(),
801 "extension handler timed out — skipped (still running on its blocking thread)"
802 );
803 crate::event_log::log_handler_invocation(
804 tag,
805 &plugin_name,
806 "Timeout",
807 duration_ms,
808 Some(format!("budget_ms={}", timeout.as_millis())),
809 );
810 }
811 }
812 }
813 None
814}
815
816impl AgentEmitter for ExtensionEmitter {
817 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
818 if let Some(stable) = translate(&event) {
822 self.dispatch(&stable);
823 }
824 Box::pin(async {})
825 }
826
827 fn try_emit(&self, event: AgentEvent) {
828 if let Some(stable) = translate(&event) {
829 self.dispatch(&stable);
830 }
831 }
832}
833
834pub struct TeeEmitter {
853 emitters: Vec<Arc<dyn AgentEmitter>>,
854}
855
856impl TeeEmitter {
857 pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
862 Self { emitters }
863 }
864}
865
866impl AgentEmitter for TeeEmitter {
867 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
868 let emitters = self.emitters.clone();
874 Box::pin(async move {
875 for e in &emitters {
876 e.emit(event.clone()).await;
877 }
878 })
879 }
880
881 fn try_emit(&self, event: AgentEvent) {
882 for e in &self.emitters {
883 e.try_emit(event.clone());
884 }
885 }
886}
887
888fn free_dispatched_event(event: &StablePluginEvent) {
892 use rpi_plugin_sdk::EventTag as T;
893 match event.tag {
894 T::MessageStart | T::MessageUpdate | T::MessageEnd => {
895 unsafe { host_free_string(event.payload.message.message) };
897 }
898 T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
899 unsafe {
901 let tc = &event.payload.tool_call;
902 host_free_string(tc.tool_call_id);
903 host_free_string(tc.tool_name);
904 host_free_string(tc.params);
905 }
906 }
907 T::ToolResult | T::ToolExecutionEnd => {
908 unsafe {
910 let tr = &event.payload.tool_result;
911 host_free_string(tr.tool_call_id);
912 host_free_string(tr.tool_name);
913 host_free_string(tr.result);
914 }
915 }
916 T::ProjectTrust
917 | T::ResourcesDiscover
918 | T::SessionStart
919 | T::SessionInfoChanged
920 | T::SessionBeforeSwitch
921 | T::SessionBeforeFork
922 | T::SessionBeforeCompact
923 | T::SessionCompact
924 | T::SessionShutdown
925 | T::SessionBeforeTree
926 | T::SessionTree
927 | T::Context
928 | T::BeforeAgentStart
929 | T::AgentStart
930 | T::AgentEnd
931 | T::AgentSettled
932 | T::TurnStart
933 | T::TurnEnd
934 | T::ModelSelect
935 | T::ThinkingLevelSelect
936 | T::UserBash
937 | T::BeforeTuiStart
938 | T::UiPromptStart
939 | T::UiPromptEnd => {
940 }
942 T::BeforeProviderRequest
946 | T::BeforeProviderHeaders
947 | T::AfterProviderResponse
948 | T::Input
949 | T::EditorChange => {
950 unsafe { host_free_string(event.payload.data.data) };
952 }
953 }
954}
955
956#[cfg(test)]
964mod tests {
965 use super::*;
966 use rpi_agent::message::AgentMessage;
967 use rpi_agent::types::AgentToolResult;
968 use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, Usage};
969 use rpi_plugin_sdk::EventTag;
970 use std::sync::atomic::{AtomicUsize, Ordering};
971 use std::sync::Mutex;
972
973 static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
981
982 #[test]
983 fn all_ten_agent_events_map_to_a_tag() {
984 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
985 role: rpi_ai::types::AssistantRole,
986 content: vec![rpi_ai::types::Content::text("hi")],
987 api: rpi_ai::types::Api::AnthropicMessages,
988 provider: "anthropic".to_string(),
989 model: "m".into(),
990 response_model: None,
991 response_id: None,
992 usage: Usage::zero(),
993 stop_reason: rpi_ai::types::StopReason::Stop,
994 deferred: None,
995 error_message: None,
996 raw_stop_reason: None,
997 end_turn: None,
998 timestamp: 0,
999 }));
1000 let events = vec![
1001 AgentEvent::AgentStart,
1002 AgentEvent::AgentEnd { messages: vec![] },
1003 AgentEvent::TurnStart,
1004 AgentEvent::TurnEnd {
1005 message: am.clone(),
1006 tool_results: vec![],
1007 },
1008 AgentEvent::MessageStart {
1009 message: am.clone(),
1010 },
1011 AgentEvent::MessageUpdate {
1012 message: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
1015 assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
1016 partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
1017 },
1018 },
1019 AgentEvent::MessageEnd { message: am },
1020 AgentEvent::ToolExecutionStart {
1021 tool_call_id: "c1".into(),
1022 tool_name: "echo".into(),
1023 args: serde_json::json!({}),
1024 },
1025 AgentEvent::ToolExecutionUpdate {
1026 tool_call_id: "c1".into(),
1027 tool_name: "echo".into(),
1028 args: serde_json::json!({}),
1029 partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
1030 },
1031 AgentEvent::ToolExecutionEnd {
1032 tool_call_id: "c1".into(),
1033 tool_name: "echo".into(),
1034 result: AgentToolResult::text("done"),
1035 is_error: false,
1036 },
1037 ];
1038 for e in &events {
1039 assert!(
1040 event_tag_for(e).is_some(),
1041 "event {:?} should map",
1042 e.type_tag()
1043 );
1044 }
1045 assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
1047 assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
1048 assert_eq!(
1049 event_tag_for(&events[7]),
1050 Some(EventTag::ToolExecutionStart)
1051 );
1052 assert!(event_tag_for(&AgentEvent::RetryScheduled {
1053 attempt: 1,
1054 max_retries: 10,
1055 delay_ms: 2_000,
1056 error: "503".into(),
1057 })
1058 .is_none());
1059 }
1060
1061 #[test]
1062 fn translate_message_update_forwards_native_text_deltas_without_snapshot() {
1063 let partial = Arc::new(AssistantMessage {
1064 role: rpi_ai::types::AssistantRole,
1065 content: vec![rpi_ai::types::Content::text("你好世界")],
1066 api: rpi_ai::types::Api::AnthropicMessages,
1067 provider: "anthropic".to_string(),
1068 model: "m".into(),
1069 response_model: None,
1070 response_id: None,
1071 usage: Usage::zero(),
1072 stop_reason: rpi_ai::types::StopReason::Pending,
1073 deferred: None,
1074 error_message: None,
1075 raw_stop_reason: None,
1076 end_turn: None,
1077 timestamp: 0,
1078 });
1079 let translate_delta = |delta: &str| {
1080 let event = AgentEvent::MessageUpdate {
1081 message: Arc::clone(&partial),
1082 assistant_message_event: AssistantMessageEvent::TextDelta {
1083 content_index: 0,
1084 delta: delta.to_string(),
1085 partial: Arc::clone(&partial),
1086 },
1087 };
1088 let stable = translate(&event).expect("message_update maps");
1089 let payload = unsafe { stable.payload.message.message.to_string_lossy() };
1090 unsafe { host_free_string(stable.payload.message.message) };
1091 serde_json::from_str::<serde_json::Value>(&payload).expect("valid payload")
1092 };
1093
1094 let first = translate_delta("你好");
1095 let second = translate_delta("世界");
1096 assert_eq!(first["assistantMessageEvent"]["type"], "text_delta");
1097 assert_eq!(first["assistantMessageEvent"]["contentIndex"], 0);
1098 assert_eq!(first["assistantMessageEvent"]["delta"], "你好");
1099 assert_eq!(second["assistantMessageEvent"]["delta"], "世界");
1100 assert!(first.get("message").is_none());
1101 assert!(first["assistantMessageEvent"].get("partial").is_none());
1102 }
1103
1104 #[test]
1105 fn translate_message_end_produces_message_payload() {
1106 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1107 role: rpi_ai::types::AssistantRole,
1108 content: vec![rpi_ai::types::Content::text("hi")],
1109 api: rpi_ai::types::Api::AnthropicMessages,
1110 provider: "anthropic".to_string(),
1111 model: "m".into(),
1112 response_model: None,
1113 response_id: None,
1114 usage: Usage::zero(),
1115 stop_reason: rpi_ai::types::StopReason::Stop,
1116 deferred: None,
1117 error_message: None,
1118 raw_stop_reason: None,
1119 end_turn: None,
1120 timestamp: 0,
1121 }));
1122 let ev = AgentEvent::MessageEnd { message: am };
1123 let stable = translate(&ev).expect("maps");
1124 assert_eq!(stable.tag, EventTag::MessageEnd);
1125 unsafe { host_free_string(stable.payload.message.message) };
1128 }
1129
1130 static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
1133
1134 extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1135 HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
1136 0
1137 }
1138
1139 #[test]
1140 fn emitter_dispatches_to_registered_handlers() {
1141 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1142 HANDLER_HITS.store(0, Ordering::SeqCst);
1143 let mut reg = crate::registry::ExtensionRegistry::new();
1144 reg.register_event_handler(
1145 "test-plugin".to_string(),
1146 EventTag::MessageEnd,
1147 counting_handler,
1148 std::ptr::null_mut(),
1149 );
1150 let snap = Arc::new(reg.snapshot());
1151 let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1152
1153 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1154 role: rpi_ai::types::AssistantRole,
1155 content: vec![rpi_ai::types::Content::text("hi")],
1156 api: rpi_ai::types::Api::AnthropicMessages,
1157 provider: "anthropic".to_string(),
1158 model: "m".into(),
1159 response_model: None,
1160 response_id: None,
1161 usage: Usage::zero(),
1162 stop_reason: rpi_ai::types::StopReason::Stop,
1163 deferred: None,
1164 error_message: None,
1165 raw_stop_reason: None,
1166 end_turn: None,
1167 timestamp: 0,
1168 }));
1169 emitter.try_emit(AgentEvent::MessageEnd { message: am });
1171 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1172
1173 reg.invalidate();
1175 let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
1176 role: rpi_ai::types::AssistantRole,
1177 content: vec![rpi_ai::types::Content::text("hi")],
1178 api: rpi_ai::types::Api::AnthropicMessages,
1179 provider: "anthropic".to_string(),
1180 model: "m".into(),
1181 response_model: None,
1182 response_id: None,
1183 usage: Usage::zero(),
1184 stop_reason: rpi_ai::types::StopReason::Stop,
1185 deferred: None,
1186 error_message: None,
1187 raw_stop_reason: None,
1188 end_turn: None,
1189 timestamp: 0,
1190 }));
1191 emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
1192 assert_eq!(
1193 HANDLER_HITS.load(Ordering::SeqCst),
1194 1,
1195 "stale registry must not dispatch"
1196 );
1197 }
1198
1199 #[tokio::test]
1202 async fn tee_emitter_fans_out_to_every_child() {
1203 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1204 use rpi_agent::events::{AgentEmitter, CollectorEmitter};
1205
1206 let (collector_a, events_a) = CollectorEmitter::new();
1208 let (collector_b, events_b) = CollectorEmitter::new();
1209 HANDLER_HITS.store(0, Ordering::SeqCst);
1210 let mut reg = crate::registry::ExtensionRegistry::new();
1211 reg.register_event_handler(
1212 "test-plugin".to_string(),
1213 EventTag::MessageEnd,
1214 counting_handler,
1215 std::ptr::null_mut(),
1216 );
1217 let snap = Arc::new(reg.snapshot());
1218 let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1219
1220 let tee = TeeEmitter::new(vec![
1221 Arc::new(collector_a),
1222 Arc::new(collector_b),
1223 Arc::new(ext),
1224 ]);
1225
1226 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1227 role: rpi_ai::types::AssistantRole,
1228 content: vec![rpi_ai::types::Content::text("hi")],
1229 api: rpi_ai::types::Api::AnthropicMessages,
1230 provider: "anthropic".to_string(),
1231 model: "m".into(),
1232 response_model: None,
1233 response_id: None,
1234 usage: Usage::zero(),
1235 stop_reason: rpi_ai::types::StopReason::Stop,
1236 deferred: None,
1237 error_message: None,
1238 raw_stop_reason: None,
1239 end_turn: None,
1240 timestamp: 0,
1241 }));
1242 tee.emit(AgentEvent::MessageEnd { message: am }).await;
1245 assert_eq!(
1246 events_a.lock().unwrap().len(),
1247 1,
1248 "collector A got the event"
1249 );
1250 assert_eq!(
1251 events_b.lock().unwrap().len(),
1252 1,
1253 "collector B got the event"
1254 );
1255 assert_eq!(
1256 HANDLER_HITS.load(Ordering::SeqCst),
1257 1,
1258 "plugin handler fired once"
1259 );
1260 }
1261
1262 #[test]
1268 fn dispatch_empty_event_fans_out_to_subscribers() {
1269 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1270 HANDLER_HITS.store(0, Ordering::SeqCst);
1271 let mut reg = crate::registry::ExtensionRegistry::new();
1272 reg.register_event_handler(
1273 "test-plugin".to_string(),
1274 EventTag::SessionStart,
1275 counting_handler,
1276 std::ptr::null_mut(),
1277 );
1278 reg.register_event_handler(
1279 "test-plugin".to_string(),
1280 EventTag::BeforeTuiStart,
1281 counting_handler,
1282 std::ptr::null_mut(),
1283 );
1284 let snap = Arc::new(reg.snapshot());
1285
1286 assert!(!dispatch_empty_event(&snap, EventTag::SessionShutdown));
1288 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 0);
1289
1290 assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1292 assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1293 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1294
1295 reg.invalidate();
1299 assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1300 assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1301 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1302 }
1303
1304 extern "C" fn aborting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1308 rpi_plugin_sdk::EVENT_HANDLER_ABORT
1309 }
1310
1311 extern "C" fn continue_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1313 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1314 }
1315
1316 extern "C" fn error_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1318 rpi_plugin_sdk::EVENT_HANDLER_ERROR
1319 }
1320
1321 #[test]
1322 fn lifecycle_timeout_budget_matches_spec() {
1323 assert_eq!(
1324 lifecycle_timeout_for(EventTag::BeforeTuiStart),
1325 std::time::Duration::from_secs(5)
1326 );
1327 assert_eq!(
1328 lifecycle_timeout_for(EventTag::SessionShutdown),
1329 std::time::Duration::from_secs(15)
1330 );
1331 assert_eq!(
1332 lifecycle_timeout_for(EventTag::SessionStart),
1333 std::time::Duration::from_secs(10)
1334 );
1335 }
1336
1337 #[tokio::test]
1340 async fn dispatch_lifecycle_event_honors_veto() {
1341 let mut reg = crate::registry::ExtensionRegistry::new();
1342 reg.register_event_handler(
1343 "veto-ext".to_string(),
1344 EventTag::BeforeTuiStart,
1345 aborting_handler,
1346 std::ptr::null_mut(),
1347 );
1348 let snap = Arc::new(reg.snapshot());
1349 let result = dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart).await;
1350 assert_eq!(
1351 result.as_deref(),
1352 Some("extension `veto-ext` vetoed BeforeTuiStart")
1353 );
1354 }
1355
1356 #[tokio::test]
1359 async fn dispatch_lifecycle_event_continue_and_error_do_not_veto() {
1360 let mut reg = crate::registry::ExtensionRegistry::new();
1361 reg.register_event_handler(
1362 "ok-ext".to_string(),
1363 EventTag::SessionStart,
1364 continue_handler,
1365 std::ptr::null_mut(),
1366 );
1367 reg.register_event_handler(
1368 "err-ext".to_string(),
1369 EventTag::SessionStart,
1370 error_handler,
1371 std::ptr::null_mut(),
1372 );
1373 let snap = Arc::new(reg.snapshot());
1374 assert!(dispatch_lifecycle_event(&snap, EventTag::SessionStart)
1375 .await
1376 .is_none());
1377 }
1378
1379 fn tool_call_session(reg: &crate::ExtensionRegistry) -> crate::ExtensionSession {
1380 crate::ExtensionSession::from_parts(
1381 Arc::new(reg.snapshot()),
1382 crate::PluginKeepalive::empty(),
1383 Vec::new(),
1384 None,
1385 )
1386 }
1387
1388 #[tokio::test]
1389 async fn tool_call_approval_error_and_stale_session_block_execution() {
1390 let mut reg = crate::ExtensionRegistry::new();
1391 reg.register_event_handler(
1392 "broken-gate".into(),
1393 EventTag::ToolCall,
1394 error_handler,
1395 std::ptr::null_mut(),
1396 );
1397 let session = tool_call_session(®);
1398 let signal = tokio_util::sync::CancellationToken::new();
1399 let ui = crate::UiDialogMailbox::new();
1400 let reason =
1401 dispatch_tool_call_event(&session, "c1", "bash", &serde_json::json!({}), &signal, &ui)
1402 .await
1403 .unwrap();
1404 assert!(reason.contains("approval failed: rc="), "{reason}");
1405 reg.invalidate();
1406 let reason =
1407 dispatch_tool_call_event(&session, "c2", "bash", &serde_json::json!({}), &signal, &ui)
1408 .await
1409 .unwrap();
1410 assert!(reason.contains("stale"), "{reason}");
1411 }
1412
1413 struct WaitingApproval {
1414 ui: crate::UiDialogMailbox,
1415 entered: tokio::sync::Notify,
1416 finished: tokio::sync::Notify,
1417 release: std::sync::atomic::AtomicBool,
1418 }
1419
1420 extern "C" fn waiting_approval(event: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
1421 let state = unsafe { Arc::from_raw(data as *const WaitingApproval) };
1423 let call_id = unsafe { event.payload.tool_call.tool_call_id.to_string_lossy() };
1424 state
1425 .ui
1426 .handle(serde_json::json!({
1427 "op": "open", "requestId": "approval", "toolCallId": call_id,
1428 "ui": {"question": "Allow?"},
1429 }))
1430 .unwrap();
1431 state.entered.notify_one();
1432 loop {
1433 if state.release.load(Ordering::SeqCst) {
1434 break;
1435 }
1436 if state.ui.poll("approval").unwrap()["status"] == "cancelled" {
1437 break;
1438 }
1439 std::thread::sleep(std::time::Duration::from_millis(2));
1440 }
1441 while !state.release.load(Ordering::SeqCst) {
1444 std::thread::sleep(std::time::Duration::from_millis(2));
1445 }
1446 state.finished.notify_one();
1447 rpi_plugin_sdk::EVENT_HANDLER_ABORT
1448 }
1449
1450 async fn check_interrupted_approval(cancel: bool) {
1451 let ui = crate::UiDialogMailbox::new();
1452 ui.attach();
1453 ui.handle(serde_json::json!({
1454 "requestId": "other", "toolCallId": "other-call", "ui": {},
1455 }))
1456 .unwrap();
1457 let state = Arc::new(WaitingApproval {
1458 ui: ui.clone(),
1459 entered: tokio::sync::Notify::new(),
1460 finished: tokio::sync::Notify::new(),
1461 release: std::sync::atomic::AtomicBool::new(false),
1462 });
1463 struct ReleaseWorker(Arc<WaitingApproval>);
1465 impl Drop for ReleaseWorker {
1466 fn drop(&mut self) {
1467 self.0.release.store(true, Ordering::SeqCst);
1468 }
1469 }
1470 let _release = ReleaseWorker(state.clone());
1471 let mut reg = crate::ExtensionRegistry::new();
1472 reg.register_event_handler(
1473 "waiting-gate".into(),
1474 EventTag::ToolCall,
1475 waiting_approval,
1476 Arc::into_raw(state.clone()) as *mut std::ffi::c_void,
1477 );
1478 let keepalive = crate::PluginKeepalive::empty();
1479 let weak = Arc::downgrade(&keepalive);
1480 let session = crate::ExtensionSession::from_parts(
1481 Arc::new(reg.snapshot()),
1482 keepalive,
1483 Vec::new(),
1484 None,
1485 );
1486 let signal = tokio_util::sync::CancellationToken::new();
1487 let task_signal = signal.clone();
1488 let task_ui = ui.clone();
1489 let task = tokio::spawn(async move {
1490 dispatch_tool_call_event_with_timeout(
1491 &session,
1492 "call",
1493 "bash",
1494 &serde_json::json!({}),
1495 &task_signal,
1496 &task_ui,
1497 if cancel {
1498 TOOL_CALL_TIMEOUT
1499 } else {
1500 std::time::Duration::from_millis(200)
1501 },
1502 )
1503 .await
1504 });
1505 tokio::time::timeout(std::time::Duration::from_secs(2), state.entered.notified())
1506 .await
1507 .unwrap();
1508 if cancel {
1509 signal.cancel();
1510 }
1511 let reason = tokio::time::timeout(std::time::Duration::from_secs(2), task)
1512 .await
1513 .unwrap()
1514 .unwrap()
1515 .unwrap();
1516 assert!(
1517 reason.contains(if cancel { "cancelled" } else { "timed out" }),
1518 "{reason}"
1519 );
1520 assert!(
1521 weak.upgrade().is_some(),
1522 "running callback must keep its library mapped"
1523 );
1524 assert_eq!(ui.poll("other").unwrap()["status"], "pending");
1525 state.release.store(true, Ordering::SeqCst);
1526 tokio::time::timeout(std::time::Duration::from_secs(2), state.finished.notified())
1527 .await
1528 .unwrap();
1529 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1530 while weak.upgrade().is_some() {
1531 tokio::task::yield_now().await;
1532 }
1533 })
1534 .await
1535 .unwrap();
1536 }
1537
1538 #[tokio::test]
1539 async fn tool_call_approval_cancel_releases_waiter_and_retains_running_library() {
1540 check_interrupted_approval(true).await;
1541 }
1542
1543 #[tokio::test]
1544 async fn tool_call_approval_timeout_blocks_and_retains_running_library() {
1545 check_interrupted_approval(false).await;
1546 }
1547
1548 #[tokio::test]
1552 async fn dispatch_tool_call_event_honors_veto() {
1553 let mut reg = crate::registry::ExtensionRegistry::new();
1554 reg.register_event_handler(
1555 "gate-ext".to_string(),
1556 EventTag::ToolCall,
1557 aborting_handler,
1558 std::ptr::null_mut(),
1559 );
1560 let session = tool_call_session(®);
1561 let veto = dispatch_tool_call_event(
1562 &session,
1563 "call_1",
1564 "bash",
1565 &serde_json::json!({"command": "rm -rf /"}),
1566 &tokio_util::sync::CancellationToken::new(),
1567 &crate::UiDialogMailbox::new(),
1568 )
1569 .await;
1570 assert_eq!(
1571 veto.as_deref(),
1572 Some("extension `gate-ext` blocked tool `bash`")
1573 );
1574 }
1575
1576 #[tokio::test]
1578 async fn dispatch_tool_call_event_continue_and_empty_do_not_veto() {
1579 let mut reg = crate::registry::ExtensionRegistry::new();
1580 reg.register_event_handler(
1581 "ok-ext".to_string(),
1582 EventTag::ToolCall,
1583 continue_handler,
1584 std::ptr::null_mut(),
1585 );
1586 let session = tool_call_session(®);
1587 assert!(dispatch_tool_call_event(
1588 &session,
1589 "call_1",
1590 "read",
1591 &serde_json::json!({"path": "x"}),
1592 &tokio_util::sync::CancellationToken::new(),
1593 &crate::UiDialogMailbox::new(),
1594 )
1595 .await
1596 .is_none());
1597
1598 let empty = crate::registry::ExtensionRegistry::new();
1599 let empty_session = tool_call_session(&empty);
1600 assert!(dispatch_tool_call_event(
1601 &empty_session,
1602 "call_2",
1603 "read",
1604 &serde_json::json!({}),
1605 &tokio_util::sync::CancellationToken::new(),
1606 &crate::UiDialogMailbox::new()
1607 )
1608 .await
1609 .is_none());
1610 }
1611
1612 #[tokio::test]
1614 async fn dispatch_lifecycle_event_no_subscribers_is_none() {
1615 let reg = crate::registry::ExtensionRegistry::new();
1616 let snap = Arc::new(reg.snapshot());
1617 assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1618 .await
1619 .is_none());
1620 }
1621
1622 #[tokio::test]
1625 async fn dispatch_lifecycle_event_stale_is_none() {
1626 let mut reg = crate::registry::ExtensionRegistry::new();
1627 reg.register_event_handler(
1628 "veto-ext".to_string(),
1629 EventTag::BeforeTuiStart,
1630 aborting_handler,
1631 std::ptr::null_mut(),
1632 );
1633 let snap = Arc::new(reg.snapshot());
1634 reg.invalidate();
1635 assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1636 .await
1637 .is_none());
1638 }
1639
1640 #[test]
1643 fn dispatch_records_handler_invocation_to_event_log() {
1644 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1645 HANDLER_HITS.store(0, Ordering::SeqCst);
1646 let dir = tempfile::tempdir().unwrap();
1647 let path = dir.path().join("events.jsonl");
1648 crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::open(
1649 path.clone(),
1650 ));
1651
1652 let mut reg = crate::registry::ExtensionRegistry::new();
1653 reg.register_event_handler(
1654 "log-ext".to_string(),
1655 EventTag::MessageEnd,
1656 counting_handler,
1657 std::ptr::null_mut(),
1658 );
1659 let snap = Arc::new(reg.snapshot());
1660 let event = StablePluginEvent::message(
1661 EventTag::MessageEnd,
1662 StbString::from_string("m".to_string()),
1663 );
1664 dispatch_to_handlers(&snap, &event);
1665
1666 crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::disabled());
1669
1670 let text = std::fs::read_to_string(&path).unwrap();
1671 assert!(text.contains(r#""event":"MessageEnd""#), "log: {text}");
1672 assert!(text.contains(r#""plugin":"log-ext""#), "log: {text}");
1673 assert!(text.contains(r#""result":"Continue""#), "log: {text}");
1674 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1675 }
1676
1677 static CLAIM_ROUTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1682
1683 extern "C" fn claiming_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1684 let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1687 assert!(seen.contains("space"), "payload not delivered: {seen}");
1688 if CLAIM_ROUTING.load(Ordering::SeqCst) {
1689 rpi_plugin_sdk::EVENT_HANDLER_CLAIMED
1690 } else {
1691 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1692 }
1693 }
1694
1695 fn input_snapshot(with_handler: bool) -> Arc<crate::registry::RegistrySnapshot> {
1696 let mut reg = crate::registry::ExtensionRegistry::new();
1697 if with_handler {
1698 reg.register_event_handler(
1699 "voice".to_string(),
1700 EventTag::Input,
1701 claiming_handler,
1702 std::ptr::null_mut(),
1703 );
1704 }
1705 Arc::new(reg.snapshot())
1706 }
1707
1708 #[test]
1709 fn claiming_dispatch_reports_claim_and_passthrough() {
1710 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1711 let snap = input_snapshot(true);
1712
1713 CLAIM_ROUTING.store(true, Ordering::SeqCst);
1714 assert!(
1715 dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1716 "a CLAIMED handler must swallow the key"
1717 );
1718
1719 CLAIM_ROUTING.store(false, Ordering::SeqCst);
1720 assert!(
1721 !dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1722 "a CONTINUE handler must let the key reach the editor"
1723 );
1724 CLAIM_ROUTING.store(true, Ordering::SeqCst);
1725 }
1726
1727 #[test]
1728 fn claiming_dispatch_without_subscribers_is_false() {
1729 let snap = input_snapshot(false);
1730 assert!(!dispatch_data_event_claiming(
1731 &snap,
1732 EventTag::Input,
1733 r#"{"key":"space"}"#
1734 ));
1735 }
1736
1737 #[test]
1743 fn editor_change_reaches_subscribers_with_its_payload() {
1744 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1745 let mut reg = crate::registry::ExtensionRegistry::new();
1746 reg.register_event_handler(
1747 "voice".to_string(),
1748 EventTag::EditorChange,
1749 editor_change_handler,
1750 std::ptr::null_mut(),
1751 );
1752 let snap = Arc::new(reg.snapshot());
1753
1754 EDITOR_CHANGE_SEEN.store(false, Ordering::SeqCst);
1755 assert!(
1756 dispatch_data_event(
1757 &snap,
1758 EventTag::EditorChange,
1759 r#"{"chars":4,"empty":false}"#
1760 ),
1761 "a subscribed handler must be invoked"
1762 );
1763 assert!(
1764 EDITOR_CHANGE_SEEN.load(Ordering::SeqCst),
1765 "payload missing or handler not called"
1766 );
1767 }
1768
1769 static EDITOR_CHANGE_SEEN: std::sync::atomic::AtomicBool =
1772 std::sync::atomic::AtomicBool::new(false);
1773
1774 extern "C" fn editor_change_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1775 let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1778 if seen.contains("chars") && seen.contains("empty") {
1779 EDITOR_CHANGE_SEEN.store(true, Ordering::SeqCst);
1780 }
1781 rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1782 }
1783}