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