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 } | 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            // Match native Pi's wire contract: message_update carries the
68            // provider event, not a growing message snapshot. Consumers that
69            // need live text read assistantMessageEvent.delta directly.
70            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            // Serialize the AgentToolResult to JSON for the result payload.
101            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        // No-payload events.
112        AgentEvent::AgentStart
113        | AgentEvent::TurnStart
114        | AgentEvent::AgentEnd { .. }
115        | AgentEvent::TurnEnd { .. } => Some(StablePluginEvent::empty(tag)),
116
117        AgentEvent::RetryScheduled { .. } => None,
118    }
119}
120
121/// Serialize the stream event inside native Pi's `message_update` envelope.
122///
123/// Do not include `partial`: it is a cumulative snapshot and the native JSON /
124/// RPC contract deliberately omits it so consumers process each delta exactly
125/// once and stream size stays linear.
126fn 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
222/// Serialize an `AgentToolResult` to the JSON shape handlers expect (same shape
223/// the tool adapter uses). Kept here (not reusing tool.rs's `result_to_stb`)
224/// because the emitter must not depend on adapter internals, and this is the
225/// event-side direction.
226fn 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/// Free every `StbString` owned by a dispatched [[`StablePluginEvent`]] via the
265/// host's `free_string`. Called after dispatch completes (the handler SHOULD
266/// have freed them, but the host keeps ownership-of-cleanup so a buggy plugin
267/// that retains/leaks can't double-free the host's allocation — the host never
268/// trusts the plugin to free, per the SDK's "receiver owns" contract: here the
269/// host is the producer → receiver is the plugin; if the plugin failed to free,
270/// the host's free is a leak fix, not a double-free, because `StbString` is
271/// `Copy` and the host's `host_free_string` reconstructs the `Box<[u8]>` and
272/// drops it — a second free of the same bytes would be UB, so this must run
273/// EXACTLY ONCE per event. The contract is: the plugin frees what it receives.
274/// To honor that, we do NOT free here; the plugin owns the free. This fn is
275/// therefore a no-op kept for documentation + future audit).
276///
277/// **In practice:** the plugin handler is contractually required to free every
278/// `StbString` it receives via the host `free_string`. The host does not
279/// double-free. If a plugin leaks, that is a plugin bug.
280#[allow(dead_code)]
281fn free_event_strings(_event: &StablePluginEvent) {
282    // intentionally empty — see doc comment.
283}
284
285// ===========================================================================
286// ExtensionEmitter — AgentEmitter impl fanning out to plugin handlers
287// ===========================================================================
288
289/// An [`AgentEmitter`] that fans each [`AgentEvent`] out to every plugin handler
290/// registered for the event's tag. Built from a [`RegistrySnapshot`] (so it
291/// shares the registry's staleness flag) and installed into
292/// `AgentHarnessOptions.agent_emitter` **alongside** the host's
293/// [`BroadcastEmitter`] — events flow to BOTH the TUI (which drains the
294/// broadcast receiver) and the plugin handlers (which receive translated
295/// `StablePluginEvent`s). The host composes the two via
296/// [`TeeEmitter`](super::TeeEmitter); this emitter alone only dispatches to
297/// plugins.
298///
299/// Dispatch is `catch_unwind`-wrapped: a panicking plugin handler is logged and
300/// skipped (abort-on-unwind policy would be too aggressive for event dispatch
301/// where one bad handler shouldn't kill the session; we log + continue, unlike
302/// tool-partial dispatch which aborts because it cannot unwind across FFI from
303/// a blocking thread). The distinction: `emit` runs on the async runtime thread
304/// where `catch_unwind` can recover cleanly; the tool partial cb runs inside
305/// `spawn_blocking` cross-FFI where recovery is unsafe.
306pub struct ExtensionEmitter {
307    snapshot: Arc<RegistrySnapshot>,
308    /// Keeps the loaded cdylibs mapped for as long as this emitter (installed
309    /// into `AgentHarnessOptions.agent_emitter`) may dispatch to handler fn
310    /// pointers that live inside them. Cloned from the load session; the
311    /// libraries unload only when every holder (adapter + emitter) drops.
312    #[allow(dead_code)]
313    keepalive: Arc<crate::PluginKeepalive>,
314}
315
316impl ExtensionEmitter {
317    /// Build an emitter over a snapshot. The `keepalive` keeps the cdylibs that
318    /// own the snapshot's handler fn pointers mapped for the emitter's lifetime.
319    pub fn new(snapshot: Arc<RegistrySnapshot>, keepalive: Arc<crate::PluginKeepalive>) -> Self {
320        Self {
321            snapshot,
322            keepalive,
323        }
324    }
325
326    /// Dispatch one stable event to all handlers for its tag. Each handler call
327    /// is `catch_unwind`-wrapped; errors (nonzero return) are logged but do not
328    /// abort the fan-out (one handler's failure doesn't block the others — mir-
329    /// rors pi's per-handler try/catch).
330    fn dispatch(&self, event: &StablePluginEvent) {
331        dispatch_to_handlers(&self.snapshot, event);
332    }
333}
334
335/// Fan an already-built event out to the tag's handlers (or free it when no
336/// handler subscribes). Shared by [`ExtensionEmitter`] and the provider-hook
337/// dispatcher ([`crate::provider_hooks`]). Handlers MUST NOT free the event
338/// strings; the host frees exactly once after the fan-out.
339pub fn dispatch_to_handlers(snapshot: &RegistrySnapshot, event: &StablePluginEvent) {
340    // Staleness guard: a stale registry (swapped-out session) does nothing.
341    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        // No subscribers — and critically, the event's StbStrings are owned
347        // by the host and must still be freed (no handler ran to free them).
348        free_dispatched_event(event);
349        return;
350    }
351    for h in handlers {
352        // P2: skip a handler whose plugin declared platforms excluding this host.
353        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        // SAFETY: the plugin warrants `handler` + `user_data` are safe to
364        // call from this thread. catch_unwind so a panic cannot cross FFI.
365        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            // `CLAIMED` from an ordinary observe dispatch is not an error — the
370            // handler simply consumed the event. Only routing dispatchers act on
371            // it (`dispatch_data_event_claiming`).
372            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    // Host frees the event's strings exactly once after the fan-out.
413    free_dispatched_event(event);
414}
415
416/// Build + dispatch a generic `data` event (JSON payload) to the tag's
417/// handlers. Returns whether any handler was invoked. Used by the B4
418/// provider-hook observer path ([`crate::provider_hooks`]) which has no
419/// matching `AgentEvent` to translate.
420pub 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
430/// Build + dispatch a **no-payload** event (e.g. `session_start`, `session_shutdown`,
431/// the rpi-specific `before_tui_start`) to the tag's handlers. Returns whether any
432/// handler was invoked. Mirrors [`dispatch_data_event`] for events whose payload
433/// is `EventEmpty`; a handler that subscribes to a no-payload tag receives the
434/// event with an empty payload.
435pub 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
445/// Like [`dispatch_data_event`], but reports whether a handler **claimed** the
446/// event by returning [`EVENT_HANDLER_CLAIMED`].
447///
448/// This exists for input routing: the TUI asks extensions "is this key yours?"
449/// and must know whether to swallow the key (skip the editor) or pass it
450/// through. A handler that subscribes but is currently inactive (e.g.
451/// push-to-talk toggled off) returns the ordinary continue code and the key
452/// falls through to normal editor handling.
453///
454/// Every handler is still invoked (fan-out is not short-circuited) so a later
455/// handler can disagree; the event is considered claimed if **any** returned
456/// [`EVENT_HANDLER_CLAIMED`].
457pub fn dispatch_data_event_claiming(
458    snapshot: &RegistrySnapshot,
459    tag: EventTag,
460    data: &str,
461) -> bool {
462    if !crate::registry::assert_active(snapshot.active_flag()) {
463        return false;
464    }
465    let handlers = snapshot.handlers_for(tag);
466    if handlers.is_empty() {
467        return false;
468    }
469    let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
470    let mut claimed = false;
471    for h in handlers {
472        if !crate::registry::platform_allows(&h.platforms) {
473            continue;
474        }
475        // SAFETY: as in `dispatch_to_handlers` — the plugin warrants the handler
476        // and its user_data are safe to call from this thread.
477        let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
478        match outcome {
479            Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => claimed = true,
480            Ok(rc) if rc != 0 => {
481                tracing::warn!(tag = ?tag, rc, "extension event handler returned nonzero");
482            }
483            Err(_) => {
484                tracing::error!(tag = ?tag, "extension event handler panicked — skipped");
485            }
486            Ok(_) => {}
487        }
488    }
489    free_dispatched_event(&event);
490    claimed
491}
492
493/// Per-lifecycle-event handler timeout budget (mirrors lifescope §6.1):
494/// startup 5s, session shutdown 15s, everything else 10s. A handler that
495/// exceeds its budget is logged + skipped (it keeps running on its blocking
496/// thread, but no longer gates startup/shutdown).
497pub fn lifecycle_timeout_for(tag: EventTag) -> std::time::Duration {
498    use rpi_plugin_sdk::EventTag as T;
499    match tag {
500        T::BeforeTuiStart => std::time::Duration::from_secs(5),
501        T::SessionShutdown => std::time::Duration::from_secs(15),
502        _ => std::time::Duration::from_secs(10),
503    }
504}
505
506/// Invoke one no-payload lifecycle handler, catching unwinds at the FFI boundary.
507///
508/// Takes [`RegisteredHandler`](crate::registry::RegisteredHandler) **by value**
509/// so the caller's `spawn_blocking` closure captures the whole struct (which has
510/// `unsafe impl Send`) rather than its disjoint fields: `user_data` is a raw
511/// `*mut c_void` and is NOT `Send` on its own (Rust 2021 disjoint closure
512/// capture would otherwise move the raw pointer directly into the closure env
513/// and fail the `Send` bound).
514fn call_lifecycle_handler(
515    h: crate::registry::RegisteredHandler,
516    tag: EventTag,
517) -> std::thread::Result<i32> {
518    let event = StablePluginEvent::empty(tag);
519    // SAFETY: the plugin warrants `h.handler` + `h.user_data` are safe to call
520    // from any thread; catch_unwind so a panic cannot cross FFI.
521    catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)))
522}
523
524/// Veto-aware, timeout-bounded dispatch for a **no-payload lifecycle event**
525/// (`BeforeTuiStart` / `SessionStart` / `SessionShutdown`).
526///
527/// Unlike [`dispatch_empty_event`] (fire-and-forget observe fan-out), this:
528///
529/// 1. Runs each handler on a blocking thread with a per-event timeout
530///    ([`lifecycle_timeout_for`]) — a hung handler is logged + skipped, it
531///    does not stall startup or shutdown.
532/// 2. Honors the SDK's [`EVENT_HANDLER_ABORT`] veto code: the first handler to
533///    return it stops the fan-out and this returns `Some(reason)`.
534///
535/// Returns `None` when no handler vetoed (continue the lifecycle); `Some` when
536/// a handler aborted. A stale registry (swapped-out session) returns `None`.
537/// The **caller** decides what a veto means for the phase — e.g. a
538/// `BeforeTuiStart` veto aborts startup before the TUI initializes.
539pub async fn dispatch_lifecycle_event(
540    snapshot: &RegistrySnapshot,
541    tag: EventTag,
542) -> Option<String> {
543    // Staleness guard: a stale registry (swapped-out session) does nothing.
544    if !crate::registry::assert_active(snapshot.active_flag()) {
545        return None;
546    }
547    let handlers = snapshot.handlers_for(tag).to_vec();
548    if handlers.is_empty() {
549        return None;
550    }
551    // Lifecycle tags are no-payload: the event is `EventEmpty`. We build it
552    // inside the helper (via `spawn_blocking`) so the closure env only carries
553    // Send values.
554    let timeout = lifecycle_timeout_for(tag);
555    for h in handlers {
556        // P2: skip a handler whose plugin declared platforms excluding this host.
557        if !crate::registry::platform_allows(&h.platforms) {
558            crate::event_log::log_handler_invocation(
559                tag,
560                &h.plugin,
561                "PlatformSkip",
562                0,
563                Some(format!("host={}", crate::registry::current_platform())),
564            );
565            continue;
566        }
567        // Capture the owning extension's display name for the veto message
568        // before the handler is moved into the blocking closure.
569        let plugin_name = h.plugin.clone();
570        let h_task = h;
571        let tag_task = tag;
572        let started = std::time::Instant::now();
573        let outcome = tokio::time::timeout(
574            timeout,
575            tokio::task::spawn_blocking(move || call_lifecycle_handler(h_task, tag_task)),
576        )
577        .await;
578        let duration_ms = started.elapsed().as_millis() as u64;
579        match outcome {
580            Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => {
581                tracing::warn!(
582                    tag = ?tag,
583                    plugin = %plugin_name,
584                    "extension handler vetoed lifecycle event"
585                );
586                crate::event_log::log_handler_invocation(
587                    tag,
588                    &plugin_name,
589                    "Abort",
590                    duration_ms,
591                    None,
592                );
593                return Some(format!("extension `{plugin_name}` vetoed {tag:?}"));
594            }
595            Ok(Ok(Ok(rc))) => {
596                let result = if rc == 0 { "Continue" } else { "Error" };
597                crate::event_log::log_handler_invocation(
598                    tag,
599                    &plugin_name,
600                    result,
601                    duration_ms,
602                    if rc == 0 {
603                        None
604                    } else {
605                        Some(format!("rc={rc}"))
606                    },
607                );
608            }
609            Ok(Ok(Err(_))) => {
610                tracing::error!(tag = ?tag, "extension handler panicked — skipped");
611                crate::event_log::log_handler_invocation(
612                    tag,
613                    &plugin_name,
614                    "Panic",
615                    duration_ms,
616                    None,
617                );
618            }
619            Ok(Err(join_err)) => {
620                tracing::error!(
621                    tag = ?tag,
622                    error = %join_err,
623                    "extension handler task join failed — skipped"
624                );
625                crate::event_log::log_handler_invocation(
626                    tag,
627                    &plugin_name,
628                    "JoinFailed",
629                    duration_ms,
630                    Some(join_err.to_string()),
631                );
632            }
633            Err(_elapsed) => {
634                tracing::warn!(
635                    tag = ?tag,
636                    timeout_ms = timeout.as_millis(),
637                    "extension handler timed out — skipped (still running on its blocking thread)"
638                );
639                crate::event_log::log_handler_invocation(
640                    tag,
641                    &plugin_name,
642                    "Timeout",
643                    duration_ms,
644                    Some(format!("budget_ms={}", timeout.as_millis())),
645                );
646            }
647        }
648    }
649    None
650}
651
652impl AgentEmitter for ExtensionEmitter {
653    fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
654        // Translate + dispatch synchronously (the emitter's emit is awaited by
655        // the loop in event order; dispatch is non-blocking fn-pointer calls).
656        // If the event maps to no tag, do nothing.
657        if let Some(stable) = translate(&event) {
658            self.dispatch(&stable);
659        }
660        Box::pin(async {})
661    }
662
663    fn try_emit(&self, event: AgentEvent) {
664        if let Some(stable) = translate(&event) {
665            self.dispatch(&stable);
666        }
667    }
668}
669
670// ===========================================================================
671// TeeEmitter — fan an AgentEvent out to N AgentEmitters (host + plugins)
672// ===========================================================================
673
674/// An [`AgentEmitter`] that forwards every event to each of its children, in
675/// registration order. The host builds one around `[BroadcastEmitter (→ TUI),
676/// ExtensionEmitter (→ plugin handlers)]` so a single `AgentHarnessOptions
677/// .agent_emitter` slot feeds both consumers: the TUI keeps rendering from its
678/// broadcast receiver, and plugin `on()` handlers receive translated
679/// `StablePluginEvent`s.
680///
681/// `emit` awaits each child in turn (the loop awaits `emit`, so order matches
682/// registration); `try_emit` calls each child's `try_emit` (the tool
683/// `on_update` path — non-blocking). A child that panics is isolated by the
684/// child's own `catch_unwind` where applicable (the `ExtensionEmitter` does);
685/// the `BroadcastEmitter` cannot panic (it's a `tx.send`). We do NOT wrap the
686/// fan-out itself in `catch_unwind` — each child is responsible for its own
687/// soundness, and a generic wrapper would mask a child's contract violation.
688pub struct TeeEmitter {
689    emitters: Vec<Arc<dyn AgentEmitter>>,
690}
691
692impl TeeEmitter {
693    /// Build a tee over the given emitters. Order is preserved: `emit`/`try_emit`
694    /// visit them front-to-back. A single-child tee is a trivial passthrough
695    /// (the host uses that when no extensions loaded, so the code path is
696    /// uniform).
697    pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
698        Self { emitters }
699    }
700}
701
702impl AgentEmitter for TeeEmitter {
703    fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
704        // We can't hold `&self` across an await boundary into a 'static future
705        // cheaply here without cloning the Arcs — so clone them and drive the
706        // fan-out inside a pinned async block. Each child's emit returns a
707        // no-op future (both BroadcastEmitter and ExtensionEmitter complete
708        // synchronously), so this is effectively a synchronous loop in practice.
709        let emitters = self.emitters.clone();
710        Box::pin(async move {
711            for e in &emitters {
712                e.emit(event.clone()).await;
713            }
714        })
715    }
716
717    fn try_emit(&self, event: AgentEvent) {
718        for e in &self.emitters {
719            e.try_emit(event.clone());
720        }
721    }
722}
723
724/// Free every owning `StbString` in a dispatched event exactly once via the
725/// host's `free_string`. Called by [`ExtensionEmitter::dispatch`] after the
726/// fan-out completes. Handlers MUST NOT free event strings (host owns cleanup).
727fn free_dispatched_event(event: &StablePluginEvent) {
728    use rpi_plugin_sdk::EventTag as T;
729    match event.tag {
730        T::MessageStart | T::MessageUpdate | T::MessageEnd => {
731            // SAFETY: tag matches the message variant.
732            unsafe { host_free_string(event.payload.message.message) };
733        }
734        T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
735            // SAFETY: tag matches the tool_call variant.
736            unsafe {
737                let tc = &event.payload.tool_call;
738                host_free_string(tc.tool_call_id);
739                host_free_string(tc.tool_name);
740                host_free_string(tc.params);
741            }
742        }
743        T::ToolResult | T::ToolExecutionEnd => {
744            // SAFETY: tag matches the tool_result variant.
745            unsafe {
746                let tr = &event.payload.tool_result;
747                host_free_string(tr.tool_call_id);
748                host_free_string(tr.tool_name);
749                host_free_string(tr.result);
750            }
751        }
752        T::ProjectTrust
753        | T::ResourcesDiscover
754        | T::SessionStart
755        | T::SessionInfoChanged
756        | T::SessionBeforeSwitch
757        | T::SessionBeforeFork
758        | T::SessionBeforeCompact
759        | T::SessionCompact
760        | T::SessionShutdown
761        | T::SessionBeforeTree
762        | T::SessionTree
763        | T::Context
764        | T::BeforeAgentStart
765        | T::AgentStart
766        | T::AgentEnd
767        | T::AgentSettled
768        | T::TurnStart
769        | T::TurnEnd
770        | T::ModelSelect
771        | T::ThinkingLevelSelect
772        | T::UserBash
773        | T::BeforeTuiStart
774        | T::UiPromptStart
775        | T::UiPromptEnd => {
776            // no payload today.
777        }
778        // The B4 provider-hook observer events, and the `Input` /`EditorChange`
779        // events dispatched by the TUI, carry a generic data payload (built by
780        // `dispatch_data_event`); free the single StbString.
781        T::BeforeProviderRequest
782        | T::BeforeProviderHeaders
783        | T::AfterProviderResponse
784        | T::Input
785        | T::EditorChange => {
786            // SAFETY: these tags are only ever constructed as data payloads.
787            unsafe { host_free_string(event.payload.data.data) };
788        }
789    }
790}
791
792// SAFETY note on the `unsafe { host_free_string(...) }` calls above:
793// `host_free_string` is itself a safe `extern "C" fn` (it reconstructs a
794// `Box<[u8]>` from ptr+len and drops it, idempotent on null/empty). The
795// `unsafe` block is required only because reading the union payload is
796// `unsafe` (the compiler can't verify tag/variant match) — which we guarantee
797// by matching on `event.tag` first. So the union read is sound.
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use rpi_agent::message::AgentMessage;
803    use rpi_agent::types::AgentToolResult;
804    use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, Usage};
805    use rpi_plugin_sdk::EventTag;
806    use std::sync::atomic::{AtomicUsize, Ordering};
807    use std::sync::Mutex;
808
809    // The two handler-fan-out tests share the process-global `HANDLER_HITS`
810    // counter (the handler is an `extern "C" fn` that can't capture per-test
811    // state). Parallel #[test] execution would have one test's `store(0)`
812    // wipe the other's in-flight increment. Hold this lock for the ENTIRE
813    // body of both tests so their reset/dispatch windows don't overlap. The
814    // dispatch is synchronous (fn-pointer calls), so the guard is released
815    // before the test returns — no handler outlives the test.
816    static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
817
818    #[test]
819    fn all_ten_agent_events_map_to_a_tag() {
820        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
821            role: rpi_ai::types::AssistantRole,
822            content: vec![rpi_ai::types::Content::text("hi")],
823            api: rpi_ai::types::Api::AnthropicMessages,
824            provider: "anthropic".to_string(),
825            model: "m".into(),
826            response_model: None,
827            response_id: None,
828            usage: Usage::zero(),
829            stop_reason: rpi_ai::types::StopReason::Stop,
830            deferred: None,
831            error_message: None,
832            raw_stop_reason: None,
833            end_turn: None,
834            timestamp: 0,
835        }));
836        let events = vec![
837            AgentEvent::AgentStart,
838            AgentEvent::AgentEnd { messages: vec![] },
839            AgentEvent::TurnStart,
840            AgentEvent::TurnEnd {
841                message: am.clone(),
842                tool_results: vec![],
843            },
844            AgentEvent::MessageStart {
845                message: am.clone(),
846            },
847            AgentEvent::MessageUpdate {
848                // `MessageUpdate` carries the shared partial snapshot, so build
849                // the same `Arc` the provider would have handed over.
850                message: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
851                assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
852                    partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
853                },
854            },
855            AgentEvent::MessageEnd { message: am },
856            AgentEvent::ToolExecutionStart {
857                tool_call_id: "c1".into(),
858                tool_name: "echo".into(),
859                args: serde_json::json!({}),
860            },
861            AgentEvent::ToolExecutionUpdate {
862                tool_call_id: "c1".into(),
863                tool_name: "echo".into(),
864                args: serde_json::json!({}),
865                partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
866            },
867            AgentEvent::ToolExecutionEnd {
868                tool_call_id: "c1".into(),
869                tool_name: "echo".into(),
870                result: AgentToolResult::text("done"),
871                is_error: false,
872            },
873        ];
874        for e in &events {
875            assert!(
876                event_tag_for(e).is_some(),
877                "event {:?} should map",
878                e.type_tag()
879            );
880        }
881        // Spot-check the fold targets.
882        assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
883        assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
884        assert_eq!(
885            event_tag_for(&events[7]),
886            Some(EventTag::ToolExecutionStart)
887        );
888        assert!(event_tag_for(&AgentEvent::RetryScheduled {
889            attempt: 1,
890            max_retries: 10,
891            delay_ms: 2_000,
892            error: "503".into(),
893        })
894        .is_none());
895    }
896
897    #[test]
898    fn translate_message_update_forwards_native_text_deltas_without_snapshot() {
899        let partial = Arc::new(AssistantMessage {
900            role: rpi_ai::types::AssistantRole,
901            content: vec![rpi_ai::types::Content::text("你好世界")],
902            api: rpi_ai::types::Api::AnthropicMessages,
903            provider: "anthropic".to_string(),
904            model: "m".into(),
905            response_model: None,
906            response_id: None,
907            usage: Usage::zero(),
908            stop_reason: rpi_ai::types::StopReason::Pending,
909            deferred: None,
910            error_message: None,
911            raw_stop_reason: None,
912            end_turn: None,
913            timestamp: 0,
914        });
915        let translate_delta = |delta: &str| {
916            let event = AgentEvent::MessageUpdate {
917                message: Arc::clone(&partial),
918                assistant_message_event: AssistantMessageEvent::TextDelta {
919                    content_index: 0,
920                    delta: delta.to_string(),
921                    partial: Arc::clone(&partial),
922                },
923            };
924            let stable = translate(&event).expect("message_update maps");
925            let payload = unsafe { stable.payload.message.message.to_string_lossy() };
926            unsafe { host_free_string(stable.payload.message.message) };
927            serde_json::from_str::<serde_json::Value>(&payload).expect("valid payload")
928        };
929
930        let first = translate_delta("你好");
931        let second = translate_delta("世界");
932        assert_eq!(first["assistantMessageEvent"]["type"], "text_delta");
933        assert_eq!(first["assistantMessageEvent"]["contentIndex"], 0);
934        assert_eq!(first["assistantMessageEvent"]["delta"], "你好");
935        assert_eq!(second["assistantMessageEvent"]["delta"], "世界");
936        assert!(first.get("message").is_none());
937        assert!(first["assistantMessageEvent"].get("partial").is_none());
938    }
939
940    #[test]
941    fn translate_message_end_produces_message_payload() {
942        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
943            role: rpi_ai::types::AssistantRole,
944            content: vec![rpi_ai::types::Content::text("hi")],
945            api: rpi_ai::types::Api::AnthropicMessages,
946            provider: "anthropic".to_string(),
947            model: "m".into(),
948            response_model: None,
949            response_id: None,
950            usage: Usage::zero(),
951            stop_reason: rpi_ai::types::StopReason::Stop,
952            deferred: None,
953            error_message: None,
954            raw_stop_reason: None,
955            end_turn: None,
956            timestamp: 0,
957        }));
958        let ev = AgentEvent::MessageEnd { message: am };
959        let stable = translate(&ev).expect("maps");
960        assert_eq!(stable.tag, EventTag::MessageEnd);
961        // free the payload string (host owns cleanup).
962        // SAFETY: tag == MessageEnd.
963        unsafe { host_free_string(stable.payload.message.message) };
964    }
965
966    // --- an emitter fan-out test with an in-process handler -----------------
967
968    static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
969
970    extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
971        HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
972        0
973    }
974
975    #[test]
976    fn emitter_dispatches_to_registered_handlers() {
977        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
978        HANDLER_HITS.store(0, Ordering::SeqCst);
979        let mut reg = crate::registry::ExtensionRegistry::new();
980        reg.register_event_handler(
981            "test-plugin".to_string(),
982            EventTag::MessageEnd,
983            counting_handler,
984            std::ptr::null_mut(),
985        );
986        let snap = Arc::new(reg.snapshot());
987        let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
988
989        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
990            role: rpi_ai::types::AssistantRole,
991            content: vec![rpi_ai::types::Content::text("hi")],
992            api: rpi_ai::types::Api::AnthropicMessages,
993            provider: "anthropic".to_string(),
994            model: "m".into(),
995            response_model: None,
996            response_id: None,
997            usage: Usage::zero(),
998            stop_reason: rpi_ai::types::StopReason::Stop,
999            deferred: None,
1000            error_message: None,
1001            raw_stop_reason: None,
1002            end_turn: None,
1003            timestamp: 0,
1004        }));
1005        // Use try_emit (sync) — no runtime needed.
1006        emitter.try_emit(AgentEvent::MessageEnd { message: am });
1007        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1008
1009        // Stale registry → no dispatch.
1010        reg.invalidate();
1011        let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
1012            role: rpi_ai::types::AssistantRole,
1013            content: vec![rpi_ai::types::Content::text("hi")],
1014            api: rpi_ai::types::Api::AnthropicMessages,
1015            provider: "anthropic".to_string(),
1016            model: "m".into(),
1017            response_model: None,
1018            response_id: None,
1019            usage: Usage::zero(),
1020            stop_reason: rpi_ai::types::StopReason::Stop,
1021            deferred: None,
1022            error_message: None,
1023            raw_stop_reason: None,
1024            end_turn: None,
1025            timestamp: 0,
1026        }));
1027        emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
1028        assert_eq!(
1029            HANDLER_HITS.load(Ordering::SeqCst),
1030            1,
1031            "stale registry must not dispatch"
1032        );
1033    }
1034
1035    // --- TeeEmitter: fan-out to both the broadcast + the extension emitter ----
1036
1037    #[tokio::test]
1038    async fn tee_emitter_fans_out_to_every_child() {
1039        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1040        use rpi_agent::events::{AgentEmitter, CollectorEmitter};
1041
1042        // Two collector emitters + record how many plugin handler hits land.
1043        let (collector_a, events_a) = CollectorEmitter::new();
1044        let (collector_b, events_b) = CollectorEmitter::new();
1045        HANDLER_HITS.store(0, Ordering::SeqCst);
1046        let mut reg = crate::registry::ExtensionRegistry::new();
1047        reg.register_event_handler(
1048            "test-plugin".to_string(),
1049            EventTag::MessageEnd,
1050            counting_handler,
1051            std::ptr::null_mut(),
1052        );
1053        let snap = Arc::new(reg.snapshot());
1054        let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1055
1056        let tee = TeeEmitter::new(vec![
1057            Arc::new(collector_a),
1058            Arc::new(collector_b),
1059            Arc::new(ext),
1060        ]);
1061
1062        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1063            role: rpi_ai::types::AssistantRole,
1064            content: vec![rpi_ai::types::Content::text("hi")],
1065            api: rpi_ai::types::Api::AnthropicMessages,
1066            provider: "anthropic".to_string(),
1067            model: "m".into(),
1068            response_model: None,
1069            response_id: None,
1070            usage: Usage::zero(),
1071            stop_reason: rpi_ai::types::StopReason::Stop,
1072            deferred: None,
1073            error_message: None,
1074            raw_stop_reason: None,
1075            end_turn: None,
1076            timestamp: 0,
1077        }));
1078        // Broadcast path: both collectors receive the event, and the extension
1079        // emitter dispatches to the one registered handler.
1080        tee.emit(AgentEvent::MessageEnd { message: am }).await;
1081        assert_eq!(
1082            events_a.lock().unwrap().len(),
1083            1,
1084            "collector A got the event"
1085        );
1086        assert_eq!(
1087            events_b.lock().unwrap().len(),
1088            1,
1089            "collector B got the event"
1090        );
1091        assert_eq!(
1092            HANDLER_HITS.load(Ordering::SeqCst),
1093            1,
1094            "plugin handler fired once"
1095        );
1096    }
1097
1098    // --- dispatch_empty_event: no-payload lifecycle events (P0 session events)
1099
1100    /// Verifies `dispatch_empty_event` fans a no-payload event (e.g. the
1101    /// rpi-specific `BeforeTuiStart` / session lifecycle tags) to every
1102    /// subscriber and reports whether any handler was invoked.
1103    #[test]
1104    fn dispatch_empty_event_fans_out_to_subscribers() {
1105        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1106        HANDLER_HITS.store(0, Ordering::SeqCst);
1107        let mut reg = crate::registry::ExtensionRegistry::new();
1108        reg.register_event_handler(
1109            "test-plugin".to_string(),
1110            EventTag::SessionStart,
1111            counting_handler,
1112            std::ptr::null_mut(),
1113        );
1114        reg.register_event_handler(
1115            "test-plugin".to_string(),
1116            EventTag::BeforeTuiStart,
1117            counting_handler,
1118            std::ptr::null_mut(),
1119        );
1120        let snap = Arc::new(reg.snapshot());
1121
1122        // No subscriber for SessionShutdown → no handler invoked.
1123        assert!(!dispatch_empty_event(&snap, EventTag::SessionShutdown));
1124        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 0);
1125
1126        // SessionStart + BeforeTuiStart both have one subscriber each.
1127        assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1128        assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1129        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1130
1131        // Stale registry (session swapped): dispatch is a silent no-op inside
1132        // `dispatch_to_handlers` — no handler is invoked — though the fn still
1133        // reports subscribers existed (matches `dispatch_data_event` semantics).
1134        reg.invalidate();
1135        assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1136        assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1137        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1138    }
1139
1140    // --- dispatch_lifecycle_event: veto + timeout (P1) ---
1141
1142    /// A lifecycle handler that vetoes (returns the SDK abort code).
1143    extern "C" fn aborting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1144        rpi_plugin_sdk::EVENT_HANDLER_ABORT
1145    }
1146
1147    /// A lifecycle handler that returns success (`0`) — no veto.
1148    extern "C" fn continue_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1149        rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1150    }
1151
1152    /// A lifecycle handler that returns a generic handled error — no veto.
1153    extern "C" fn error_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1154        rpi_plugin_sdk::EVENT_HANDLER_ERROR
1155    }
1156
1157    #[test]
1158    fn lifecycle_timeout_budget_matches_spec() {
1159        assert_eq!(
1160            lifecycle_timeout_for(EventTag::BeforeTuiStart),
1161            std::time::Duration::from_secs(5)
1162        );
1163        assert_eq!(
1164            lifecycle_timeout_for(EventTag::SessionShutdown),
1165            std::time::Duration::from_secs(15)
1166        );
1167        assert_eq!(
1168            lifecycle_timeout_for(EventTag::SessionStart),
1169            std::time::Duration::from_secs(10)
1170        );
1171    }
1172
1173    /// A handler returning `EVENT_HANDLER_ABORT` vetoes the phase; the returned
1174    /// reason names the owning extension.
1175    #[tokio::test]
1176    async fn dispatch_lifecycle_event_honors_veto() {
1177        let mut reg = crate::registry::ExtensionRegistry::new();
1178        reg.register_event_handler(
1179            "veto-ext".to_string(),
1180            EventTag::BeforeTuiStart,
1181            aborting_handler,
1182            std::ptr::null_mut(),
1183        );
1184        let snap = Arc::new(reg.snapshot());
1185        let result = dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart).await;
1186        assert_eq!(
1187            result.as_deref(),
1188            Some("extension `veto-ext` vetoed BeforeTuiStart")
1189        );
1190    }
1191
1192    /// A `0` (continue) or generic nonzero (handled error) return does NOT veto;
1193    /// the fan-out still visits every handler and returns `None`.
1194    #[tokio::test]
1195    async fn dispatch_lifecycle_event_continue_and_error_do_not_veto() {
1196        let mut reg = crate::registry::ExtensionRegistry::new();
1197        reg.register_event_handler(
1198            "ok-ext".to_string(),
1199            EventTag::SessionStart,
1200            continue_handler,
1201            std::ptr::null_mut(),
1202        );
1203        reg.register_event_handler(
1204            "err-ext".to_string(),
1205            EventTag::SessionStart,
1206            error_handler,
1207            std::ptr::null_mut(),
1208        );
1209        let snap = Arc::new(reg.snapshot());
1210        assert!(dispatch_lifecycle_event(&snap, EventTag::SessionStart)
1211            .await
1212            .is_none());
1213    }
1214
1215    /// No subscribers → no veto, no work.
1216    #[tokio::test]
1217    async fn dispatch_lifecycle_event_no_subscribers_is_none() {
1218        let reg = crate::registry::ExtensionRegistry::new();
1219        let snap = Arc::new(reg.snapshot());
1220        assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1221            .await
1222            .is_none());
1223    }
1224
1225    /// A stale registry (swapped-out session) is a no-op — a veto from a stale
1226    /// handler must NOT abort the new session's startup.
1227    #[tokio::test]
1228    async fn dispatch_lifecycle_event_stale_is_none() {
1229        let mut reg = crate::registry::ExtensionRegistry::new();
1230        reg.register_event_handler(
1231            "veto-ext".to_string(),
1232            EventTag::BeforeTuiStart,
1233            aborting_handler,
1234            std::ptr::null_mut(),
1235        );
1236        let snap = Arc::new(reg.snapshot());
1237        reg.invalidate();
1238        assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1239            .await
1240            .is_none());
1241    }
1242
1243    /// The observe fan-out records one JSONL line per handler when the global
1244    /// event logger is enabled.
1245    #[test]
1246    fn dispatch_records_handler_invocation_to_event_log() {
1247        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1248        HANDLER_HITS.store(0, Ordering::SeqCst);
1249        let dir = tempfile::tempdir().unwrap();
1250        let path = dir.path().join("events.jsonl");
1251        crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::open(
1252            path.clone(),
1253        ));
1254
1255        let mut reg = crate::registry::ExtensionRegistry::new();
1256        reg.register_event_handler(
1257            "log-ext".to_string(),
1258            EventTag::MessageEnd,
1259            counting_handler,
1260            std::ptr::null_mut(),
1261        );
1262        let snap = Arc::new(reg.snapshot());
1263        let event = StablePluginEvent::message(
1264            EventTag::MessageEnd,
1265            StbString::from_string("m".to_string()),
1266        );
1267        dispatch_to_handlers(&snap, &event);
1268
1269        // Restore the disabled default so later tests don't write into the
1270        // (soon-deleted) temp file.
1271        crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::disabled());
1272
1273        let text = std::fs::read_to_string(&path).unwrap();
1274        assert!(text.contains(r#""event":"MessageEnd""#), "log: {text}");
1275        assert!(text.contains(r#""plugin":"log-ext""#), "log: {text}");
1276        assert!(text.contains(r#""result":"Continue""#), "log: {text}");
1277        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1278    }
1279
1280    // --- key-routing (`dispatch_data_event_claiming`) ----------------------
1281
1282    /// Handler that claims the event unless `CLAIM_ROUTING` says otherwise.
1283    /// `CLAIM_ROUTING` is global because an `extern "C" fn` cannot capture.
1284    static CLAIM_ROUTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1285
1286    extern "C" fn claiming_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1287        // The payload must be readable as a data event (this is also what
1288        // exercises the `Input` free path not leaking/aliasing).
1289        let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1290        assert!(seen.contains("space"), "payload not delivered: {seen}");
1291        if CLAIM_ROUTING.load(Ordering::SeqCst) {
1292            rpi_plugin_sdk::EVENT_HANDLER_CLAIMED
1293        } else {
1294            rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1295        }
1296    }
1297
1298    fn input_snapshot(with_handler: bool) -> Arc<crate::registry::RegistrySnapshot> {
1299        let mut reg = crate::registry::ExtensionRegistry::new();
1300        if with_handler {
1301            reg.register_event_handler(
1302                "voice".to_string(),
1303                EventTag::Input,
1304                claiming_handler,
1305                std::ptr::null_mut(),
1306            );
1307        }
1308        Arc::new(reg.snapshot())
1309    }
1310
1311    #[test]
1312    fn claiming_dispatch_reports_claim_and_passthrough() {
1313        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1314        let snap = input_snapshot(true);
1315
1316        CLAIM_ROUTING.store(true, Ordering::SeqCst);
1317        assert!(
1318            dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1319            "a CLAIMED handler must swallow the key"
1320        );
1321
1322        CLAIM_ROUTING.store(false, Ordering::SeqCst);
1323        assert!(
1324            !dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1325            "a CONTINUE handler must let the key reach the editor"
1326        );
1327        CLAIM_ROUTING.store(true, Ordering::SeqCst);
1328    }
1329
1330    #[test]
1331    fn claiming_dispatch_without_subscribers_is_false() {
1332        let snap = input_snapshot(false);
1333        assert!(!dispatch_data_event_claiming(
1334            &snap,
1335            EventTag::Input,
1336            r#"{"key":"space"}"#
1337        ));
1338    }
1339
1340    // --- editor-change (`dispatch_data_event`) -----------------------------
1341
1342    /// The `EditorChange` notification reaches subscribers with its payload
1343    /// intact and returns "no veto" (it is an observer event: the draft change
1344    /// has already happened and must never be blocked).
1345    #[test]
1346    fn editor_change_reaches_subscribers_with_its_payload() {
1347        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1348        let mut reg = crate::registry::ExtensionRegistry::new();
1349        reg.register_event_handler(
1350            "voice".to_string(),
1351            EventTag::EditorChange,
1352            editor_change_handler,
1353            std::ptr::null_mut(),
1354        );
1355        let snap = Arc::new(reg.snapshot());
1356
1357        EDITOR_CHANGE_SEEN.store(false, Ordering::SeqCst);
1358        assert!(
1359            dispatch_data_event(
1360                &snap,
1361                EventTag::EditorChange,
1362                r#"{"chars":4,"empty":false}"#
1363            ),
1364            "a subscribed handler must be invoked"
1365        );
1366        assert!(
1367            EDITOR_CHANGE_SEEN.load(Ordering::SeqCst),
1368            "payload missing or handler not called"
1369        );
1370    }
1371
1372    /// Handler that records it saw a well-formed editor-change payload. The
1373    /// flag is global because an `extern "C" fn` cannot capture.
1374    static EDITOR_CHANGE_SEEN: std::sync::atomic::AtomicBool =
1375        std::sync::atomic::AtomicBool::new(false);
1376
1377    extern "C" fn editor_change_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1378        // Reading the data payload here also exercises the `EditorChange` free
1379        // path (a wrong arm in `free_dispatched_event` would corrupt/leak).
1380        let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1381        if seen.contains("chars") && seen.contains("empty") {
1382            EDITOR_CHANGE_SEEN.store(true, Ordering::SeqCst);
1383        }
1384        rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1385    }
1386}