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, ToolDefinition, ToolManifest, ToolProvider,
7    ToolResult, TurnInput,
8};
9
10pub use lash_sansio::{
11    CheckpointKind, PluginMessage, PluginRuntimeEvent, PromptContribution, ToolCatalogContribution,
12};
13
14mod actions;
15mod error;
16mod history;
17mod hooks;
18mod protocol;
19mod registrar;
20mod registry;
21pub mod runtime_host;
22mod runtime_impl;
23mod services;
24mod session_obj;
25mod session_types;
26mod snapshot;
27mod tool_catalog;
28mod trigger_registry;
29
30pub(crate) use actions::{
31    ErasedPluginCommandOutcome, ErasedPluginTaskOutcome, PluginCommandHandler,
32    PluginCommandInvokeFuture, PluginQueryHandler, PluginQueryInvokeFuture, PluginTaskHandler,
33    PluginTaskInvokeFuture, RegisteredPluginCommand, RegisteredPluginQuery, RegisteredPluginTask,
34};
35pub use actions::{
36    PluginCommand, PluginCommandContext, PluginCommandOutcome, PluginCommandReceipt,
37    PluginOperation, PluginOperationDef, PluginOperationFailure, PluginOperationFuture,
38    PluginOperationKind, PluginQuery, PluginQueryContext, PluginRuntimeDirective, PluginTask,
39    PluginTaskContext, PluginTaskOutcome, PluginTaskReceipt, ProcessReadService, SessionParam,
40    SessionReadService, plugin_operation_def,
41};
42pub use error::PluginError;
43pub use history::{
44    CompactionContext, ContextCompaction, ContextCompactor, ContextError, SessionReadView,
45    TurnContextTransform, TurnTransformContext,
46};
47pub use hooks::{
48    AfterToolCallHook, AfterTurnHook, AssistantResponseHook, AssistantResponseHookContext,
49    AssistantResponseTransform, AssistantStreamFinishReason, AssistantStreamFinishedContext,
50    AssistantStreamFinishedHook, AssistantStreamHook, AssistantStreamHookContext,
51    AssistantStreamTransform, BeforeToolCallHook, BeforeTurnHook, CheckpointHook,
52    CheckpointHookContext, PluginFuture, PluginLifecycleEvent, PluginLifecycleEventHook,
53    PluginLifecycleFuture, PluginSessionTask, PromptContributor, PromptHookContext,
54    SessionConfigChangedContext, SessionConfigMutator, SessionStateChangedContext,
55    ToolCallHookContext, ToolCatalogContributor, ToolResultHookContext,
56    ToolResultProjectionContext, ToolResultProjector, TurnHookContext, TurnResultHookContext,
57    TurnResultSummary,
58};
59pub use protocol::{
60    AssistantProseProjectorPlugin, CodeExecutorPlugin, PluginOptions, ProtocolBeforeLlmCallContext,
61    ProtocolDriverPlugin, ProtocolLlmCallAction, ProtocolRuntimeContext, ProtocolSessionContext,
62    ProtocolSessionMaterialization, 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, ProcessEngineContributionContext, SessionPlugin,
74    SessionReadyContext, StaticPluginFactory,
75};
76pub use runtime_host::{
77    AppendSessionNodesRequest, AppendSessionNodesResult, DirectCompletion, DirectLlmCompletion,
78    SessionGraphService, SessionLifecycleService, SessionStateService, SessionTurnInput,
79    SessionTurnRequest,
80};
81pub use runtime_impl::{PluginHost, SessionAuthorityContext};
82#[cfg(any(test, feature = "testing"))]
83pub(crate) use services::NoopSessionManager;
84pub use services::{PersistentRuntimeServices, PluginOperationInvokeError, RuntimeServices};
85pub use session_obj::PluginSession;
86pub use session_types::{
87    AgentFrameAssignment, AgentFrameId, AgentFrameReason, AgentFrameRecord, AgentFrameStatus,
88    OpenAgentFrameRequest, OpenAgentFrameResult, PluginOwned, SessionAppendNode,
89    SessionContextOverlay, SessionCreateRequest, SessionHandle, SessionPluginSource,
90    SessionRelation, SessionSnapshot, SessionStartPoint, SessionToolAccess, SubagentSessionContext,
91};
92pub(crate) use snapshot::{InMemorySnapshotReader, InMemorySnapshotWriter};
93pub use snapshot::{
94    PluginSessionSnapshot, PluginSnapshotArtifact, PluginSnapshotEntry, PluginSnapshotMeta,
95    SnapshotReader, SnapshotWriter,
96};
97pub use tool_catalog::{
98    CheckpointApplication, PluginAbort, PluginDirective, PrepareTurnRequest, ToolCatalogContext,
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![ToolDefinition::raw(
178                "tool:mock_tool",
179                "mock_tool",
180                "",
181                json!({
182                    "type": "object",
183                    "properties": { "value": { "type": "string" } },
184                    "required": ["value"],
185                    "additionalProperties": false
186                }),
187                json!({ "type": "string" }),
188            )]
189        }
190    }
191
192    struct MockPluginFactory;
193
194    impl PluginFactory for MockPluginFactory {
195        fn id(&self) -> &'static str {
196            "mock"
197        }
198
199        fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
200            Ok(Arc::new(MockPlugin {
201                session_id: ctx.session_id.clone(),
202            }))
203        }
204    }
205
206    const TEST_EXTENSION_ID: &str = "test.extension";
207
208    struct ExtensionPluginFactory;
209
210    impl PluginFactory for ExtensionPluginFactory {
211        fn id(&self) -> &'static str {
212            "extension_resource"
213        }
214
215        fn extension_contributions(&self) -> Vec<PluginExtensionContribution> {
216            vec![PluginExtensionContribution::from_value(
217                TEST_EXTENSION_ID,
218                json!({ "resource": "clock.alarm" }),
219            )]
220        }
221
222        fn build(
223            &self,
224            _ctx: &PluginSessionContext,
225        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
226            Ok(Arc::new(ExtensionPlugin))
227        }
228    }
229
230    struct ExtensionPlugin;
231
232    impl SessionPlugin for ExtensionPlugin {
233        fn id(&self) -> &'static str {
234            "extension_resource"
235        }
236
237        fn register(&self, _reg: &mut PluginRegistrar) -> Result<(), PluginError> {
238            Ok(())
239        }
240    }
241
242    struct MockPlugin {
243        session_id: String,
244    }
245
246    use crate::testing::MockSessionManager;
247
248    impl SessionPlugin for MockPlugin {
249        fn id(&self) -> &'static str {
250            "mock"
251        }
252
253        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
254            reg.tools().provider(Arc::new(MockToolProvider))?;
255            reg.prompt().contribute(Arc::new(|_ctx| {
256                Box::pin(async move {
257                    Ok(vec![
258                        PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
259                        PromptContribution::guidance("Dynamic Note", "dynamic note")
260                            .with_priority(1),
261                    ])
262                })
263            }));
264            let session_id = self.session_id.clone();
265            reg.operations().query(
266                PluginOperationDef {
267                    name: "mock.echo".to_string(),
268                    description: "echo".to_string(),
269                    kind: PluginOperationKind::Query,
270                    session_param: SessionParam::Optional,
271                    input_schema: json!({}),
272                    output_schema: json!({}),
273                },
274                Arc::new(move |ctx, args| {
275                    let session_id = session_id.clone();
276                    Box::pin(async move {
277                        Ok(json!({
278                            "session_id": ctx.session_id,
279                            "plugin_session_id": session_id,
280                            "args": args,
281                        }))
282                    })
283                }),
284            )?;
285            reg.operations()
286                .typed_query::<TypedEchoOp, _, _>(move |ctx, args| async move {
287                    Ok(TypedEchoOutput {
288                        value: args.value,
289                        session_id: ctx.session_id,
290                    })
291                })?;
292            Ok(())
293        }
294
295        fn snapshot(
296            &self,
297            _writer: &mut dyn SnapshotWriter,
298        ) -> Result<PluginSnapshotMeta, PluginError> {
299            Ok(PluginSnapshotMeta {
300                plugin_id: self.id().to_string(),
301                plugin_version: self.version().to_string(),
302                revision: self.snapshot_revision(),
303                state: Some(json!({"session_id": self.session_id})),
304            })
305        }
306    }
307
308    #[test]
309    fn plugin_host_collects_factory_extension_contributions() {
310        let host = PluginHost::new(vec![Arc::new(ExtensionPluginFactory)]);
311
312        assert_eq!(
313            host.extensions().payloads(TEST_EXTENSION_ID),
314            &[json!({ "resource": "clock.alarm" })]
315        );
316        let session = host.build_session("root", None).expect("session");
317        assert_eq!(
318            session.extensions().payloads(TEST_EXTENSION_ID),
319            &[json!({ "resource": "clock.alarm" })]
320        );
321    }
322
323    #[test]
324    fn declared_triggers_enter_session_catalog() {
325        struct TriggerEventOnlyFactory;
326
327        impl PluginFactory for TriggerEventOnlyFactory {
328            fn id(&self) -> &'static str {
329                "trigger_only"
330            }
331
332            fn build(
333                &self,
334                _ctx: &PluginSessionContext,
335            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
336                Ok(Arc::new(TriggerEventOnlyPlugin))
337            }
338        }
339
340        struct TriggerEventOnlyPlugin;
341
342        impl SessionPlugin for TriggerEventOnlyPlugin {
343            fn id(&self) -> &'static str {
344                "trigger_only"
345            }
346
347            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
348                reg.triggers().declare(crate::TriggerEvent::new(
349                    "Button",
350                    "ui.button",
351                    "pressed",
352                    crate::LashSchema::any(),
353                ))
354            }
355        }
356
357        let host = PluginHost::new(vec![Arc::new(TriggerEventOnlyFactory)]);
358
359        let session = host.build_session("root", None).expect("session");
360        assert!(
361            session
362                .triggers()
363                .get("Button", "ui.button", "pressed")
364                .is_some()
365        );
366        let event = session
367            .triggers()
368            .get("Button", "ui.button", "pressed")
369            .expect("button event");
370        assert_eq!(event.source_type(), "ui.button.pressed");
371    }
372
373    #[tokio::test]
374    async fn session_collects_tools_and_prompts() {
375        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
376        let session = host.build_session("root", None).expect("session");
377        let tool_names = session
378            .tools()
379            .tool_manifests()
380            .into_iter()
381            .map(|manifest| manifest.name)
382            .collect::<std::collections::BTreeSet<_>>();
383        assert!(tool_names.contains("mock_tool"));
384        assert!(tool_names.contains("batch"));
385        let contributions = session
386            .collect_prompt_contributions(PromptHookContext {
387                session_id: "root".to_string(),
388                sessions: Arc::new(MockSessionManager::default()),
389                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
390                protocol_turn_options: ProtocolTurnOptions::default(),
391                turn_context: crate::TurnContext::default(),
392            })
393            .await
394            .expect("prompt contributions");
395        assert_eq!(
396            contributions,
397            vec![
398                PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
399                PromptContribution::guidance("Dynamic Note", "dynamic note").with_priority(1),
400            ]
401        );
402    }
403
404    #[tokio::test]
405    async fn external_query_defaults_to_current_session_when_requested() {
406        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
407        let session = host.build_session("root", None).expect("session");
408        let (_plugin_id, result) = session
409            .query_plugin(
410                "mock.echo",
411                json!({"ok":true}),
412                None,
413                true,
414                Arc::new(NoopSessionManager),
415                Arc::new(NoopSessionManager),
416            )
417            .await
418            .expect("invoke");
419        assert_eq!(
420            result.get("session_id").and_then(|v| v.as_str()),
421            Some("root")
422        );
423    }
424
425    #[tokio::test]
426    async fn plugin_query_generates_schema_and_invokes_typed_output() {
427        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
428        let session = host.build_session("root", None).expect("session");
429
430        let def = session
431            .plugin_operations()
432            .into_iter()
433            .find(|def| def.name == TypedEchoOp::NAME)
434            .expect("typed op definition");
435        assert_eq!(def.kind, PluginOperationKind::Query);
436        assert_eq!(def.session_param, SessionParam::Optional);
437        let value_type = def
438            .input_schema
439            .pointer("/schema/properties/value/type")
440            .or_else(|| def.input_schema.pointer("/properties/value/type"))
441            .and_then(serde_json::Value::as_str);
442        assert_eq!(value_type, Some("string"));
443
444        let (_plugin_id, output) = session
445            .query_plugin(
446                TypedEchoOp::NAME,
447                serde_json::to_value(TypedEchoArgs {
448                    value: "hello".to_string(),
449                })
450                .unwrap(),
451                None,
452                true,
453                Arc::new(NoopSessionManager),
454                Arc::new(NoopSessionManager),
455            )
456            .await
457            .expect("typed invoke");
458        let output: TypedEchoOutput = serde_json::from_value(output).unwrap();
459        assert_eq!(output.value, "hello");
460        assert_eq!(output.session_id.as_deref(), Some("root"));
461    }
462
463    #[test]
464    fn plugin_operation_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.operations()
474                    .typed_query::<TypedEchoOp, _, _>(move |ctx, args| async move {
475                        Ok(TypedEchoOutput {
476                            value: args.value,
477                            session_id: ctx.session_id,
478                        })
479                    })?;
480                reg.operations()
481                    .typed_query::<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 operation should fail"),
507                Err(err) => err,
508            };
509        assert!(err.to_string().contains("duplicate plugin operation name"));
510    }
511
512    #[tokio::test]
513    async fn typed_external_query_errors_on_invalid_output() {
514        struct BadOp;
515        impl PluginOperation for BadOp {
516            const NAME: &'static str = "mock.echo";
517            const DESCRIPTION: &'static str = "bad typed projection over raw op";
518            const SESSION_PARAM: SessionParam = SessionParam::Optional;
519            type Args = TypedEchoArgs;
520            type Output = TypedEchoOutput;
521        }
522        impl PluginQuery for BadOp {}
523
524        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
525        let session = host.build_session("root", None).expect("session");
526        let (_plugin_id, output) = session
527            .query_plugin(
528                BadOp::NAME,
529                serde_json::to_value(TypedEchoArgs {
530                    value: "hello".to_string(),
531                })
532                .unwrap(),
533                None,
534                true,
535                Arc::new(NoopSessionManager),
536                Arc::new(NoopSessionManager),
537            )
538            .await
539            .expect("raw query");
540        let err = serde_json::from_value::<TypedEchoOutput>(output)
541            .expect_err("raw output shape should not match typed output");
542        assert!(err.to_string().contains("missing field"));
543    }
544
545    #[tokio::test]
546    async fn plugin_session_queries_registered_session() {
547        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
548        let session = host.build_session("root", None).expect("session");
549
550        let (_plugin_id, result) = session
551            .query_plugin(
552                "mock.echo",
553                json!({"ok":true}),
554                Some("root".to_string()),
555                false,
556                Arc::new(NoopSessionManager),
557                Arc::new(NoopSessionManager),
558            )
559            .await
560            .expect("invoke");
561        assert_eq!(
562            result.get("session_id").and_then(|v| v.as_str()),
563            Some("root")
564        );
565        assert_eq!(
566            result.get("plugin_session_id").and_then(|v| v.as_str()),
567            Some("root")
568        );
569    }
570
571    #[tokio::test]
572    async fn plugin_session_queries_forked_session() {
573        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
574        let root = host.build_session("root", None).expect("root");
575        let child = root.fork_for_session("child").expect("child");
576
577        let (_plugin_id, result) = child
578            .query_plugin(
579                "mock.echo",
580                json!({"ok":true}),
581                Some("child".to_string()),
582                false,
583                Arc::new(NoopSessionManager),
584                Arc::new(NoopSessionManager),
585            )
586            .await
587            .expect("invoke");
588        assert_eq!(
589            result.get("session_id").and_then(|v| v.as_str()),
590            Some("child")
591        );
592        assert_eq!(
593            result.get("plugin_session_id").and_then(|v| v.as_str()),
594            Some("child")
595        );
596
597        drop(child);
598    }
599
600    #[test]
601    fn plugin_host_unregisters_sessions() {
602        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
603        let _session = host.build_session("root", None).expect("session");
604        assert!(host.session("root").is_ok());
605        host.unregister_session("root").expect("unregister");
606        match host.session("root") {
607            Err(PluginOperationInvokeError::UnknownSession(id)) => assert_eq!(id, "root"),
608            Ok(_) => panic!("expected missing session"),
609            Err(other) => panic!("unexpected error: {other}"),
610        }
611    }
612
613    #[test]
614    fn snapshot_round_trip_preserves_plugin_entries() {
615        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
616        let session = host.build_session("root", None).expect("session");
617        let snapshot = session.snapshot().expect("snapshot");
618        assert!(snapshot.plugins.contains_key("mock"));
619        let restored = host
620            .build_session("child", Some(&snapshot))
621            .expect("restored");
622        let restored_snapshot = restored.snapshot().expect("snapshot");
623        assert!(restored_snapshot.plugins.contains_key("mock"));
624    }
625
626    #[test]
627    fn runtime_services_are_backed_by_plugin_sessions() {
628        let host = PluginHost::new(vec![Arc::new(StaticPluginFactory::new(
629            "mock_tool",
630            PluginSpec::new()
631                .with_tool_provider(Arc::new(MockToolProvider) as Arc<dyn ToolProvider>),
632        ))]);
633        let services = RuntimeServices::new(host.build_session("root", None).expect("session"));
634        assert_eq!(services.plugins.session_id(), "root");
635        assert!(
636            services
637                .plugins
638                .tools()
639                .tool_manifests()
640                .iter()
641                .any(|tool| tool.name == "mock_tool")
642        );
643    }
644
645    struct ProjectorPluginFactory {
646        plugin_id: &'static str,
647    }
648
649    impl PluginFactory for ProjectorPluginFactory {
650        fn id(&self) -> &'static str {
651            self.plugin_id
652        }
653
654        fn build(
655            &self,
656            _ctx: &PluginSessionContext,
657        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
658            Ok(Arc::new(ProjectorPlugin {
659                plugin_id: self.plugin_id,
660            }))
661        }
662    }
663
664    struct ProjectorPlugin {
665        plugin_id: &'static str,
666    }
667
668    impl SessionPlugin for ProjectorPlugin {
669        fn id(&self) -> &'static str {
670            self.plugin_id
671        }
672
673        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
674            reg.tool_results().projector(Arc::new(|ctx| {
675                Box::pin(async move {
676                    Ok(crate::ModelToolReturn::from_output(
677                        ctx.call_id,
678                        ctx.tool_name,
679                        &ctx.output,
680                    ))
681                })
682            }))
683        }
684    }
685
686    #[test]
687    fn duplicate_tool_result_projectors_are_rejected() {
688        let host = PluginHost::new(vec![
689            Arc::new(ProjectorPluginFactory {
690                plugin_id: "projector-a",
691            }),
692            Arc::new(ProjectorPluginFactory {
693                plugin_id: "projector-b",
694            }),
695        ]);
696        let err = match host.build_session("root", None) {
697            Ok(_) => panic!("duplicate projector"),
698            Err(err) => err,
699        };
700        assert!(err.to_string().contains("duplicate tool result projector"));
701        assert!(err.to_string().contains("projector-a"));
702        assert!(err.to_string().contains("projector-b"));
703    }
704}