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, ToolCatalogContribution,
12    ToolCatalogOverride,
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 tool_catalog;
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    CompactionContext, ContextCompaction, ContextCompactor, ContextError, SessionReadView,
40    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, ToolCatalogContributor, ToolDiscoveryContributor, ToolResultHookContext,
50    ToolResultProjectionContext, ToolResultProjector, TurnHookContext, TurnResultHookContext,
51    TurnResultSummary,
52};
53pub use protocol::{
54    AssistantProseProjectorPlugin, CodeExecutorPlugin, PluginOptions, ProtocolBeforeLlmCallContext,
55    ProtocolDriverPlugin, ProtocolLlmCallAction, ProtocolRuntimeContext, ProtocolSessionContext,
56    ProtocolSessionPlugin,
57};
58pub use registrar::{
59    ContextRegistrations, ExecutionRegistrations, OutputRegistrations, PluginActionRegistrations,
60    PluginRegistrar, PromptRegistrations, ProtocolRegistrations, SessionRegistrations,
61    ToolCallRegistrations, ToolCatalogRegistrations, ToolRegistrations, ToolResultRegistrations,
62    TriggerEventRegistrations, TurnRegistrations,
63};
64pub(crate) use registrar::{PluginContributions, RegisteredHook};
65pub use registry::{
66    PluginExtensionContribution, PluginExtensions, PluginFactory, PluginSessionContext, PluginSpec,
67    PluginSpecBuilder, PluginSpecFactory, 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    OpenAgentFrameRequest, OpenAgentFrameResult, PluginOwned, SessionAppendNode,
81    SessionContextOverlay, SessionCreateRequest, SessionHandle, SessionPluginSource,
82    SessionRelation, SessionSnapshot, SessionStartPoint, SessionToolAccess, SubagentSessionContext,
83};
84pub(crate) use snapshot::{InMemorySnapshotReader, InMemorySnapshotWriter};
85pub use snapshot::{
86    PluginSessionSnapshot, PluginSnapshotArtifact, PluginSnapshotEntry, PluginSnapshotMeta,
87    SnapshotReader, SnapshotWriter,
88};
89pub use tool_catalog::{
90    CheckpointApplication, PluginAbort, PluginDirective, PrepareTurnRequest, ToolCatalogContext,
91    ToolDiscoveryContext, ToolDiscoveryContribution, ToolDiscoveryToolContribution,
92    TurnFinalization, TurnPreparation,
93};
94pub(crate) use tool_catalog::{emit_plugin_runtime_events, plugin_runtime_session_events};
95pub(crate) fn builtin_plugin_factories() -> Vec<Arc<dyn PluginFactory>> {
96    // Protocol plugins must be registered by the embedder before calling
97    // `PluginHost::build_session`. Unit tests use an in-tree fake to avoid
98    // a dev-dep cycle through the protocol crates.
99    let factories: Vec<Arc<dyn PluginFactory>> =
100        vec![Arc::new(trigger_registry::TriggerResourcePluginFactory)];
101    #[cfg(not(test))]
102    return factories;
103
104    #[cfg(test)]
105    {
106        factories
107            .into_iter()
108            .chain(crate::testing::test_standard_protocol_factories())
109            .collect()
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use schemars::JsonSchema;
116    use serde::{Deserialize, Serialize};
117    use serde_json::json;
118
119    use super::*;
120    use crate::{SessionSnapshot, ToolDefinition};
121
122    struct MockToolProvider;
123
124    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
125    struct TypedEchoArgs {
126        value: String,
127    }
128
129    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
130    struct TypedEchoOutput {
131        value: String,
132        session_id: Option<String>,
133    }
134
135    struct TypedEchoOp;
136
137    impl PluginAction for TypedEchoOp {
138        const NAME: &'static str = "mock.typed_echo";
139        const DESCRIPTION: &'static str = "typed echo";
140        const KIND: PluginActionKind = PluginActionKind::Query;
141        const SESSION_PARAM: SessionParam = SessionParam::Optional;
142        type Args = TypedEchoArgs;
143        type Output = TypedEchoOutput;
144    }
145
146    #[async_trait::async_trait]
147    impl ToolProvider for MockToolProvider {
148        fn tool_manifests(&self) -> Vec<ToolManifest> {
149            self.tool_definitions()
150                .into_iter()
151                .map(|tool| tool.manifest())
152                .collect()
153        }
154
155        fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
156            self.tool_definitions()
157                .into_iter()
158                .find(|tool| tool.name() == name)
159                .map(|tool| Arc::new(tool.contract()))
160        }
161
162        async fn execute(&self, call: crate::ToolCall<'_>) -> ToolResult {
163            ToolResult::ok(call.args.clone())
164        }
165    }
166
167    impl MockToolProvider {
168        fn tool_definitions(&self) -> Vec<ToolDefinition> {
169            vec![
170                ToolDefinition::raw(
171                    "tool:mock_tool",
172                    "mock_tool",
173                    "",
174                    json!({
175                        "type": "object",
176                        "properties": { "value": { "type": "string" } },
177                        "required": ["value"],
178                        "additionalProperties": false
179                    }),
180                    json!({ "type": "string" }),
181                )
182                .with_availability(crate::ToolAvailabilityConfig::callable()),
183            ]
184        }
185    }
186
187    struct MockPluginFactory;
188
189    impl PluginFactory for MockPluginFactory {
190        fn id(&self) -> &'static str {
191            "mock"
192        }
193
194        fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
195            Ok(Arc::new(MockPlugin {
196                session_id: ctx.session_id.clone(),
197            }))
198        }
199    }
200
201    const TEST_EXTENSION_ID: &str = "test.extension";
202
203    struct ExtensionPluginFactory;
204
205    impl PluginFactory for ExtensionPluginFactory {
206        fn id(&self) -> &'static str {
207            "extension_resource"
208        }
209
210        fn extension_contributions(&self) -> Vec<PluginExtensionContribution> {
211            vec![PluginExtensionContribution::from_value(
212                TEST_EXTENSION_ID,
213                json!({ "resource": "clock.alarm" }),
214            )]
215        }
216
217        fn build(
218            &self,
219            _ctx: &PluginSessionContext,
220        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
221            Ok(Arc::new(ExtensionPlugin))
222        }
223    }
224
225    struct ExtensionPlugin;
226
227    impl SessionPlugin for ExtensionPlugin {
228        fn id(&self) -> &'static str {
229            "extension_resource"
230        }
231
232        fn register(&self, _reg: &mut PluginRegistrar) -> Result<(), PluginError> {
233            Ok(())
234        }
235    }
236
237    struct MockPlugin {
238        session_id: String,
239    }
240
241    use crate::testing::MockSessionManager;
242
243    impl SessionPlugin for MockPlugin {
244        fn id(&self) -> &'static str {
245            "mock"
246        }
247
248        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
249            reg.tools().provider(Arc::new(MockToolProvider))?;
250            reg.prompt().contribute(Arc::new(|_ctx| {
251                Box::pin(async move {
252                    Ok(vec![
253                        PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
254                        PromptContribution::guidance("Dynamic Note", "dynamic note")
255                            .with_priority(1),
256                    ])
257                })
258            }));
259            let session_id = self.session_id.clone();
260            reg.actions().op(
261                PluginActionDef {
262                    name: "mock.echo".to_string(),
263                    description: "echo".to_string(),
264                    kind: PluginActionKind::Query,
265                    session_param: SessionParam::Optional,
266                    input_schema: json!({}),
267                    output_schema: json!({}),
268                },
269                Arc::new(move |ctx, args| {
270                    let session_id = session_id.clone();
271                    Box::pin(async move {
272                        ToolResult::ok(json!({
273                            "session_id": ctx.session_id,
274                            "plugin_session_id": session_id,
275                            "args": args,
276                        }))
277                    })
278                }),
279            )?;
280            reg.actions()
281                .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
282                    Ok(TypedEchoOutput {
283                        value: args.value,
284                        session_id: ctx.session_id,
285                    })
286                })?;
287            Ok(())
288        }
289
290        fn snapshot(
291            &self,
292            _writer: &mut dyn SnapshotWriter,
293        ) -> Result<PluginSnapshotMeta, PluginError> {
294            Ok(PluginSnapshotMeta {
295                plugin_id: self.id().to_string(),
296                plugin_version: self.version().to_string(),
297                revision: self.snapshot_revision(),
298                state: Some(json!({"session_id": self.session_id})),
299            })
300        }
301    }
302
303    #[test]
304    fn plugin_host_collects_factory_extension_contributions() {
305        let host = PluginHost::new(vec![Arc::new(ExtensionPluginFactory)]);
306
307        assert_eq!(
308            host.extensions().payloads(TEST_EXTENSION_ID),
309            &[json!({ "resource": "clock.alarm" })]
310        );
311        let session = host.build_session("root", None).expect("session");
312        assert_eq!(
313            session.extensions().payloads(TEST_EXTENSION_ID),
314            &[json!({ "resource": "clock.alarm" })]
315        );
316    }
317
318    #[test]
319    fn declared_triggers_enter_session_catalog() {
320        struct TriggerEventOnlyFactory;
321
322        impl PluginFactory for TriggerEventOnlyFactory {
323            fn id(&self) -> &'static str {
324                "trigger_only"
325            }
326
327            fn build(
328                &self,
329                _ctx: &PluginSessionContext,
330            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
331                Ok(Arc::new(TriggerEventOnlyPlugin))
332            }
333        }
334
335        struct TriggerEventOnlyPlugin;
336
337        impl SessionPlugin for TriggerEventOnlyPlugin {
338            fn id(&self) -> &'static str {
339                "trigger_only"
340            }
341
342            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
343                reg.triggers().declare(crate::TriggerEvent::new(
344                    "Button",
345                    "ui.button",
346                    "pressed",
347                    crate::LashSchema::any(),
348                ))
349            }
350        }
351
352        let host = PluginHost::new(vec![Arc::new(TriggerEventOnlyFactory)]);
353
354        let session = host.build_session("root", None).expect("session");
355        assert!(
356            session
357                .triggers()
358                .get("Button", "ui.button", "pressed")
359                .is_some()
360        );
361        let event = session
362            .triggers()
363            .get("Button", "ui.button", "pressed")
364            .expect("button event");
365        assert_eq!(event.source_type(), "ui.button.pressed");
366    }
367
368    #[tokio::test]
369    async fn session_collects_tools_and_prompts() {
370        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
371        let session = host.build_session("root", None).expect("session");
372        let tool_names = session
373            .tools()
374            .tool_manifests()
375            .into_iter()
376            .map(|manifest| manifest.name)
377            .collect::<std::collections::BTreeSet<_>>();
378        assert!(tool_names.contains("mock_tool"));
379        assert!(tool_names.contains("batch"));
380        let contributions = session
381            .collect_prompt_contributions(PromptHookContext {
382                session_id: "root".to_string(),
383                sessions: Arc::new(MockSessionManager::default()),
384                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
385                protocol_turn_options: ProtocolTurnOptions::default(),
386                turn_context: crate::TurnContext::default(),
387            })
388            .await
389            .expect("prompt contributions");
390        assert_eq!(
391            contributions,
392            vec![
393                PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
394                PromptContribution::guidance("Dynamic Note", "dynamic note").with_priority(1),
395            ]
396        );
397    }
398
399    #[tokio::test]
400    async fn external_invoke_defaults_to_current_session_when_requested() {
401        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
402        let session = host.build_session("root", None).expect("session");
403        let result = session
404            .invoke_plugin_action(
405                "mock.echo",
406                json!({"ok":true}),
407                None,
408                true,
409                Arc::new(MockSessionManager::default()),
410                Arc::new(MockSessionManager::default()),
411                Arc::new(MockSessionManager::default()),
412                Arc::new(crate::UnavailableProcessService),
413            )
414            .await
415            .expect("invoke");
416        assert!(result.is_success());
417        assert_eq!(
418            result
419                .value_for_projection()
420                .get("session_id")
421                .and_then(|v| v.as_str()),
422            Some("root")
423        );
424    }
425
426    #[tokio::test]
427    async fn plugin_action_generates_schema_and_invokes_typed_output() {
428        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
429        let session = host.build_session("root", None).expect("session");
430
431        let def = session
432            .plugin_actions()
433            .into_iter()
434            .find(|def| def.name == TypedEchoOp::NAME)
435            .expect("typed op definition");
436        assert_eq!(def.kind, PluginActionKind::Query);
437        assert_eq!(def.session_param, SessionParam::Optional);
438        let value_type = def
439            .input_schema
440            .pointer("/schema/properties/value/type")
441            .or_else(|| def.input_schema.pointer("/properties/value/type"))
442            .and_then(serde_json::Value::as_str);
443        assert_eq!(value_type, Some("string"));
444
445        let output = session
446            .call_plugin_action::<TypedEchoOp>(
447                TypedEchoArgs {
448                    value: "hello".to_string(),
449                },
450                None,
451                true,
452                Arc::new(MockSessionManager::default()),
453                Arc::new(MockSessionManager::default()),
454                Arc::new(MockSessionManager::default()),
455                Arc::new(crate::UnavailableProcessService),
456            )
457            .await
458            .expect("typed invoke");
459        assert_eq!(output.value, "hello");
460        assert_eq!(output.session_id.as_deref(), Some("root"));
461    }
462
463    #[test]
464    fn plugin_action_rejects_duplicate_names() {
465        struct DuplicatePlugin;
466
467        impl SessionPlugin for DuplicatePlugin {
468            fn id(&self) -> &'static str {
469                "duplicate"
470            }
471
472            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
473                reg.actions()
474                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
475                        Ok(TypedEchoOutput {
476                            value: args.value,
477                            session_id: ctx.session_id,
478                        })
479                    })?;
480                reg.actions()
481                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
482                        Ok(TypedEchoOutput {
483                            value: args.value,
484                            session_id: ctx.session_id,
485                        })
486                    })
487            }
488        }
489
490        struct DuplicateFactory;
491        impl PluginFactory for DuplicateFactory {
492            fn id(&self) -> &'static str {
493                "duplicate"
494            }
495
496            fn build(
497                &self,
498                _ctx: &PluginSessionContext,
499            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
500                Ok(Arc::new(DuplicatePlugin))
501            }
502        }
503
504        let err =
505            match PluginHost::new(vec![Arc::new(DuplicateFactory)]).build_session("root", None) {
506                Ok(_) => panic!("duplicate typed plugin action should fail"),
507                Err(err) => err,
508            };
509        assert!(err.to_string().contains("duplicate plugin action name"));
510    }
511
512    #[tokio::test]
513    async fn typed_external_invoke_errors_on_failed_or_invalid_output() {
514        struct BadOp;
515        impl PluginAction for BadOp {
516            const NAME: &'static str = "mock.echo";
517            const DESCRIPTION: &'static str = "bad typed projection over raw op";
518            const KIND: PluginActionKind = PluginActionKind::Query;
519            const SESSION_PARAM: SessionParam = SessionParam::Optional;
520            type Args = TypedEchoArgs;
521            type Output = TypedEchoOutput;
522        }
523
524        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
525        let session = host.build_session("root", None).expect("session");
526        let err = session
527            .call_plugin_action::<BadOp>(
528                TypedEchoArgs {
529                    value: "hello".to_string(),
530                },
531                None,
532                true,
533                Arc::new(MockSessionManager::default()),
534                Arc::new(MockSessionManager::default()),
535                Arc::new(MockSessionManager::default()),
536                Arc::new(crate::UnavailableProcessService),
537            )
538            .await
539            .expect_err("raw output shape should not match typed output");
540        assert!(err.to_string().contains("invalid mock.echo output"));
541    }
542
543    #[tokio::test]
544    async fn plugin_host_can_invoke_plugin_action_for_registered_session() {
545        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
546        let _session = host.build_session("root", None).expect("session");
547
548        let result = host
549            .invoke_plugin_action_for_session(
550                "root",
551                "mock.echo",
552                json!({"ok":true}),
553                Arc::new(MockSessionManager::default()),
554                Arc::new(MockSessionManager::default()),
555                Arc::new(MockSessionManager::default()),
556                Arc::new(crate::UnavailableProcessService),
557            )
558            .await
559            .expect("invoke");
560        assert!(result.is_success());
561        assert_eq!(
562            result
563                .value_for_projection()
564                .get("session_id")
565                .and_then(|v| v.as_str()),
566            Some("root")
567        );
568        assert_eq!(
569            result
570                .value_for_projection()
571                .get("plugin_session_id")
572                .and_then(|v| v.as_str()),
573            Some("root")
574        );
575    }
576
577    #[tokio::test]
578    async fn plugin_host_can_invoke_plugin_action_for_forked_session() {
579        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
580        let root = host.build_session("root", None).expect("root");
581        let child = root.fork_for_session("child").expect("child");
582
583        let result = host
584            .invoke_plugin_action_for_session(
585                "child",
586                "mock.echo",
587                json!({"ok":true}),
588                Arc::new(MockSessionManager::default()),
589                Arc::new(MockSessionManager::default()),
590                Arc::new(MockSessionManager::default()),
591                Arc::new(crate::UnavailableProcessService),
592            )
593            .await
594            .expect("invoke");
595        assert!(result.is_success());
596        assert_eq!(
597            result
598                .value_for_projection()
599                .get("session_id")
600                .and_then(|v| v.as_str()),
601            Some("child")
602        );
603        assert_eq!(
604            result
605                .value_for_projection()
606                .get("plugin_session_id")
607                .and_then(|v| v.as_str()),
608            Some("child")
609        );
610
611        drop(child);
612    }
613
614    #[test]
615    fn plugin_host_unregisters_sessions() {
616        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
617        let _session = host.build_session("root", None).expect("session");
618        assert!(host.session("root").is_ok());
619        host.unregister_session("root").expect("unregister");
620        match host.session("root") {
621            Err(PluginActionInvokeError::UnknownSession(id)) => assert_eq!(id, "root"),
622            Ok(_) => panic!("expected missing session"),
623            Err(other) => panic!("unexpected error: {other}"),
624        }
625    }
626
627    #[test]
628    fn snapshot_round_trip_preserves_plugin_entries() {
629        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
630        let session = host.build_session("root", None).expect("session");
631        let snapshot = session.snapshot().expect("snapshot");
632        assert!(snapshot.plugins.contains_key("mock"));
633        let restored = host
634            .build_session("child", Some(&snapshot))
635            .expect("restored");
636        let restored_snapshot = restored.snapshot().expect("snapshot");
637        assert!(restored_snapshot.plugins.contains_key("mock"));
638    }
639
640    #[test]
641    fn runtime_services_are_backed_by_plugin_sessions() {
642        let host = PluginHost::new(vec![Arc::new(StaticPluginFactory::new(
643            "mock_tool",
644            PluginSpec::new()
645                .with_tool_provider(Arc::new(MockToolProvider) as Arc<dyn ToolProvider>),
646        ))]);
647        let services = RuntimeServices::new(host.build_session("root", None).expect("session"));
648        assert_eq!(services.plugins.session_id(), "root");
649        assert!(
650            services
651                .plugins
652                .tools()
653                .tool_manifests()
654                .iter()
655                .any(|tool| tool.name == "mock_tool")
656        );
657    }
658
659    struct ProjectorPluginFactory {
660        plugin_id: &'static str,
661    }
662
663    impl PluginFactory for ProjectorPluginFactory {
664        fn id(&self) -> &'static str {
665            self.plugin_id
666        }
667
668        fn build(
669            &self,
670            _ctx: &PluginSessionContext,
671        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
672            Ok(Arc::new(ProjectorPlugin {
673                plugin_id: self.plugin_id,
674            }))
675        }
676    }
677
678    struct ProjectorPlugin {
679        plugin_id: &'static str,
680    }
681
682    impl SessionPlugin for ProjectorPlugin {
683        fn id(&self) -> &'static str {
684            self.plugin_id
685        }
686
687        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
688            reg.tool_results().projector(Arc::new(|ctx| {
689                Box::pin(async move {
690                    Ok(crate::ModelToolReturn::from_output(
691                        ctx.call_id,
692                        ctx.tool_name,
693                        &ctx.output,
694                    ))
695                })
696            }))
697        }
698    }
699
700    #[test]
701    fn duplicate_tool_result_projectors_are_rejected() {
702        let host = PluginHost::new(vec![
703            Arc::new(ProjectorPluginFactory {
704                plugin_id: "projector-a",
705            }),
706            Arc::new(ProjectorPluginFactory {
707                plugin_id: "projector-b",
708            }),
709        ]);
710        let err = match host.build_session("root", None) {
711            Ok(_) => panic!("duplicate projector"),
712            Err(err) => err,
713        };
714        assert!(err.to_string().contains("duplicate tool result projector"));
715        assert!(err.to_string().contains("projector-a"));
716        assert!(err.to_string().contains("projector-b"));
717    }
718}