rpi_extensions/registry.rs
1//! The extension registry — accumulates registrations during a plugin's
2//! `rpi_plugin_register` call, then snapshots for the host session.
3//!
4//! `HostApi` (in [`crate`]) holds an `ExtensionRegistry` behind a `Mutex` while
5//! the plugin's register trampolines run; after register completes the host
6//! [`take`]s it and builds a [`RegistrySnapshot`] the session keeps for its
7//! lifetime. Staleness across a session swap / `/reload` is guarded by a shared
8//! `Arc<AtomicBool>` "active" flag ([`assert_active`]).
9//!
10//! [`take`]: crate::HostApi::take_registry
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14
15use rpi_agent::error::AgentError;
16use rpi_ai::types::Tool;
17use rpi_plugin_sdk::{
18 BeforeAgentStartFn, CommandHandlerFn, EventHandlerFn, EventTag, FreeStringFn, OAuthRequestFn,
19 ProviderRequestFn, ProviderStreamRequestFn, RenderFn, ResourcesDiscoverFn, EVENT_TAG_COUNT,
20};
21
22use crate::tool::PluginToolHandle;
23
24/// A provider package's catalog and authentication policy, supplied through
25/// `declare({"providers":[...]})`. Credentials are always resolved by the host.
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ProviderManifest {
29 pub id: String,
30 pub models: Vec<rpi_ai::Model>,
31 #[serde(default)]
32 pub api_key_env: Option<String>,
33 #[serde(default = "requires_auth_default")]
34 pub requires_auth: bool,
35}
36
37fn requires_auth_default() -> bool {
38 true
39}
40
41// ---------------------------------------------------------------------------
42// Registered-item records
43// ---------------------------------------------------------------------------
44
45/// A tool an extension registered: the provider-facing [`Tool`] schema + the
46/// plugin's 4-function handle ([`PluginToolHandle`]) the host drives via
47/// [`PluginToolAdapter`](crate::PluginToolAdapter).
48///
49/// `tool` is public so the host can merge schemas; `handle` is crate-private
50/// (only [`PluginToolAdapter`](crate::PluginToolAdapter) drives it). `Clone`
51/// derives because [`Tool`] is `Clone` and [`PluginToolHandle`] is `Copy`.
52#[derive(Clone)]
53pub struct ExtensionTool {
54 /// The provider-facing tool definition (name/description/parameters).
55 pub tool: Tool,
56 pub(crate) handle: PluginToolHandle,
57}
58
59impl ExtensionTool {
60 pub fn new(tool: Tool, handle: PluginToolHandle) -> Self {
61 Self { tool, handle }
62 }
63
64 /// The plugin-side function handles backing this tool. `Copy` (fn pointers
65 /// + a `FreeStringFn`), so handing it out is free. Public so the host
66 /// (e.g. rpi-cli's session builder) can wrap a registered tool in a
67 /// [`PluginToolAdapter`](crate::PluginToolAdapter) outside this crate.
68 pub fn handle(&self) -> PluginToolHandle {
69 self.handle
70 }
71}
72
73/// A registered slash command and its plugin callback.
74/// The host supplies an opaque owner-scoped runtime handle, not plugin memory.
75/// The plugin makes its callback safe to invoke from any host thread.
76#[derive(Clone)]
77pub struct RegisteredCommand {
78 pub name: String,
79 pub description: String,
80 pub handler: CommandHandlerFn,
81 pub user_data: *mut std::ffi::c_void,
82}
83
84/// A keyboard shortcut declared by a native extension (e.g. `"space"` for
85/// push-to-talk). The host records it so the TUI can route keys to it; the
86/// owning plugin receives the key via its `Input` event handler.
87///
88/// `key` is a normalized key name (`"space"`, `"enter"`, `"c"`, `"f1"`)
89/// matching the editor's keybinding vocabulary.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct RegisteredShortcut {
92 pub key: String,
93 pub description: String,
94 /// The owning extension's display name (host-side only, for diagnostics).
95 pub plugin: String,
96}
97
98// SAFETY: user_data is an opaque host registry handle (never dereferenced),
99// and plugin callbacks support invocation from any host thread.
100unsafe impl Send for RegisteredCommand {}
101unsafe impl Sync for RegisteredCommand {}
102
103/// A CLI flag declared by a native extension. Values are supplied by the
104/// host's parsed `Args::unknown_flags` map and read by the plugin through the
105/// `RuntimeActionId::GetCliFlag` action.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct RegisteredFlag {
108 pub name: String,
109 pub description: String,
110}
111
112/// A registered `on(tag)` event handler. `user_data` is the plugin's opaque
113/// context, passed back unchanged on every dispatch. `plugin` is the display
114/// name of the owning extension (host-side only — stamped from the cdylib path
115/// at registration; not part of the ABI), used for lifecycle veto diagnostics
116/// ("extension `foo` aborted `before_tui_start`").
117///
118/// `priority`/`platforms` are the owning plugin's **declaration** (P2): the
119/// snapshot sorts handlers by `priority` (smaller first, stable), and dispatch
120/// skips a handler whose non-empty `platforms` excludes the host platform.
121/// Both default to `100` / empty (all platforms); a plugin sets them via the
122/// unified C ABI `declare` callback.
123///
124/// SAFETY contract: the plugin guarantees `handler` is safe to call from any
125/// thread (the host dispatches from the async emitter thread) and `user_data`
126/// is valid for the registry's lifetime. The host never frees `user_data`
127/// (plugin-owned).
128#[derive(Clone)]
129pub struct RegisteredHandler {
130 /// Display name of the owning extension (from the cdylib file stem).
131 pub plugin: String,
132 /// Dispatch order (smaller runs first). Default `100`.
133 pub priority: i32,
134 /// Platforms this handler's plugin supports (empty = all). Matched against
135 /// [`current_platform`].
136 pub platforms: Vec<String>,
137 pub handler: EventHandlerFn,
138 pub user_data: *mut std::ffi::c_void,
139}
140// SAFETY: fn pointers are Send+Sync; `user_data` is an opaque plugin pointer the
141// plugin warrants is thread-safe to pass to `handler` from any thread. The host
142// only reads it through `handler`.
143unsafe impl Send for RegisteredHandler {}
144unsafe impl Sync for RegisteredHandler {}
145
146/// A registered `resources_discover` handler (B5b). The `out` [`StbString`] the
147/// handler produces is **plugin-owned**, so the host reclaims it via the
148/// plugin's own `plugin_free_string` traveled alongside. `user_data` is the
149/// plugin's opaque context. SAFETY: same as [`RegisteredHandler`] — the plugin
150/// warrants `handler` is callable from any thread and `user_data` is valid for
151/// the registry's lifetime; the host never frees `user_data`.
152#[derive(Clone, Copy)]
153pub struct ResourcesDiscoverHandler {
154 pub handler: ResourcesDiscoverFn,
155 pub plugin_free_string: FreeStringFn,
156 pub user_data: *mut std::ffi::c_void,
157}
158// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
159// thread-safe; the host only reads `user_data` through `handler`.
160unsafe impl Send for ResourcesDiscoverHandler {}
161unsafe impl Sync for ResourcesDiscoverHandler {}
162
163/// A registered `before_agent_start` handler (system-prompt transform). Same
164/// ownership shape as [`ResourcesDiscoverHandler`]: the handler produces a
165/// **plugin-owned** `out` [`StbString`] the host reclaims via the plugin's own
166/// `plugin_free_string`, which therefore travels alongside. `user_data` is the
167/// plugin's opaque context.
168///
169/// SAFETY: same as [`RegisteredHandler`] — the plugin warrants `handler` is
170/// callable from any thread and `user_data` is valid for the registry's
171/// lifetime; the host never frees `user_data`.
172#[derive(Clone, Copy)]
173pub struct BeforeAgentStartHandler {
174 pub handler: BeforeAgentStartFn,
175 pub plugin_free_string: FreeStringFn,
176 pub user_data: *mut std::ffi::c_void,
177}
178// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
179// thread-safe; the host only reads `user_data` through `handler`.
180unsafe impl Send for BeforeAgentStartHandler {}
181unsafe impl Sync for BeforeAgentStartHandler {}
182
183/// A registered custom provider (B5c). The host wraps `request_fn` in a
184/// [`PluggableProvider`](crate::PluggableProvider) impl of `rpi_ai::Provider`;
185/// its `stream_simple` drives `request_fn` on `spawn_blocking` (the sync fn
186/// can't own a chunked stream), reads the **plugin-owned** `out` JSON (a full
187/// assistant message), reclaims it via `plugin_free_string`, parses it to an
188/// [`AssistantMessage`](rpi_ai::types::AssistantMessage), and emits it as one
189/// terminal `Done` chunk (v1 one-shot, documented divergence from pi's async
190/// streaming). `provider_id`/`base_url`/`api_style` carry the provider's
191/// identity (copied from the borrowed `StbStringRef`s at registration); the fn
192/// pointers + `user_data` live as long as the plugin (keepalive-mapped).
193/// `user_data` is the plugin's opaque context, passed back on every
194/// `request_fn` call.
195///
196/// `stream_fn` is the **streaming** capability ([`ProviderStreamRequestFn`],
197/// registered through `register_streaming_provider`). When present the host
198/// drives it instead of `request_fn`: the plugin emits per-chunk envelopes that
199/// the host converts into `AssistantMessageEvent`s *as they arrive*, then the
200/// same plugin-owned `out` message still supplies the terminal event. `None`
201/// keeps the v1 one-shot behavior, so an existing plugin is unaffected.
202///
203/// That single signature also carries the cancellation protocol: the host always
204/// passes a [`ProviderStreamControl`](rpi_plugin_sdk::ProviderStreamControl), and
205/// whether the plugin polls it is the plugin's choice. There is deliberately no
206/// separate "cancellable" registration — one method, and the caller decides.
207///
208/// SAFETY: the plugin warrants `request_fn`/`stream_fn` are callable from any
209/// thread (the host calls them from a `spawn_blocking` pool thread) and
210/// `user_data` is valid for the plugin's lifetime; the host never frees
211/// `user_data`.
212#[derive(Clone)]
213pub struct RegisteredProvider {
214 pub provider_id: String,
215 pub base_url: String,
216 pub api_style: String,
217 pub request_fn: ProviderRequestFn,
218 /// Streaming request fn (streaming providers only).
219 pub stream_fn: Option<ProviderStreamRequestFn>,
220 pub plugin_free_string: FreeStringFn,
221 pub user_data: *mut std::ffi::c_void,
222}
223
224/// A provider-owned OAuth implementation. `manifest_json` is validated JSON
225/// copied from the plugin; the callback receives action envelopes such as
226/// `{\"action\":\"begin\"}` and returns plugin-owned JSON.
227#[derive(Clone)]
228pub struct RegisteredOAuthProvider {
229 /// The owning extension display name, used to scope credentials and
230 /// diagnostics. This is host-owned metadata and is not supplied by the
231 /// plugin.
232 pub plugin: String,
233 pub manifest_json: String,
234 pub request_fn: OAuthRequestFn,
235 pub plugin_free_string: FreeStringFn,
236 pub user_data: *mut std::ffi::c_void,
237}
238// SAFETY: as for RegisteredProvider, the plugin must keep its opaque context
239// live and make request_fn safe to call from any host thread.
240unsafe impl Send for RegisteredOAuthProvider {}
241unsafe impl Sync for RegisteredOAuthProvider {}
242// SAFETY: fn pointers + owned `String`s + an opaque plugin pointer the plugin
243// warrants is thread-safe; the host never frees `user_data`.
244unsafe impl Send for RegisteredProvider {}
245unsafe impl Sync for RegisteredProvider {}
246
247/// A registered message/markdown/entry renderer (B5c). The interactive TUI
248/// consumes all three kinds through the JSON component adapter. `render_fn` produces a
249/// **plugin-owned** `out` [`StbString`] the host reclaims via
250/// `plugin_free_string`; `user_data` is passed back on every render call. `name`
251/// is copied from the borrowed `StbStringRef` at registration.
252///
253/// SAFETY: same as [`RegisteredProvider`].
254#[derive(Clone)]
255pub struct RegisteredRenderer {
256 pub name: String,
257 pub kind: RegisteredRendererKind,
258 pub render_fn: RenderFn,
259 pub plugin_free_string: FreeStringFn,
260 pub user_data: *mut std::ffi::c_void,
261}
262
263/// Which render path this renderer targets — mirrors the three distinct
264/// `register_*` slots (`register_message_renderer` /
265/// `register_markdown_transformer` / `register_entry_renderer`).
266#[derive(Clone, Copy, PartialEq, Eq)]
267pub enum RegisteredRendererKind {
268 Message,
269 Markdown,
270 Entry,
271}
272// SAFETY: same as [`RegisteredProvider`] — fn pointers + owned `String` + an
273// opaque plugin pointer the plugin warrants is thread-safe.
274unsafe impl Send for RegisteredRenderer {}
275unsafe impl Sync for RegisteredRenderer {}
276
277/// One flat registration record, for iteration/diagnostics. Built on demand
278/// from the typed vecs in [`RegistrySnapshot`].
279#[derive(Clone)]
280pub enum RegistryEntry {
281 Tool(ExtensionTool),
282 Command(RegisteredCommand),
283 Flag(RegisteredFlag),
284}
285
286// ---------------------------------------------------------------------------
287// P2 — plugin declarations (priority + platforms)
288// ---------------------------------------------------------------------------
289
290/// Default dispatch priority for a plugin that declares none. Handlers run
291/// smallest-priority-first; ties break by registration order.
292pub const DEFAULT_PRIORITY: i32 = 100;
293
294/// The host platform, as matched against a plugin's declared `platforms`.
295/// Platform set used by extension capability declarations.
296pub fn current_platform() -> &'static str {
297 if cfg!(target_os = "android") {
298 "android"
299 } else if cfg!(target_os = "linux") {
300 // Termux reports `target_os = "android"` above; a plain Linux is here.
301 "linux"
302 } else if cfg!(target_os = "macos") {
303 "macos"
304 } else if cfg!(target_os = "windows") {
305 "windows"
306 } else {
307 "unknown"
308 }
309}
310
311/// Whether a handler with the given declared `platforms` should run on this
312/// host. Empty `platforms` = all platforms; a non-empty list requires the host
313/// platform to be present (`"all"` is accepted as a wildcard).
314pub fn platform_allows(platforms: &[String]) -> bool {
315 if platforms.is_empty() {
316 return true;
317 }
318 let here = current_platform();
319 platforms
320 .iter()
321 .any(|p| p.eq_ignore_ascii_case("all") || p.eq_ignore_ascii_case(here))
322}
323
324// ---------------------------------------------------------------------------
325// ExtensionRegistry — accumulated during register, then snapshotted
326// ---------------------------------------------------------------------------
327
328/// Accumulates registrations from one or more plugins' `rpi_plugin_register`
329/// calls. Held by [`HostApi`](crate::HostApi) during register; the host then
330/// [`take_registry`](crate::HostApi::take_registry) and builds a snapshot.
331///
332/// Merge semantics mirror pi: **first-registration-wins** on tool/command name
333/// collision (a later registration for an existing name is dropped, not an
334/// overwrite). Event handlers accumulate (multiple per tag — fan-out).
335pub struct ExtensionRegistry {
336 tools: Vec<ExtensionTool>,
337 commands: Vec<RegisteredCommand>,
338 flags: Vec<RegisteredFlag>,
339 /// Keyboard shortcuts declared by plugins (first-wins by `key`). Recorded
340 /// so the TUI can route a key press/release to the owning plugin's `Input`
341 /// event handler instead of consuming it as editor text.
342 shortcuts: Vec<RegisteredShortcut>,
343 /// `handlers[tag as usize]` — all handlers subscribed to that tag.
344 handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
345 /// `resources_discover` handlers (B5b). Fan-out on discovery, in registration
346 /// order. Unlike event handlers (which key off a tag in a fixed array), these
347 /// are a single flat list — `resources_discover` has its own out-param
348 /// signature and is never dispatched through the fire-and-forget event path.
349 resources_discover: Vec<ResourcesDiscoverHandler>,
350 /// `before_agent_start` system-prompt transformers. A flat list for the same
351 /// reason as `resources_discover`: this is an out-param handler (the plugin
352 /// returns the prompt to install), not a fire-and-forget event subscription,
353 /// so it is not indexed by a tag. Chained in registration order.
354 before_agent_start: Vec<BeforeAgentStartHandler>,
355 /// Registered custom providers (B5c). The host wraps each in a
356 /// [`PluggableProvider`](crate::PluggableProvider). First-registration-wins on
357 /// `provider_id`.
358 providers: Vec<RegisteredProvider>,
359 provider_manifests: std::collections::BTreeMap<String, ProviderManifest>,
360 /// Provider-owned OAuth implementations. First-registration-wins on the
361 /// manifest `id` field.
362 oauth_providers: Vec<RegisteredOAuthProvider>,
363 /// Registered renderers (B5c), split by kind at registration. First-wins on
364 /// `(kind, name)`. The host records these now; TUI consumption is B5e.
365 renderers: Vec<RegisteredRenderer>,
366 /// P2: the registering plugin's declared dispatch priority. Stamped onto
367 /// every handler it registers; a later `declare` also retro-updates them.
368 priority: i32,
369 /// P2: the registering plugin's declared platforms (empty = all).
370 platforms: Vec<String>,
371 /// A plugin's `{"ownsStdio":true}` claim: this host process's stdin/stdout
372 /// belong to the plugin (an ACP/MCP-style stdio server), so the host must
373 /// defer `read_piped_stdin` until after extension load and run the headless
374 /// `Rpc` lifecycle instead of Print/TUI. Monotonic across `absorb` (any
375 /// claiming plugin projects `true`). The scoped ActionBridge arbitrates
376 /// exclusivity before registries are merged. Default `false`.
377 owns_stdio: bool,
378 /// Shared staleness flag. `true` while the session owning this registry is
379 /// active; set `false` on swap/`/reload`. Tool/event dispatch checks it.
380 active: Arc<AtomicBool>,
381}
382
383impl Default for ExtensionRegistry {
384 fn default() -> Self {
385 Self::new()
386 }
387}
388
389impl ExtensionRegistry {
390 /// Build an empty, active registry.
391 pub fn new() -> Self {
392 let handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT] =
393 std::array::from_fn(|_| Vec::new());
394 Self {
395 tools: Vec::new(),
396 commands: Vec::new(),
397 flags: Vec::new(),
398 shortcuts: Vec::new(),
399 handlers,
400 resources_discover: Vec::new(),
401 before_agent_start: Vec::new(),
402 providers: Vec::new(),
403 provider_manifests: std::collections::BTreeMap::new(),
404 oauth_providers: Vec::new(),
405 renderers: Vec::new(),
406 priority: DEFAULT_PRIORITY,
407 platforms: Vec::new(),
408 owns_stdio: false,
409 active: Arc::new(AtomicBool::new(true)),
410 }
411 }
412
413 /// Register a tool. Returns `true` if a **prior** tool of the same name was
414 /// kept (first-wins: the new one is dropped). Returns `false` on insert.
415 pub fn register_tool(&mut self, tool: Tool, handle: PluginToolHandle) -> bool {
416 if self.tools.iter().any(|t| t.tool.name == tool.name) {
417 // First-wins: keep the prior, drop the new handle. We must destroy
418 // the dropped handle's allocation? No — `handle` is just fn pointers
419 // + a FreeStringFn (all Copy, no allocation). Dropping it is a no-op.
420 // A real per-tool plugin allocation only exists after `execute`
421 // produces a StepHandle; we never call execute for a dropped
422 // registration, so nothing to free.
423 return true;
424 }
425 self.tools.push(ExtensionTool::new(tool, handle));
426 false
427 }
428
429 /// Register a slash command (first-wins by name). `true` if a prior was kept.
430 pub fn register_command(
431 &mut self,
432 name: String,
433 description: String,
434 handler: CommandHandlerFn,
435 user_data: *mut std::ffi::c_void,
436 ) -> bool {
437 if self.commands.iter().any(|c| c.name == name) {
438 return true;
439 }
440 self.commands.push(RegisteredCommand {
441 name,
442 description,
443 handler,
444 user_data,
445 });
446 false
447 }
448
449 /// Register a CLI flag (first-wins by name). Returns `true` when an
450 /// earlier extension already declared the same flag.
451 pub fn register_flag(&mut self, name: String, description: String) -> bool {
452 if self.flags.iter().any(|flag| flag.name == name) {
453 return true;
454 }
455 self.flags.push(RegisteredFlag { name, description });
456 false
457 }
458
459 /// Declare a keyboard shortcut (first-wins by `key`). Returns `true` when an
460 /// earlier extension already claimed the same key.
461 ///
462 /// Recording a shortcut does **not** bind the key to a callback: the owning
463 /// plugin receives presses/releases through its `Input` event handler and
464 /// decides what to do. The host uses this list only to know which keys are
465 /// claimed, so it can route them to extensions instead of the editor.
466 pub fn register_shortcut(&mut self, plugin: String, key: String, description: String) -> bool {
467 if self.shortcuts.iter().any(|s| s.key == key) {
468 return true;
469 }
470 self.shortcuts.push(RegisteredShortcut {
471 key,
472 description,
473 plugin,
474 });
475 false
476 }
477
478 /// Subscribe a handler to `tag`. Multiple handlers per tag are kept
479 /// (fan-out on dispatch). Always inserts; returns `false`. `plugin` is the
480 /// owning extension's display name (host-side only, for diagnostics).
481 pub fn register_event_handler(
482 &mut self,
483 plugin: String,
484 tag: EventTag,
485 handler: EventHandlerFn,
486 user_data: *mut std::ffi::c_void,
487 ) -> bool {
488 let idx = tag as usize;
489 if idx < EVENT_TAG_COUNT {
490 // Stamp the registering plugin's current declaration (P2). A plugin
491 // typically declares BEFORE registering handlers; `apply_declaration`
492 // also retro-updates any handler registered before the declare call.
493 self.handlers[idx].push(RegisteredHandler {
494 plugin,
495 priority: self.priority,
496 platforms: self.platforms.clone(),
497 handler,
498 user_data,
499 });
500 }
501 false
502 }
503
504 /// Apply a plugin's declaration parsed from the unified C ABI `declare` JSON
505 /// (`{"priority":60,"platforms":["linux","macos"]}`). Unknown keys are
506 /// ignored; malformed values leave the corresponding field unchanged.
507 /// Retro-updates any handlers already registered (so declare order doesn't
508 /// matter). Returns the number of fields applied.
509 pub fn apply_declaration(
510 &mut self,
511 priority: Option<i32>,
512 platforms: Option<Vec<String>>,
513 ) -> usize {
514 let mut applied = 0;
515 if let Some(priority) = priority {
516 self.priority = priority;
517 for tag_handlers in self.handlers.iter_mut() {
518 for h in tag_handlers.iter_mut() {
519 h.priority = priority;
520 }
521 }
522 applied += 1;
523 }
524 if let Some(platforms) = platforms {
525 self.platforms = platforms.clone();
526 for tag_handlers in self.handlers.iter_mut() {
527 for h in tag_handlers.iter_mut() {
528 h.platforms = platforms.clone();
529 }
530 }
531 applied += 1;
532 }
533 applied
534 }
535
536 /// Parse a v3 `declare` JSON payload and apply it (P2). Recognised keys:
537 /// `priority` (integer), `platforms` (array of strings) and `ownsStdio`
538 /// (boolean; see [`ExtensionRegistry::declared_owns_stdio`]). Returns `true`
539 /// if the payload parsed as JSON (even with no recognised keys), `false` on
540 /// a parse error.
541 pub fn apply_declaration_json(&mut self, json: &str) -> bool {
542 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
543 return false;
544 };
545 if let Some(providers) = value.get("providers") {
546 let Ok(manifests) = serde_json::from_value::<Vec<ProviderManifest>>(providers.clone())
547 else {
548 return false;
549 };
550 if manifests.iter().any(|manifest| {
551 manifest.id.trim().is_empty()
552 || manifest
553 .models
554 .iter()
555 .any(|model| model.id.trim().is_empty() || model.provider != manifest.id)
556 }) {
557 return false;
558 }
559 for manifest in manifests {
560 self.provider_manifests
561 .insert(manifest.id.clone(), manifest);
562 }
563 }
564 let priority = value
565 .get("priority")
566 .and_then(|v| v.as_i64())
567 .map(|v| v as i32);
568 let platforms = value
569 .get("platforms")
570 .and_then(|v| v.as_array())
571 .map(|arr| {
572 arr.iter()
573 .filter_map(|v| v.as_str().map(|s| s.to_string()))
574 .collect::<Vec<String>>()
575 });
576 if let Some(owns) = value.get("ownsStdio").and_then(|v| v.as_bool()) {
577 // A claim, not a toggle: once a plugin owns stdio it stays claimed
578 // (and `absorb` ORs it across plugins).
579 self.owns_stdio = self.owns_stdio || owns;
580 }
581 self.apply_declaration(priority, platforms);
582 true
583 }
584
585 /// Register a `resources_discover` handler (B5b). Multiple handlers are kept
586 /// (fan-out on discovery, registration order). Always inserts; returns `false`.
587 pub fn register_resources_discover(
588 &mut self,
589 handler: ResourcesDiscoverFn,
590 plugin_free_string: FreeStringFn,
591 user_data: *mut std::ffi::c_void,
592 ) -> bool {
593 self.resources_discover.push(ResourcesDiscoverHandler {
594 handler,
595 plugin_free_string,
596 user_data,
597 });
598 false
599 }
600
601 /// Register a `before_agent_start` system-prompt transformer. Multiple
602 /// handlers are kept (chained in registration order: each sees the prompt the
603 /// previous one returned, mirroring pi `runner.ts:1329-1347`). Always
604 /// inserts; returns `false`.
605 pub fn register_before_agent_start(
606 &mut self,
607 handler: BeforeAgentStartFn,
608 plugin_free_string: FreeStringFn,
609 user_data: *mut std::ffi::c_void,
610 ) -> bool {
611 self.before_agent_start.push(BeforeAgentStartHandler {
612 handler,
613 plugin_free_string,
614 user_data,
615 });
616 false
617 }
618
619 /// Register a custom provider (B5c). First-registration-wins on `provider_id`
620 /// (a later registration for an existing id is dropped, mirroring tool/command
621 /// first-wins). Returns `true` if a prior provider of the same id was kept.
622 pub fn register_provider(&mut self, provider: RegisteredProvider) -> bool {
623 if self
624 .providers
625 .iter()
626 .any(|p| p.provider_id == provider.provider_id)
627 {
628 return true;
629 }
630 self.providers.push(provider);
631 false
632 }
633
634 /// Register the **streaming** form of a custom provider (see
635 /// [`RegisteredProvider::stream_fn`]).
636 ///
637 /// Differs from [`register_provider`](Self::register_provider) in one way:
638 /// upgrading an id that already has a **one-shot** registration replaces it
639 /// instead of being dropped, because streaming is a strict superset of the
640 /// one-shot contract — losing the upgrade to first-wins would silently keep
641 /// the plugin on the non-streaming path. A registration that arrives when the
642 /// id already has a **streaming** fn is still dropped (first streaming
643 /// registration wins). Returns `true` when an existing streaming provider
644 /// was kept.
645 pub fn register_streaming_provider(&mut self, provider: RegisteredProvider) -> bool {
646 debug_assert!(
647 provider.stream_fn.is_some(),
648 "register_streaming_provider requires stream_fn; use register_provider otherwise"
649 );
650 if let Some(existing) = self
651 .providers
652 .iter_mut()
653 .find(|p| p.provider_id == provider.provider_id)
654 {
655 if existing.stream_fn.is_some() {
656 // Two streaming registrations for one id: first wins, like every
657 // other registrar in this registry.
658 return true;
659 }
660 // One-shot → streaming upgrade. Keep the record's slot (and its
661 // position) but adopt the richer capability + any identity the
662 // plugin re-sent.
663 existing.stream_fn = provider.stream_fn;
664 existing.request_fn = provider.request_fn;
665 existing.base_url = provider.base_url;
666 existing.api_style = provider.api_style;
667 existing.plugin_free_string = provider.plugin_free_string;
668 existing.user_data = provider.user_data;
669 return false;
670 }
671 self.providers.push(provider);
672 false
673 }
674
675 /// Register a provider-owned OAuth implementation. The manifest must be a
676 /// JSON object with a non-empty string `id`; malformed manifests are
677 /// rejected. Returns `true` when an existing provider won.
678 pub fn register_oauth_provider(&mut self, provider: RegisteredOAuthProvider) -> bool {
679 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
680 else {
681 return true;
682 };
683 let Some(id) = manifest.get("id").and_then(serde_json::Value::as_str) else {
684 return true;
685 };
686 if id.trim().is_empty()
687 || self.oauth_providers.iter().any(|p| {
688 serde_json::from_str::<serde_json::Value>(&p.manifest_json)
689 .ok()
690 .and_then(|v| {
691 v.get("id")
692 .and_then(serde_json::Value::as_str)
693 .map(str::to_owned)
694 })
695 .as_deref()
696 == Some(id)
697 })
698 {
699 return true;
700 }
701 self.oauth_providers.push(provider);
702 false
703 }
704
705 /// Register a renderer (B5c — message/markdown/entry). First-wins on
706 /// `(kind, name)`. Returns `true` if a prior renderer of the same kind+name
707 /// was kept.
708 pub fn register_renderer(&mut self, renderer: RegisteredRenderer) -> bool {
709 if self
710 .renderers
711 .iter()
712 .any(|r| r.kind == renderer.kind && r.name == renderer.name)
713 {
714 return true;
715 }
716 self.renderers.push(renderer);
717 false
718 }
719
720 /// Build a snapshot the host session keeps. The registry's `active` flag is
721 /// shared (Arc) so a later `invalidate` on the registry also invalidates the
722 /// snapshot — important for cross-session staleness.
723 pub fn snapshot(&self) -> RegistrySnapshot {
724 // P2: dispatch order is priority-first (smaller first). `sort_by_key` is
725 // stable, so equal priorities keep registration order (the prior
726 // contract). Sorting per tag here means every snapshot — including the
727 // merged session snapshot built via `absorb` — is pre-ordered.
728 let mut handlers = self.handlers.clone();
729 for tag_handlers in handlers.iter_mut() {
730 tag_handlers.sort_by_key(|h| h.priority);
731 }
732 RegistrySnapshot {
733 tools: self
734 .tools
735 .iter()
736 .map(|t| ExtensionTool {
737 tool: t.tool.clone(),
738 handle: t.handle,
739 })
740 .collect(),
741 commands: self.commands.clone(),
742 flags: self.flags.clone(),
743 shortcuts: self.shortcuts.clone(),
744 handlers,
745 resources_discover: self.resources_discover.clone(),
746 before_agent_start: self.before_agent_start.clone(),
747 providers: self.providers.clone(),
748 provider_manifests: self.provider_manifests.clone(),
749 oauth_providers: self.oauth_providers.clone(),
750 renderers: self.renderers.clone(),
751 owns_stdio: self.owns_stdio,
752 active: Arc::clone(&self.active),
753 }
754 }
755
756 /// Mark this registry (and any snapshot sharing its flag) as stale.
757 pub fn invalidate(&self) {
758 self.active.store(false, Ordering::SeqCst);
759 }
760
761 /// Whether this registry is still active (not invalidated).
762 pub fn is_active(&self) -> bool {
763 self.active.load(Ordering::SeqCst)
764 }
765
766 /// P2: the plugin's declared dispatch priority (default [`DEFAULT_PRIORITY`]).
767 pub fn declared_priority(&self) -> i32 {
768 self.priority
769 }
770
771 /// P2: the plugin's declared platforms (empty = all).
772 pub fn declared_platforms(&self) -> &[String] {
773 &self.platforms
774 }
775
776 /// Whether this plugin claimed ownership of the host process's stdio
777 /// (`declare {"ownsStdio":true}`). Default `false`.
778 pub fn declared_owns_stdio(&self) -> bool {
779 self.owns_stdio
780 }
781
782 /// Absorb another registry's registrations into this one, first-wins on
783 /// name (tools/commands) and appending (event handlers). Used by
784 /// [`load_dir`](crate::loader::load_dir)/`merge_registries` to fold
785 /// per-plugin registries into one session registry in load order. Consumes
786 /// `other` (its `active` flag is discarded — the session registry's flag
787 /// wins, since it owns session lifetime).
788 pub(crate) fn absorb(&mut self, mut other: ExtensionRegistry) {
789 for t in other.tools.drain(..) {
790 if self.tools.iter().any(|x| x.tool.name == t.tool.name) {
791 // first-wins: keep the prior, discard the new. Nothing to free
792 // (handle is fn pointers). Emit no diagnostic here — the loader
793 // may surface collisions if desired; v1 silent first-wins.
794 continue;
795 }
796 self.tools.push(t);
797 }
798 for c in other.commands.drain(..) {
799 if self.commands.iter().any(|x| x.name == c.name) {
800 continue;
801 }
802 self.commands.push(c);
803 }
804 for flag in other.flags.drain(..) {
805 if self.flags.iter().any(|existing| existing.name == flag.name) {
806 continue;
807 }
808 self.flags.push(flag);
809 }
810 for shortcut in other.shortcuts.drain(..) {
811 if self
812 .shortcuts
813 .iter()
814 .any(|existing| existing.key == shortcut.key)
815 {
816 continue;
817 }
818 self.shortcuts.push(shortcut);
819 }
820 for (tag_idx, handlers) in other.handlers.iter_mut().enumerate() {
821 self.handlers[tag_idx].append(handlers);
822 }
823 self.resources_discover
824 .append(&mut other.resources_discover);
825 self.before_agent_start
826 .append(&mut other.before_agent_start);
827 for p in other.providers.drain(..) {
828 if self
829 .providers
830 .iter()
831 .any(|x| x.provider_id == p.provider_id)
832 {
833 continue;
834 }
835 if let Some(manifest) = other.provider_manifests.remove(&p.provider_id) {
836 self.provider_manifests
837 .insert(p.provider_id.clone(), manifest);
838 }
839 self.providers.push(p);
840 }
841 for oauth in other.oauth_providers.drain(..) {
842 if self.oauth_providers.iter().any(|existing| {
843 let existing_id =
844 serde_json::from_str::<serde_json::Value>(&existing.manifest_json)
845 .ok()
846 .and_then(|value| {
847 value
848 .get("id")
849 .and_then(serde_json::Value::as_str)
850 .map(str::to_owned)
851 });
852 let oauth_id = serde_json::from_str::<serde_json::Value>(&oauth.manifest_json)
853 .ok()
854 .and_then(|value| {
855 value
856 .get("id")
857 .and_then(serde_json::Value::as_str)
858 .map(str::to_owned)
859 });
860 existing_id.is_some() && existing_id == oauth_id
861 }) {
862 continue;
863 }
864 self.oauth_providers.push(oauth);
865 }
866 for r in other.renderers.drain(..) {
867 if self
868 .renderers
869 .iter()
870 .any(|x| x.kind == r.kind && x.name == r.name)
871 {
872 continue;
873 }
874 self.renderers.push(r);
875 }
876 // stdio ownership is a claim: any claiming plugin makes the merged
877 // session own stdio. The live bridge has already checked the exclusive
878 // owner; this flag projects that claim into the immutable snapshot.
879 self.owns_stdio |= other.owns_stdio;
880 }
881}
882
883// ---------------------------------------------------------------------------
884// RegistrySnapshot — the host session's immutable view
885// ---------------------------------------------------------------------------
886
887/// An immutable snapshot of an [`ExtensionRegistry`] the host session keeps for
888/// its lifetime. Tools are wrapped in [`ExtensionTool`] so the host can build
889/// [`PluginToolAdapter`](crate::PluginToolAdapter)s; event handlers are grouped
890/// by tag for the [`ExtensionEmitter`](crate::ExtensionEmitter) to fan out.
891///
892/// The `active` flag is shared with the source registry, so invalidating the
893/// registry (on session swap) invalidates this snapshot too.
894pub struct RegistrySnapshot {
895 tools: Vec<ExtensionTool>,
896 commands: Vec<RegisteredCommand>,
897 flags: Vec<RegisteredFlag>,
898 shortcuts: Vec<RegisteredShortcut>,
899 handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
900 resources_discover: Vec<ResourcesDiscoverHandler>,
901 before_agent_start: Vec<BeforeAgentStartHandler>,
902 providers: Vec<RegisteredProvider>,
903 provider_manifests: std::collections::BTreeMap<String, ProviderManifest>,
904 oauth_providers: Vec<RegisteredOAuthProvider>,
905 renderers: Vec<RegisteredRenderer>,
906 /// A plugin's `{"ownsStdio":true}` claim (merged OR across plugins). The
907 /// host reads this after `load_session` to defer `read_piped_stdin` and run
908 /// the headless `Rpc` lifecycle. See [`ExtensionRegistry::declared_owns_stdio`].
909 owns_stdio: bool,
910 active: Arc<AtomicBool>,
911}
912
913impl RegistrySnapshot {
914 /// The extension tools (schema + handle), insertion order.
915 pub fn tools(&self) -> &[ExtensionTool] {
916 &self.tools
917 }
918
919 /// Consume the snapshot into the owned tool list (for building adapters).
920 pub fn into_tools(self) -> Vec<ExtensionTool> {
921 self.tools
922 }
923
924 /// The registered slash commands.
925 pub fn commands(&self) -> &[RegisteredCommand] {
926 &self.commands
927 }
928
929 /// The registered CLI flags, insertion order.
930 pub fn flags(&self) -> &[RegisteredFlag] {
931 &self.flags
932 }
933
934 /// Whether any loaded plugin claimed ownership of the host process's stdio
935 /// (`declare {"ownsStdio":true}`). The host uses this to defer
936 /// `read_piped_stdin` and run the headless `Rpc` lifecycle.
937 pub fn owns_stdio(&self) -> bool {
938 self.owns_stdio
939 }
940
941 /// Keyboard shortcuts declared by extensions, insertion order.
942 pub fn shortcuts(&self) -> &[RegisteredShortcut] {
943 &self.shortcuts
944 }
945
946 /// Whether any extension has claimed `key` as a shortcut. Used by the TUI
947 /// to route that key to extension `Input` events instead of the editor.
948 pub fn has_shortcut(&self, key: &str) -> bool {
949 self.shortcuts.iter().any(|s| s.key == key)
950 }
951
952 /// Handlers subscribed to `tag` (empty slice if none).
953 pub fn handlers_for(&self, tag: EventTag) -> &[RegisteredHandler] {
954 let idx = tag as usize;
955 if idx < EVENT_TAG_COUNT {
956 &self.handlers[idx]
957 } else {
958 &[]
959 }
960 }
961
962 /// The `resources_discover` handlers, registration order (B5b). Empty when
963 /// no plugin registered a discovery handler.
964 pub fn resources_discover(&self) -> &[ResourcesDiscoverHandler] {
965 &self.resources_discover
966 }
967
968 /// The `before_agent_start` system-prompt transformers, registration order.
969 /// Empty when no plugin asked to transform the prompt — the common case, and
970 /// the one callers treat as "leave the prompt alone".
971 pub fn before_agent_start(&self) -> &[BeforeAgentStartHandler] {
972 &self.before_agent_start
973 }
974
975 /// The registered custom providers (B5c), registration order. The host wraps
976 /// each in a [`PluggableProvider`](crate::PluggableProvider). Empty when no
977 /// plugin registered a provider.
978 pub fn providers(&self) -> &[RegisteredProvider] {
979 &self.providers
980 }
981
982 /// Only declarations backed by a successfully registered provider are used.
983 pub fn provider_manifest(&self, id: &str) -> Option<&ProviderManifest> {
984 self.providers
985 .iter()
986 .any(|provider| provider.provider_id == id)
987 .then(|| self.provider_manifests.get(id))
988 .flatten()
989 }
990
991 /// All registered renderers (B5c), registration order, across all three
992 /// kinds.
993 pub fn renderers(&self) -> &[RegisteredRenderer] {
994 &self.renderers
995 }
996
997 /// OAuth providers registered by extensions, in registration order.
998 pub fn oauth_providers(&self) -> &[RegisteredOAuthProvider] {
999 &self.oauth_providers
1000 }
1001
1002 /// Find an OAuth provider by its manifest `id`.
1003 pub fn oauth_provider(&self, provider_id: &str) -> Option<&RegisteredOAuthProvider> {
1004 self.oauth_providers.iter().find(|provider| {
1005 serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
1006 .ok()
1007 .and_then(|value| {
1008 value
1009 .get("id")
1010 .and_then(serde_json::Value::as_str)
1011 .map(|id| id == provider_id)
1012 })
1013 .unwrap_or(false)
1014 })
1015 }
1016
1017 /// The registered renderers of a specific kind (B5c). The TUI dispatches
1018 /// message and entry renderers through the same JSON adapter used by the
1019 /// markdown transformer.
1020 pub fn renderers_of(&self, kind: RegisteredRendererKind) -> Vec<RegisteredRenderer> {
1021 self.renderers
1022 .iter()
1023 .filter(|r| r.kind == kind)
1024 .cloned()
1025 .collect()
1026 }
1027
1028 /// A flat iterator of all registrations (tools + commands), for diagnostics.
1029 pub fn entries(&self) -> Vec<RegistryEntry> {
1030 let mut v: Vec<RegistryEntry> = Vec::new();
1031 for t in &self.tools {
1032 v.push(RegistryEntry::Tool(ExtensionTool {
1033 tool: t.tool.clone(),
1034 handle: t.handle,
1035 }));
1036 }
1037 for c in &self.commands {
1038 v.push(RegistryEntry::Command(c.clone()));
1039 }
1040 for flag in &self.flags {
1041 v.push(RegistryEntry::Flag(flag.clone()));
1042 }
1043 v
1044 }
1045
1046 /// Whether this snapshot's session is still active.
1047 pub fn is_active(&self) -> bool {
1048 self.active.load(Ordering::SeqCst)
1049 }
1050
1051 /// The shared active flag (for [`assert_active`]).
1052 pub fn active_flag(&self) -> &Arc<AtomicBool> {
1053 &self.active
1054 }
1055}
1056
1057/// Staleness guard: `true` while the owning session is still active. Called
1058/// before dispatching an extension event or driving an extension tool so a
1059/// stale registry (from a swapped-out session) can't act. Mirrors pi's
1060/// `ExtensionRuntimeState` active check.
1061pub fn assert_active(active: &Arc<AtomicBool>) -> bool {
1062 active.load(Ordering::SeqCst)
1063}
1064
1065/// Build an `AgentError` for a stale-registry access.
1066#[allow(dead_code)]
1067pub(crate) fn stale_error() -> AgentError {
1068 AgentError::State("extensions registry is stale (session swapped/reloaded)".into())
1069}
1070
1071// Suppress unused-fn warning until B3 wires dispatch through the snapshot.
1072#[allow(dead_code)]
1073fn _ensure_handler_accessor_used(snap: &RegistrySnapshot) {
1074 let _ = snap.handlers_for(EventTag::MessageEnd);
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 #[test]
1082 fn shortcuts_first_wins_and_merge() {
1083 let mut first = ExtensionRegistry::new();
1084 assert!(!first.register_shortcut("voice".into(), "space".into(), "talk".into()));
1085 // Second claim on the same key is rejected (first-wins).
1086 assert!(first.register_shortcut("other".into(), "space".into(), "also".into()));
1087 assert!(!first.register_shortcut("voice".into(), "enter".into(), "send".into()));
1088
1089 let mut second = ExtensionRegistry::new();
1090 assert!(!second.register_shortcut("other".into(), "space".into(), "dup".into()));
1091 assert!(!second.register_shortcut("other".into(), "f1".into(), "help".into()));
1092
1093 first.absorb(second);
1094 let snap = first.snapshot();
1095 assert_eq!(snap.shortcuts().len(), 3);
1096 assert_eq!(snap.shortcuts()[0].key, "space");
1097 assert_eq!(snap.shortcuts()[0].plugin, "voice");
1098 assert_eq!(snap.shortcuts()[1].key, "enter");
1099 assert_eq!(snap.shortcuts()[2].key, "f1");
1100 // The claimed-key query the TUI routes on.
1101 assert!(snap.has_shortcut("space"));
1102 assert!(!snap.has_shortcut("tab"));
1103 }
1104
1105 #[test]
1106 fn cli_flags_snapshot_and_merge_first_wins() {
1107 let mut first = ExtensionRegistry::new();
1108 assert!(!first.register_flag("server".into(), "Start server".into()));
1109 assert!(first.register_flag("server".into(), "Different description".into()));
1110
1111 let mut second = ExtensionRegistry::new();
1112 assert!(!second.register_flag("server".into(), "Second server".into()));
1113 assert!(!second.register_flag("port".into(), "Listen port".into()));
1114
1115 first.absorb(second);
1116 let snapshot = first.snapshot();
1117 let flags = snapshot.flags();
1118 assert_eq!(flags.len(), 2);
1119 assert_eq!(flags[0].name, "server");
1120 assert_eq!(flags[0].description, "Start server");
1121 assert_eq!(flags[1].name, "port");
1122 }
1123
1124 extern "C" fn noop_handler(
1125 _ev: rpi_plugin_sdk::StablePluginEvent,
1126 _ud: *mut std::ffi::c_void,
1127 ) -> i32 {
1128 0
1129 }
1130
1131 fn handler_of<'a>(
1132 snap: &'a RegistrySnapshot,
1133 tag: EventTag,
1134 i: usize,
1135 ) -> &'a RegisteredHandler {
1136 &snap.handlers_for(tag)[i]
1137 }
1138
1139 /// Two plugins declaring different priorities: the merged snapshot orders
1140 /// the lower-priority plugin's handler first (smaller runs first).
1141 #[test]
1142 fn priority_orders_handlers_after_merge() {
1143 let tag = EventTag::SessionStart;
1144
1145 let mut late = ExtensionRegistry::new();
1146 late.apply_declaration(Some(200), None);
1147 late.register_event_handler("late".into(), tag, noop_handler, std::ptr::null_mut());
1148
1149 let mut early = ExtensionRegistry::new();
1150 early.apply_declaration(Some(50), None);
1151 early.register_event_handler("early".into(), tag, noop_handler, std::ptr::null_mut());
1152
1153 // Absorb in the "wrong" order — snapshot must still sort by priority.
1154 let mut session = ExtensionRegistry::new();
1155 session.absorb(late);
1156 session.absorb(early);
1157 let snap = session.snapshot();
1158 assert_eq!(handler_of(&snap, tag, 0).plugin, "early");
1159 assert_eq!(handler_of(&snap, tag, 1).plugin, "late");
1160 }
1161
1162 /// Equal priorities keep registration order (stable sort).
1163 #[test]
1164 fn equal_priority_is_stable() {
1165 let tag = EventTag::TurnEnd;
1166 let mut reg = ExtensionRegistry::new();
1167 reg.register_event_handler("a".into(), tag, noop_handler, std::ptr::null_mut());
1168 reg.register_event_handler("b".into(), tag, noop_handler, std::ptr::null_mut());
1169 let snap = reg.snapshot();
1170 assert_eq!(handler_of(&snap, tag, 0).plugin, "a");
1171 assert_eq!(handler_of(&snap, tag, 1).plugin, "b");
1172 }
1173
1174 /// A declaration applied AFTER handlers exist retro-updates them (declare
1175 /// order does not matter).
1176 #[test]
1177 fn declaration_retro_updates_existing_handlers() {
1178 let tag = EventTag::SessionStart;
1179 let mut reg = ExtensionRegistry::new();
1180 reg.register_event_handler("p".into(), tag, noop_handler, std::ptr::null_mut());
1181 assert_eq!(
1182 handler_of(®.snapshot(), tag, 0).priority,
1183 DEFAULT_PRIORITY
1184 );
1185
1186 assert!(reg.apply_declaration_json(r#"{"priority":7,"platforms":["linux"]}"#));
1187 let snap = reg.snapshot();
1188 assert_eq!(handler_of(&snap, tag, 0).priority, 7);
1189 assert_eq!(
1190 handler_of(&snap, tag, 0).platforms,
1191 vec!["linux".to_string()]
1192 );
1193 }
1194
1195 /// Malformed declaration JSON is rejected (returns `false`); recognised keys
1196 /// apply.
1197 #[test]
1198 fn apply_declaration_json_parses_and_rejects() {
1199 let mut reg = ExtensionRegistry::new();
1200 assert!(!reg.apply_declaration_json("not json"));
1201 assert!(reg.apply_declaration_json("{}"));
1202 assert!(reg.apply_declaration_json(r#"{"priority":3}"#));
1203 assert_eq!(reg.apply_declaration(Some(3), None), 1);
1204 }
1205
1206 /// `declare {"ownsStdio":true}` claims the host process's stdio; it is
1207 /// monotonic (a later `false` does not un-claim) and merges by OR.
1208 #[test]
1209 fn declaration_owns_stdio_is_monotonic_and_merges() {
1210 let mut reg = ExtensionRegistry::new();
1211 assert!(!reg.declared_owns_stdio());
1212 assert!(!reg.snapshot().owns_stdio());
1213
1214 assert!(reg.apply_declaration_json(r#"{"ownsStdio":true}"#));
1215 assert!(reg.declared_owns_stdio());
1216 assert!(reg.snapshot().owns_stdio());
1217
1218 // A later `false` must not release an established claim.
1219 assert!(reg.apply_declaration_json(r#"{"ownsStdio":false}"#));
1220 assert!(reg.declared_owns_stdio());
1221
1222 // `absorb` ORs ownership across plugins (any claiming plugin wins).
1223 let mut plain = ExtensionRegistry::new();
1224 let mut claiming = ExtensionRegistry::new();
1225 assert!(claiming.apply_declaration_json(r#"{"ownsStdio":true}"#));
1226 plain.absorb(claiming);
1227 assert!(plain.declared_owns_stdio());
1228 assert!(plain.snapshot().owns_stdio());
1229 }
1230
1231 #[test]
1232 fn platform_allows_matches_host_and_wildcard() {
1233 assert!(platform_allows(&[]));
1234 assert!(platform_allows(&["all".to_string()]));
1235 assert!(platform_allows(&["ALL".to_string()]));
1236 assert!(platform_allows(&[current_platform().to_string()]));
1237 assert!(!platform_allows(&["nonexistent-os".to_string()]));
1238 // Empty host subset is fine; a non-matching list is not.
1239 assert!(!platform_allows(&["definitely-not-this-host".to_string()]));
1240 }
1241
1242 extern "C" fn noop_oauth(
1243 _request: rpi_plugin_sdk::StbStringRef,
1244 _out: *mut rpi_plugin_sdk::StbString,
1245 _user_data: *mut std::ffi::c_void,
1246 ) -> i32 {
1247 0
1248 }
1249
1250 extern "C" fn noop_free(_value: rpi_plugin_sdk::StbString) {}
1251
1252 #[test]
1253 fn oauth_manifest_registers_and_deduplicates() {
1254 let mut reg = ExtensionRegistry::new();
1255 let make = |manifest: &str| RegisteredOAuthProvider {
1256 plugin: "test-plugin".to_string(),
1257 manifest_json: manifest.to_string(),
1258 request_fn: noop_oauth,
1259 plugin_free_string: noop_free,
1260 user_data: std::ptr::null_mut(),
1261 };
1262 assert!(!reg.register_oauth_provider(make(r#"{"id":"acme","displayName":"Acme"}"#)));
1263 assert!(reg.register_oauth_provider(make(r#"{"id":"acme"}"#)));
1264 assert!(reg.register_oauth_provider(make(r#"{"displayName":"missing-id"}"#)));
1265 assert_eq!(reg.snapshot().oauth_providers().len(), 1);
1266 }
1267}
1268
1269#[cfg(test)]
1270mod provider_manifest_tests {
1271 use super::*;
1272 use rpi_plugin_sdk::{StbString, StbStringRef};
1273 extern "C" fn request(_: StbStringRef, _: *mut StbString, _: *mut std::ffi::c_void) -> i32 {
1274 1
1275 }
1276 fn record() -> RegisteredProvider {
1277 RegisteredProvider {
1278 provider_id: "package".into(),
1279 base_url: "http://localhost".into(),
1280 api_style: "openai-completions".into(),
1281 request_fn: request,
1282 stream_fn: None,
1283 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
1284 user_data: std::ptr::null_mut(),
1285 }
1286 }
1287 fn declaration(id: &str, model_id: &str) -> String {
1288 serde_json::json!({"providers":[{"id":id,"models":[rpi_ai::Model::new(model_id, model_id,
1289 rpi_ai::Api::OpenaiCompletions,"package","http://localhost")]}]})
1290 .to_string()
1291 }
1292 #[test]
1293 fn catalog_requires_registration_and_stays_with_the_winning_provider() {
1294 let mut first = ExtensionRegistry::new();
1295 assert!(first.apply_declaration_json(&declaration("package", "first")));
1296 assert!(first.snapshot().provider_manifest("package").is_none());
1297 first.register_provider(record());
1298 let manifest = first
1299 .snapshot()
1300 .provider_manifest("package")
1301 .unwrap()
1302 .clone();
1303 assert!(
1304 manifest.requires_auth,
1305 "packages require authentication by default"
1306 );
1307 assert_eq!(manifest.models[0].id, "first");
1308 let mut second = ExtensionRegistry::new();
1309 second.register_provider(record());
1310 assert!(second.apply_declaration_json(&declaration("package", "second")));
1311 first.absorb(second);
1312 assert_eq!(
1313 first
1314 .snapshot()
1315 .provider_manifest("package")
1316 .unwrap()
1317 .models[0]
1318 .id,
1319 "first"
1320 );
1321 }
1322 #[test]
1323 fn a_package_cannot_attach_a_catalog_to_another_providers_identity() {
1324 let mut registry = ExtensionRegistry::new();
1325 registry.register_provider(record());
1326 assert!(!registry.apply_declaration_json(&declaration("other", "wrong")));
1327 assert!(registry.snapshot().provider_manifest("package").is_none());
1328 }
1329}