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/// Timeout budget for a `ToolCall` approval dispatch. Generous because the
446/// handler may wait on a human (e.g. an ACP `session/request_permission`).
447pub const TOOL_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
448
449/// Dispatch a tool approval against a live session. Only a successful continue
450/// permits execution; stale sessions, cancellation, timeout and handler errors
451/// block it. Blocking callbacks retain the plugin libraries until they return,
452/// even when the async caller has already stopped waiting.
453pub async fn dispatch_tool_call_event(
454    session: &crate::ExtensionSession,
455    tool_call_id: &str,
456    tool_name: &str,
457    params: &serde_json::Value,
458    signal: &tokio_util::sync::CancellationToken,
459    ui_dialog: &crate::UiDialogMailbox,
460) -> Option<String> {
461    dispatch_tool_call_event_with_timeout(
462        session,
463        tool_call_id,
464        tool_name,
465        params,
466        signal,
467        ui_dialog,
468        TOOL_CALL_TIMEOUT,
469    )
470    .await
471}
472
473async fn dispatch_tool_call_event_with_timeout(
474    session: &crate::ExtensionSession,
475    tool_call_id: &str,
476    tool_name: &str,
477    params: &serde_json::Value,
478    signal: &tokio_util::sync::CancellationToken,
479    ui_dialog: &crate::UiDialogMailbox,
480    timeout: std::time::Duration,
481) -> Option<String> {
482    let snapshot = session.snapshot()?;
483    if !crate::registry::assert_active(snapshot.active_flag()) {
484        return Some(format!(
485            "tool `{tool_name}` approval session is stale; retry after reload"
486        ));
487    }
488    for h in snapshot.handlers_for(EventTag::ToolCall).iter().cloned() {
489        if !crate::registry::platform_allows(&h.platforms) {
490            continue;
491        }
492        if signal.is_cancelled() {
493            ui_dialog.cancel_tool_call(tool_call_id);
494            return Some(format!("tool `{tool_name}` approval cancelled"));
495        }
496        if !crate::registry::assert_active(snapshot.active_flag()) {
497            return Some(format!(
498                "tool `{tool_name}` approval session is stale; retry after reload"
499            ));
500        }
501        let plugin_name = h.plugin.clone();
502        let id = tool_call_id.to_string();
503        let name = tool_name.to_string();
504        let payload = params.to_string();
505        let keepalive = session.keepalive();
506        let started = std::time::Instant::now();
507        // Capture the whole RegisteredHandler rather than its raw user_data.
508        // The callback's own keepalive also survives timeout/cancellation.
509        let mut task = tokio::task::spawn_blocking(move || {
510            let _keepalive = keepalive;
511            call_tool_call_handler(h, id, name, payload)
512        });
513        let outcome = tokio::select! {
514            biased;
515            _ = signal.cancelled() => {
516                task.abort(); // Prevent a queued callback from starting.
517                ui_dialog.cancel_tool_call(tool_call_id);
518                crate::event_log::log_handler_invocation(
519                    EventTag::ToolCall, &plugin_name, "Cancelled",
520                    started.elapsed().as_millis() as u64, None,
521                );
522                return Some(format!("tool `{tool_name}` approval cancelled"));
523            }
524            outcome = tokio::time::timeout(timeout, &mut task) => outcome,
525        };
526        let duration_ms = started.elapsed().as_millis() as u64;
527        let (status, error) = match outcome {
528            Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_CONTINUE => ("Continue", None),
529            Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => (
530                "Abort",
531                Some(format!(
532                    "extension `{plugin_name}` blocked tool `{tool_name}`"
533                )),
534            ),
535            Ok(Ok(Ok(rc))) => (
536                "Error",
537                Some(format!(
538                    "extension `{plugin_name}` tool `{tool_name}` approval failed: rc={rc}"
539                )),
540            ),
541            Ok(Ok(Err(_))) => (
542                "Panic",
543                Some(format!(
544                    "extension `{plugin_name}` tool `{tool_name}` approval panicked"
545                )),
546            ),
547            Ok(Err(error)) => (
548                "JoinFailed",
549                Some(format!(
550                    "extension `{plugin_name}` tool `{tool_name}` approval task failed: {error}"
551                )),
552            ),
553            Err(_) => {
554                task.abort();
555                (
556                    "Timeout",
557                    Some(format!(
558                        "extension `{plugin_name}` tool `{tool_name}` approval timed out"
559                    )),
560                )
561            }
562        };
563        crate::event_log::log_handler_invocation(
564            EventTag::ToolCall,
565            &plugin_name,
566            status,
567            duration_ms,
568            error.clone(),
569        );
570        if let Some(reason) = error {
571            ui_dialog.cancel_tool_call(tool_call_id);
572            return Some(reason);
573        }
574        // A reload can invalidate this session while the callback is running.
575        if !crate::registry::assert_active(snapshot.active_flag()) {
576            ui_dialog.cancel_tool_call(tool_call_id);
577            return Some(format!(
578                "tool `{tool_name}` approval session is stale; retry after reload"
579            ));
580        }
581    }
582    None
583}
584
585/// Like [`dispatch_data_event`], but reports whether a handler **claimed** the
586/// event by returning [`EVENT_HANDLER_CLAIMED`].
587///
588/// This exists for input routing: the TUI asks extensions "is this key yours?"
589/// and must know whether to swallow the key (skip the editor) or pass it
590/// through. A handler that subscribes but is currently inactive (e.g.
591/// push-to-talk toggled off) returns the ordinary continue code and the key
592/// falls through to normal editor handling.
593///
594/// Every handler is still invoked (fan-out is not short-circuited) so a later
595/// handler can disagree; the event is considered claimed if **any** returned
596/// [`EVENT_HANDLER_CLAIMED`].
597pub fn dispatch_data_event_claiming(
598    snapshot: &RegistrySnapshot,
599    tag: EventTag,
600    data: &str,
601) -> bool {
602    if !crate::registry::assert_active(snapshot.active_flag()) {
603        return false;
604    }
605    let handlers = snapshot.handlers_for(tag);
606    if handlers.is_empty() {
607        return false;
608    }
609    let event = StablePluginEvent::data(tag, StbString::from_string(data.to_string()));
610    let mut claimed = false;
611    for h in handlers {
612        if !crate::registry::platform_allows(&h.platforms) {
613            continue;
614        }
615        // SAFETY: as in `dispatch_to_handlers` — the plugin warrants the handler
616        // and its user_data are safe to call from this thread.
617        let outcome = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
618        match outcome {
619            Ok(rc) if rc == rpi_plugin_sdk::EVENT_HANDLER_CLAIMED => claimed = true,
620            Ok(rc) if rc != 0 => {
621                tracing::warn!(tag = ?tag, rc, "extension event handler returned nonzero");
622            }
623            Err(_) => {
624                tracing::error!(tag = ?tag, "extension event handler panicked — skipped");
625            }
626            Ok(_) => {}
627        }
628    }
629    free_dispatched_event(&event);
630    claimed
631}
632
633/// Per-lifecycle-event handler timeout budget (see docs/extensions/lifecycle.md):
634/// startup 5s, session shutdown 15s, everything else 10s. A handler that
635/// exceeds its budget is logged + skipped (it keeps running on its blocking
636/// thread, but no longer gates startup/shutdown).
637pub fn lifecycle_timeout_for(tag: EventTag) -> std::time::Duration {
638    use rpi_plugin_sdk::EventTag as T;
639    match tag {
640        T::BeforeTuiStart => std::time::Duration::from_secs(5),
641        T::SessionShutdown => std::time::Duration::from_secs(15),
642        _ => std::time::Duration::from_secs(10),
643    }
644}
645
646/// Invoke one no-payload lifecycle handler, catching unwinds at the FFI boundary.
647///
648/// Takes [`RegisteredHandler`](crate::registry::RegisteredHandler) **by value**
649/// so the caller's `spawn_blocking` closure captures the whole struct (which has
650/// `unsafe impl Send`) rather than its disjoint fields: `user_data` is a raw
651/// `*mut c_void` and is NOT `Send` on its own (Rust 2021 disjoint closure
652/// capture would otherwise move the raw pointer directly into the closure env
653/// and fail the `Send` bound).
654fn call_lifecycle_handler(
655    h: crate::registry::RegisteredHandler,
656    tag: EventTag,
657) -> std::thread::Result<i32> {
658    let event = StablePluginEvent::empty(tag);
659    // SAFETY: the plugin warrants `h.handler` + `h.user_data` are safe to call
660    // from any thread; catch_unwind so a panic cannot cross FFI.
661    catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)))
662}
663
664/// Invoke one `ToolCall` handler on a blocking thread. Takes the whole
665/// [`RegisteredHandler`](crate::registry::RegisteredHandler) **by value** for the
666/// same `Send` reason as [`call_lifecycle_handler`]: the closure must capture the
667/// `Send` struct whole, not its non-`Send` `user_data` field. Builds and frees
668/// the tool-call event on this thread (a `StablePluginEvent` holds raw pointers).
669fn call_tool_call_handler(
670    h: crate::registry::RegisteredHandler,
671    tool_call_id: String,
672    tool_name: String,
673    params: String,
674) -> std::thread::Result<i32> {
675    let event = StablePluginEvent::tool_call(
676        EventTag::ToolCall,
677        StbString::from_string(tool_call_id),
678        StbString::from_string(tool_name),
679        StbString::from_string(params),
680    );
681    // SAFETY: the plugin warrants `h.handler` + `h.user_data` are safe to call
682    // from any thread; catch_unwind so a panic cannot cross FFI.
683    let rc = catch_unwind(AssertUnwindSafe(|| (h.handler)(event, h.user_data)));
684    free_dispatched_event(&event);
685    rc
686}
687
688/// Veto-aware, timeout-bounded dispatch for a **no-payload lifecycle event**
689/// (`BeforeTuiStart` / `SessionStart` / `SessionShutdown`).
690///
691/// Unlike [`dispatch_empty_event`] (fire-and-forget observe fan-out), this:
692///
693/// 1. Runs each handler on a blocking thread with a per-event timeout
694///    ([`lifecycle_timeout_for`]) — a hung handler is logged + skipped, it
695///    does not stall startup or shutdown.
696/// 2. Honors the SDK's [`EVENT_HANDLER_ABORT`] veto code: the first handler to
697///    return it stops the fan-out and this returns `Some(reason)`.
698///
699/// Returns `None` when no handler vetoed (continue the lifecycle); `Some` when
700/// a handler aborted. A stale registry (swapped-out session) returns `None`.
701/// The **caller** decides what a veto means for the phase — e.g. a
702/// `BeforeTuiStart` veto aborts startup before the TUI initializes.
703pub async fn dispatch_lifecycle_event(
704    snapshot: &RegistrySnapshot,
705    tag: EventTag,
706) -> Option<String> {
707    // Staleness guard: a stale registry (swapped-out session) does nothing.
708    if !crate::registry::assert_active(snapshot.active_flag()) {
709        return None;
710    }
711    let handlers = snapshot.handlers_for(tag).to_vec();
712    if handlers.is_empty() {
713        return None;
714    }
715    // Lifecycle tags are no-payload: the event is `EventEmpty`. We build it
716    // inside the helper (via `spawn_blocking`) so the closure env only carries
717    // Send values.
718    let timeout = lifecycle_timeout_for(tag);
719    for h in handlers {
720        // P2: skip a handler whose plugin declared platforms excluding this host.
721        if !crate::registry::platform_allows(&h.platforms) {
722            crate::event_log::log_handler_invocation(
723                tag,
724                &h.plugin,
725                "PlatformSkip",
726                0,
727                Some(format!("host={}", crate::registry::current_platform())),
728            );
729            continue;
730        }
731        // Capture the owning extension's display name for the veto message
732        // before the handler is moved into the blocking closure.
733        let plugin_name = h.plugin.clone();
734        let h_task = h;
735        let tag_task = tag;
736        let started = std::time::Instant::now();
737        let outcome = tokio::time::timeout(
738            timeout,
739            tokio::task::spawn_blocking(move || call_lifecycle_handler(h_task, tag_task)),
740        )
741        .await;
742        let duration_ms = started.elapsed().as_millis() as u64;
743        match outcome {
744            Ok(Ok(Ok(rc))) if rc == rpi_plugin_sdk::EVENT_HANDLER_ABORT => {
745                tracing::warn!(
746                    tag = ?tag,
747                    plugin = %plugin_name,
748                    "extension handler vetoed lifecycle event"
749                );
750                crate::event_log::log_handler_invocation(
751                    tag,
752                    &plugin_name,
753                    "Abort",
754                    duration_ms,
755                    None,
756                );
757                return Some(format!("extension `{plugin_name}` vetoed {tag:?}"));
758            }
759            Ok(Ok(Ok(rc))) => {
760                let result = if rc == 0 { "Continue" } else { "Error" };
761                crate::event_log::log_handler_invocation(
762                    tag,
763                    &plugin_name,
764                    result,
765                    duration_ms,
766                    if rc == 0 {
767                        None
768                    } else {
769                        Some(format!("rc={rc}"))
770                    },
771                );
772            }
773            Ok(Ok(Err(_))) => {
774                tracing::error!(tag = ?tag, "extension handler panicked — skipped");
775                crate::event_log::log_handler_invocation(
776                    tag,
777                    &plugin_name,
778                    "Panic",
779                    duration_ms,
780                    None,
781                );
782            }
783            Ok(Err(join_err)) => {
784                tracing::error!(
785                    tag = ?tag,
786                    error = %join_err,
787                    "extension handler task join failed — skipped"
788                );
789                crate::event_log::log_handler_invocation(
790                    tag,
791                    &plugin_name,
792                    "JoinFailed",
793                    duration_ms,
794                    Some(join_err.to_string()),
795                );
796            }
797            Err(_elapsed) => {
798                tracing::warn!(
799                    tag = ?tag,
800                    timeout_ms = timeout.as_millis(),
801                    "extension handler timed out — skipped (still running on its blocking thread)"
802                );
803                crate::event_log::log_handler_invocation(
804                    tag,
805                    &plugin_name,
806                    "Timeout",
807                    duration_ms,
808                    Some(format!("budget_ms={}", timeout.as_millis())),
809                );
810            }
811        }
812    }
813    None
814}
815
816impl AgentEmitter for ExtensionEmitter {
817    fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
818        // Translate + dispatch synchronously (the emitter's emit is awaited by
819        // the loop in event order; dispatch is non-blocking fn-pointer calls).
820        // If the event maps to no tag, do nothing.
821        if let Some(stable) = translate(&event) {
822            self.dispatch(&stable);
823        }
824        Box::pin(async {})
825    }
826
827    fn try_emit(&self, event: AgentEvent) {
828        if let Some(stable) = translate(&event) {
829            self.dispatch(&stable);
830        }
831    }
832}
833
834// ===========================================================================
835// TeeEmitter — fan an AgentEvent out to N AgentEmitters (host + plugins)
836// ===========================================================================
837
838/// An [`AgentEmitter`] that forwards every event to each of its children, in
839/// registration order. The host builds one around `[BroadcastEmitter (→ TUI),
840/// ExtensionEmitter (→ plugin handlers)]` so a single `AgentHarnessOptions
841/// .agent_emitter` slot feeds both consumers: the TUI keeps rendering from its
842/// broadcast receiver, and plugin `on()` handlers receive translated
843/// `StablePluginEvent`s.
844///
845/// `emit` awaits each child in turn (the loop awaits `emit`, so order matches
846/// registration); `try_emit` calls each child's `try_emit` (the tool
847/// `on_update` path — non-blocking). A child that panics is isolated by the
848/// child's own `catch_unwind` where applicable (the `ExtensionEmitter` does);
849/// the `BroadcastEmitter` cannot panic (it's a `tx.send`). We do NOT wrap the
850/// fan-out itself in `catch_unwind` — each child is responsible for its own
851/// soundness, and a generic wrapper would mask a child's contract violation.
852pub struct TeeEmitter {
853    emitters: Vec<Arc<dyn AgentEmitter>>,
854}
855
856impl TeeEmitter {
857    /// Build a tee over the given emitters. Order is preserved: `emit`/`try_emit`
858    /// visit them front-to-back. A single-child tee is a trivial passthrough
859    /// (the host uses that when no extensions loaded, so the code path is
860    /// uniform).
861    pub fn new(emitters: Vec<Arc<dyn AgentEmitter>>) -> Self {
862        Self { emitters }
863    }
864}
865
866impl AgentEmitter for TeeEmitter {
867    fn emit(&self, event: AgentEvent) -> BoxFuture<'static, ()> {
868        // We can't hold `&self` across an await boundary into a 'static future
869        // cheaply here without cloning the Arcs — so clone them and drive the
870        // fan-out inside a pinned async block. Each child's emit returns a
871        // no-op future (both BroadcastEmitter and ExtensionEmitter complete
872        // synchronously), so this is effectively a synchronous loop in practice.
873        let emitters = self.emitters.clone();
874        Box::pin(async move {
875            for e in &emitters {
876                e.emit(event.clone()).await;
877            }
878        })
879    }
880
881    fn try_emit(&self, event: AgentEvent) {
882        for e in &self.emitters {
883            e.try_emit(event.clone());
884        }
885    }
886}
887
888/// Free every owning `StbString` in a dispatched event exactly once via the
889/// host's `free_string`. Called by [`ExtensionEmitter::dispatch`] after the
890/// fan-out completes. Handlers MUST NOT free event strings (host owns cleanup).
891fn free_dispatched_event(event: &StablePluginEvent) {
892    use rpi_plugin_sdk::EventTag as T;
893    match event.tag {
894        T::MessageStart | T::MessageUpdate | T::MessageEnd => {
895            // SAFETY: tag matches the message variant.
896            unsafe { host_free_string(event.payload.message.message) };
897        }
898        T::ToolCall | T::ToolExecutionStart | T::ToolExecutionUpdate => {
899            // SAFETY: tag matches the tool_call variant.
900            unsafe {
901                let tc = &event.payload.tool_call;
902                host_free_string(tc.tool_call_id);
903                host_free_string(tc.tool_name);
904                host_free_string(tc.params);
905            }
906        }
907        T::ToolResult | T::ToolExecutionEnd => {
908            // SAFETY: tag matches the tool_result variant.
909            unsafe {
910                let tr = &event.payload.tool_result;
911                host_free_string(tr.tool_call_id);
912                host_free_string(tr.tool_name);
913                host_free_string(tr.result);
914            }
915        }
916        T::ProjectTrust
917        | T::ResourcesDiscover
918        | T::SessionStart
919        | T::SessionInfoChanged
920        | T::SessionBeforeSwitch
921        | T::SessionBeforeFork
922        | T::SessionBeforeCompact
923        | T::SessionCompact
924        | T::SessionShutdown
925        | T::SessionBeforeTree
926        | T::SessionTree
927        | T::Context
928        | T::BeforeAgentStart
929        | T::AgentStart
930        | T::AgentEnd
931        | T::AgentSettled
932        | T::TurnStart
933        | T::TurnEnd
934        | T::ModelSelect
935        | T::ThinkingLevelSelect
936        | T::UserBash
937        | T::BeforeTuiStart
938        | T::UiPromptStart
939        | T::UiPromptEnd => {
940            // no payload today.
941        }
942        // The B4 provider-hook observer events, and the `Input` /`EditorChange`
943        // events dispatched by the TUI, carry a generic data payload (built by
944        // `dispatch_data_event`); free the single StbString.
945        T::BeforeProviderRequest
946        | T::BeforeProviderHeaders
947        | T::AfterProviderResponse
948        | T::Input
949        | T::EditorChange => {
950            // SAFETY: these tags are only ever constructed as data payloads.
951            unsafe { host_free_string(event.payload.data.data) };
952        }
953    }
954}
955
956// SAFETY note on the `unsafe { host_free_string(...) }` calls above:
957// `host_free_string` is itself a safe `extern "C" fn` (it reconstructs a
958// `Box<[u8]>` from ptr+len and drops it, idempotent on null/empty). The
959// `unsafe` block is required only because reading the union payload is
960// `unsafe` (the compiler can't verify tag/variant match) — which we guarantee
961// by matching on `event.tag` first. So the union read is sound.
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use rpi_agent::message::AgentMessage;
967    use rpi_agent::types::AgentToolResult;
968    use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, Usage};
969    use rpi_plugin_sdk::EventTag;
970    use std::sync::atomic::{AtomicUsize, Ordering};
971    use std::sync::Mutex;
972
973    // The two handler-fan-out tests share the process-global `HANDLER_HITS`
974    // counter (the handler is an `extern "C" fn` that can't capture per-test
975    // state). Parallel #[test] execution would have one test's `store(0)`
976    // wipe the other's in-flight increment. Hold this lock for the ENTIRE
977    // body of both tests so their reset/dispatch windows don't overlap. The
978    // dispatch is synchronous (fn-pointer calls), so the guard is released
979    // before the test returns — no handler outlives the test.
980    static HANDLER_TEST_LOCK: Mutex<()> = Mutex::new(());
981
982    #[test]
983    fn all_ten_agent_events_map_to_a_tag() {
984        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
985            role: rpi_ai::types::AssistantRole,
986            content: vec![rpi_ai::types::Content::text("hi")],
987            api: rpi_ai::types::Api::AnthropicMessages,
988            provider: "anthropic".to_string(),
989            model: "m".into(),
990            response_model: None,
991            response_id: None,
992            usage: Usage::zero(),
993            stop_reason: rpi_ai::types::StopReason::Stop,
994            deferred: None,
995            error_message: None,
996            raw_stop_reason: None,
997            end_turn: None,
998            timestamp: 0,
999        }));
1000        let events = vec![
1001            AgentEvent::AgentStart,
1002            AgentEvent::AgentEnd { messages: vec![] },
1003            AgentEvent::TurnStart,
1004            AgentEvent::TurnEnd {
1005                message: am.clone(),
1006                tool_results: vec![],
1007            },
1008            AgentEvent::MessageStart {
1009                message: am.clone(),
1010            },
1011            AgentEvent::MessageUpdate {
1012                // `MessageUpdate` carries the shared partial snapshot, so build
1013                // the same `Arc` the provider would have handed over.
1014                message: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
1015                assistant_message_event: rpi_ai::types::AssistantMessageEvent::Start {
1016                    partial: std::sync::Arc::new((*am.as_assistant().unwrap()).clone()),
1017                },
1018            },
1019            AgentEvent::MessageEnd { message: am },
1020            AgentEvent::ToolExecutionStart {
1021                tool_call_id: "c1".into(),
1022                tool_name: "echo".into(),
1023                args: serde_json::json!({}),
1024            },
1025            AgentEvent::ToolExecutionUpdate {
1026                tool_call_id: "c1".into(),
1027                tool_name: "echo".into(),
1028                args: serde_json::json!({}),
1029                partial_result: std::sync::Arc::new(AgentToolResult::text("...")),
1030            },
1031            AgentEvent::ToolExecutionEnd {
1032                tool_call_id: "c1".into(),
1033                tool_name: "echo".into(),
1034                result: AgentToolResult::text("done"),
1035                is_error: false,
1036            },
1037        ];
1038        for e in &events {
1039            assert!(
1040                event_tag_for(e).is_some(),
1041                "event {:?} should map",
1042                e.type_tag()
1043            );
1044        }
1045        // Spot-check the fold targets.
1046        assert_eq!(event_tag_for(&events[0]), Some(EventTag::AgentStart));
1047        assert_eq!(event_tag_for(&events[2]), Some(EventTag::TurnStart));
1048        assert_eq!(
1049            event_tag_for(&events[7]),
1050            Some(EventTag::ToolExecutionStart)
1051        );
1052        assert!(event_tag_for(&AgentEvent::RetryScheduled {
1053            attempt: 1,
1054            max_retries: 10,
1055            delay_ms: 2_000,
1056            error: "503".into(),
1057        })
1058        .is_none());
1059    }
1060
1061    #[test]
1062    fn translate_message_update_forwards_native_text_deltas_without_snapshot() {
1063        let partial = Arc::new(AssistantMessage {
1064            role: rpi_ai::types::AssistantRole,
1065            content: vec![rpi_ai::types::Content::text("你好世界")],
1066            api: rpi_ai::types::Api::AnthropicMessages,
1067            provider: "anthropic".to_string(),
1068            model: "m".into(),
1069            response_model: None,
1070            response_id: None,
1071            usage: Usage::zero(),
1072            stop_reason: rpi_ai::types::StopReason::Pending,
1073            deferred: None,
1074            error_message: None,
1075            raw_stop_reason: None,
1076            end_turn: None,
1077            timestamp: 0,
1078        });
1079        let translate_delta = |delta: &str| {
1080            let event = AgentEvent::MessageUpdate {
1081                message: Arc::clone(&partial),
1082                assistant_message_event: AssistantMessageEvent::TextDelta {
1083                    content_index: 0,
1084                    delta: delta.to_string(),
1085                    partial: Arc::clone(&partial),
1086                },
1087            };
1088            let stable = translate(&event).expect("message_update maps");
1089            let payload = unsafe { stable.payload.message.message.to_string_lossy() };
1090            unsafe { host_free_string(stable.payload.message.message) };
1091            serde_json::from_str::<serde_json::Value>(&payload).expect("valid payload")
1092        };
1093
1094        let first = translate_delta("你好");
1095        let second = translate_delta("世界");
1096        assert_eq!(first["assistantMessageEvent"]["type"], "text_delta");
1097        assert_eq!(first["assistantMessageEvent"]["contentIndex"], 0);
1098        assert_eq!(first["assistantMessageEvent"]["delta"], "你好");
1099        assert_eq!(second["assistantMessageEvent"]["delta"], "世界");
1100        assert!(first.get("message").is_none());
1101        assert!(first["assistantMessageEvent"].get("partial").is_none());
1102    }
1103
1104    #[test]
1105    fn translate_message_end_produces_message_payload() {
1106        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1107            role: rpi_ai::types::AssistantRole,
1108            content: vec![rpi_ai::types::Content::text("hi")],
1109            api: rpi_ai::types::Api::AnthropicMessages,
1110            provider: "anthropic".to_string(),
1111            model: "m".into(),
1112            response_model: None,
1113            response_id: None,
1114            usage: Usage::zero(),
1115            stop_reason: rpi_ai::types::StopReason::Stop,
1116            deferred: None,
1117            error_message: None,
1118            raw_stop_reason: None,
1119            end_turn: None,
1120            timestamp: 0,
1121        }));
1122        let ev = AgentEvent::MessageEnd { message: am };
1123        let stable = translate(&ev).expect("maps");
1124        assert_eq!(stable.tag, EventTag::MessageEnd);
1125        // free the payload string (host owns cleanup).
1126        // SAFETY: tag == MessageEnd.
1127        unsafe { host_free_string(stable.payload.message.message) };
1128    }
1129
1130    // --- an emitter fan-out test with an in-process handler -----------------
1131
1132    static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
1133
1134    extern "C" fn counting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1135        HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
1136        0
1137    }
1138
1139    #[test]
1140    fn emitter_dispatches_to_registered_handlers() {
1141        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1142        HANDLER_HITS.store(0, Ordering::SeqCst);
1143        let mut reg = crate::registry::ExtensionRegistry::new();
1144        reg.register_event_handler(
1145            "test-plugin".to_string(),
1146            EventTag::MessageEnd,
1147            counting_handler,
1148            std::ptr::null_mut(),
1149        );
1150        let snap = Arc::new(reg.snapshot());
1151        let emitter = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1152
1153        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1154            role: rpi_ai::types::AssistantRole,
1155            content: vec![rpi_ai::types::Content::text("hi")],
1156            api: rpi_ai::types::Api::AnthropicMessages,
1157            provider: "anthropic".to_string(),
1158            model: "m".into(),
1159            response_model: None,
1160            response_id: None,
1161            usage: Usage::zero(),
1162            stop_reason: rpi_ai::types::StopReason::Stop,
1163            deferred: None,
1164            error_message: None,
1165            raw_stop_reason: None,
1166            end_turn: None,
1167            timestamp: 0,
1168        }));
1169        // Use try_emit (sync) — no runtime needed.
1170        emitter.try_emit(AgentEvent::MessageEnd { message: am });
1171        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1172
1173        // Stale registry → no dispatch.
1174        reg.invalidate();
1175        let am2 = AgentMessage::Assistant(Box::new(AssistantMessage {
1176            role: rpi_ai::types::AssistantRole,
1177            content: vec![rpi_ai::types::Content::text("hi")],
1178            api: rpi_ai::types::Api::AnthropicMessages,
1179            provider: "anthropic".to_string(),
1180            model: "m".into(),
1181            response_model: None,
1182            response_id: None,
1183            usage: Usage::zero(),
1184            stop_reason: rpi_ai::types::StopReason::Stop,
1185            deferred: None,
1186            error_message: None,
1187            raw_stop_reason: None,
1188            end_turn: None,
1189            timestamp: 0,
1190        }));
1191        emitter.try_emit(AgentEvent::MessageEnd { message: am2 });
1192        assert_eq!(
1193            HANDLER_HITS.load(Ordering::SeqCst),
1194            1,
1195            "stale registry must not dispatch"
1196        );
1197    }
1198
1199    // --- TeeEmitter: fan-out to both the broadcast + the extension emitter ----
1200
1201    #[tokio::test]
1202    async fn tee_emitter_fans_out_to_every_child() {
1203        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1204        use rpi_agent::events::{AgentEmitter, CollectorEmitter};
1205
1206        // Two collector emitters + record how many plugin handler hits land.
1207        let (collector_a, events_a) = CollectorEmitter::new();
1208        let (collector_b, events_b) = CollectorEmitter::new();
1209        HANDLER_HITS.store(0, Ordering::SeqCst);
1210        let mut reg = crate::registry::ExtensionRegistry::new();
1211        reg.register_event_handler(
1212            "test-plugin".to_string(),
1213            EventTag::MessageEnd,
1214            counting_handler,
1215            std::ptr::null_mut(),
1216        );
1217        let snap = Arc::new(reg.snapshot());
1218        let ext = ExtensionEmitter::new(snap, crate::loader::PluginKeepalive::empty());
1219
1220        let tee = TeeEmitter::new(vec![
1221            Arc::new(collector_a),
1222            Arc::new(collector_b),
1223            Arc::new(ext),
1224        ]);
1225
1226        let am = AgentMessage::Assistant(Box::new(AssistantMessage {
1227            role: rpi_ai::types::AssistantRole,
1228            content: vec![rpi_ai::types::Content::text("hi")],
1229            api: rpi_ai::types::Api::AnthropicMessages,
1230            provider: "anthropic".to_string(),
1231            model: "m".into(),
1232            response_model: None,
1233            response_id: None,
1234            usage: Usage::zero(),
1235            stop_reason: rpi_ai::types::StopReason::Stop,
1236            deferred: None,
1237            error_message: None,
1238            raw_stop_reason: None,
1239            end_turn: None,
1240            timestamp: 0,
1241        }));
1242        // Broadcast path: both collectors receive the event, and the extension
1243        // emitter dispatches to the one registered handler.
1244        tee.emit(AgentEvent::MessageEnd { message: am }).await;
1245        assert_eq!(
1246            events_a.lock().unwrap().len(),
1247            1,
1248            "collector A got the event"
1249        );
1250        assert_eq!(
1251            events_b.lock().unwrap().len(),
1252            1,
1253            "collector B got the event"
1254        );
1255        assert_eq!(
1256            HANDLER_HITS.load(Ordering::SeqCst),
1257            1,
1258            "plugin handler fired once"
1259        );
1260    }
1261
1262    // --- dispatch_empty_event: no-payload lifecycle events (P0 session events)
1263
1264    /// Verifies `dispatch_empty_event` fans a no-payload event (e.g. the
1265    /// rpi-specific `BeforeTuiStart` / session lifecycle tags) to every
1266    /// subscriber and reports whether any handler was invoked.
1267    #[test]
1268    fn dispatch_empty_event_fans_out_to_subscribers() {
1269        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1270        HANDLER_HITS.store(0, Ordering::SeqCst);
1271        let mut reg = crate::registry::ExtensionRegistry::new();
1272        reg.register_event_handler(
1273            "test-plugin".to_string(),
1274            EventTag::SessionStart,
1275            counting_handler,
1276            std::ptr::null_mut(),
1277        );
1278        reg.register_event_handler(
1279            "test-plugin".to_string(),
1280            EventTag::BeforeTuiStart,
1281            counting_handler,
1282            std::ptr::null_mut(),
1283        );
1284        let snap = Arc::new(reg.snapshot());
1285
1286        // No subscriber for SessionShutdown → no handler invoked.
1287        assert!(!dispatch_empty_event(&snap, EventTag::SessionShutdown));
1288        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 0);
1289
1290        // SessionStart + BeforeTuiStart both have one subscriber each.
1291        assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1292        assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1293        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1294
1295        // Stale registry (session swapped): dispatch is a silent no-op inside
1296        // `dispatch_to_handlers` — no handler is invoked — though the fn still
1297        // reports subscribers existed (matches `dispatch_data_event` semantics).
1298        reg.invalidate();
1299        assert!(dispatch_empty_event(&snap, EventTag::SessionStart));
1300        assert!(dispatch_empty_event(&snap, EventTag::BeforeTuiStart));
1301        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 2);
1302    }
1303
1304    // --- dispatch_lifecycle_event: veto + timeout (P1) ---
1305
1306    /// A lifecycle handler that vetoes (returns the SDK abort code).
1307    extern "C" fn aborting_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1308        rpi_plugin_sdk::EVENT_HANDLER_ABORT
1309    }
1310
1311    /// A lifecycle handler that returns success (`0`) — no veto.
1312    extern "C" fn continue_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1313        rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1314    }
1315
1316    /// A lifecycle handler that returns a generic handled error — no veto.
1317    extern "C" fn error_handler(_ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1318        rpi_plugin_sdk::EVENT_HANDLER_ERROR
1319    }
1320
1321    #[test]
1322    fn lifecycle_timeout_budget_matches_spec() {
1323        assert_eq!(
1324            lifecycle_timeout_for(EventTag::BeforeTuiStart),
1325            std::time::Duration::from_secs(5)
1326        );
1327        assert_eq!(
1328            lifecycle_timeout_for(EventTag::SessionShutdown),
1329            std::time::Duration::from_secs(15)
1330        );
1331        assert_eq!(
1332            lifecycle_timeout_for(EventTag::SessionStart),
1333            std::time::Duration::from_secs(10)
1334        );
1335    }
1336
1337    /// A handler returning `EVENT_HANDLER_ABORT` vetoes the phase; the returned
1338    /// reason names the owning extension.
1339    #[tokio::test]
1340    async fn dispatch_lifecycle_event_honors_veto() {
1341        let mut reg = crate::registry::ExtensionRegistry::new();
1342        reg.register_event_handler(
1343            "veto-ext".to_string(),
1344            EventTag::BeforeTuiStart,
1345            aborting_handler,
1346            std::ptr::null_mut(),
1347        );
1348        let snap = Arc::new(reg.snapshot());
1349        let result = dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart).await;
1350        assert_eq!(
1351            result.as_deref(),
1352            Some("extension `veto-ext` vetoed BeforeTuiStart")
1353        );
1354    }
1355
1356    /// A `0` (continue) or generic nonzero (handled error) return does NOT veto;
1357    /// the fan-out still visits every handler and returns `None`.
1358    #[tokio::test]
1359    async fn dispatch_lifecycle_event_continue_and_error_do_not_veto() {
1360        let mut reg = crate::registry::ExtensionRegistry::new();
1361        reg.register_event_handler(
1362            "ok-ext".to_string(),
1363            EventTag::SessionStart,
1364            continue_handler,
1365            std::ptr::null_mut(),
1366        );
1367        reg.register_event_handler(
1368            "err-ext".to_string(),
1369            EventTag::SessionStart,
1370            error_handler,
1371            std::ptr::null_mut(),
1372        );
1373        let snap = Arc::new(reg.snapshot());
1374        assert!(dispatch_lifecycle_event(&snap, EventTag::SessionStart)
1375            .await
1376            .is_none());
1377    }
1378
1379    fn tool_call_session(reg: &crate::ExtensionRegistry) -> crate::ExtensionSession {
1380        crate::ExtensionSession::from_parts(
1381            Arc::new(reg.snapshot()),
1382            crate::PluginKeepalive::empty(),
1383            Vec::new(),
1384            None,
1385        )
1386    }
1387
1388    #[tokio::test]
1389    async fn tool_call_approval_error_and_stale_session_block_execution() {
1390        let mut reg = crate::ExtensionRegistry::new();
1391        reg.register_event_handler(
1392            "broken-gate".into(),
1393            EventTag::ToolCall,
1394            error_handler,
1395            std::ptr::null_mut(),
1396        );
1397        let session = tool_call_session(&reg);
1398        let signal = tokio_util::sync::CancellationToken::new();
1399        let ui = crate::UiDialogMailbox::new();
1400        let reason =
1401            dispatch_tool_call_event(&session, "c1", "bash", &serde_json::json!({}), &signal, &ui)
1402                .await
1403                .unwrap();
1404        assert!(reason.contains("approval failed: rc="), "{reason}");
1405        reg.invalidate();
1406        let reason =
1407            dispatch_tool_call_event(&session, "c2", "bash", &serde_json::json!({}), &signal, &ui)
1408                .await
1409                .unwrap();
1410        assert!(reason.contains("stale"), "{reason}");
1411    }
1412
1413    struct WaitingApproval {
1414        ui: crate::UiDialogMailbox,
1415        entered: tokio::sync::Notify,
1416        finished: tokio::sync::Notify,
1417        release: std::sync::atomic::AtomicBool,
1418    }
1419
1420    extern "C" fn waiting_approval(event: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
1421        // The test owns this context until the worker reports completion.
1422        let state = unsafe { Arc::from_raw(data as *const WaitingApproval) };
1423        let call_id = unsafe { event.payload.tool_call.tool_call_id.to_string_lossy() };
1424        state
1425            .ui
1426            .handle(serde_json::json!({
1427                "op": "open", "requestId": "approval", "toolCallId": call_id,
1428                "ui": {"question": "Allow?"},
1429            }))
1430            .unwrap();
1431        state.entered.notify_one();
1432        loop {
1433            if state.release.load(Ordering::SeqCst) {
1434                break;
1435            }
1436            if state.ui.poll("approval").unwrap()["status"] == "cancelled" {
1437                break;
1438            }
1439            std::thread::sleep(std::time::Duration::from_millis(2));
1440        }
1441        // Stay inside the callback so the test can check library retention
1442        // after the async dispatch has already returned.
1443        while !state.release.load(Ordering::SeqCst) {
1444            std::thread::sleep(std::time::Duration::from_millis(2));
1445        }
1446        state.finished.notify_one();
1447        rpi_plugin_sdk::EVENT_HANDLER_ABORT
1448    }
1449
1450    async fn check_interrupted_approval(cancel: bool) {
1451        let ui = crate::UiDialogMailbox::new();
1452        ui.attach();
1453        ui.handle(serde_json::json!({
1454            "requestId": "other", "toolCallId": "other-call", "ui": {},
1455        }))
1456        .unwrap();
1457        let state = Arc::new(WaitingApproval {
1458            ui: ui.clone(),
1459            entered: tokio::sync::Notify::new(),
1460            finished: tokio::sync::Notify::new(),
1461            release: std::sync::atomic::AtomicBool::new(false),
1462        });
1463        // Release on failed assertions too, so the blocking runtime can exit.
1464        struct ReleaseWorker(Arc<WaitingApproval>);
1465        impl Drop for ReleaseWorker {
1466            fn drop(&mut self) {
1467                self.0.release.store(true, Ordering::SeqCst);
1468            }
1469        }
1470        let _release = ReleaseWorker(state.clone());
1471        let mut reg = crate::ExtensionRegistry::new();
1472        reg.register_event_handler(
1473            "waiting-gate".into(),
1474            EventTag::ToolCall,
1475            waiting_approval,
1476            Arc::into_raw(state.clone()) as *mut std::ffi::c_void,
1477        );
1478        let keepalive = crate::PluginKeepalive::empty();
1479        let weak = Arc::downgrade(&keepalive);
1480        let session = crate::ExtensionSession::from_parts(
1481            Arc::new(reg.snapshot()),
1482            keepalive,
1483            Vec::new(),
1484            None,
1485        );
1486        let signal = tokio_util::sync::CancellationToken::new();
1487        let task_signal = signal.clone();
1488        let task_ui = ui.clone();
1489        let task = tokio::spawn(async move {
1490            dispatch_tool_call_event_with_timeout(
1491                &session,
1492                "call",
1493                "bash",
1494                &serde_json::json!({}),
1495                &task_signal,
1496                &task_ui,
1497                if cancel {
1498                    TOOL_CALL_TIMEOUT
1499                } else {
1500                    std::time::Duration::from_millis(200)
1501                },
1502            )
1503            .await
1504        });
1505        tokio::time::timeout(std::time::Duration::from_secs(2), state.entered.notified())
1506            .await
1507            .unwrap();
1508        if cancel {
1509            signal.cancel();
1510        }
1511        let reason = tokio::time::timeout(std::time::Duration::from_secs(2), task)
1512            .await
1513            .unwrap()
1514            .unwrap()
1515            .unwrap();
1516        assert!(
1517            reason.contains(if cancel { "cancelled" } else { "timed out" }),
1518            "{reason}"
1519        );
1520        assert!(
1521            weak.upgrade().is_some(),
1522            "running callback must keep its library mapped"
1523        );
1524        assert_eq!(ui.poll("other").unwrap()["status"], "pending");
1525        state.release.store(true, Ordering::SeqCst);
1526        tokio::time::timeout(std::time::Duration::from_secs(2), state.finished.notified())
1527            .await
1528            .unwrap();
1529        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1530            while weak.upgrade().is_some() {
1531                tokio::task::yield_now().await;
1532            }
1533        })
1534        .await
1535        .unwrap();
1536    }
1537
1538    #[tokio::test]
1539    async fn tool_call_approval_cancel_releases_waiter_and_retains_running_library() {
1540        check_interrupted_approval(true).await;
1541    }
1542
1543    #[tokio::test]
1544    async fn tool_call_approval_timeout_blocks_and_retains_running_library() {
1545        check_interrupted_approval(false).await;
1546    }
1547
1548    // --- dispatch_tool_call_event: veto (B3b before_tool_call) ---
1549
1550    /// A `ToolCall` handler that vetoes blocks the tool and names the tool.
1551    #[tokio::test]
1552    async fn dispatch_tool_call_event_honors_veto() {
1553        let mut reg = crate::registry::ExtensionRegistry::new();
1554        reg.register_event_handler(
1555            "gate-ext".to_string(),
1556            EventTag::ToolCall,
1557            aborting_handler,
1558            std::ptr::null_mut(),
1559        );
1560        let session = tool_call_session(&reg);
1561        let veto = dispatch_tool_call_event(
1562            &session,
1563            "call_1",
1564            "bash",
1565            &serde_json::json!({"command": "rm -rf /"}),
1566            &tokio_util::sync::CancellationToken::new(),
1567            &crate::UiDialogMailbox::new(),
1568        )
1569        .await;
1570        assert_eq!(
1571            veto.as_deref(),
1572            Some("extension `gate-ext` blocked tool `bash`")
1573        );
1574    }
1575
1576    /// A continue code does not veto; no subscribers is also `None`.
1577    #[tokio::test]
1578    async fn dispatch_tool_call_event_continue_and_empty_do_not_veto() {
1579        let mut reg = crate::registry::ExtensionRegistry::new();
1580        reg.register_event_handler(
1581            "ok-ext".to_string(),
1582            EventTag::ToolCall,
1583            continue_handler,
1584            std::ptr::null_mut(),
1585        );
1586        let session = tool_call_session(&reg);
1587        assert!(dispatch_tool_call_event(
1588            &session,
1589            "call_1",
1590            "read",
1591            &serde_json::json!({"path": "x"}),
1592            &tokio_util::sync::CancellationToken::new(),
1593            &crate::UiDialogMailbox::new(),
1594        )
1595        .await
1596        .is_none());
1597
1598        let empty = crate::registry::ExtensionRegistry::new();
1599        let empty_session = tool_call_session(&empty);
1600        assert!(dispatch_tool_call_event(
1601            &empty_session,
1602            "call_2",
1603            "read",
1604            &serde_json::json!({}),
1605            &tokio_util::sync::CancellationToken::new(),
1606            &crate::UiDialogMailbox::new()
1607        )
1608        .await
1609        .is_none());
1610    }
1611
1612    /// No subscribers → no veto, no work.
1613    #[tokio::test]
1614    async fn dispatch_lifecycle_event_no_subscribers_is_none() {
1615        let reg = crate::registry::ExtensionRegistry::new();
1616        let snap = Arc::new(reg.snapshot());
1617        assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1618            .await
1619            .is_none());
1620    }
1621
1622    /// A stale registry (swapped-out session) is a no-op — a veto from a stale
1623    /// handler must NOT abort the new session's startup.
1624    #[tokio::test]
1625    async fn dispatch_lifecycle_event_stale_is_none() {
1626        let mut reg = crate::registry::ExtensionRegistry::new();
1627        reg.register_event_handler(
1628            "veto-ext".to_string(),
1629            EventTag::BeforeTuiStart,
1630            aborting_handler,
1631            std::ptr::null_mut(),
1632        );
1633        let snap = Arc::new(reg.snapshot());
1634        reg.invalidate();
1635        assert!(dispatch_lifecycle_event(&snap, EventTag::BeforeTuiStart)
1636            .await
1637            .is_none());
1638    }
1639
1640    /// The observe fan-out records one JSONL line per handler when the global
1641    /// event logger is enabled.
1642    #[test]
1643    fn dispatch_records_handler_invocation_to_event_log() {
1644        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1645        HANDLER_HITS.store(0, Ordering::SeqCst);
1646        let dir = tempfile::tempdir().unwrap();
1647        let path = dir.path().join("events.jsonl");
1648        crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::open(
1649            path.clone(),
1650        ));
1651
1652        let mut reg = crate::registry::ExtensionRegistry::new();
1653        reg.register_event_handler(
1654            "log-ext".to_string(),
1655            EventTag::MessageEnd,
1656            counting_handler,
1657            std::ptr::null_mut(),
1658        );
1659        let snap = Arc::new(reg.snapshot());
1660        let event = StablePluginEvent::message(
1661            EventTag::MessageEnd,
1662            StbString::from_string("m".to_string()),
1663        );
1664        dispatch_to_handlers(&snap, &event);
1665
1666        // Restore the disabled default so later tests don't write into the
1667        // (soon-deleted) temp file.
1668        crate::event_log::set_event_logger_for_test(crate::event_log::EventLogger::disabled());
1669
1670        let text = std::fs::read_to_string(&path).unwrap();
1671        assert!(text.contains(r#""event":"MessageEnd""#), "log: {text}");
1672        assert!(text.contains(r#""plugin":"log-ext""#), "log: {text}");
1673        assert!(text.contains(r#""result":"Continue""#), "log: {text}");
1674        assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
1675    }
1676
1677    // --- key-routing (`dispatch_data_event_claiming`) ----------------------
1678
1679    /// Handler that claims the event unless `CLAIM_ROUTING` says otherwise.
1680    /// `CLAIM_ROUTING` is global because an `extern "C" fn` cannot capture.
1681    static CLAIM_ROUTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1682
1683    extern "C" fn claiming_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1684        // The payload must be readable as a data event (this is also what
1685        // exercises the `Input` free path not leaking/aliasing).
1686        let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1687        assert!(seen.contains("space"), "payload not delivered: {seen}");
1688        if CLAIM_ROUTING.load(Ordering::SeqCst) {
1689            rpi_plugin_sdk::EVENT_HANDLER_CLAIMED
1690        } else {
1691            rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1692        }
1693    }
1694
1695    fn input_snapshot(with_handler: bool) -> Arc<crate::registry::RegistrySnapshot> {
1696        let mut reg = crate::registry::ExtensionRegistry::new();
1697        if with_handler {
1698            reg.register_event_handler(
1699                "voice".to_string(),
1700                EventTag::Input,
1701                claiming_handler,
1702                std::ptr::null_mut(),
1703            );
1704        }
1705        Arc::new(reg.snapshot())
1706    }
1707
1708    #[test]
1709    fn claiming_dispatch_reports_claim_and_passthrough() {
1710        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1711        let snap = input_snapshot(true);
1712
1713        CLAIM_ROUTING.store(true, Ordering::SeqCst);
1714        assert!(
1715            dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1716            "a CLAIMED handler must swallow the key"
1717        );
1718
1719        CLAIM_ROUTING.store(false, Ordering::SeqCst);
1720        assert!(
1721            !dispatch_data_event_claiming(&snap, EventTag::Input, r#"{"key":"space"}"#),
1722            "a CONTINUE handler must let the key reach the editor"
1723        );
1724        CLAIM_ROUTING.store(true, Ordering::SeqCst);
1725    }
1726
1727    #[test]
1728    fn claiming_dispatch_without_subscribers_is_false() {
1729        let snap = input_snapshot(false);
1730        assert!(!dispatch_data_event_claiming(
1731            &snap,
1732            EventTag::Input,
1733            r#"{"key":"space"}"#
1734        ));
1735    }
1736
1737    // --- editor-change (`dispatch_data_event`) -----------------------------
1738
1739    /// The `EditorChange` notification reaches subscribers with its payload
1740    /// intact and returns "no veto" (it is an observer event: the draft change
1741    /// has already happened and must never be blocked).
1742    #[test]
1743    fn editor_change_reaches_subscribers_with_its_payload() {
1744        let _guard = HANDLER_TEST_LOCK.lock().unwrap();
1745        let mut reg = crate::registry::ExtensionRegistry::new();
1746        reg.register_event_handler(
1747            "voice".to_string(),
1748            EventTag::EditorChange,
1749            editor_change_handler,
1750            std::ptr::null_mut(),
1751        );
1752        let snap = Arc::new(reg.snapshot());
1753
1754        EDITOR_CHANGE_SEEN.store(false, Ordering::SeqCst);
1755        assert!(
1756            dispatch_data_event(
1757                &snap,
1758                EventTag::EditorChange,
1759                r#"{"chars":4,"empty":false}"#
1760            ),
1761            "a subscribed handler must be invoked"
1762        );
1763        assert!(
1764            EDITOR_CHANGE_SEEN.load(Ordering::SeqCst),
1765            "payload missing or handler not called"
1766        );
1767    }
1768
1769    /// Handler that records it saw a well-formed editor-change payload. The
1770    /// flag is global because an `extern "C" fn` cannot capture.
1771    static EDITOR_CHANGE_SEEN: std::sync::atomic::AtomicBool =
1772        std::sync::atomic::AtomicBool::new(false);
1773
1774    extern "C" fn editor_change_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
1775        // Reading the data payload here also exercises the `EditorChange` free
1776        // path (a wrong arm in `free_dispatched_event` would corrupt/leak).
1777        let seen = unsafe { ev.payload.data.data.to_string_lossy() };
1778        if seen.contains("chars") && seen.contains("empty") {
1779            EDITOR_CHANGE_SEEN.store(true, Ordering::SeqCst);
1780        }
1781        rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
1782    }
1783}