Skip to main content

lash_core/plugin/
mod.rs

1use std::future::Future;
2use std::sync::Arc;
3
4use crate::runtime::AssembledTurn;
5use crate::{
6    MessageRole, ProtocolTurnOptions, SessionPolicy, ToolAvailability, ToolDefinition,
7    ToolManifest, ToolProvider, ToolResult, TurnInput,
8};
9
10pub use lash_sansio::{
11    CheckpointKind, PluginMessage, PluginRuntimeEvent, PromptContribution, ToolSurfaceContribution,
12    ToolSurfaceOverride,
13};
14
15mod actions;
16mod error;
17mod history;
18mod hooks;
19mod protocol;
20mod registrar;
21mod registry;
22pub mod runtime_host;
23mod runtime_impl;
24mod services;
25mod session_obj;
26mod session_types;
27mod snapshot;
28mod surface;
29mod trigger_registry;
30
31pub(crate) use actions::RegisteredPluginAction;
32pub use actions::{
33    PluginAction, PluginActionContext, PluginActionDef, PluginActionFailure, PluginActionFuture,
34    PluginActionHandler, PluginActionInvokeFuture, PluginActionKind, SessionParam,
35    plugin_action_def,
36};
37pub use error::PluginError;
38pub use history::{
39    HistoryError, HistoryRewriteMetadata, HistoryRewriter, HistoryState, RewriteContext,
40    RewriteTrigger, SessionReadView, TurnContextTransform, TurnTransformContext,
41};
42pub use hooks::{
43    AfterToolCallHook, AfterTurnHook, AssistantResponseHook, AssistantResponseHookContext,
44    AssistantResponseTransform, AssistantStreamHook, AssistantStreamHookContext,
45    AssistantStreamTransform, BeforeToolCallHook, BeforeTurnHook, CheckpointHook,
46    CheckpointHookContext, PluginFuture, PluginLifecycleEvent, PluginLifecycleEventHook,
47    PluginLifecycleFuture, PluginSessionTask, PromptContributor, PromptHookContext,
48    SessionConfigChangedContext, SessionConfigMutator, SessionStateChangedContext,
49    ToolCallHookContext, ToolDiscoveryContributor, ToolResultHookContext,
50    ToolResultProjectionContext, ToolResultProjector, ToolSurfaceContributor, TurnHookContext,
51    TurnResultHookContext, TurnResultSummary,
52};
53pub use protocol::{
54    AssistantProseProjectorPlugin, CodeExecutorPlugin, PluginOptions, ProtocolBeforeLlmCallContext,
55    ProtocolDriverPlugin, ProtocolLlmCallAction, ProtocolRuntimeContext, ProtocolSessionContext,
56    ProtocolSessionPlugin,
57};
58pub use registrar::{
59    ExecutionRegistrations, HistoryRegistrations, HostEventRegistrations, OutputRegistrations,
60    PluginActionRegistrations, PluginRegistrar, PromptRegistrations, ProtocolRegistrations,
61    SessionRegistrations, SurfaceRegistrations, ToolCallRegistrations, ToolRegistrations,
62    ToolResultRegistrations, TurnRegistrations,
63};
64pub(crate) use registrar::{PluginContributions, RegisteredHook};
65pub use registry::{
66    PluginFactory, PluginSessionContext, PluginSpec, PluginSpecBuilder, PluginSpecFactory,
67    SessionPlugin, SessionReadyContext, StaticPluginFactory,
68};
69pub use runtime_host::{
70    AppendSessionNodesRequest, AppendSessionNodesResult, DirectCompletion, DirectLlmCompletion,
71    SessionGraphService, SessionLifecycleService, SessionStateService, SessionTurnInput,
72    SessionTurnRequest,
73};
74pub use runtime_impl::{PluginHost, SessionAuthorityContext};
75pub(crate) use services::NoopSessionManager;
76pub use services::{PersistentRuntimeServices, PluginActionInvokeError, RuntimeServices};
77pub use session_obj::PluginSession;
78pub use session_types::{
79    AgentFrameAssignment, AgentFrameId, AgentFrameReason, AgentFrameRecord, AgentFrameStatus,
80    PluginOwned, SessionAppendNode, SessionContextSurface, SessionCreateRequest, SessionHandle,
81    SessionPluginSource, SessionRelation, SessionSnapshot, SessionStartPoint, SessionToolAccess,
82    SubagentSessionContext,
83};
84pub(crate) use snapshot::{InMemorySnapshotReader, InMemorySnapshotWriter};
85pub use snapshot::{
86    PluginSessionSnapshot, PluginSnapshotArtifact, PluginSnapshotEntry, PluginSnapshotMeta,
87    SnapshotReader, SnapshotWriter,
88};
89pub use surface::{
90    CheckpointApplication, PluginAbort, PluginDirective, PrepareTurnRequest, ToolDiscoveryContext,
91    ToolDiscoveryContribution, ToolDiscoveryToolContribution, ToolSurfaceContext, TurnFinalization,
92    TurnPreparation,
93};
94pub(crate) use surface::{emit_plugin_runtime_events, plugin_runtime_session_events};
95pub(crate) use trigger_registry::{SessionTriggerRegistry, SessionTriggerRoute};
96pub use trigger_registry::{TriggerRegistration, TriggerSourceType, TriggerTargetSummary};
97pub(crate) fn builtin_plugin_factories() -> Vec<Arc<dyn PluginFactory>> {
98    // Protocol plugins must be registered by the embedder before calling
99    // `PluginHost::build_session`. Unit tests use an in-tree fake to avoid
100    // a dev-dep cycle through the protocol crates.
101    let factories: Vec<Arc<dyn PluginFactory>> =
102        vec![Arc::new(trigger_registry::SessionTriggerPluginFactory)];
103    #[cfg(not(test))]
104    return factories;
105
106    #[cfg(test)]
107    {
108        factories
109            .into_iter()
110            .chain(crate::testing::test_standard_protocol_factories())
111            .collect()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use schemars::JsonSchema;
118    use serde::{Deserialize, Serialize};
119    use serde_json::json;
120
121    use super::*;
122    use crate::{SessionSnapshot, ToolDefinition};
123
124    struct MockToolProvider;
125
126    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
127    struct TypedEchoArgs {
128        value: String,
129    }
130
131    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
132    struct TypedEchoOutput {
133        value: String,
134        session_id: Option<String>,
135    }
136
137    struct TypedEchoOp;
138
139    impl PluginAction for TypedEchoOp {
140        const NAME: &'static str = "mock.typed_echo";
141        const DESCRIPTION: &'static str = "typed echo";
142        const KIND: PluginActionKind = PluginActionKind::Query;
143        const SESSION_PARAM: SessionParam = SessionParam::Optional;
144        type Args = TypedEchoArgs;
145        type Output = TypedEchoOutput;
146    }
147
148    #[async_trait::async_trait]
149    impl ToolProvider for MockToolProvider {
150        fn tool_manifests(&self) -> Vec<ToolManifest> {
151            self.tool_definitions()
152                .into_iter()
153                .map(|tool| tool.manifest())
154                .collect()
155        }
156
157        fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
158            self.tool_definitions()
159                .into_iter()
160                .find(|tool| tool.name() == name)
161                .map(|tool| Arc::new(tool.contract()))
162        }
163
164        async fn execute(&self, call: crate::ToolCall<'_>) -> ToolResult {
165            ToolResult::ok(call.args.clone())
166        }
167    }
168
169    impl MockToolProvider {
170        fn tool_definitions(&self) -> Vec<ToolDefinition> {
171            vec![
172                ToolDefinition::raw(
173                    "tool:mock_tool",
174                    "mock_tool",
175                    "",
176                    json!({
177                        "type": "object",
178                        "properties": { "value": { "type": "string" } },
179                        "required": ["value"],
180                        "additionalProperties": false
181                    }),
182                    json!({ "type": "string" }),
183                )
184                .with_availability(crate::ToolAvailabilityConfig::callable()),
185            ]
186        }
187    }
188
189    struct MockPluginFactory;
190
191    impl PluginFactory for MockPluginFactory {
192        fn id(&self) -> &'static str {
193            "mock"
194        }
195
196        fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
197            Ok(Arc::new(MockPlugin {
198                session_id: ctx.session_id.clone(),
199            }))
200        }
201    }
202
203    struct TriggerResourcePluginFactory;
204
205    impl PluginFactory for TriggerResourcePluginFactory {
206        fn id(&self) -> &'static str {
207            "trigger_resource"
208        }
209
210        fn lashlang_resources(&self) -> lashlang::ResourceCatalog {
211            let mut resources = lashlang::ResourceCatalog::new();
212            resources
213                .add_trigger_source_constructor(
214                    ["clock", "Alarm"],
215                    lashlang::TypeExpr::Object(vec![lashlang::TypeField {
216                        name: "at".into(),
217                        ty: lashlang::TypeExpr::Str,
218                        optional: false,
219                    }]),
220                    lashlang::NamedDataType::object(
221                        "clock.Tick",
222                        vec![lashlang::TypeField {
223                            name: "fired_at".into(),
224                            ty: lashlang::TypeExpr::Str,
225                            optional: false,
226                        }],
227                    )
228                    .expect("valid clock tick type"),
229                )
230                .expect("valid clock trigger source");
231            resources
232        }
233
234        fn build(
235            &self,
236            _ctx: &PluginSessionContext,
237        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
238            Ok(Arc::new(TriggerResourcePlugin))
239        }
240    }
241
242    struct TriggerResourcePlugin;
243
244    impl SessionPlugin for TriggerResourcePlugin {
245        fn id(&self) -> &'static str {
246            "trigger_resource"
247        }
248
249        fn register(&self, _reg: &mut PluginRegistrar) -> Result<(), PluginError> {
250            Ok(())
251        }
252    }
253
254    struct MockPlugin {
255        session_id: String,
256    }
257
258    use crate::testing::MockSessionManager;
259
260    impl SessionPlugin for MockPlugin {
261        fn id(&self) -> &'static str {
262            "mock"
263        }
264
265        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
266            reg.tools().provider(Arc::new(MockToolProvider))?;
267            reg.prompt().contribute(Arc::new(|_ctx| {
268                Box::pin(async move {
269                    Ok(vec![
270                        PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
271                        PromptContribution::guidance("Dynamic Note", "dynamic note")
272                            .with_priority(1),
273                    ])
274                })
275            }));
276            let session_id = self.session_id.clone();
277            reg.actions().op(
278                PluginActionDef {
279                    name: "mock.echo".to_string(),
280                    description: "echo".to_string(),
281                    kind: PluginActionKind::Query,
282                    session_param: SessionParam::Optional,
283                    input_schema: json!({}),
284                    output_schema: json!({}),
285                },
286                Arc::new(move |ctx, args| {
287                    let session_id = session_id.clone();
288                    Box::pin(async move {
289                        ToolResult::ok(json!({
290                            "session_id": ctx.session_id,
291                            "plugin_session_id": session_id,
292                            "args": args,
293                        }))
294                    })
295                }),
296            )?;
297            reg.actions()
298                .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
299                    Ok(TypedEchoOutput {
300                        value: args.value,
301                        session_id: ctx.session_id,
302                    })
303                })?;
304            Ok(())
305        }
306
307        fn snapshot(
308            &self,
309            _writer: &mut dyn SnapshotWriter,
310        ) -> Result<PluginSnapshotMeta, PluginError> {
311            Ok(PluginSnapshotMeta {
312                plugin_id: self.id().to_string(),
313                plugin_version: self.version().to_string(),
314                revision: self.snapshot_revision(),
315                state: Some(json!({"session_id": self.session_id})),
316            })
317        }
318    }
319
320    #[test]
321    fn plugin_host_collects_factory_lashlang_resources() {
322        let host = PluginHost::new(vec![Arc::new(TriggerResourcePluginFactory)]);
323
324        assert!(
325            host.lashlang_resources()
326                .resolve_value_constructor(&["clock", "Alarm"])
327                .is_some()
328        );
329        let session = host.build_session("root", None).expect("session");
330        assert!(
331            session
332                .lashlang_resources()
333                .resolve_value_constructor(&["clock", "Alarm"])
334                .is_some()
335        );
336    }
337
338    #[test]
339    fn declared_host_events_enter_session_lashlang_resources() {
340        struct HostEventOnlyFactory;
341
342        impl PluginFactory for HostEventOnlyFactory {
343            fn id(&self) -> &'static str {
344                "host_event_only"
345            }
346
347            fn build(
348                &self,
349                _ctx: &PluginSessionContext,
350            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
351                Ok(Arc::new(HostEventOnlyPlugin))
352            }
353        }
354
355        struct HostEventOnlyPlugin;
356
357        impl SessionPlugin for HostEventOnlyPlugin {
358            fn id(&self) -> &'static str {
359                "host_event_only"
360            }
361
362            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
363                reg.host_events().declare(crate::HostEvent::new(
364                    "Button",
365                    "ui.button",
366                    "pressed",
367                    lashlang::NamedDataType::object(
368                        "ui.button.Pressed",
369                        vec![lashlang::TypeField {
370                            name: "button".into(),
371                            ty: lashlang::TypeExpr::Str,
372                            optional: false,
373                        }],
374                    )
375                    .expect("valid button payload type"),
376                ))
377            }
378        }
379
380        let host = PluginHost::new(vec![Arc::new(HostEventOnlyFactory)]);
381
382        assert!(
383            host.lashlang_resources()
384                .resolve_trigger_source("ui.button.pressed")
385                .is_none()
386        );
387        let session = host.build_session("root", None).expect("session");
388        assert!(
389            session
390                .host_events()
391                .get("Button", "ui.button", "pressed")
392                .is_some()
393        );
394        assert!(
395            session
396                .lashlang_resources()
397                .resolve_trigger_source("ui.button.pressed")
398                .is_some()
399        );
400        assert!(
401            session
402                .lashlang_resources()
403                .resolve_value_constructor(&["ui", "button", "pressed"])
404                .is_some()
405        );
406    }
407
408    #[tokio::test]
409    async fn session_collects_tools_and_prompts() {
410        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
411        let session = host.build_session("root", None).expect("session");
412        let tool_names = session
413            .tools()
414            .tool_manifests()
415            .into_iter()
416            .map(|manifest| manifest.name)
417            .collect::<std::collections::BTreeSet<_>>();
418        assert!(tool_names.contains("mock_tool"));
419        assert!(tool_names.contains("batch"));
420        let contributions = session
421            .collect_prompt_contributions(PromptHookContext {
422                session_id: "root".to_string(),
423                sessions: Arc::new(MockSessionManager::default()),
424                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
425                protocol_turn_options: ProtocolTurnOptions::default(),
426                turn_context: crate::TurnContext::default(),
427            })
428            .await
429            .expect("prompt contributions");
430        assert_eq!(
431            contributions,
432            vec![
433                PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
434                PromptContribution::guidance("Dynamic Note", "dynamic note").with_priority(1),
435            ]
436        );
437    }
438
439    #[tokio::test]
440    async fn external_invoke_defaults_to_current_session_when_requested() {
441        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
442        let session = host.build_session("root", None).expect("session");
443        let result = session
444            .invoke_plugin_action(
445                "mock.echo",
446                json!({"ok":true}),
447                None,
448                true,
449                Arc::new(MockSessionManager::default()),
450                Arc::new(MockSessionManager::default()),
451                Arc::new(MockSessionManager::default()),
452                Arc::new(crate::UnavailableProcessService),
453            )
454            .await
455            .expect("invoke");
456        assert!(result.is_success());
457        assert_eq!(
458            result
459                .value_for_projection()
460                .get("session_id")
461                .and_then(|v| v.as_str()),
462            Some("root")
463        );
464    }
465
466    #[tokio::test]
467    async fn plugin_action_generates_schema_and_invokes_typed_output() {
468        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
469        let session = host.build_session("root", None).expect("session");
470
471        let def = session
472            .plugin_actions()
473            .into_iter()
474            .find(|def| def.name == TypedEchoOp::NAME)
475            .expect("typed op definition");
476        assert_eq!(def.kind, PluginActionKind::Query);
477        assert_eq!(def.session_param, SessionParam::Optional);
478        let value_type = def
479            .input_schema
480            .pointer("/schema/properties/value/type")
481            .or_else(|| def.input_schema.pointer("/properties/value/type"))
482            .and_then(serde_json::Value::as_str);
483        assert_eq!(value_type, Some("string"));
484
485        let output = session
486            .call_plugin_action::<TypedEchoOp>(
487                TypedEchoArgs {
488                    value: "hello".to_string(),
489                },
490                None,
491                true,
492                Arc::new(MockSessionManager::default()),
493                Arc::new(MockSessionManager::default()),
494                Arc::new(MockSessionManager::default()),
495                Arc::new(crate::UnavailableProcessService),
496            )
497            .await
498            .expect("typed invoke");
499        assert_eq!(output.value, "hello");
500        assert_eq!(output.session_id.as_deref(), Some("root"));
501    }
502
503    #[test]
504    fn plugin_action_rejects_duplicate_names() {
505        struct DuplicatePlugin;
506
507        impl SessionPlugin for DuplicatePlugin {
508            fn id(&self) -> &'static str {
509                "duplicate"
510            }
511
512            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
513                reg.actions()
514                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
515                        Ok(TypedEchoOutput {
516                            value: args.value,
517                            session_id: ctx.session_id,
518                        })
519                    })?;
520                reg.actions()
521                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
522                        Ok(TypedEchoOutput {
523                            value: args.value,
524                            session_id: ctx.session_id,
525                        })
526                    })
527            }
528        }
529
530        struct DuplicateFactory;
531        impl PluginFactory for DuplicateFactory {
532            fn id(&self) -> &'static str {
533                "duplicate"
534            }
535
536            fn build(
537                &self,
538                _ctx: &PluginSessionContext,
539            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
540                Ok(Arc::new(DuplicatePlugin))
541            }
542        }
543
544        let err =
545            match PluginHost::new(vec![Arc::new(DuplicateFactory)]).build_session("root", None) {
546                Ok(_) => panic!("duplicate typed plugin action should fail"),
547                Err(err) => err,
548            };
549        assert!(err.to_string().contains("duplicate plugin action name"));
550    }
551
552    #[tokio::test]
553    async fn typed_external_invoke_errors_on_failed_or_invalid_output() {
554        struct BadOp;
555        impl PluginAction for BadOp {
556            const NAME: &'static str = "mock.echo";
557            const DESCRIPTION: &'static str = "bad typed projection over raw op";
558            const KIND: PluginActionKind = PluginActionKind::Query;
559            const SESSION_PARAM: SessionParam = SessionParam::Optional;
560            type Args = TypedEchoArgs;
561            type Output = TypedEchoOutput;
562        }
563
564        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
565        let session = host.build_session("root", None).expect("session");
566        let err = session
567            .call_plugin_action::<BadOp>(
568                TypedEchoArgs {
569                    value: "hello".to_string(),
570                },
571                None,
572                true,
573                Arc::new(MockSessionManager::default()),
574                Arc::new(MockSessionManager::default()),
575                Arc::new(MockSessionManager::default()),
576                Arc::new(crate::UnavailableProcessService),
577            )
578            .await
579            .expect_err("raw output shape should not match typed output");
580        assert!(err.to_string().contains("invalid mock.echo output"));
581    }
582
583    #[tokio::test]
584    async fn plugin_host_can_invoke_plugin_action_for_registered_session() {
585        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
586        let _session = host.build_session("root", None).expect("session");
587
588        let result = host
589            .invoke_plugin_action_for_session(
590                "root",
591                "mock.echo",
592                json!({"ok":true}),
593                Arc::new(MockSessionManager::default()),
594                Arc::new(MockSessionManager::default()),
595                Arc::new(MockSessionManager::default()),
596                Arc::new(crate::UnavailableProcessService),
597            )
598            .await
599            .expect("invoke");
600        assert!(result.is_success());
601        assert_eq!(
602            result
603                .value_for_projection()
604                .get("session_id")
605                .and_then(|v| v.as_str()),
606            Some("root")
607        );
608        assert_eq!(
609            result
610                .value_for_projection()
611                .get("plugin_session_id")
612                .and_then(|v| v.as_str()),
613            Some("root")
614        );
615    }
616
617    #[tokio::test]
618    async fn plugin_host_can_invoke_plugin_action_for_forked_session() {
619        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
620        let root = host.build_session("root", None).expect("root");
621        let child = root.fork_for_session("child").expect("child");
622
623        let result = host
624            .invoke_plugin_action_for_session(
625                "child",
626                "mock.echo",
627                json!({"ok":true}),
628                Arc::new(MockSessionManager::default()),
629                Arc::new(MockSessionManager::default()),
630                Arc::new(MockSessionManager::default()),
631                Arc::new(crate::UnavailableProcessService),
632            )
633            .await
634            .expect("invoke");
635        assert!(result.is_success());
636        assert_eq!(
637            result
638                .value_for_projection()
639                .get("session_id")
640                .and_then(|v| v.as_str()),
641            Some("child")
642        );
643        assert_eq!(
644            result
645                .value_for_projection()
646                .get("plugin_session_id")
647                .and_then(|v| v.as_str()),
648            Some("child")
649        );
650
651        drop(child);
652    }
653
654    #[test]
655    fn plugin_host_unregisters_sessions() {
656        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
657        let _session = host.build_session("root", None).expect("session");
658        assert!(host.session("root").is_ok());
659        host.unregister_session("root").expect("unregister");
660        match host.session("root") {
661            Err(PluginActionInvokeError::UnknownSession(id)) => assert_eq!(id, "root"),
662            Ok(_) => panic!("expected missing session"),
663            Err(other) => panic!("unexpected error: {other}"),
664        }
665    }
666
667    #[test]
668    fn snapshot_round_trip_preserves_plugin_entries() {
669        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
670        let session = host.build_session("root", None).expect("session");
671        let snapshot = session.snapshot().expect("snapshot");
672        assert!(snapshot.plugins.contains_key("mock"));
673        let restored = host
674            .build_session("child", Some(&snapshot))
675            .expect("restored");
676        let restored_snapshot = restored.snapshot().expect("snapshot");
677        assert!(restored_snapshot.plugins.contains_key("mock"));
678    }
679
680    #[test]
681    fn runtime_services_are_backed_by_plugin_sessions() {
682        let host = PluginHost::new(vec![Arc::new(StaticPluginFactory::new(
683            "mock_tool",
684            PluginSpec::new()
685                .with_tool_provider(Arc::new(MockToolProvider) as Arc<dyn ToolProvider>),
686        ))]);
687        let services = RuntimeServices::new(host.build_session("root", None).expect("session"));
688        assert_eq!(services.plugins.session_id(), "root");
689        assert!(
690            services
691                .plugins
692                .tools()
693                .tool_manifests()
694                .iter()
695                .any(|tool| tool.name == "mock_tool")
696        );
697    }
698
699    struct ProjectorPluginFactory {
700        plugin_id: &'static str,
701    }
702
703    impl PluginFactory for ProjectorPluginFactory {
704        fn id(&self) -> &'static str {
705            self.plugin_id
706        }
707
708        fn build(
709            &self,
710            _ctx: &PluginSessionContext,
711        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
712            Ok(Arc::new(ProjectorPlugin {
713                plugin_id: self.plugin_id,
714            }))
715        }
716    }
717
718    struct ProjectorPlugin {
719        plugin_id: &'static str,
720    }
721
722    impl SessionPlugin for ProjectorPlugin {
723        fn id(&self) -> &'static str {
724            self.plugin_id
725        }
726
727        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
728            reg.tool_results().projector(Arc::new(|ctx| {
729                Box::pin(async move {
730                    Ok(crate::ModelToolReturn::from_output(
731                        ctx.call_id,
732                        ctx.tool_name,
733                        &ctx.output,
734                    ))
735                })
736            }))
737        }
738    }
739
740    #[test]
741    fn duplicate_tool_result_projectors_are_rejected() {
742        let host = PluginHost::new(vec![
743            Arc::new(ProjectorPluginFactory {
744                plugin_id: "projector-a",
745            }),
746            Arc::new(ProjectorPluginFactory {
747                plugin_id: "projector-b",
748            }),
749        ]);
750        let err = match host.build_session("root", None) {
751            Ok(_) => panic!("duplicate projector"),
752            Err(err) => err,
753        };
754        assert!(err.to_string().contains("duplicate tool result projector"));
755        assert!(err.to_string().contains("projector-a"));
756        assert!(err.to_string().contains("projector-b"));
757    }
758}