1use std::collections::{BTreeMap, VecDeque};
2use std::fmt;
3use std::sync::{Arc, Mutex};
4
5use tokio::time::Instant;
6
7use crate::cognitive_context::CognitiveContextSession;
8use crate::commands::SlashCommand;
9use crate::context::ContextProvider;
10use crate::hooks::HookBinding;
11use crate::skills::Skill;
12use crate::subagent::AgentDefinition;
13use crate::tools::Tool;
14
15use super::{
16 CapabilityEffect, CapabilityId, CapabilityKind, CapabilityProjectionError,
17 CapabilityReadinessPlan, CapabilitySet, CapabilityValue, CodeCatalogGeneration,
18 KnowledgeSurfaceBinding, McpBinding, ScopeClosePolicy, Sha256Digest, UiBinding,
19 UseGenerationLeaseProvider,
20};
21
22#[derive(Debug)]
25pub struct CapabilityProjection {
26 set: Arc<CapabilitySet>,
27 readiness: Arc<CapabilityReadinessPlan>,
28 values: BTreeMap<CapabilityId, CapabilityValue>,
29}
30
31impl CapabilityProjection {
32 pub fn new(
33 set: Arc<CapabilitySet>,
34 values: impl IntoIterator<Item = (CapabilityId, CapabilityValue)>,
35 ) -> Result<Arc<Self>, CapabilityProjectionError> {
36 let readiness = Arc::new(CapabilityReadinessPlan::from_set(&set)?);
37 Self::with_readiness(set, readiness, values)
38 }
39
40 pub(super) fn with_readiness(
41 set: Arc<CapabilitySet>,
42 readiness: Arc<CapabilityReadinessPlan>,
43 values: impl IntoIterator<Item = (CapabilityId, CapabilityValue)>,
44 ) -> Result<Arc<Self>, CapabilityProjectionError> {
45 if !readiness.matches(&set) {
46 return Err(CapabilityProjectionError::ReadinessPlanMismatch {
47 expected_generation: set.generation().get(),
48 actual_generation: readiness.generation().get(),
49 digest_mismatch: readiness.digest() != set.digest(),
50 });
51 }
52 let mut canonical = BTreeMap::new();
53 for (id, value) in values {
54 if canonical.insert(id.clone(), value).is_some() {
55 return Err(CapabilityProjectionError::DuplicateValue {
56 capability: id.to_string(),
57 });
58 }
59 }
60
61 for (id, descriptor) in set.iter() {
62 let Some(value) = canonical.get(id) else {
63 return Err(CapabilityProjectionError::MissingValue {
64 capability: id.to_string(),
65 });
66 };
67 if value.kind() != descriptor.id().kind() {
68 return Err(CapabilityProjectionError::KindMismatch {
69 capability: id.to_string(),
70 descriptor_kind: descriptor.id().kind(),
71 value_kind: value.kind(),
72 });
73 }
74 if value
75 .public_name()
76 .is_some_and(|actual| actual != descriptor.public_name())
77 {
78 return Err(CapabilityProjectionError::PublicNameMismatch {
79 capability: id.to_string(),
80 expected: descriptor.public_name().to_owned(),
81 actual: value
82 .public_name()
83 .expect("is_some_and already observed a public name")
84 .to_owned(),
85 });
86 }
87 if let CapabilityValue::Ui(binding) = value {
88 if descriptor.surface_digest() != binding.surface_digest() {
89 return Err(CapabilityProjectionError::SurfaceDigestMismatch {
90 capability: id.to_string(),
91 expected: descriptor.surface_digest().to_string(),
92 actual: binding.surface_digest().to_string(),
93 });
94 }
95 for dependency in descriptor.dependencies() {
96 if !matches!(
97 dependency.kind(),
98 CapabilityKind::Tool
99 | CapabilityKind::Skill
100 | CapabilityKind::Mcp
101 | CapabilityKind::Flow
102 ) {
103 return Err(CapabilityProjectionError::UnsupportedUiDependencyKind {
104 capability: id.to_string(),
105 dependency: dependency.to_string(),
106 dependency_kind: dependency.kind(),
107 });
108 }
109 }
110 }
111 if let CapabilityValue::KnowledgeSurface(binding) = value {
112 if descriptor.surface_digest() != binding.surface_digest() {
113 return Err(CapabilityProjectionError::SurfaceDigestMismatch {
114 capability: id.to_string(),
115 expected: descriptor.surface_digest().to_string(),
116 actual: binding.surface_digest().to_string(),
117 });
118 }
119 }
120 }
121 for id in canonical.keys() {
122 if !set.contains(id) {
123 return Err(CapabilityProjectionError::UnexpectedValue {
124 capability: id.to_string(),
125 });
126 }
127 }
128
129 Ok(Arc::new(Self {
130 set,
131 readiness,
132 values: canonical,
133 }))
134 }
135
136 pub fn set(&self) -> &CapabilitySet {
137 &self.set
138 }
139
140 pub(crate) fn set_arc(&self) -> &Arc<CapabilitySet> {
141 &self.set
142 }
143
144 pub fn readiness_plan(&self) -> &CapabilityReadinessPlan {
145 &self.readiness
146 }
147
148 pub fn len(&self) -> usize {
149 self.values.len()
150 }
151
152 pub fn is_empty(&self) -> bool {
153 self.values.is_empty()
154 }
155
156 pub fn contains(&self, id: &CapabilityId) -> bool {
157 self.values.contains_key(id)
158 }
159
160 pub fn iter(&self) -> impl ExactSizeIterator<Item = (&CapabilityId, &CapabilityValue)> {
161 self.values.iter()
162 }
163
164 pub fn tool(&self, id: &CapabilityId) -> Option<&dyn Tool> {
165 match self.values.get(id) {
166 Some(CapabilityValue::Tool(value)) => Some(value.as_ref()),
167 _ => None,
168 }
169 }
170
171 pub fn skill(&self, id: &CapabilityId) -> Option<&Skill> {
172 match self.values.get(id) {
173 Some(CapabilityValue::Skill(value)) => Some(value.as_ref()),
174 _ => None,
175 }
176 }
177
178 pub fn agent(&self, id: &CapabilityId) -> Option<&AgentDefinition> {
179 match self.values.get(id) {
180 Some(CapabilityValue::Agent(value)) => Some(value.as_ref()),
181 _ => None,
182 }
183 }
184
185 pub fn command(&self, id: &CapabilityId) -> Option<&dyn SlashCommand> {
186 match self.values.get(id) {
187 Some(CapabilityValue::Command(value)) => Some(value.as_ref()),
188 _ => None,
189 }
190 }
191
192 pub fn hook(&self, id: &CapabilityId) -> Option<&HookBinding> {
193 match self.values.get(id) {
194 Some(CapabilityValue::Hook(value)) => Some(value.as_ref()),
195 _ => None,
196 }
197 }
198
199 pub fn mcp(&self, id: &CapabilityId) -> Option<&McpBinding> {
200 match self.values.get(id) {
201 Some(CapabilityValue::Mcp(value)) => Some(value.as_ref()),
202 _ => None,
203 }
204 }
205
206 #[cfg(feature = "dynamic-workflow")]
207 pub fn flow(&self, id: &CapabilityId) -> Option<&crate::capability::FlowBinding> {
208 match self.values.get(id) {
209 Some(CapabilityValue::Flow(value)) => Some(value.as_ref()),
210 _ => None,
211 }
212 }
213
214 pub fn knowledge(&self, id: &CapabilityId) -> Option<&CognitiveContextSession> {
215 match self.values.get(id) {
216 Some(CapabilityValue::Knowledge(value)) => Some(value.as_ref()),
217 _ => None,
218 }
219 }
220
221 pub fn knowledge_surface(&self, id: &CapabilityId) -> Option<&KnowledgeSurfaceBinding> {
222 match self.values.get(id) {
223 Some(CapabilityValue::KnowledgeSurface(value)) => Some(value.as_ref()),
224 _ => None,
225 }
226 }
227
228 pub fn ui(&self, id: &CapabilityId) -> Option<&UiBinding> {
229 match self.values.get(id) {
230 Some(CapabilityValue::Ui(value)) => Some(value.as_ref()),
231 _ => None,
232 }
233 }
234
235 pub fn context(&self, id: &CapabilityId) -> Option<&dyn ContextProvider> {
236 match self.values.get(id) {
237 Some(CapabilityValue::Context(value)) => Some(value.as_ref()),
238 _ => None,
239 }
240 }
241}
242
243#[derive(Clone, Debug, Eq, PartialEq)]
245pub struct CapabilityCatalogStamp {
246 generation: CodeCatalogGeneration,
247 digest: Sha256Digest,
248}
249
250impl CapabilityCatalogStamp {
251 fn from_projection(projection: &CapabilityProjection) -> Self {
252 Self {
253 generation: projection.set().generation(),
254 digest: projection.set().digest().clone(),
255 }
256 }
257
258 pub const fn generation(&self) -> CodeCatalogGeneration {
259 self.generation
260 }
261
262 pub fn digest(&self) -> &Sha256Digest {
263 &self.digest
264 }
265}
266
267#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct CapabilityCommitReceipt {
270 previous: CapabilityCatalogStamp,
271 committed: CapabilityCatalogStamp,
272}
273
274impl CapabilityCommitReceipt {
275 pub fn previous(&self) -> &CapabilityCatalogStamp {
276 &self.previous
277 }
278
279 pub fn committed(&self) -> &CapabilityCatalogStamp {
280 &self.committed
281 }
282}
283
284#[derive(Clone, Copy)]
285enum CleanupReason {
286 Rollback,
287 Retired,
288}
289
290struct CleanupBatch {
291 reason: CleanupReason,
292 effects: Vec<Box<dyn CapabilityEffect>>,
293}
294
295#[derive(Default)]
296struct CleanupQueue {
297 batches: Mutex<VecDeque<CleanupBatch>>,
298}
299
300impl CleanupQueue {
301 fn enqueue(&self, reason: CleanupReason, effects: Vec<Box<dyn CapabilityEffect>>) {
302 if effects.is_empty() {
303 return;
304 }
305 self.batches
306 .lock()
307 .unwrap_or_else(std::sync::PoisonError::into_inner)
308 .push_back(CleanupBatch { reason, effects });
309 }
310
311 fn take_all(&self) -> VecDeque<CleanupBatch> {
312 std::mem::take(
313 &mut *self
314 .batches
315 .lock()
316 .unwrap_or_else(std::sync::PoisonError::into_inner),
317 )
318 }
319
320 fn len(&self) -> usize {
321 self.batches
322 .lock()
323 .unwrap_or_else(std::sync::PoisonError::into_inner)
324 .len()
325 }
326}
327
328struct PublishedGeneration {
329 projection: Arc<CapabilityProjection>,
330 stamp: CapabilityCatalogStamp,
331 use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
332 effects: Mutex<Vec<Box<dyn CapabilityEffect>>>,
333 cleanup: Arc<CleanupQueue>,
334}
335
336impl PublishedGeneration {
337 fn new(
338 projection: Arc<CapabilityProjection>,
339 use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
340 effects: Vec<Box<dyn CapabilityEffect>>,
341 cleanup: Arc<CleanupQueue>,
342 ) -> Self {
343 let stamp = CapabilityCatalogStamp::from_projection(&projection);
344 Self {
345 projection,
346 stamp,
347 use_lease_provider,
348 effects: Mutex::new(effects),
349 cleanup,
350 }
351 }
352}
353
354impl Drop for PublishedGeneration {
355 fn drop(&mut self) {
356 let effects = std::mem::take(
357 self.effects
358 .get_mut()
359 .unwrap_or_else(std::sync::PoisonError::into_inner),
360 );
361 self.cleanup.enqueue(CleanupReason::Retired, effects);
362 }
363}
364
365struct CatalogState {
366 current: Arc<PublishedGeneration>,
367}
368
369pub(super) struct CatalogInner {
370 state: Mutex<CatalogState>,
371 cleanup: Arc<CleanupQueue>,
372}
373
374impl CatalogInner {
375 pub(super) fn current_stamp(&self) -> CapabilityCatalogStamp {
376 self.state
377 .lock()
378 .unwrap_or_else(std::sync::PoisonError::into_inner)
379 .current
380 .stamp
381 .clone()
382 }
383
384 pub(super) fn enqueue_rollback(&self, effects: Vec<Box<dyn CapabilityEffect>>) {
385 self.cleanup.enqueue(CleanupReason::Rollback, effects);
386 }
387
388 pub(super) fn publish(
389 &self,
390 base: &CapabilityCatalogStamp,
391 projection: Arc<CapabilityProjection>,
392 use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
393 effects: Vec<Box<dyn CapabilityEffect>>,
394 ) -> Result<CapabilityCommitReceipt, CapabilityProjectionError> {
395 let committed = CapabilityCatalogStamp::from_projection(&projection);
396 let old = {
397 let mut state = self
398 .state
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner);
401 let actual = &state.current.stamp;
402 if actual != base {
403 let error = CapabilityProjectionError::CommitConflict {
404 expected_generation: base.generation().get(),
405 expected_digest: base.digest().to_string(),
406 actual_generation: actual.generation().get(),
407 actual_digest: actual.digest().to_string(),
408 };
409 drop(state);
410 self.enqueue_rollback(effects);
411 return Err(error);
412 }
413 let published = Arc::new(PublishedGeneration::new(
414 projection,
415 use_lease_provider,
416 effects,
417 Arc::clone(&self.cleanup),
418 ));
419 std::mem::replace(&mut state.current, published)
420 };
421 let previous = old.stamp.clone();
422 drop(old);
423 Ok(CapabilityCommitReceipt {
424 previous,
425 committed,
426 })
427 }
428}
429
430pub struct CapabilityCatalog {
436 pub(super) inner: Arc<CatalogInner>,
437}
438
439impl CapabilityCatalog {
440 pub fn new(initial: Arc<CapabilityProjection>) -> Self {
441 let cleanup = Arc::new(CleanupQueue::default());
442 let current = Arc::new(PublishedGeneration::new(
443 initial,
444 None,
445 Vec::new(),
446 Arc::clone(&cleanup),
447 ));
448 Self {
449 inner: Arc::new(CatalogInner {
450 state: Mutex::new(CatalogState { current }),
451 cleanup,
452 }),
453 }
454 }
455
456 pub fn current_stamp(&self) -> CapabilityCatalogStamp {
457 self.inner.current_stamp()
458 }
459
460 pub fn pin(&self) -> CapabilityProjectionLease {
461 let generation = Arc::clone(
462 &self
463 .inner
464 .state
465 .lock()
466 .unwrap_or_else(std::sync::PoisonError::into_inner)
467 .current,
468 );
469 CapabilityProjectionLease { generation }
470 }
471
472 pub fn pending_cleanup_batches(&self) -> usize {
473 self.inner.cleanup.len()
474 }
475
476 pub(crate) fn retire_current_effects(&self) {
477 let generation = Arc::clone(
478 &self
479 .inner
480 .state
481 .lock()
482 .unwrap_or_else(std::sync::PoisonError::into_inner)
483 .current,
484 );
485 let effects = std::mem::take(
486 &mut *generation
487 .effects
488 .lock()
489 .unwrap_or_else(std::sync::PoisonError::into_inner),
490 );
491 self.inner.cleanup.enqueue(CleanupReason::Retired, effects);
492 }
493
494 pub async fn drain_cleanup(&self) -> CapabilityCleanupReport {
495 self.drain_cleanup_with_policy(ScopeClosePolicy::default())
496 .await
497 }
498
499 pub async fn drain_cleanup_with_policy(
500 &self,
501 policy: ScopeClosePolicy,
502 ) -> CapabilityCleanupReport {
503 let mut batches = self.inner.cleanup.take_all();
504 let deadline = Instant::now() + policy.timeout();
505 let mut report = CapabilityCleanupReport::default();
506
507 while let Some(mut batch) = batches.pop_front() {
508 match batch.reason {
509 CleanupReason::Rollback => report.rollback_batches += 1,
510 CleanupReason::Retired => report.retired_batches += 1,
511 }
512 while let Some(effect) = batch.effects.pop() {
513 if Instant::now() >= deadline {
514 report.effects_timed_out += 1 + batch.effects.len();
515 break;
516 }
517 match tokio::time::timeout_at(deadline, effect.close()).await {
518 Ok(Ok(())) => report.effects_closed += 1,
519 Ok(Err(_)) => report.effects_failed += 1,
520 Err(_) => report.effects_timed_out += 1,
521 }
522 }
523 }
524 report
525 }
526}
527
528impl fmt::Debug for CapabilityCatalog {
529 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
530 formatter
531 .debug_struct("CapabilityCatalog")
532 .field("current", &self.current_stamp())
533 .field("pending_cleanup_batches", &self.pending_cleanup_batches())
534 .finish()
535 }
536}
537
538#[must_use = "a projection lease pins one exact catalog generation"]
545pub struct CapabilityProjectionLease {
546 generation: Arc<PublishedGeneration>,
547}
548
549impl CapabilityProjectionLease {
550 pub fn stamp(&self) -> &CapabilityCatalogStamp {
551 &self.generation.stamp
552 }
553
554 pub fn projection(&self) -> &CapabilityProjection {
555 &self.generation.projection
556 }
557
558 pub(super) fn use_lease_provider(&self) -> Option<&Arc<dyn UseGenerationLeaseProvider>> {
559 self.generation.use_lease_provider.as_ref()
560 }
561}
562
563impl fmt::Debug for CapabilityProjectionLease {
564 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
565 formatter
566 .debug_struct("CapabilityProjectionLease")
567 .field("stamp", self.stamp())
568 .finish_non_exhaustive()
569 }
570}
571
572#[derive(Clone, Debug, Default, Eq, PartialEq)]
574pub struct CapabilityCleanupReport {
575 pub rollback_batches: usize,
576 pub retired_batches: usize,
577 pub effects_closed: usize,
578 pub effects_failed: usize,
579 pub effects_timed_out: usize,
580}
581
582impl CapabilityCleanupReport {
583 pub const fn is_clean(&self) -> bool {
584 self.effects_failed == 0 && self.effects_timed_out == 0
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::capability::{CapabilitySource, Sha256Digest};
592 use crate::cognitive_context::CognitiveContextProvider;
593 use crate::hooks::HookHandler;
594 use crate::skills::{Skill, SkillKind};
595
596 fn digest(byte: char) -> Sha256Digest {
597 assert!(
598 byte.is_ascii_hexdigit(),
599 "test digests must use hex characters"
600 );
601 Sha256Digest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap()
602 }
603
604 fn sample_skill_value(name: &str) -> CapabilityValue {
605 CapabilityValue::Skill(Arc::new(Skill {
606 name: name.to_owned(),
607 description: "coverage".to_owned(),
608 allowed_tools: None,
609 disable_model_invocation: false,
610 kind: SkillKind::Instruction,
611 content: "body".to_owned(),
612 tags: vec![],
613 version: None,
614 }))
615 }
616
617 #[test]
618 fn empty_projection_exposes_accessors_and_none_lookups() {
619 let set = CapabilitySet::empty().unwrap();
620 let projection = CapabilityProjection::new(Arc::clone(&set), []).unwrap();
621 assert!(projection.is_empty());
622 assert_eq!(projection.len(), 0);
623 assert_eq!(projection.iter().len(), 0);
624 assert!(Arc::ptr_eq(projection.set_arc(), &set));
625 assert_eq!(projection.set().generation().get(), set.generation().get());
626 assert_eq!(
627 projection.readiness_plan().generation().get(),
628 set.generation().get()
629 );
630
631 let source = CapabilitySource::builtin("a3s-code", digest('a')).unwrap();
632 let missing = CapabilityId::new(&source, CapabilityKind::Skill, "missing").unwrap();
633 assert!(!projection.contains(&missing));
634 assert!(projection.tool(&missing).is_none());
635 assert!(projection.skill(&missing).is_none());
636 assert!(projection.agent(&missing).is_none());
637 assert!(projection.command(&missing).is_none());
638 assert!(projection.hook(&missing).is_none());
639 assert!(projection.mcp(&missing).is_none());
640 assert!(projection.knowledge(&missing).is_none());
641 assert!(projection.knowledge_surface(&missing).is_none());
642 assert!(projection.ui(&missing).is_none());
643 assert!(projection.context(&missing).is_none());
644 }
645
646 #[test]
647 fn projection_rejects_duplicate_and_unexpected_values() {
648 let set = CapabilitySet::empty().unwrap();
649 let source = CapabilitySource::builtin("a3s-code", digest('b')).unwrap();
650 let id = CapabilityId::new(&source, CapabilityKind::Skill, "orphan").unwrap();
651 let value = sample_skill_value("orphan");
652
653 let duplicate = CapabilityProjection::new(
654 Arc::clone(&set),
655 [(id.clone(), value.clone()), (id.clone(), value.clone())],
656 );
657 assert!(matches!(
658 duplicate,
659 Err(CapabilityProjectionError::DuplicateValue { .. })
660 ));
661
662 let unexpected = CapabilityProjection::new(Arc::clone(&set), [(id, value)]);
663 assert!(matches!(
664 unexpected,
665 Err(CapabilityProjectionError::UnexpectedValue { .. })
666 ));
667 }
668
669 #[test]
670 fn readiness_plan_mismatch_fails_closed() {
671 let set = CapabilitySet::empty().unwrap();
672 let other = CapabilitySet::from_contributions(
673 CodeCatalogGeneration::new(2),
674 Vec::<crate::capability::CapabilityContribution>::new(),
675 )
676 .unwrap();
677 let readiness = Arc::new(CapabilityReadinessPlan::from_set(&other).unwrap());
678 let err = CapabilityProjection::with_readiness(set, readiness, []);
679 assert!(matches!(
680 err,
681 Err(CapabilityProjectionError::ReadinessPlanMismatch { .. })
682 ));
683 }
684
685 #[test]
686 fn projection_with_skill_covers_typed_lookups() {
687 let source = CapabilitySource::builtin("a3s-code", digest('c')).unwrap();
688 let descriptor = crate::capability::CapabilityDescriptor::new(
689 &source,
690 CapabilityKind::Skill,
691 "coverage-skill",
692 "coverage-skill",
693 digest('d'),
694 [],
695 )
696 .unwrap();
697 let id = descriptor.id().clone();
698 let set = CapabilitySet::from_contributions(
699 CodeCatalogGeneration::new(1),
700 [crate::capability::CapabilityContribution::new(source, [descriptor]).unwrap()],
701 )
702 .unwrap();
703 let value = sample_skill_value("coverage-skill");
704 let projection =
705 CapabilityProjection::new(Arc::clone(&set), [(id.clone(), value)]).unwrap();
706 assert_eq!(projection.len(), 1);
707 assert!(!projection.is_empty());
708 assert!(projection.contains(&id));
709 assert!(projection.skill(&id).is_some());
710 assert!(projection.tool(&id).is_none());
711 assert!(projection.agent(&id).is_none());
712 assert!(projection.command(&id).is_none());
713 assert!(projection.hook(&id).is_none());
714 assert!(projection.mcp(&id).is_none());
715 assert!(projection.knowledge(&id).is_none());
716 assert!(projection.knowledge_surface(&id).is_none());
717 assert!(projection.ui(&id).is_none());
718 assert!(projection.context(&id).is_none());
719
720 let catalog = CapabilityCatalog::new(Arc::clone(&projection));
721 let rendered = format!("{catalog:?}");
722 assert!(rendered.contains("CapabilityCatalog"));
723 assert!(rendered.contains("pending_cleanup_batches"));
724 let lease = catalog.pin();
725 let lease_dbg = format!("{lease:?}");
726 assert!(lease_dbg.contains("CapabilityProjectionLease"));
727 assert_eq!(lease.projection().len(), 1);
728 assert_eq!(lease.stamp().generation().get(), set.generation().get());
729 }
730
731 struct SlowEffect;
732 struct FailingEffect;
733
734 #[async_trait::async_trait]
735 impl CapabilityEffect for SlowEffect {
736 fn name(&self) -> &str {
737 "slow-coverage"
738 }
739
740 async fn close(self: Box<Self>) -> Result<(), crate::capability::CapabilityEffectError> {
741 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
742 Ok(())
743 }
744 }
745
746 #[async_trait::async_trait]
747 impl CapabilityEffect for FailingEffect {
748 fn name(&self) -> &str {
749 "failing-coverage"
750 }
751
752 async fn close(self: Box<Self>) -> Result<(), crate::capability::CapabilityEffectError> {
753 Err(crate::capability::CapabilityEffectError::new("boom"))
754 }
755 }
756
757 #[tokio::test]
758 async fn drain_cleanup_reports_failed_and_timed_out_effects() {
759 let set = CapabilitySet::empty().unwrap();
760 let projection = CapabilityProjection::new(Arc::clone(&set), []).unwrap();
761 let catalog = CapabilityCatalog::new(projection);
762
763 let failing: Box<dyn crate::capability::CapabilityEffect> = Box::new(FailingEffect);
764 assert_eq!(failing.name(), "failing-coverage");
765 catalog.inner.enqueue_rollback(vec![failing]);
766 let failed = catalog.drain_cleanup().await;
767 assert_eq!(failed.effects_failed, 1);
768 assert!(!failed.is_clean());
769
770 let slow: Box<dyn crate::capability::CapabilityEffect> = Box::new(SlowEffect);
771 assert_eq!(slow.name(), "slow-coverage");
772 catalog
773 .inner
774 .enqueue_rollback(vec![slow, Box::new(SlowEffect), Box::new(SlowEffect)]);
775 let policy = ScopeClosePolicy::new(std::time::Duration::from_millis(5)).expect("policy");
776 let timed_out = catalog.drain_cleanup_with_policy(policy).await;
777 assert!(
778 timed_out.effects_timed_out > 0,
779 "expected timeout accounting, got {timed_out:?}"
780 );
781 }
782
783 struct CoverageCommand;
784
785 impl crate::commands::SlashCommand for CoverageCommand {
786 fn name(&self) -> &str {
787 "coverage-cmd"
788 }
789
790 fn description(&self) -> &str {
791 "projection accessor coverage"
792 }
793
794 fn execute(
795 &self,
796 _args: &str,
797 _ctx: &crate::commands::CommandContext,
798 ) -> crate::commands::CommandOutput {
799 crate::commands::CommandOutput::text("ok")
800 }
801 }
802
803 struct CoverageHookHandler;
804
805 impl crate::hooks::HookHandler for CoverageHookHandler {
806 fn handle(&self, _event: &crate::hooks::HookEvent) -> crate::hooks::HookResponse {
807 crate::hooks::HookResponse::continue_()
808 }
809 }
810
811 struct CoverageKnowledgeProvider;
812
813 #[async_trait::async_trait]
814 impl crate::cognitive_context::CognitiveContextProvider for CoverageKnowledgeProvider {
815 fn name(&self) -> &str {
816 "coverage-knowledge"
817 }
818
819 async fn query(
820 &self,
821 _request: &crate::cognitive_context::CognitiveContextRequestV1,
822 ) -> crate::cognitive_context::CognitiveContextResult<
823 crate::cognitive_context::CognitiveContextResponseV1,
824 > {
825 Err(crate::cognitive_context::CognitiveContextError::Provider(
826 "coverage provider is query-less".into(),
827 ))
828 }
829 }
830
831 fn sample_knowledge_value() -> CapabilityValue {
832 let generation_digest =
833 "sha256:aa0beeb62f1b7b21bf70f21e6f0e858a1e4b720d313f0907209b5b9dad2eeb20";
834 let knowledge = crate::cognitive_context::CognitiveKnowledgeBindingV1::new(
835 "domain-knowledge",
836 "0.2",
837 "sha256:1def786da6d190b7b3ce0176e71d99ff1cac3f8c8cc7c0f8b76a893c544e7a90",
838 7,
839 generation_digest,
840 )
841 .unwrap();
842 let binding = crate::cognitive_context::CognitivePackageBindingV1::new(
843 "contra-sense/handbook",
844 "0.1.0",
845 7,
846 generation_digest,
847 "sha256:1e0f0a0162f5b290887ade8886af69fbba4548c863df026178e3550c77813455",
848 knowledge,
849 crate::cognitive_context::CognitiveContextLimits::default(),
850 )
851 .unwrap();
852 CapabilityValue::Knowledge(Arc::new(
853 crate::cognitive_context::CognitiveContextSession::new(
854 binding,
855 Arc::new(CoverageKnowledgeProvider),
856 )
857 .unwrap(),
858 ))
859 }
860
861 #[tokio::test]
862 async fn projection_typed_some_accessors_hit_each_capability_arm() {
863 let source = CapabilitySource::builtin("a3s-code", digest('e')).unwrap();
864 let kinds = [
865 (CapabilityKind::Agent, "coverage-agent", "coverage-agent"),
866 (CapabilityKind::Command, "coverage-cmd", "coverage-cmd"),
867 (CapabilityKind::Hook, "coverage-hook", "coverage-hook"),
868 (CapabilityKind::Mcp, "coverage-mcp", "coverage-mcp"),
869 (
870 CapabilityKind::Knowledge,
871 "coverage-knowledge",
872 "coverage-knowledge",
873 ),
874 (
875 CapabilityKind::Context,
876 "coverage-context",
877 "coverage-context",
878 ),
879 ];
880 let descriptors = kinds
881 .iter()
882 .enumerate()
883 .map(|(index, (kind, name, public_name))| {
884 crate::capability::CapabilityDescriptor::new(
885 &source,
886 *kind,
887 *name,
888 *public_name,
889 digest(char::from_digit((index + 1) as u32, 16).unwrap_or('f')),
890 [],
891 )
892 .unwrap()
893 })
894 .collect::<Vec<_>>();
895 let ids = descriptors
896 .iter()
897 .map(|descriptor| descriptor.id().clone())
898 .collect::<Vec<_>>();
899 let set = CapabilitySet::from_contributions(
900 CodeCatalogGeneration::new(3),
901 [crate::capability::CapabilityContribution::new(source, descriptors).unwrap()],
902 )
903 .unwrap();
904
905 let (mcp_binding, _transport, _client) = crate::mcp::test_support::ready_binding(
906 "coverage-mcp",
907 "v1",
908 vec![crate::mcp::test_support::mcp_tool("ping", "ping")],
909 )
910 .await;
911 let values = [
912 (
913 ids[0].clone(),
914 CapabilityValue::Agent(Arc::new(crate::subagent::AgentDefinition::new(
915 "coverage-agent",
916 "projection coverage",
917 ))),
918 ),
919 (
920 ids[1].clone(),
921 CapabilityValue::Command(Arc::new(CoverageCommand)),
922 ),
923 (
924 ids[2].clone(),
925 CapabilityValue::Hook(Arc::new(crate::hooks::HookBinding::new(
926 crate::hooks::Hook::new(
927 "coverage-hook",
928 crate::hooks::HookEventType::PreToolUse,
929 ),
930 Arc::new(CoverageHookHandler),
931 ))),
932 ),
933 (ids[3].clone(), CapabilityValue::Mcp(mcp_binding)),
934 (ids[4].clone(), sample_knowledge_value()),
935 (
936 ids[5].clone(),
937 CapabilityValue::Context(Arc::new(crate::context::StaticContextProvider::new(
938 "coverage-context",
939 ))),
940 ),
941 ];
942 let projection = CapabilityProjection::new(Arc::clone(&set), values).unwrap();
943 assert!(projection.agent(&ids[0]).is_some());
944 assert_eq!(projection.agent(&ids[0]).unwrap().name, "coverage-agent");
945 assert!(projection.command(&ids[1]).is_some());
946 let command = projection.command(&ids[1]).unwrap();
947 assert_eq!(command.name(), "coverage-cmd");
948 assert_eq!(command.description(), "projection accessor coverage");
949 let command_ctx = crate::commands::CommandContext {
950 session_id: "coverage".into(),
951 workspace: "/tmp".into(),
952 model: "test".into(),
953 history_len: 0,
954 total_tokens: 0,
955 total_cost: 0.0,
956 tool_names: vec![],
957 mcp_servers: vec![],
958 };
959 assert_eq!(command.execute("", &command_ctx).text, "ok");
960 assert!(projection.hook(&ids[2]).is_some());
961 assert_eq!(projection.hook(&ids[2]).unwrap().hook().id, "coverage-hook");
962 let hook_response = CoverageHookHandler.handle(&crate::hooks::HookEvent::SessionStart(
963 crate::hooks::SessionStartEvent {
964 session_id: "coverage".into(),
965 system_prompt: None,
966 model_provider: "test".into(),
967 model_name: "coverage".into(),
968 },
969 ));
970 assert_eq!(hook_response.action, crate::hooks::HookAction::Continue);
971 assert!(projection.mcp(&ids[3]).is_some());
972 assert_eq!(
973 projection.mcp(&ids[3]).unwrap().server_name(),
974 "coverage-mcp"
975 );
976 assert!(projection.knowledge(&ids[4]).is_some());
977 assert_eq!(
978 projection.knowledge(&ids[4]).unwrap().provider_name(),
979 "coverage-knowledge"
980 );
981 let knowledge_provider = CoverageKnowledgeProvider;
982 assert_eq!(
983 crate::cognitive_context::CognitiveContextProvider::name(&knowledge_provider),
984 "coverage-knowledge"
985 );
986 let knowledge_binding = projection.knowledge(&ids[4]).unwrap().binding().clone();
987 let knowledge_request = crate::cognitive_context::CognitiveContextRequestV1::new(
988 "coverage-session",
989 "coverage query",
990 knowledge_binding,
991 )
992 .expect("knowledge request");
993 let knowledge_err = knowledge_provider
994 .query(&knowledge_request)
995 .await
996 .expect_err("coverage provider stays query-less");
997 assert!(matches!(
998 knowledge_err,
999 crate::cognitive_context::CognitiveContextError::Provider(_)
1000 ));
1001 assert!(projection.context(&ids[5]).is_some());
1002 assert_eq!(
1003 projection.context(&ids[5]).unwrap().name(),
1004 "coverage-context"
1005 );
1006 assert!(projection.skill(&ids[0]).is_none());
1008 assert!(projection.ui(&ids[1]).is_none());
1009 assert!(projection.tool(&ids[5]).is_none());
1010 assert!(projection.knowledge_surface(&ids[4]).is_none());
1011 }
1012
1013 #[test]
1014 fn projection_rejects_ui_surface_digest_and_dependency_mismatches() {
1015 let source = CapabilitySource::builtin("a3s-code", digest('9')).unwrap();
1016 let document = crate::capability::UiDocument::new(
1017 crate::capability::UiAsset::new(
1018 crate::capability::UiAssetKind::Html,
1019 "<!doctype html><main>ui</main>",
1020 )
1021 .unwrap(),
1022 [],
1023 [],
1024 )
1025 .unwrap();
1026 let ui = crate::capability::UiBinding::new(crate::capability::UiBindingSpec {
1027 public_name: "ui-surface".to_owned(),
1028 title: "UI".to_owned(),
1029 description: "surface coverage".to_owned(),
1030 icon: "panel-top".to_owned(),
1031 order: 1,
1032 document,
1033 })
1034 .unwrap();
1035 let bad_digest = digest('a');
1036 let descriptor = crate::capability::CapabilityDescriptor::new(
1037 &source,
1038 CapabilityKind::Ui,
1039 "ui-surface",
1040 "ui-surface",
1041 bad_digest,
1042 [],
1043 )
1044 .unwrap();
1045 let id = descriptor.id().clone();
1046 let set = CapabilitySet::from_contributions(
1047 CodeCatalogGeneration::new(5),
1048 [
1049 crate::capability::CapabilityContribution::new(source.clone(), [descriptor])
1050 .unwrap(),
1051 ],
1052 )
1053 .unwrap();
1054 let err = CapabilityProjection::new(
1055 Arc::clone(&set),
1056 [(id, CapabilityValue::Ui(Arc::new(ui.clone())))],
1057 );
1058 assert!(matches!(
1059 err,
1060 Err(CapabilityProjectionError::SurfaceDigestMismatch { .. })
1061 ));
1062
1063 let agent_dep = CapabilityId::new(&source, CapabilityKind::Agent, "blocked-dep").unwrap();
1064 let agent_descriptor = crate::capability::CapabilityDescriptor::new(
1065 &source,
1066 CapabilityKind::Agent,
1067 "blocked-dep",
1068 "blocked-dep",
1069 digest('b'),
1070 [],
1071 )
1072 .unwrap();
1073 let agent_id = agent_descriptor.id().clone();
1074 let descriptor = crate::capability::CapabilityDescriptor::new(
1075 &source,
1076 CapabilityKind::Ui,
1077 "ui-surface",
1078 "ui-surface",
1079 ui.surface_digest().clone(),
1080 [agent_dep],
1081 )
1082 .unwrap();
1083 let id = descriptor.id().clone();
1084 let set = CapabilitySet::from_contributions(
1085 CodeCatalogGeneration::new(6),
1086 [crate::capability::CapabilityContribution::new(
1087 source,
1088 [agent_descriptor, descriptor],
1089 )
1090 .unwrap()],
1091 )
1092 .unwrap();
1093 let err = CapabilityProjection::new(
1094 Arc::clone(&set),
1095 [
1096 (
1097 agent_id,
1098 CapabilityValue::Agent(Arc::new(crate::subagent::AgentDefinition::new(
1099 "blocked-dep",
1100 "dependency present only to admit the ui descriptor",
1101 ))),
1102 ),
1103 (id, CapabilityValue::Ui(Arc::new(ui))),
1104 ],
1105 );
1106 assert!(matches!(
1107 err,
1108 Err(CapabilityProjectionError::UnsupportedUiDependencyKind { .. })
1109 ));
1110 }
1111
1112 #[test]
1113 fn projection_rejects_public_name_mismatch() {
1114 let source = CapabilitySource::builtin("a3s-code", digest('7')).unwrap();
1115 let descriptor = crate::capability::CapabilityDescriptor::new(
1116 &source,
1117 CapabilityKind::Skill,
1118 "expected-name",
1119 "expected-name",
1120 digest('8'),
1121 [],
1122 )
1123 .unwrap();
1124 let id = descriptor.id().clone();
1125 let set = CapabilitySet::from_contributions(
1126 CodeCatalogGeneration::new(4),
1127 [crate::capability::CapabilityContribution::new(source, [descriptor]).unwrap()],
1128 )
1129 .unwrap();
1130 let err =
1131 CapabilityProjection::new(Arc::clone(&set), [(id, sample_skill_value("actual-name"))]);
1132 assert!(matches!(
1133 err,
1134 Err(CapabilityProjectionError::PublicNameMismatch { .. })
1135 ));
1136 }
1137}