Skip to main content

gpui_rhai/
capability.rs

1use std::collections::btree_map::Entry;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4use std::sync::mpsc::Receiver;
5
6use semver::{Version, VersionReq};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{ModuleId, RUNTIME_API_VERSION, SchemaValidationError, UiValue, ValueSchema};
11
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
13#[serde(try_from = "String", into = "String")]
14pub struct CapabilityId(String);
15
16impl CapabilityId {
17    /// Parse a namespaced capability identifier such as `app.settings`.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`CapabilityError::InvalidId`] for invalid identifiers.
22    pub fn parse(value: impl Into<String>) -> Result<Self, CapabilityError> {
23        let value = value.into();
24        if value
25            .split_once('.')
26            .is_some_and(|(namespace, name)| is_identifier(namespace) && is_identifier(name))
27        {
28            Ok(Self(value))
29        } else {
30            Err(CapabilityError::InvalidId(value))
31        }
32    }
33
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl TryFrom<String> for CapabilityId {
41    type Error = CapabilityError;
42
43    fn try_from(value: String) -> Result<Self, Self::Error> {
44        Self::parse(value)
45    }
46}
47
48impl From<CapabilityId> for String {
49    fn from(value: CapabilityId) -> Self {
50        value.0
51    }
52}
53
54fn is_identifier(value: &str) -> bool {
55    !value.is_empty()
56        && !value.starts_with('_')
57        && !value.ends_with('_')
58        && !value.contains("__")
59        && value.chars().all(|character| {
60            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
61        })
62}
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct CapabilityMethod {
66    pub input: ValueSchema,
67    pub output: ValueSchema,
68}
69
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71pub struct CapabilityDescriptor {
72    pub id: CapabilityId,
73    pub version: Version,
74    pub methods: BTreeMap<String, CapabilityMethod>,
75}
76
77pub trait CapabilityHandler {
78    /// Execute a schema-validated capability method.
79    ///
80    /// # Errors
81    ///
82    /// Returns a human-readable handler error. The registry validates the
83    /// returned value against the declared output schema.
84    fn call(&mut self, method: &str, input: UiValue) -> Result<UiValue, String>;
85}
86
87pub struct TaskWork {
88    work: Box<dyn FnOnce(crate::TaskCancellation) -> Result<UiValue, String> + Send + 'static>,
89}
90
91impl TaskWork {
92    #[must_use]
93    pub fn new(work: impl FnOnce() -> Result<UiValue, String> + Send + 'static) -> Self {
94        Self {
95            work: Box::new(move |_| work()),
96        }
97    }
98
99    #[must_use]
100    pub fn cancellable(
101        work: impl FnOnce(crate::TaskCancellation) -> Result<UiValue, String> + Send + 'static,
102    ) -> Self {
103        Self {
104            work: Box::new(work),
105        }
106    }
107
108    pub(crate) fn run(self, cancellation: crate::TaskCancellation) -> Result<UiValue, String> {
109        (self.work)(cancellation)
110    }
111}
112
113pub trait AsyncCapabilityHandler {
114    /// Build one-shot background work after input validation.
115    ///
116    /// # Errors
117    ///
118    /// Returns a human-readable setup error before a task is spawned.
119    fn start(&mut self, method: &str, input: UiValue) -> Result<TaskWork, String>;
120}
121
122/// One continuous subscription producer.
123///
124/// The work owns the producer loop and should not return until the stream is
125/// finished. Returning closes the subscription and invalidates every cloned
126/// [`crate::SubscriptionEmitter`] with
127/// [`crate::SubscriptionCloseReason::WorkReturned`].
128pub struct SubscriptionWork {
129    work: Box<dyn FnOnce(crate::SubscriptionEmitter) + Send + 'static>,
130}
131
132impl SubscriptionWork {
133    /// Build a subscription from its complete blocking producer loop.
134    #[must_use]
135    pub fn new(work: impl FnOnce(crate::SubscriptionEmitter) + Send + 'static) -> Self {
136        Self {
137            work: Box::new(work),
138        }
139    }
140
141    /// Forward values from a channel until its senders are dropped or the
142    /// subscription is closed.
143    #[must_use]
144    pub fn from_receiver(receiver: Receiver<UiValue>) -> Self {
145        Self::new(move |emitter| {
146            loop {
147                if emitter.close_reason().is_some() {
148                    break;
149                }
150                match receiver.recv_timeout(std::time::Duration::from_millis(50)) {
151                    Ok(value) => {
152                        if emitter.emit_blocking(value).is_err() {
153                            break;
154                        }
155                    }
156                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
157                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
158                }
159            }
160        })
161    }
162
163    pub(crate) fn run(self, emitter: crate::SubscriptionEmitter) {
164        (self.work)(emitter);
165    }
166}
167
168pub trait SubscriptionCapabilityHandler {
169    /// Build continuous producer work after input validation.
170    ///
171    /// The returned [`SubscriptionWork`] is the producer loop. It must remain
172    /// running for as long as external code may use the emitter; returning from
173    /// it closes the subscription immediately.
174    ///
175    /// # Errors
176    ///
177    /// Returns a human-readable setup error before subscription startup.
178    fn subscribe(&mut self, method: &str, input: UiValue) -> Result<SubscriptionWork, String>;
179}
180
181struct CapabilityEntry {
182    descriptor: CapabilityDescriptor,
183    sync: Option<Box<dyn CapabilityHandler>>,
184    task: Option<Box<dyn AsyncCapabilityHandler>>,
185    subscription: Option<Box<dyn SubscriptionCapabilityHandler>>,
186}
187
188#[derive(Default)]
189pub struct CapabilityRegistry {
190    entries: BTreeMap<CapabilityId, CapabilityEntry>,
191    active: BTreeSet<CapabilityId>,
192}
193
194impl fmt::Debug for CapabilityRegistry {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter
197            .debug_struct("CapabilityRegistry")
198            .field("registered", &self.entries.keys().collect::<Vec<_>>())
199            .field("active", &self.active)
200            .finish()
201    }
202}
203
204impl CapabilityRegistry {
205    #[must_use]
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Register a host implementation and its public schema.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`CapabilityError`] for duplicate IDs, empty method sets, or
215    /// invalid method names.
216    pub fn register(
217        &mut self,
218        descriptor: CapabilityDescriptor,
219        handler: impl CapabilityHandler + 'static,
220    ) -> Result<(), CapabilityError> {
221        validate_descriptor(&descriptor)?;
222        let entry = self.entry_for_descriptor(descriptor)?;
223        if entry.sync.is_some() {
224            return Err(CapabilityError::DuplicateMode("sync"));
225        }
226        entry.sync = Some(Box::new(handler));
227        Ok(())
228    }
229
230    /// Register one-shot task methods for a capability descriptor.
231    ///
232    /// # Errors
233    ///
234    /// Returns descriptor or duplicate-mode errors.
235    pub fn register_async(
236        &mut self,
237        descriptor: CapabilityDescriptor,
238        handler: impl AsyncCapabilityHandler + 'static,
239    ) -> Result<(), CapabilityError> {
240        validate_descriptor(&descriptor)?;
241        let entry = self.entry_for_descriptor(descriptor)?;
242        if entry.task.is_some() {
243            return Err(CapabilityError::DuplicateMode("task"));
244        }
245        entry.task = Some(Box::new(handler));
246        Ok(())
247    }
248
249    /// Register continuous subscription methods for a capability descriptor.
250    ///
251    /// # Errors
252    ///
253    /// Returns descriptor or duplicate-mode errors.
254    pub fn register_subscription(
255        &mut self,
256        descriptor: CapabilityDescriptor,
257        handler: impl SubscriptionCapabilityHandler + 'static,
258    ) -> Result<(), CapabilityError> {
259        validate_descriptor(&descriptor)?;
260        let entry = self.entry_for_descriptor(descriptor)?;
261        if entry.subscription.is_some() {
262            return Err(CapabilityError::DuplicateMode("subscription"));
263        }
264        entry.subscription = Some(Box::new(handler));
265        Ok(())
266    }
267
268    fn entry_for_descriptor(
269        &mut self,
270        descriptor: CapabilityDescriptor,
271    ) -> Result<&mut CapabilityEntry, CapabilityError> {
272        let id = descriptor.id.clone();
273        match self.entries.entry(id.clone()) {
274            Entry::Vacant(entry) => Ok(entry.insert(CapabilityEntry {
275                descriptor,
276                sync: None,
277                task: None,
278                subscription: None,
279            })),
280            Entry::Occupied(entry) => {
281                if entry.get().descriptor == descriptor {
282                    Ok(entry.into_mut())
283                } else {
284                    Err(CapabilityError::DescriptorMismatch(id))
285                }
286            }
287        }
288    }
289
290    /// Validate and activate exactly the capabilities declared by an app.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`CapabilityError::Missing`] or
295    /// [`CapabilityError::VersionMismatch`].
296    pub fn activate(
297        &mut self,
298        requirements: &BTreeMap<CapabilityId, VersionReq>,
299    ) -> Result<(), CapabilityError> {
300        let mut active = BTreeSet::new();
301        for (id, requirement) in requirements {
302            let entry = self
303                .entries
304                .get(id)
305                .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
306            if !requirement.matches(&entry.descriptor.version) {
307                return Err(CapabilityError::VersionMismatch {
308                    id: id.clone(),
309                    required: requirement.clone(),
310                    actual: entry.descriptor.version.clone(),
311                });
312            }
313            active.insert(id.clone());
314        }
315        self.active = active;
316        Ok(())
317    }
318
319    /// Invoke an activated method through schema validation on both sides.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`CapabilityError`] for undeclared access, unknown methods,
324    /// invalid values, or a handler failure.
325    pub fn call(
326        &mut self,
327        id: &CapabilityId,
328        method: &str,
329        input: UiValue,
330    ) -> Result<UiValue, CapabilityError> {
331        if !self.active.contains(id) {
332            return Err(CapabilityError::NotDeclared(id.clone()));
333        }
334        let entry = self
335            .entries
336            .get_mut(id)
337            .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
338        let schema = entry
339            .descriptor
340            .methods
341            .get(method)
342            .cloned()
343            .ok_or_else(|| CapabilityError::UnknownMethod {
344                id: id.clone(),
345                method: method.to_owned(),
346            })?;
347        schema
348            .input
349            .validate_ui_value(&input)
350            .map_err(|source| CapabilityError::InvalidInput {
351                id: id.clone(),
352                method: method.to_owned(),
353                source,
354            })?;
355        let handler = entry
356            .sync
357            .as_mut()
358            .ok_or(CapabilityError::UnsupportedMode("sync"))?;
359        let output = handler
360            .call(method, input)
361            .map_err(|message| CapabilityError::Handler {
362                id: id.clone(),
363                method: method.to_owned(),
364                message,
365            })?;
366        schema.output.validate_ui_value(&output).map_err(|source| {
367            CapabilityError::InvalidOutput {
368                id: id.clone(),
369                method: method.to_owned(),
370                source,
371            }
372        })?;
373        Ok(output)
374    }
375
376    /// Build schema-validated one-shot work for the task registry.
377    ///
378    /// # Errors
379    ///
380    /// Returns capability access, schema, mode, or handler setup errors.
381    pub fn start_task(
382        &mut self,
383        id: &CapabilityId,
384        method: &str,
385        input: UiValue,
386    ) -> Result<(TaskWork, ValueSchema), CapabilityError> {
387        let (entry, schema) = self.entry_and_method(id, method, &input)?;
388        let handler = entry
389            .task
390            .as_mut()
391            .ok_or(CapabilityError::UnsupportedMode("task"))?;
392        let work = handler
393            .start(method, input)
394            .map_err(|message| CapabilityError::Handler {
395                id: id.clone(),
396                method: method.to_owned(),
397                message,
398            })?;
399        Ok((work, schema.output))
400    }
401
402    /// Build schema-validated continuous work for the subscription registry.
403    ///
404    /// # Errors
405    ///
406    /// Returns capability access, schema, mode, or handler setup errors.
407    pub fn start_subscription(
408        &mut self,
409        id: &CapabilityId,
410        method: &str,
411        input: UiValue,
412    ) -> Result<(SubscriptionWork, ValueSchema), CapabilityError> {
413        let (entry, schema) = self.entry_and_method(id, method, &input)?;
414        let handler = entry
415            .subscription
416            .as_mut()
417            .ok_or(CapabilityError::UnsupportedMode("subscription"))?;
418        let work =
419            handler
420                .subscribe(method, input)
421                .map_err(|message| CapabilityError::Handler {
422                    id: id.clone(),
423                    method: method.to_owned(),
424                    message,
425                })?;
426        Ok((work, schema.output))
427    }
428
429    fn entry_and_method(
430        &mut self,
431        id: &CapabilityId,
432        method: &str,
433        input: &UiValue,
434    ) -> Result<(&mut CapabilityEntry, CapabilityMethod), CapabilityError> {
435        if !self.active.contains(id) {
436            return Err(CapabilityError::NotDeclared(id.clone()));
437        }
438        let entry = self
439            .entries
440            .get_mut(id)
441            .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
442        let schema = entry
443            .descriptor
444            .methods
445            .get(method)
446            .cloned()
447            .ok_or_else(|| CapabilityError::UnknownMethod {
448                id: id.clone(),
449                method: method.to_owned(),
450            })?;
451        schema
452            .input
453            .validate_ui_value(input)
454            .map_err(|source| CapabilityError::InvalidInput {
455                id: id.clone(),
456                method: method.to_owned(),
457                source,
458            })?;
459        Ok((entry, schema))
460    }
461}
462
463fn validate_descriptor(descriptor: &CapabilityDescriptor) -> Result<(), CapabilityError> {
464    if descriptor.methods.is_empty() {
465        return Err(CapabilityError::NoMethods(descriptor.id.clone()));
466    }
467    if let Some(method) = descriptor
468        .methods
469        .keys()
470        .find(|method| !is_identifier(method))
471    {
472        return Err(CapabilityError::InvalidMethod(method.clone()));
473    }
474    Ok(())
475}
476
477#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
478pub struct AppManifest {
479    pub entry: ModuleId,
480    pub runtime_api: u32,
481    #[serde(default)]
482    pub capabilities: BTreeMap<CapabilityId, VersionReq>,
483}
484
485impl AppManifest {
486    #[must_use]
487    pub fn new(entry: ModuleId) -> Self {
488        Self {
489            entry,
490            runtime_api: RUNTIME_API_VERSION,
491            capabilities: BTreeMap::new(),
492        }
493    }
494
495    /// Add one namespaced capability requirement from generated or host code.
496    ///
497    /// # Errors
498    ///
499    /// Returns identifier or semantic-version requirement parse errors.
500    pub fn with_capability(mut self, id: &str, requirement: &str) -> Result<Self, CapabilityError> {
501        let id = CapabilityId::parse(id)?;
502        let requirement = VersionReq::parse(requirement).map_err(|source| {
503            CapabilityError::InvalidVersionRequirement {
504                requirement: requirement.to_owned(),
505                source,
506            }
507        })?;
508        self.capabilities.insert(id, requirement);
509        Ok(self)
510    }
511
512    /// Validate runtime compatibility and activate declared capabilities.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`CapabilityError::RuntimeApiMismatch`] or an activation error.
517    pub fn activate(&self, registry: &mut CapabilityRegistry) -> Result<(), CapabilityError> {
518        if self.runtime_api != RUNTIME_API_VERSION {
519            return Err(CapabilityError::RuntimeApiMismatch {
520                required: self.runtime_api,
521                actual: RUNTIME_API_VERSION,
522            });
523        }
524        registry.activate(&self.capabilities)
525    }
526
527    /// Verify that every installed component capability is explicitly declared
528    /// by the application with the same compatibility requirement.
529    ///
530    /// # Errors
531    ///
532    /// Returns missing or mismatched component capability declarations.
533    pub fn validate_components(
534        &self,
535        components: &crate::ComponentRegistry,
536    ) -> Result<(), CapabilityError> {
537        for (component_id, component) in components.iter() {
538            for (raw_id, required) in &component.metadata.capabilities {
539                let id = CapabilityId::parse(raw_id)?;
540                let Some(declared) = self.capabilities.get(&id) else {
541                    return Err(CapabilityError::ComponentCapabilityMissing {
542                        component: component_id.clone(),
543                        capability: id,
544                    });
545                };
546                if declared != required {
547                    return Err(CapabilityError::ComponentCapabilityMismatch {
548                        component: component_id.clone(),
549                        capability: id,
550                        component_required: required.clone(),
551                        app_declared: declared.clone(),
552                    });
553                }
554            }
555        }
556        Ok(())
557    }
558}
559
560#[derive(Debug, Error)]
561pub enum CapabilityError {
562    #[error("capability ID `{0}` must be a namespaced `snake_case` identifier")]
563    InvalidId(String),
564    #[error("invalid capability version requirement `{requirement}`: {source}")]
565    InvalidVersionRequirement {
566        requirement: String,
567        #[source]
568        source: semver::Error,
569    },
570    #[error("capability `{0:?}` was registered with conflicting descriptors")]
571    DescriptorMismatch(CapabilityId),
572    #[error("capability handler mode `{0}` is already registered")]
573    DuplicateMode(&'static str),
574    #[error("capability does not implement `{0}` mode")]
575    UnsupportedMode(&'static str),
576    #[error("capability `{0:?}` must declare at least one method")]
577    NoMethods(CapabilityId),
578    #[error("capability method `{0}` must be a `snake_case` identifier")]
579    InvalidMethod(String),
580    #[error("required capability `{0:?}` is not registered")]
581    Missing(CapabilityId),
582    #[error("capability `{id:?}` requires {required}, host provides {actual}")]
583    VersionMismatch {
584        id: CapabilityId,
585        required: VersionReq,
586        actual: Version,
587    },
588    #[error("capability `{0:?}` was not declared by the application manifest")]
589    NotDeclared(CapabilityId),
590    #[error("capability `{id:?}` has no method `{method}`")]
591    UnknownMethod { id: CapabilityId, method: String },
592    #[error("input for `{id:?}.{method}` is invalid: {source}")]
593    InvalidInput {
594        id: CapabilityId,
595        method: String,
596        source: SchemaValidationError,
597    },
598    #[error("output from `{id:?}.{method}` is invalid: {source}")]
599    InvalidOutput {
600        id: CapabilityId,
601        method: String,
602        source: SchemaValidationError,
603    },
604    #[error("capability `{id:?}.{method}` failed: {message}")]
605    Handler {
606        id: CapabilityId,
607        method: String,
608        message: String,
609    },
610    #[error("application requires runtime API {required}, current API is {actual}")]
611    RuntimeApiMismatch { required: u32, actual: u32 },
612    #[error(
613        "component `{component}` requires capability `{capability:?}` missing from the app manifest"
614    )]
615    ComponentCapabilityMissing {
616        component: ModuleId,
617        capability: CapabilityId,
618    },
619    #[error(
620        "component `{component}` requires capability `{capability:?}` at {component_required}, but the app declares {app_declared}"
621    )]
622    ComponentCapabilityMismatch {
623        component: ModuleId,
624        capability: CapabilityId,
625        component_required: VersionReq,
626        app_declared: VersionReq,
627    },
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::{
634        ComponentDefinition, ComponentMetadata, ComponentRegistry, ComponentSchema, RuntimeApiRange,
635    };
636
637    struct Echo;
638
639    impl CapabilityHandler for Echo {
640        fn call(&mut self, method: &str, input: UiValue) -> Result<UiValue, String> {
641            if method == "echo" {
642                Ok(input)
643            } else {
644                Err("unexpected method".to_owned())
645            }
646        }
647    }
648
649    struct AsyncEcho;
650
651    impl AsyncCapabilityHandler for AsyncEcho {
652        fn start(&mut self, method: &str, input: UiValue) -> Result<TaskWork, String> {
653            if method == "echo" {
654                Ok(TaskWork::new(move || Ok(input)))
655            } else {
656                Err("unexpected method".to_owned())
657            }
658        }
659    }
660
661    fn descriptor() -> CapabilityDescriptor {
662        CapabilityDescriptor {
663            id: CapabilityId::parse("app.echo").unwrap(),
664            version: Version::new(1, 2, 0),
665            methods: BTreeMap::from([(
666                "echo".to_owned(),
667                CapabilityMethod {
668                    input: ValueSchema::string(),
669                    output: ValueSchema::string(),
670                },
671            )]),
672        }
673    }
674
675    #[test]
676    fn manifest_controls_capability_access_and_versions() {
677        let mut registry = CapabilityRegistry::new();
678        registry.register(descriptor(), Echo).unwrap();
679        assert!(matches!(
680            registry.call(
681                &CapabilityId::parse("app.echo").unwrap(),
682                "echo",
683                UiValue::String("no".to_owned()),
684            ),
685            Err(CapabilityError::NotDeclared(_))
686        ));
687
688        let manifest = AppManifest {
689            entry: ModuleId::parse("main").unwrap(),
690            runtime_api: RUNTIME_API_VERSION,
691            capabilities: BTreeMap::from([(
692                CapabilityId::parse("app.echo").unwrap(),
693                VersionReq::parse("^1.0").unwrap(),
694            )]),
695        };
696        manifest.activate(&mut registry).unwrap();
697        assert_eq!(
698            registry
699                .call(
700                    &CapabilityId::parse("app.echo").unwrap(),
701                    "echo",
702                    UiValue::String("yes".to_owned()),
703                )
704                .unwrap(),
705            UiValue::String("yes".to_owned())
706        );
707    }
708
709    #[test]
710    fn manifest_must_cover_component_capability_requirements() {
711        let component = ComponentDefinition::new(
712            ComponentMetadata {
713                id: ModuleId::parse("components/remote_status").unwrap(),
714                export: "RemoteStatus".to_owned(),
715                version: Version::new(0, 1, 0),
716                runtime_api: RuntimeApiRange::new(2, 3),
717                dependencies: BTreeSet::new(),
718                capabilities: BTreeMap::from([(
719                    "app.echo".to_owned(),
720                    VersionReq::parse("^1.0").unwrap(),
721                )]),
722                assets: BTreeSet::new(),
723            },
724            ComponentSchema::default(),
725        )
726        .unwrap();
727        let mut components = ComponentRegistry::new();
728        components.register(component, RUNTIME_API_VERSION).unwrap();
729        let missing = AppManifest::new(ModuleId::parse("main").unwrap());
730        assert!(matches!(
731            missing.validate_components(&components),
732            Err(CapabilityError::ComponentCapabilityMissing { .. })
733        ));
734        let declared = missing.with_capability("app.echo", "^1.0").unwrap();
735        declared.validate_components(&components).unwrap();
736    }
737
738    #[test]
739    fn output_is_validated_even_for_rust_handlers() {
740        struct BadOutput;
741        impl CapabilityHandler for BadOutput {
742            fn call(&mut self, _: &str, _: UiValue) -> Result<UiValue, String> {
743                Ok(UiValue::Bool(true))
744            }
745        }
746        let mut registry = CapabilityRegistry::new();
747        registry.register(descriptor(), BadOutput).unwrap();
748        registry
749            .activate(&BTreeMap::from([(
750                CapabilityId::parse("app.echo").unwrap(),
751                VersionReq::STAR,
752            )]))
753            .unwrap();
754        assert!(matches!(
755            registry.call(
756                &CapabilityId::parse("app.echo").unwrap(),
757                "echo",
758                UiValue::String("input".to_owned()),
759            ),
760            Err(CapabilityError::InvalidOutput { .. })
761        ));
762    }
763
764    #[test]
765    fn async_handler_builds_validated_worker_without_moving_rhai_callbacks() {
766        let mut registry = CapabilityRegistry::new();
767        registry.register_async(descriptor(), AsyncEcho).unwrap();
768        registry
769            .activate(&BTreeMap::from([(
770                CapabilityId::parse("app.echo").unwrap(),
771                VersionReq::STAR,
772            )]))
773            .unwrap();
774        let (work, output) = registry
775            .start_task(
776                &CapabilityId::parse("app.echo").unwrap(),
777                "echo",
778                UiValue::String("async".to_owned()),
779            )
780            .unwrap();
781        let value = work.run(crate::TaskCancellation::default()).unwrap();
782        output.validate_ui_value(&value).unwrap();
783        assert_eq!(value, UiValue::String("async".to_owned()));
784    }
785}