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