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