Skip to main content

rpi_extensions/
provider_hooks.rs

1//! B4 — extension provider hooks. Bridges an extension's `on(BeforeProviderRequest)`
2//! / `on(BeforeProviderHeaders)` / `on(AfterProviderResponse)` handlers into
3//! rpi-ai's [`ProviderHooks`] slot, so every provider call fans the live
4//! request/response context out to the subscribed plugins.
5//!
6//! **Observer semantics (v1):** the SDK `EventHandlerFn` returns `i32` (no
7//! result payload), so a handler observes the request/response — it can record
8//! state, rotate an external token store, emit diagnostics — but cannot PATCH
9//! the live `SimpleStreamOptions`. The `before_request` hook therefore returns
10//! `None` (no patch); a future ABI extension (an `out` pointer on the handler)
11//! would unlock the patch path. The harness treats a `None` return as
12//! "leave opts untouched", so the observer path is safe.
13
14use std::sync::{Arc, Mutex};
15
16use rpi_ai::types::{AssistantMessage, Content, Message, UserContent};
17use rpi_ai::Context;
18use rpi_ai::{ContextPatch, Model, ProviderHooks, SimpleStreamOptions, SimpleStreamOptionsPatch};
19use rpi_plugin_sdk::EventTag;
20
21use crate::loader::ExtensionSession;
22use crate::prompt_transform::emit_before_agent_start;
23use crate::registry::RegistrySnapshot;
24use crate::translate::dispatch_data_event;
25use crate::PluginKeepalive;
26
27/// A [`ProviderHooks`] that dispatches to the registered extension handlers.
28/// Keeps the cdylib mappings alive via the keepalive (the handler fn pointers
29/// live inside the plugins).
30pub struct ExtensionProviderHooks {
31    snapshot: Arc<RegistrySnapshot>,
32    _keepalive: Arc<PluginKeepalive>,
33    /// Timestamp of the last user message we emitted `BeforeAgentStart` for.
34    /// The hook fires once per provider call, but `before_agent_start` must
35    /// fire once per prompt, so the prompt timestamp is the dedupe key.
36    last_before_agent_start: Mutex<Option<i64>>,
37    /// The prompt transform result for the prompt identified by the timestamp,
38    /// cached so it is computed **once per prompt** rather than once per
39    /// provider call. pi computes it once before the run and reuses it
40    /// (`_runSystemPromptOptions`, reset in the run's `finally`); a tool loop
41    /// makes several provider calls for one prompt, and re-running every
42    /// registered handler on each of them would both repeat side effects and
43    /// let an unstable handler change the prompt mid-run.
44    ///
45    /// `Some((timestamp, prompt))` where `prompt: None` records "this prompt was
46    /// transformed, result was no-change" — distinct from "not yet computed",
47    /// so handlers are not re-run just because they declined to change anything.
48    last_forced_prompt: Mutex<Option<(i64, Option<String>)>>,
49}
50
51impl ExtensionProviderHooks {
52    /// Build hooks over a loaded extension session's registry snapshot. Returns
53    /// `None` when nothing needs this bridge (so a session without provider or
54    /// prompt hooks runs the plain no-op path).
55    ///
56    /// Two independent reasons to install it, and both must be checked:
57    ///
58    /// - **Provider-hook tags** — a plugin observing request/response. Includes
59    ///   `BeforeAgentStart`, which is emitted from this bridge (see
60    ///   [`Self::before_request`]) because the harness never emits a prompt
61    ///   message event.
62    /// - **`before_agent_start` transformers** — a plugin that *replaces* the
63    ///   system prompt. This is a separate slot, not an event subscription, so a
64    ///   plugin that only transforms the prompt registers no handlers at all;
65    ///   keying this check on the tags alone would skip the bridge and silently
66    ///   ignore its transform.
67    pub fn from_session(session: &ExtensionSession) -> Option<Self> {
68        let snapshot = session.snapshot_arc()?;
69        let observes_events = [
70            EventTag::BeforeProviderRequest,
71            EventTag::BeforeProviderHeaders,
72            EventTag::AfterProviderResponse,
73            EventTag::BeforeAgentStart,
74        ]
75        .iter()
76        .any(|t| !snapshot.handlers_for(*t).is_empty());
77        let transforms_prompt = !snapshot.before_agent_start().is_empty();
78        if !observes_events && !transforms_prompt {
79            return None;
80        }
81        Some(Self {
82            snapshot,
83            _keepalive: session.keepalive(),
84            last_before_agent_start: Mutex::new(None),
85            last_forced_prompt: Mutex::new(None),
86        })
87    }
88}
89
90impl ProviderHooks for ExtensionProviderHooks {
91    /// pi's `transform_context` / `before_agent_start`-returns-`systemPrompt`:
92    /// let a plugin replace the prompt the request carries.
93    ///
94    /// Computed once per prompt and cached, so every provider call of a run
95    /// (the tool loop) sees the same prompt — see
96    /// [`Self::last_forced_prompt`]. Returns `None` when no plugin registered a
97    /// transformer, when they all declined, or when the registry is stale, all
98    /// of which mean "leave the prompt alone".
99    fn transform_context(&self, _model: &Model, ctx: &Context) -> Option<ContextPatch> {
100        if self.snapshot.before_agent_start().is_empty() {
101            return None;
102        }
103        // Keyed on the user prompt, like the observer event below: the prompt is
104        // what identifies a turn, and it is stable across the run's provider
105        // calls. With no user message (a continuation) there is nothing to key
106        // on, so the transform is skipped rather than applied to an unknown turn.
107        let (timestamp, prompt, image_count) = latest_user_prompt(&ctx.messages)?;
108
109        let mut cache = self.last_forced_prompt.lock().unwrap();
110        if cache.as_ref().map(|(ts, _)| *ts) != Some(timestamp) {
111            let event = before_agent_start_event(&prompt, image_count, ctx);
112            let forced = emit_before_agent_start(
113                ctx.system_prompt.as_deref().unwrap_or_default(),
114                &event.to_string(),
115                &self.snapshot,
116            );
117            *cache = Some((timestamp, forced));
118        }
119        cache
120            .as_ref()
121            .and_then(|(_, forced)| forced.clone())
122            .map(|system_prompt| ContextPatch {
123                system_prompt: Some(system_prompt),
124            })
125    }
126
127    /// Fan the planned request out to `BeforeProviderRequest` +
128    /// `BeforeProviderHeaders` subscribers. Returns `None` for the stream options
129    /// — observer-only (the event handler ABI has no result channel); a plugin's
130    /// influence on the *prompt* travels through [`Self::transform_context`]
131    /// instead.
132    fn before_request(
133        &self,
134        model: &Model,
135        ctx: &Context,
136        opts: &SimpleStreamOptions,
137    ) -> Option<SimpleStreamOptionsPatch> {
138        // (A) pi's `before_agent_start`: observe each NEW user prompt exactly
139        // once. The harness persists prompts before the run and calls
140        // `run_agent_loop` with an empty prompts vec, so no
141        // MessageStart/MessageEnd reaches plugins for them. This bridge is the
142        // first place the prompt is visible, so it emits the pi-equivalent
143        // event. Dedupe by prompt timestamp: one run can make several provider
144        // calls, but `before_agent_start` fires once per prompt.
145        if let Some((timestamp, prompt, image_count)) = latest_user_prompt(&ctx.messages) {
146            let mut last = self.last_before_agent_start.lock().unwrap();
147            if *last != Some(timestamp) {
148                *last = Some(timestamp);
149                drop(last);
150                let event = before_agent_start_event(&prompt, image_count, ctx);
151                // Synchronous, like the request dispatch below: a subscriber's
152                // `BeforeAgentStart` handler runs before any
153                // `BeforeProviderRequest` of this turn reaches it, so the turn is
154                // fully identified (session included) by the time its first
155                // generation is created.
156                dispatch_data_event(
157                    &self.snapshot,
158                    EventTag::BeforeAgentStart,
159                    &event.to_string(),
160                );
161            }
162        }
163
164        let request = serde_json::json!({
165            "model": model.id,
166            "provider": model.provider,
167            "baseUrl": model.base_url,
168            "reasoning": model.reasoning,
169            "apiKey": opts.api_key,
170            "timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
171            "headers": opts.headers,
172            "metadata": opts.metadata,
173            "maxTokens": opts.max_tokens,
174            "temperature": opts.temperature,
175            // (B) the model-facing messages, so observers (e.g. rpi-langfuse)
176            // can record the real generation input. `ctx` was previously
177            // discarded entirely, which is why generation/trace input was
178            // always null for plugins.
179            "messages": ctx.messages,
180        });
181        dispatch_data_event(
182            &self.snapshot,
183            EventTag::BeforeProviderRequest,
184            &request.to_string(),
185        );
186        let headers = serde_json::json!({ "headers": opts.headers });
187        dispatch_data_event(
188            &self.snapshot,
189            EventTag::BeforeProviderHeaders,
190            &headers.to_string(),
191        );
192        None
193    }
194
195    /// Fan the terminal assistant message out to `AfterProviderResponse`
196    /// subscribers.
197    fn after_response(&self, _model: &Model, message: &AssistantMessage) {
198        let json = serde_json::to_value(message).unwrap_or(serde_json::json!({}));
199        dispatch_data_event(
200            &self.snapshot,
201            EventTag::AfterProviderResponse,
202            &json.to_string(),
203        );
204    }
205}
206
207/// The `before_agent_start` envelope: what the observer event carries and what
208/// a prompt-transforming handler reads.
209///
210/// One builder for both, so an observer and a transformer cannot be shown
211/// different views of the same turn.
212///
213/// `systemPrompt` is read from `ctx`, which means the **post-transform** prompt
214/// when the observer event is dispatched: rpi emits that event from
215/// `before_request`, and `ProviderHooks::transform_context` runs first (the same
216/// order pi uses in `assistant.ts` — transform, then request). So an observer
217/// sees the prompt the provider is about to be given. pi's own
218/// `before_agent_start` fires earlier in the run and therefore carries the base
219/// prompt; a plugin that needs the pre-transform value should capture it from
220/// its own transformer's input instead of from this event.
221fn before_agent_start_event(prompt: &str, image_count: usize, ctx: &Context) -> serde_json::Value {
222    serde_json::json!({
223        "prompt": prompt,
224        "imageCount": image_count,
225        "systemPrompt": ctx.system_prompt,
226        // The session this process serves. A plugin that traces the run reports
227        // it as the session id (nothing else tells an observer which session it
228        // is watching). Read from the environment per turn rather than captured
229        // at harness build, so an in-process session swap is reflected on the
230        // very next turn.
231        "sessionId": std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).ok(),
232        // Who this extension is embedded in, so it can label its own output
233        // (trace name, tags, `service.name`) without hardcoding a brand that a
234        // rename would silently invalidate. Sent per turn, so it describes the
235        // process actually running rather than the SDK the plugin was built
236        // against.
237        "host": host_identity(),
238    })
239}
240
241/// The host's own identity, as handed to plugins.
242///
243/// The name is the plugin contract's constant ([`rpi_plugin_sdk::HOST_NAME`])
244/// and the version is this host crate's own `CARGO_PKG_VERSION` — every
245/// workspace crate shares one version, so this is the running host's version
246/// and not the SDK a plugin was compiled against. Both are compile-time
247/// aliases, so there is exactly one place to change either.
248fn host_identity() -> serde_json::Value {
249    serde_json::json!({
250        "name": rpi_plugin_sdk::HOST_NAME,
251        "version": env!("CARGO_PKG_VERSION"),
252    })
253}
254
255/// Text/timestamp/image-count of the most recent user message. Mirrors the
256/// `prompt`/`images` fields pi puts on its `before_agent_start` event.
257fn latest_user_prompt(messages: &[Message]) -> Option<(i64, String, usize)> {
258    messages.iter().rev().find_map(|message| match message {
259        Message::User(user) => Some((
260            user.timestamp,
261            user_content_text(&user.content),
262            user_content_image_count(&user.content),
263        )),
264        _ => None,
265    })
266}
267
268fn user_content_text(content: &UserContent) -> String {
269    match content {
270        UserContent::Text(text) => text.clone(),
271        UserContent::Blocks(blocks) => blocks
272            .iter()
273            .filter_map(|block| match block {
274                Content::Text(text) => Some(text.text.as_str()),
275                _ => None,
276            })
277            .collect::<Vec<_>>()
278            .join("\n"),
279    }
280}
281
282fn user_content_image_count(content: &UserContent) -> usize {
283    match content {
284        UserContent::Text(_) => 0,
285        UserContent::Blocks(blocks) => blocks
286            .iter()
287            .filter(|block| matches!(block, Content::Image(_)))
288            .count(),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use rpi_plugin_sdk::{EventTag, StablePluginEvent};
296    use std::sync::atomic::{AtomicUsize, Ordering};
297    use std::sync::Mutex;
298
299    static PROVIDER_HITS: AtomicUsize = AtomicUsize::new(0);
300    static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
301    static CAPTURED: Mutex<Vec<(EventTag, String)>> = Mutex::new(Vec::new());
302
303    extern "C" fn capture_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
304        let payload = unsafe { ev.payload.data.data.to_string_lossy() };
305        CAPTURED.lock().unwrap().push((ev.tag, payload));
306        0
307    }
308
309    extern "C" fn counting_provider_handler(
310        ev: StablePluginEvent,
311        _ud: *mut std::ffi::c_void,
312    ) -> i32 {
313        // Count every dispatch on the subscribed tags (BeforeProviderRequest +
314        // BeforeProviderHeaders). The `model` check validates the request-event
315        // payload shape separately — it must NOT gate the count, or the headers
316        // event (whose payload is `{"headers":...}` with no `model` key) would
317        // be silently dropped and the fan-out count would be wrong.
318        let s = unsafe { ev.payload.data.data.to_string_lossy() };
319        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
320            // The request event carries `model`; the headers event does not.
321            // Both are valid dispatches — count unconditionally, and sanity-check
322            // the request payload when `model` is present.
323            if v.get("model").is_some() {
324                assert!(v.get("model").is_some(), "request event must carry model");
325            }
326            PROVIDER_HITS.fetch_add(1, Ordering::SeqCst);
327        }
328        0
329    }
330
331    /// `before_agent_start` fires once per NEW user prompt (not once per
332    /// provider call in a run), and the request payload carries the
333    /// model-facing `messages`.
334    #[test]
335    fn before_request_emits_before_agent_start_once_and_forwards_messages() {
336        let _guard = PROVIDER_LOCK.lock().unwrap();
337        CAPTURED.lock().unwrap().clear();
338
339        let mut registry = crate::registry::ExtensionRegistry::new();
340        registry.register_event_handler(
341            "test-plugin".to_string(),
342            EventTag::BeforeAgentStart,
343            capture_handler,
344            std::ptr::null_mut(),
345        );
346        registry.register_event_handler(
347            "test-plugin".to_string(),
348            EventTag::BeforeProviderRequest,
349            capture_handler,
350            std::ptr::null_mut(),
351        );
352        let snapshot = Arc::new(registry.snapshot());
353        let hooks = ExtensionProviderHooks {
354            snapshot: Arc::clone(&snapshot),
355            _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
356            last_before_agent_start: Mutex::new(None),
357            last_forced_prompt: Mutex::new(None),
358        };
359        let model = rpi_ai::Model::new(
360            "m",
361            "m",
362            rpi_ai::Api::AnthropicMessages,
363            "anthropic",
364            "https://api.anthropic.com",
365        );
366        let ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
367            rpi_ai::types::UserMessage::new(
368                rpi_ai::types::UserContent::Text("hello".to_string()),
369                42,
370            ),
371        )]);
372        let opts = rpi_ai::SimpleStreamOptions::default();
373
374        // Two provider calls in the same run (same prompt) → one
375        // before_agent_start, two before_provider_request observations.
376        hooks.before_request(&model, &ctx, &opts);
377        hooks.before_request(&model, &ctx, &opts);
378
379        let captured = CAPTURED.lock().unwrap().clone();
380        let agent_starts: Vec<_> = captured
381            .iter()
382            .filter(|(tag, _)| matches!(tag, EventTag::BeforeAgentStart))
383            .collect();
384        assert_eq!(
385            agent_starts.len(),
386            1,
387            "before_agent_start must fire once per prompt: {captured:?}"
388        );
389        let start_payload: serde_json::Value =
390            serde_json::from_str(&agent_starts[0].1).expect("before_agent_start payload is JSON");
391        assert_eq!(start_payload["prompt"], "hello");
392        assert_eq!(start_payload["imageCount"], 0);
393        // The session id rides along so an observer can name the session on the
394        // very first turn (the prompt is the only per-turn channel it gets).
395        // Absent when this process has no session id in its environment.
396        assert_eq!(
397            start_payload.get("sessionId").map(|v| v.is_null()),
398            Some(std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).is_err()),
399            "sessionId must mirror the host's session id env var: {}",
400            agent_starts[0].1
401        );
402        // And the host identifies itself, so a plugin can label its own output
403        // (trace name, tags, service.name) without hardcoding a brand.
404        assert_eq!(start_payload["host"]["name"], rpi_plugin_sdk::HOST_NAME);
405        assert_eq!(
406            start_payload["host"]["version"],
407            env!("CARGO_PKG_VERSION"),
408            "the host reports the version of the host, not of the plugin SDK"
409        );
410
411        let requests: Vec<_> = captured
412            .iter()
413            .filter(|(tag, _)| matches!(tag, EventTag::BeforeProviderRequest))
414            .collect();
415        assert_eq!(requests.len(), 2, "every provider call is observed");
416        assert!(
417            requests[0].1.contains("\"messages\""),
418            "request payload must forward messages: {}",
419            requests[0].1
420        );
421    }
422
423    /// `dispatch_data_event` fans the JSON to the tag's handlers, and the
424    /// `ExtensionProviderHooks::before_request` path dispatches BOTH the
425    /// request and headers events to their subscribers.
426    #[test]
427    fn provider_hooks_dispatch_to_subscribers() {
428        let _guard = PROVIDER_LOCK.lock().unwrap();
429        PROVIDER_HITS.store(0, Ordering::SeqCst);
430
431        let mut registry = crate::registry::ExtensionRegistry::new();
432        let handler: rpi_plugin_sdk::EventHandlerFn = counting_provider_handler;
433        registry.register_event_handler(
434            "test-plugin".to_string(),
435            EventTag::BeforeProviderRequest,
436            handler,
437            std::ptr::null_mut(),
438        );
439        registry.register_event_handler(
440            "test-plugin".to_string(),
441            EventTag::BeforeProviderHeaders,
442            handler,
443            std::ptr::null_mut(),
444        );
445        let snapshot = Arc::new(registry.snapshot());
446
447        // dispatch_data_event directly: one request event ⇒ one hit.
448        assert!(dispatch_data_event(
449            &snapshot,
450            EventTag::BeforeProviderRequest,
451            r#"{"model":"m"}"#,
452        ));
453        assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 1);
454
455        // through ExtensionProviderHooks::before_request. The hook fires BOTH
456        // BeforeProviderRequest AND BeforeProviderHeaders (each with the same
457        // counting handler subscribed), so 2 more hits ⇒ 3 total. The headers
458        // event's payload is `{"headers":...}` (no `model` key) — the handler
459        // counts it regardless (see counting_provider_handler).
460        let hooks = ExtensionProviderHooks {
461            snapshot: Arc::clone(&snapshot),
462            _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
463            last_before_agent_start: Mutex::new(None),
464            last_forced_prompt: Mutex::new(None),
465        };
466        let model = rpi_ai::Model::new(
467            "m",
468            "m",
469            rpi_ai::Api::AnthropicMessages,
470            "anthropic",
471            "https://api.anthropic.com",
472        );
473        let ctx = rpi_ai::Context::default();
474        let opts = rpi_ai::SimpleStreamOptions::default();
475        let patch = hooks.before_request(&model, &ctx, &opts);
476        assert!(patch.is_none(), "observer-only v1: no patch returned");
477        assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 3);
478    }
479
480    /// A tag with no subscribers dispatches nothing (and doesn't free anything
481    /// dangling — dispatch_data_event short-circuits before building events).
482    #[test]
483    fn provider_hooks_no_subscribers_is_noop() {
484        let registry = crate::registry::ExtensionRegistry::new();
485        let snapshot = Arc::new(registry.snapshot());
486        assert!(!dispatch_data_event(
487            &snapshot,
488            EventTag::AfterProviderResponse,
489            "{}",
490        ));
491    }
492
493    // ---- before_agent_start prompt transform ------------------------------
494    //
495    // These use in-process `extern "C"` handlers rather than a cdylib: the ABI
496    // round-trip has its own end-to-end test (`plugin_stub_smoke`), so what is
497    // left to pin here is the semantics — chaining, failure isolation, and the
498    // "no change" signal — which are easier to express with direct handlers.
499
500    /// Handler that returns `<marker>` + whatever prompt it was handed, so a
501    /// test can observe the chain by the markers accumulating in order.
502    extern "C" fn append_marker(
503        event_json: rpi_plugin_sdk::StbStringRef,
504        out: *mut rpi_plugin_sdk::StbString,
505        user_data: *mut std::ffi::c_void,
506    ) -> i32 {
507        // SAFETY: host guarantees the ref for this call; the marker arrives as
508        // `user_data` (a `&'static str` the test leaked).
509        let envelope = unsafe { event_json.as_str() };
510        let marker = unsafe { &*(user_data as *const &'static str) };
511        let current = serde_json::from_str::<serde_json::Value>(envelope)
512            .ok()
513            .and_then(|v| v.get("systemPrompt")?.as_str().map(str::to_string))
514            .unwrap_or_default();
515        let json = serde_json::json!({ "systemPrompt": format!("{current}{marker}") });
516        unsafe {
517            *out = rpi_plugin_sdk::StbString::from_string(json.to_string());
518        }
519        0
520    }
521
522    /// Handler that declines to change anything (empty object ⇒ no-op).
523    extern "C" fn declines(
524        _event_json: rpi_plugin_sdk::StbStringRef,
525        _out: *mut rpi_plugin_sdk::StbString,
526        _user_data: *mut std::ffi::c_void,
527    ) -> i32 {
528        0
529    }
530
531    /// Handler that reports a handled error.
532    extern "C" fn fails(
533        _event_json: rpi_plugin_sdk::StbStringRef,
534        _out: *mut rpi_plugin_sdk::StbString,
535        _user_data: *mut std::ffi::c_void,
536    ) -> i32 {
537        7
538    }
539
540    /// Build hooks whose snapshot has the given prompt transformers.
541    fn hooks_with(
542        handlers: Vec<crate::registry::BeforeAgentStartHandler>,
543    ) -> ExtensionProviderHooks {
544        let mut registry = crate::registry::ExtensionRegistry::new();
545        for h in handlers {
546            registry.register_before_agent_start(h.handler, h.plugin_free_string, h.user_data);
547        }
548        ExtensionProviderHooks {
549            snapshot: Arc::new(registry.snapshot()),
550            _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
551            last_before_agent_start: Mutex::new(None),
552            last_forced_prompt: Mutex::new(None),
553        }
554    }
555
556    /// Transformers chain in registration order: each sees the previous one's
557    /// result. This is pi's `runner.ts` behavior (a later handler observes the
558    /// prompt an earlier one set), and it is what makes "append" composable.
559    #[test]
560    fn prompt_transformers_chain_in_registration_order() {
561        let a: &'static str = " A";
562        let b: &'static str = " B";
563        let leak = |s: &'static str| Box::leak(Box::new(s)) as *mut &'static str as *mut _;
564        let hooks = hooks_with(vec![
565            crate::registry::BeforeAgentStartHandler {
566                handler: append_marker,
567                plugin_free_string: noop_free,
568                user_data: leak(a),
569            },
570            crate::registry::BeforeAgentStartHandler {
571                handler: append_marker,
572                plugin_free_string: noop_free,
573                user_data: leak(b),
574            },
575        ]);
576
577        let model = rpi_ai::Model::new(
578            "m",
579            "m",
580            rpi_ai::Api::AnthropicMessages,
581            "anthropic",
582            "https://api.anthropic.com",
583        );
584        let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
585            rpi_ai::types::UserMessage::new(
586                rpi_ai::types::UserContent::Text("hello".to_string()),
587                1,
588            ),
589        )]);
590        ctx.system_prompt = Some("base".to_string());
591
592        let patch = hooks
593            .transform_context(&model, &ctx)
594            .expect("both handlers changed the prompt");
595        // Both markers present, in order — proof the second handler saw the
596        // first's output rather than the original prompt.
597        assert_eq!(patch.system_prompt.as_deref(), Some("base A B"));
598    }
599
600    /// A failing handler leaves the prompt as the previous handler left it and
601    /// the fan-out continues (pi isolates a throwing handler; it does not abort
602    /// the run or discard earlier results).
603    #[test]
604    fn a_failing_transformer_is_skipped_and_the_chain_continues() {
605        let after: &'static str = " AFTER";
606        let leaked = Box::leak(Box::new(after)) as *mut &'static str as *mut _;
607        let hooks = hooks_with(vec![
608            crate::registry::BeforeAgentStartHandler {
609                handler: fails,
610                plugin_free_string: noop_free,
611                user_data: std::ptr::null_mut(),
612            },
613            crate::registry::BeforeAgentStartHandler {
614                handler: append_marker,
615                plugin_free_string: noop_free,
616                user_data: leaked,
617            },
618        ]);
619
620        let model = rpi_ai::Model::new(
621            "m",
622            "m",
623            rpi_ai::Api::AnthropicMessages,
624            "anthropic",
625            "https://api.anthropic.com",
626        );
627        let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
628            rpi_ai::types::UserMessage::new(
629                rpi_ai::types::UserContent::Text("hello".to_string()),
630                1,
631            ),
632        )]);
633        ctx.system_prompt = Some("base".to_string());
634
635        let patch = hooks.transform_context(&model, &ctx).expect("changed");
636        assert_eq!(
637            patch.system_prompt.as_deref(),
638            Some("base AFTER"),
639            "the failing handler must contribute nothing and not stop the next one"
640        );
641    }
642
643    /// All handlers declining ⇒ `None`, i.e. the harness leaves the prompt
644    /// alone. A plugin that only observes must be able to say nothing without
645    /// installing an empty prompt.
646    #[test]
647    fn declining_transformers_leave_the_prompt_untouched() {
648        let hooks = hooks_with(vec![crate::registry::BeforeAgentStartHandler {
649            handler: declines,
650            plugin_free_string: noop_free,
651            user_data: std::ptr::null_mut(),
652        }]);
653        let model = rpi_ai::Model::new(
654            "m",
655            "m",
656            rpi_ai::Api::AnthropicMessages,
657            "anthropic",
658            "https://api.anthropic.com",
659        );
660        let ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
661            rpi_ai::types::UserMessage::new(
662                rpi_ai::types::UserContent::Text("hello".to_string()),
663                1,
664            ),
665        )]);
666        assert!(
667            hooks.transform_context(&model, &ctx).is_none(),
668            "no handler changed the prompt ⇒ no patch ⇒ harness leaves it alone"
669        );
670    }
671
672    /// No registered transformer ⇒ `None` and the ABI is never entered. A
673    /// session with only observer plugins must not pay for (or risk) this path.
674    #[test]
675    fn no_transformers_means_no_patch() {
676        let hooks = hooks_with(vec![]);
677        let model = rpi_ai::Model::new(
678            "m",
679            "m",
680            rpi_ai::Api::AnthropicMessages,
681            "anthropic",
682            "https://api.anthropic.com",
683        );
684        let ctx = rpi_ai::Context::default();
685        assert!(hooks.transform_context(&model, &ctx).is_none());
686    }
687
688    /// Minimal free fn for handlers whose `out` is built by the SDK (they never
689    /// hand back a plugin allocation in these tests, but the registry requires
690    /// the field).
691    extern "C" fn noop_free(_s: rpi_plugin_sdk::StbString) {}
692
693    /// The `BeforeAgentStart` observer sees the **post-transform** prompt.
694    ///
695    /// rpi emits that event from `before_request`, and `transform_context` runs
696    /// first (the order `agent_harness::build_stream_fn` uses, mirroring pi's
697    /// `assistant.ts`). So an observer is told what the provider is about to be
698    /// given, not what it would have been given. This is a deliberate contract
699    /// and a subtle one: swapping the two calls in `build_stream_fn` would
700    /// silently change what every observer reports, with no other test failing.
701    #[test]
702    fn the_observer_sees_the_prompt_after_the_transform() {
703        let marker: &'static str = " MARK";
704        let leaked = Box::leak(Box::new(marker)) as *mut &'static str as *mut _;
705        let _guard = PROVIDER_LOCK.lock().unwrap();
706        CAPTURED.lock().unwrap().clear();
707
708        let mut registry = crate::registry::ExtensionRegistry::new();
709        registry.register_event_handler(
710            "test-plugin".to_string(),
711            EventTag::BeforeAgentStart,
712            capture_handler,
713            std::ptr::null_mut(),
714        );
715        registry.register_before_agent_start(append_marker, noop_free, leaked);
716        let hooks = ExtensionProviderHooks {
717            snapshot: Arc::new(registry.snapshot()),
718            _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
719            last_before_agent_start: Mutex::new(None),
720            last_forced_prompt: Mutex::new(None),
721        };
722
723        let model = rpi_ai::Model::new(
724            "m",
725            "m",
726            rpi_ai::Api::AnthropicMessages,
727            "anthropic",
728            "https://api.anthropic.com",
729        );
730        let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
731            rpi_ai::types::UserMessage::new(
732                rpi_ai::types::UserContent::Text("hello".to_string()),
733                1,
734            ),
735        )]);
736        ctx.system_prompt = Some("base".to_string());
737
738        // How the harness drives the two hooks: transform first, then apply, then
739        // observe from the patched context.
740        let patch = hooks
741            .transform_context(&model, &ctx)
742            .expect("transformer changed the prompt");
743        patch.apply(&mut ctx);
744        hooks.before_request(&model, &ctx, &rpi_ai::SimpleStreamOptions::default());
745
746        let captured = CAPTURED.lock().unwrap().clone();
747        let start = captured
748            .iter()
749            .find(|(tag, _)| matches!(tag, EventTag::BeforeAgentStart))
750            .expect("BeforeAgentStart was dispatched");
751        let payload: serde_json::Value = serde_json::from_str(&start.1).expect("JSON");
752        assert_eq!(
753            payload["systemPrompt"], "base MARK",
754            "observers must be shown the prompt the provider will actually get"
755        );
756    }
757}