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