rpi_extensions/translate.rs
1//! `AgentEvent → StablePluginEvent` translation, plus the [`ExtensionEmitter`]
2//! that impls [`AgentEmitter`] by subscribing to the host's
3//! `broadcast::Sender<AgentEvent>`, folding each event into a stable event, and
4//! fan-out dispatching to every registered handler for the event's tag — all
5//! dispatch wrapped in `catch_unwind` (a panicky plugin handler must not unwind
6//! across FFI).
7//!
8//! The 10 already-emitted `AgentEvent` variants fold into their matching
9//! `StablePluginEvent` tags now. The remaining `on()` tags (33 total) light up
10//! as B3/B4/B5 add the emission points. Tags with no registered handlers are a
11//! cheap no-op (empty slice → no dispatch).
12
13use std::panic::{catch_unwind, AssertUnwindSafe};
14use std::sync::Arc;
15
16use futures::future::BoxFuture;
17use rpi_agent::events::{AgentEmitter, AgentEvent};
18use rpi_plugin_sdk::{EventTag, StablePluginEvent, StbString};
19
20use crate::host_free_string;
21use crate::registry::RegistrySnapshot;
22
23/// Map a native [`AgentEvent`] to its pi `on()` [`EventTag`], or `None` if the
24/// host event has no stable-event counterpart (e.g. some internal-only variants
25/// — none today, all 10 map). This is the **fold** described in the crate docs:
26/// the ten `AgentEvent` variants map onto the 10 matching tags; the rest of the
27/// 33-category surface is driven by direct host emission in B3+.
28pub fn event_tag_for(event: &AgentEvent) -> Option<EventTag> {
29 match event {
30 AgentEvent::AgentStart => Some(EventTag::AgentStart),
31 AgentEvent::AgentEnd { .. } => Some(EventTag::AgentEnd),
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
43/// Serialize an [`AgentMessage`](rpi_agent::message::AgentMessage) to an owning
44/// `StbString` for a message-payload event. The host produced this string, so
45/// the **handler** (plugin side) frees it via the host's `free_string`.
46fn message_to_stb(message: &rpi_agent::message::AgentMessage) -> StbString {
47 // AgentMessage is Serialize (#[serde(tag="kind")]); use serde_json round-trip.
48 let text = serde_json::to_string(message).unwrap_or_else(|_| "null".to_string());
49 StbString::from_string(text)
50}
51
52/// Build the stable event for an `AgentEvent`, or `None` if the event carries
53/// no payload that maps onto a stable variant (always maps today, but kept as
54/// `Option` for forward-compat with unmapped future variants). Ownership of any
55/// `StbString` in the returned event passes to the handler.
56pub fn translate(event: &AgentEvent) -> Option<StablePluginEvent> {
57 let tag = event_tag_for(event)?;
58 match event {
59 AgentEvent::MessageStart { message }
60 | AgentEvent::MessageUpdate { message, .. }
61 | AgentEvent::MessageEnd { message } => {
62 let stb = message_to_stb(message);
63 Some(StablePluginEvent::message(tag, stb))
64 }
65
66 AgentEvent::ToolExecutionStart {
67 tool_call_id,
68 tool_name,
69 args,
70 }
71 | AgentEvent::ToolExecutionUpdate {
72 tool_call_id,
73 tool_name,
74 args,
75 ..
76 } => Some(StablePluginEvent::tool_call(
77 tag,
78 StbString::from_string(tool_call_id.clone()),
79 StbString::from_string(tool_name.clone()),
80 StbString::from_string(serde_json::to_string(args).unwrap_or_else(|_| "null".into())),
81 )),
82
83 AgentEvent::ToolExecutionEnd {
84 tool_call_id,
85 tool_name,
86 result,
87 is_error,
88 } => {
89 // Serialize the AgentToolResult to JSON for the result payload.
90 let result_json = agent_tool_result_to_json(result);
91 Some(StablePluginEvent::tool_result(
92 tag,
93 StbString::from_string(tool_call_id.clone()),
94 StbString::from_string(tool_name.clone()),
95 StbString::from_string(result_json),
96 *is_error,
97 ))
98 }
99
100 // No-payload events.
101 AgentEvent::AgentStart
102 | AgentEvent::TurnStart
103 | AgentEvent::AgentEnd { .. }
104 | AgentEvent::TurnEnd { .. } => Some(StablePluginEvent::empty(tag)),
105 }
106}
107
108/// Serialize an `AgentToolResult` to the JSON shape handlers expect (same shape
109/// the tool adapter uses). Kept here (not reusing tool.rs's `result_to_stb`)
110/// because the emitter must not depend on adapter internals, and this is the
111/// event-side direction.
112fn agent_tool_result_to_json(result: &rpi_agent::types::AgentToolResult) -> String {
113 let mut txt = String::new();
114 txt.push('{');
115 txt.push_str("\"content\":[");
116 for (i, c) in result.content.iter().enumerate() {
117 if i > 0 {
118 txt.push(',');
119 }
120 match c {
121 rpi_agent::types::TextContentOrImage::Text(t) => {
122 txt.push_str(
123 &serde_json::to_string(&serde_json::json!({ "type": "text", "text": t.text }))
124 .unwrap_or_else(|_| "\"\"".into()),
125 );
126 }
127 rpi_agent::types::TextContentOrImage::Image(img) => {
128 txt.push_str(
129 &serde_json::to_string(&serde_json::json!({
130 "type": "image",
131 "data": img.data,
132 "mimeType": img.mime_type,
133 }))
134 .unwrap_or_else(|_| "\"\"".into()),
135 );
136 }
137 }
138 }
139 txt.push(']');
140 txt.push_str(",\"details\":");
141 txt.push_str(&serde_json::to_string(&result.details).unwrap_or_else(|_| "null".into()));
142 txt.push_str(",\"terminate\":");
143 txt.push_str(if result.terminate { "true" } else { "false" });
144 txt.push_str(",\"addedToolNames\":");
145 txt.push_str(&serde_json::to_string(&result.added_tool_names).unwrap_or_else(|_| "[]".into()));
146 txt.push('}');
147 txt
148}
149
150/// Free every `StbString` owned by a dispatched [[`StablePluginEvent`]] via the
151/// host's `free_string`. Called after dispatch completes (the handler SHOULD
152/// have freed them, but the host keeps ownership-of-cleanup so a buggy plugin
153/// that retains/leaks can't double-free the host's allocation — the host never
154/// trusts the plugin to free, per the SDK's "receiver owns" contract: here the
155/// host is the producer → receiver is the plugin; if the plugin failed to free,
156/// the host's free is a leak fix, not a double-free, because `StbString` is
157/// `Copy` and the host's `host_free_string` reconstructs the `Box<[u8]>` and
158/// drops it — a second free of the same bytes would be UB, so this must run
159/// EXACTLY ONCE per event. The contract is: the plugin frees what it receives.
160/// To honor that, we do NOT free here; the plugin owns the free. This fn is
161/// therefore a no-op kept for documentation + future audit).
162///
163/// **In practice:** the plugin handler is contractually required to free every
164/// `StbString` it receives via the host `free_string`. The host does not
165/// double-free. If a plugin leaks, that is a plugin bug.
166#[allow(dead_code)]
167fn free_event_strings(_event: &StablePluginEvent) {
168 // intentionally empty — see doc comment.
169}
170
171// ===========================================================================
172// ExtensionEmitter — AgentEmitter impl fanning out to plugin handlers
173// ===========================================================================
174
175/// An [`AgentEmitter`] that fans each [`AgentEvent`] out to every plugin handler
176/// registered for the event's tag. Built from a [`RegistrySnapshot`] (so it
177/// shares the registry's staleness flag) and installed into
178/// `AgentHarnessOptions.agent_emitter` **alongside** the host's
179/// [`BroadcastEmitter`] — events flow to BOTH the TUI (which drains the
180/// broadcast receiver) and the plugin handlers (which receive translated
181/// `StablePluginEvent`s). The host composes the two via
182/// [`TeeEmitter`](super::TeeEmitter); this emitter alone only dispatches to
183/// plugins.
184///
185/// Dispatch is `catch_unwind`-wrapped: a panicking plugin handler is logged and
186/// skipped (abort-on-unwind policy would be too aggressive for event dispatch
187/// where one bad handler shouldn't kill the session; we log + continue, unlike
188/// tool-partial dispatch which aborts because it cannot unwind across FFI from
189/// a blocking thread). The distinction: `emit` runs on the async runtime thread
190/// where `catch_unwind` can recover cleanly; the tool partial cb runs inside
191/// `spawn_blocking` cross-FFI where recovery is unsafe.
192pub struct ExtensionEmitter {
193 snapshot: Arc<RegistrySnapshot>,
194 /// Keeps the loaded cdylibs mapped for as long as this emitter (installed
195 /// into `AgentHarnessOptions.agent_emitter`) may dispatch to handler fn
196 /// pointers that live inside them. Cloned from the load session; the
197 /// libraries unload only when every holder (adapter + emitter) drops.
198 #[allow(dead_code)]
199 keepalive: Arc<crate::PluginKeepalive>,
200}
201
202impl ExtensionEmitter {
203 /// Build an emitter over a snapshot. The `keepalive` keeps the cdylibs that
204 /// own the snapshot's handler fn pointers mapped for the emitter's lifetime.
205 pub fn new(snapshot: Arc<RegistrySnapshot>, keepalive: Arc<crate::PluginKeepalive>) -> Self {
206 Self {
207 snapshot,
208 keepalive,
209 }
210 }
211
212 /// Dispatch one stable event to all handlers for its tag. Each handler call
213 /// is `catch_unwind`-wrapped; errors (nonzero return) are logged but do not
214 /// abort the fan-out (one handler's failure doesn't block the others — mir-
215 /// rors pi's per-handler try/catch).
216 fn dispatch(&self, event: &StablePluginEvent) {
217 dispatch_to_handlers(&self.snapshot, event);
218 }
219}
220
221/// Fan an already-built event out to the tag's handlers (or free it when no
222/// handler subscribes). Shared by [`ExtensionEmitter`] and the provider-hook
223/// dispatcher ([`crate::provider_hooks`]). Handlers MUST NOT free the event
224/// strings; the host frees exactly once after the fan-out.
225pub fn dispatch_to_handlers(snapshot: &RegistrySnapshot, event: &StablePluginEvent) {
226 // Staleness guard: a stale registry (swapped-out session) does nothing.
227 if !crate::registry::assert_active(snapshot.active_flag()) {
228 return;
229 }
230 let handlers = snapshot.handlers_for(event.tag);
231 if handlers.is_empty() {
232 // No subscribers — and critically, the event's StbStrings are owned
233 // by the host and must still be freed (no handler ran to free them).
234 free_dispatched_event(event);
235 return;
236 }
237 for h in handlers {
238 // SAFETY: the plugin warrants `handler` + `user_data` are safe to
239 // call from this thread. catch_unwind so a panic cannot cross FFI.
240 let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(*event, h.user_data)));
241 match outcome {
242 Ok(rc) if rc != 0 => {
243 tracing::warn!(tag = ?event.tag, rc, "extension event handler returned nonzero");
244 }
245 Ok(_) => {}
246 Err(_) => {
247 tracing::error!(tag = ?event.tag, "extension event handler panicked — skipped");
248 }
249 }
250 }
251 // Host frees the event's strings exactly once after the fan-out.
252 free_dispatched_event(event);
253}
254
255/// Build + dispatch a generic `data` event (JSON payload) to the tag's
256/// handlers. Returns whether any handler was invoked. Used by the B4
257/// provider-hook observer path ([`crate::provider_hooks`]) which has no
258/// matching `AgentEvent` to translate.
259pub fn dispatch_data_event(snapshot: &RegistrySnapshot, tag: EventTag, data: &str) -> bool {
260 let handlers = snapshot.handlers_for(tag);
261 if handlers.is_empty() {
262 return false;
263 }
264 let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
265 dispatch_to_handlers(snapshot, &event);
266 true
267}
268
269impl AgentEmitter for ExtensionEmitter {
270 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
271 // Translate + dispatch synchronously (the emitter's emit is awaited by
272 // the loop in event order; dispatch is non-blocking fn-pointer calls).
273 // If the event maps to no tag, do nothing.
274 if let Some(stable) = translate(&event) {
275 self.dispatch(&stable);
276 }
277 Box::pin(async {})
278 }
279
280 fn try_emit(&self, event: AgentEvent) {
281 if let Some(stable) = translate(&event) {
282 self.dispatch(&stable);
283 }
284 }
285}
286
287// ===========================================================================
288// TeeEmitter — fan an AgentEvent out to N AgentEmitters (host + plugins)
289// ===========================================================================
290
291/// An [`AgentEmitter`] that forwards every event to each of its children, in
292/// registration order. The host builds one around `[BroadcastEmitter (→ TUI),
293/// ExtensionEmitter (→ plugin handlers)]` so a single `AgentHarnessOptions
294/// .agent_emitter` slot feeds both consumers: the TUI keeps rendering from its
295/// broadcast receiver, and plugin `on()` handlers receive translated
296/// `StablePluginEvent`s.
297///
298/// `emit` awaits each child in turn (the loop awaits `emit`, so order matches
299/// registration); `try_emit` calls each child's `try_emit` (the tool
300/// `on_update` path — non-blocking). A child that panics is isolated by the
301/// child's own `catch_unwind` where applicable (the `ExtensionEmitter` does);
302/// the `BroadcastEmitter` cannot panic (it's a `tx.send`). We do NOT wrap the
303/// fan-out itself in `catch_unwind` — each child is responsible for its own
304/// soundness, and a generic wrapper would mask a child's contract violation.
305pub struct TeeEmitter {
306 emitters: Vec<Arc<dyn AgentEmitter>>,
307}
308
309impl TeeEmitter {
310 /// Build a tee over the given emitters. Order is preserved: `emit`/`try_emit`
311 /// visit them front-to-back. A single-child tee is a trivial passthrough
312 /// (the host uses that when no extensions loaded, so the code path is
313 /// uniform).
314 pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
315 Self { emitters }
316 }
317}
318
319impl AgentEmitter for TeeEmitter {
320 fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
321 // We can't hold `&self` across an await boundary into a 'static future
322 // cheaply here without cloning the Arcs — so clone them and drive the
323 // fan-out inside a pinned async block. Each child's emit returns a
324 // no-op future (both BroadcastEmitter and ExtensionEmitter complete
325 // synchronously), so this is effectively a synchronous loop in practice.
326 let emitters = self.emitters.clone();
327 Box::pin(async move {
328 for e in &emitters {
329 e.emit(event.clone()).await;
330 }
331 })
332 }
333
334 fn try_emit(&self, event: AgentEvent) {
335 for e in &self.emitters {
336 e.try_emit(event.clone());
337 }
338 }
339}
340
341/// Free every owning `StbString` in a dispatched event exactly once via the
342/// host's `free_string`. Called by [`ExtensionEmitter::dispatch`] after the
343/// fan-out completes. Handlers MUST NOT free event strings (host owns cleanup).
344fn free_dispatched_event(event: &StablePluginEvent) {
345 use rpi_plugin_sdk::EventTag as T;
346 match event.tag {
347 T::MessageStart | T::MessageUpdate | T::MessageEnd => {
348 // SAFETY: tag matches the message variant.
349 unsafe { host_free_string(event.payload.message.message) };
350 }
351 T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
352 // SAFETY: tag matches the tool_call variant.
353 unsafe {
354 let tc = &event.payload.tool_call;
355 host_free_string(tc.tool_call_id);
356 host_free_string(tc.tool_name);
357 host_free_string(tc.params);
358 }
359 }
360 T::ToolResult | T::ToolExecutionEnd => {
361 // SAFETY: tag matches the tool_result variant.
362 unsafe {
363 let tr = &event.payload.tool_result;
364 host_free_string(tr.tool_call_id);
365 host_free_string(tr.tool_name);
366 host_free_string(tr.result);
367 }
368 }
369 T::ProjectTrust
370 | T::ResourcesDiscover
371 | T::SessionStart
372 | T::SessionInfoChanged
373 | T::SessionBeforeSwitch
374 | T::SessionBeforeFork
375 | T::SessionBeforeCompact
376 | T::SessionCompact
377 | T::SessionShutdown
378 | T::SessionBeforeTree
379 | T::SessionTree
380 | T::Context
381 | T::BeforeAgentStart
382 | T::AgentStart
383 | T::AgentEnd
384 | T::AgentSettled
385 | T::TurnStart
386 | T::TurnEnd
387 | T::ModelSelect
388 | T::ThinkingLevelSelect
389 | T::UserBash
390 | T::Input => {
391 // no payload today.
392 }
393 // The B4 provider-hook observer events carry a generic data payload
394 // (built by `dispatch_data_event`); free the single StbString.
395 T::BeforeProviderRequest | T::BeforeProviderHeaders | T::AfterProviderResponse => {
396 // SAFETY: these tags are only ever constructed as data payloads.
397 unsafe { host_free_string(event.payload.data.data) };
398 }
399 }
400}
401
402// SAFETY note on the `unsafe { host_free_string(...) }` calls above:
403// `host_free_string` is itself a safe `extern "C" fn` (it reconstructs a
404// `Box<[u8]>` from ptr+len and drops it, idempotent on null/empty). The
405// `unsafe` block is required only because reading the union payload is
406// `unsafe` (the compiler can't verify tag/variant match) — which we guarantee
407// by matching on `event.tag` first. So the union read is sound.
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use rpi_agent::message::AgentMessage;
413 use rpi_agent::types::AgentToolResult;
414 use rpi_ai::types::{AssistantMessage, Usage};
415 use rpi_plugin_sdk::EventTag;
416 use std::sync::atomic::{AtomicUsize, Ordering};
417 use std::sync::Mutex;
418
419 // The two handler-fan-out tests share the process-global `HANDLER_HITS`
420 // counter (the handler is an `extern "C" fn` that can't capture per-test
421 // state). Parallel #[test] execution would have one test's `store(0)`
422 // wipe the other's in-flight increment. Hold this lock for the ENTIRE
423 // body of both tests so their reset/dispatch windows don't overlap. The
424 // dispatch is synchronous (fn-pointer calls), so the guard is released
425 // before the test returns — no handler outlives the test.
426 static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
427
428 #[test]
429 fn all_ten_agent_events_map_to_a_tag() {
430 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
431 role: rpi_ai::types::AssistantRole,
432 content: vec![rpi_ai::types::Content::text("hi")],
433 api: rpi_ai::types::Api::AnthropicMessages,
434 provider: "anthropic".to_string(),
435 model: "m".into(),
436 response_model: None,
437 response_id: None,
438 usage: Usage::zero(),
439 stop_reason: rpi_ai::types::StopReason::Stop,
440 deferred: None,
441 error_message: None,
442 raw_stop_reason: None,
443 end_turn: None,
444 timestamp: 0,
445 }));
446 let events = vec![
447 AgentEvent::AgentStart,
448 AgentEvent::AgentEnd { messages: vec![] },
449 AgentEvent::TurnStart,
450 AgentEvent::TurnEnd {
451 message: am.clone(),
452 tool_results: vec![],
453 },
454 AgentEvent::MessageStart {
455 message: am.clone(),
456 },
457 AgentEvent::MessageUpdate {
458 message: am.clone(),
459 assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
460 partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
461 },
462 },
463 AgentEvent::MessageEnd { message: am },
464 AgentEvent::ToolExecutionStart {
465 tool_call_id: "c1".into(),
466 tool_name: "echo".into(),
467 args: serde_json::json!({}),
468 },
469 AgentEvent::ToolExecutionUpdate {
470 tool_call_id: "c1".into(),
471 tool_name: "echo".into(),
472 args: serde_json::json!({}),
473 partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
474 },
475 AgentEvent::ToolExecutionEnd {
476 tool_call_id: "c1".into(),
477 tool_name: "echo".into(),
478 result: AgentToolResult::text("done"),
479 is_error: false,
480 },
481 ];
482 for e in &events {
483 assert!(
484 event_tag_for(e).is_some(),
485 "event {:?} should map",
486 e.type_tag()
487 );
488 }
489 // Spot-check the fold targets.
490 assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
491 assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
492 assert_eq!(
493 event_tag_for(&events[7]),
494 Some(EventTag::ToolExecutionStart)
495 );
496 }
497
498 #[test]
499 fn translate_message_end_produces_message_payload() {
500 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
501 role: rpi_ai::types::AssistantRole,
502 content: vec![rpi_ai::types::Content::text("hi")],
503 api: rpi_ai::types::Api::AnthropicMessages,
504 provider: "anthropic".to_string(),
505 model: "m".into(),
506 response_model: None,
507 response_id: None,
508 usage: Usage::zero(),
509 stop_reason: rpi_ai::types::StopReason::Stop,
510 deferred: None,
511 error_message: None,
512 raw_stop_reason: None,
513 end_turn: None,
514 timestamp: 0,
515 }));
516 let ev = AgentEvent::MessageEnd { message: am };
517 let stable = translate(&ev).expect("maps");
518 assert_eq!(stable.tag, EventTag::MessageEnd);
519 // free the payload string (host owns cleanup).
520 // SAFETY: tag == MessageEnd.
521 unsafe { host_free_string(stable.payload.message.message) };
522 }
523
524 // --- an emitter fan-out test with an in-process handler -----------------
525
526 static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
527
528 extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
529 HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
530 0
531 }
532
533 #[test]
534 fn emitter_dispatches_to_registered_handlers() {
535 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
536 HANDLER_HITS.store(0, Ordering::SeqCst);
537 let mut reg = crate::registry::ExtensionRegistry::new();
538 reg.register_event_handler(EventTag::MessageEnd, counting_handler, std::ptr::null_mut());
539 let snap = Arc::new(reg.snapshot());
540 let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
541
542 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
543 role: rpi_ai::types::AssistantRole,
544 content: vec![rpi_ai::types::Content::text("hi")],
545 api: rpi_ai::types::Api::AnthropicMessages,
546 provider: "anthropic".to_string(),
547 model: "m".into(),
548 response_model: None,
549 response_id: None,
550 usage: Usage::zero(),
551 stop_reason: rpi_ai::types::StopReason::Stop,
552 deferred: None,
553 error_message: None,
554 raw_stop_reason: None,
555 end_turn: None,
556 timestamp: 0,
557 }));
558 // Use try_emit (sync) — no runtime needed.
559 emitter.try_emit(AgentEvent::MessageEnd { message: am });
560 assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
561
562 // Stale registry → no dispatch.
563 reg.invalidate();
564 let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
565 role: rpi_ai::types::AssistantRole,
566 content: vec![rpi_ai::types::Content::text("hi")],
567 api: rpi_ai::types::Api::AnthropicMessages,
568 provider: "anthropic".to_string(),
569 model: "m".into(),
570 response_model: None,
571 response_id: None,
572 usage: Usage::zero(),
573 stop_reason: rpi_ai::types::StopReason::Stop,
574 deferred: None,
575 error_message: None,
576 raw_stop_reason: None,
577 end_turn: None,
578 timestamp: 0,
579 }));
580 emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
581 assert_eq!(
582 HANDLER_HITS.load(Ordering::SeqCst),
583 1,
584 "stale registry must not dispatch"
585 );
586 }
587
588 // --- TeeEmitter: fan-out to both the broadcast + the extension emitter ----
589
590 #[tokio::test]
591 async fn tee_emitter_fans_out_to_every_child() {
592 let _guard = HANDLER_TEST_LOCK.lock().unwrap();
593 use rpi_agent::events::{AgentEmitter, CollectorEmitter};
594
595 // Two collector emitters + record how many plugin handler hits land.
596 let (collector_a, events_a) = CollectorEmitter::new();
597 let (collector_b, events_b) = CollectorEmitter::new();
598 HANDLER_HITS.store(0, Ordering::SeqCst);
599 let mut reg = crate::registry::ExtensionRegistry::new();
600 reg.register_event_handler(EventTag::MessageEnd, counting_handler, std::ptr::null_mut());
601 let snap = Arc::new(reg.snapshot());
602 let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
603
604 let tee = TeeEmitter::new(vec![
605 Arc::new(collector_a),
606 Arc::new(collector_b),
607 Arc::new(ext),
608 ]);
609
610 let am = AgentMessage::Assistant(Box::new(AssistantMessage {
611 role: rpi_ai::types::AssistantRole,
612 content: vec![rpi_ai::types::Content::text("hi")],
613 api: rpi_ai::types::Api::AnthropicMessages,
614 provider: "anthropic".to_string(),
615 model: "m".into(),
616 response_model: None,
617 response_id: None,
618 usage: Usage::zero(),
619 stop_reason: rpi_ai::types::StopReason::Stop,
620 deferred: None,
621 error_message: None,
622 raw_stop_reason: None,
623 end_turn: None,
624 timestamp: 0,
625 }));
626 // Broadcast path: both collectors receive the event, and the extension
627 // emitter dispatches to the one registered handler.
628 tee.emit(AgentEvent::MessageEnd { message: am }).await;
629 assert_eq!(
630 events_a.lock().unwrap().len(),
631 1,
632 "collector A got the event"
633 );
634 assert_eq!(
635 events_b.lock().unwrap().len(),
636 1,
637 "collector B got the event"
638 );
639 assert_eq!(
640 HANDLER_HITS.load(Ordering::SeqCst),
641 1,
642 "plugin handler fired once"
643 );
644 }
645}