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 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}