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