Skip to main content

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}