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