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