Skip to main content

agent_engine/extensions/
lifecycle.rs

1//! Task 20 — capability-driven extension lifecycle (spec §7.5).
2//!
3//! Typed inventory/classification of extension manifests plus OPTIONAL,
4//! ADDITIVE passive `deferred` declarations. Absent declarations keep the
5//! documented legacy EAGER lifecycle byte-for-byte (no protocol version
6//! bump); present declarations are trusted local manifest expectations:
7//! they enable spawn deferral and MUST match the runtime's initialize
8//! declarations exactly (names, schemas, digests) before any registration
9//! or grant — a mismatch shuts the child down and fails closed.
10//!
11//! Nothing in this module spawns a process or touches the network: it
12//! reads bounded local manifest data and mints dormant descriptor tools.
13
14use std::collections::HashSet;
15use std::sync::Arc;
16
17use serde_json::Value;
18
19use super::manifest::ExtensionManifest;
20use super::runtime::process::RegisteredExtensionToolSpec;
21use super::validation::validate_id_segment;
22use crate::tools::catalog::SchemaDigest;
23use crate::tools::{Tool, ToolContext, ToolOrigin};
24use agent_core::BoundedText;
25
26/// Bounds for passive tool declarations (parity with the MCP descriptor
27/// cache budgets).
28pub const DECLARED_MAX_TOOLS: usize = 256;
29pub const DECLARED_MAX_PROVIDERS: usize = 16;
30pub const DECLARED_NAME_MAX_BYTES: usize = 128;
31pub const DECLARED_DESCRIPTION_MAX_BYTES: usize = 1024;
32pub const DECLARED_SCHEMA_MAX_BYTES: usize = 64 * 1024;
33/// Bound on passive memory/context contribution provider declarations
34/// (continuous-memory spec §7.1). Deliberately small: one extension has no
35/// legitimate reason to contribute many distinct context surfaces.
36pub const DECLARED_MAX_CONTEXT_PROVIDERS: usize = 8;
37
38/// OPTIONAL additive manifest block. Absent => legacy eager lifecycle.
39#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
40pub struct DeferredDeclarations {
41    /// EXACT tools the extension will register at initialize.
42    #[serde(default)]
43    pub tools: Vec<DeclaredExtensionTool>,
44    /// TYPED `RegisteredProviderSpec`-compatible provider metadata,
45    /// deeply bounded/validated here so malformed passive metadata can
46    /// never reach provider routing later.
47    #[serde(default)]
48    pub providers: Vec<DeclaredExtensionProvider>,
49    /// Optional lifecycle hint for extensions without tools/providers.
50    #[serde(default)]
51    pub lifecycle: Option<DeferredLifecycle>,
52    /// TYPED memory/context contribution provider declarations
53    /// (continuous-memory spec §7.1). ADDITIVE: absent => empty =>
54    /// byte-compatible with every existing manifest (`skip_serializing_if`
55    /// keeps serialized output identical too). COMPLETELY UNRELATED to
56    /// `providers` above (model/LLM provider metadata): a context provider
57    /// is dormant capability metadata that never spawns anything at load
58    /// and stays dormant until an exact memory-context lease (task A6) is
59    /// granted.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub context_providers: Vec<DeclaredExtensionContextProvider>,
62}
63
64/// Passive provider declaration, field-compatible with the runtime's
65/// `RegisteredProviderSpec` so a validated declaration can later back
66/// metadata-only registration.
67#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68pub struct DeclaredExtensionProvider {
69    pub id: String,
70    pub display_name: String,
71    #[serde(default)]
72    pub description: String,
73    #[serde(default)]
74    pub models: Vec<DeclaredExtensionProviderModel>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub config_schema: Option<Value>,
77}
78
79/// Field-compatible with `RegisteredProviderModelSpec`.
80#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
81pub struct DeclaredExtensionProviderModel {
82    pub id: String,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub display_name: Option<String>,
85    #[serde(default)]
86    pub capabilities: Value,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub context_window: Option<u64>,
89}
90
91/// Bounds for provider declarations.
92pub const DECLARED_MAX_PROVIDER_MODELS: usize = 64;
93pub const DECLARED_DISPLAY_MAX_BYTES: usize = 256;
94
95/// Passive MEMORY/CONTEXT contribution provider declaration
96/// (continuous-memory spec §7.1) — e.g. Axel's
97/// `extension:axel-memory-manager:project-memory`. DISTINCT from
98/// [`DeclaredExtensionProvider`] (model/LLM provider metadata): this
99/// declares a dormant context-contribution capability. Discovery, schema
100/// export, status inspection, and normal first prompts never spawn the
101/// owning extension; activation is exclusively a future exact
102/// memory-context lease (task A6).
103#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104pub struct DeclaredExtensionContextProvider {
105    /// Local provider id (no ':'; the runtime address is composed as
106    /// `extension:<plugin>:<id>`). Bounded like other declared names.
107    pub id: String,
108    /// Capability label (e.g. "project-memory"), bounded like a name.
109    pub capability: String,
110    /// Human-readable description, bounded like other declared descriptions.
111    #[serde(default)]
112    pub description: String,
113    /// Declared context payload schema version; 0 is invalid (fail closed).
114    pub schema_version: u32,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "lowercase")]
119pub enum DeferredLifecycle {
120    /// Start only on the first authorized subscribed hook event.
121    Hook,
122    /// Start only on explicit user action (UI/sidecar class).
123    User,
124}
125
126/// One passively declared tool: exactly the fields the runtime will
127/// register at initialize, pinned locally so activation can validate the
128/// live declaration against operator-known expectations.
129#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
130pub struct DeclaredExtensionTool {
131    pub name: String,
132    #[serde(default)]
133    pub description: String,
134    pub input_schema: Value,
135}
136
137/// Typed extension lifecycle classes (spec §7.5).
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ExtensionClass {
140    /// No usable passive declarations: documented eager compatibility.
141    LegacyEager,
142    ToolOnly,
143    Provider,
144    HookLifecycle,
145    UiSidecar,
146    /// ONLY memory/context contribution providers declared
147    /// (continuous-memory spec §7.1): dormant until an exact
148    /// memory-context lease is granted (task A6).
149    ContextProvider,
150    Mixed,
151}
152
153/// Whether a deferred block declares at least one memory/context
154/// contribution provider (continuous-memory spec §7.1).
155pub fn has_context_provider(declared: &DeferredDeclarations) -> bool {
156    !declared.context_providers.is_empty()
157}
158
159/// Classify a manifest from its passive declarations. Pure and local.
160pub fn classify(manifest: &ExtensionManifest) -> ExtensionClass {
161    let Some(deferred) = &manifest.deferred else {
162        return ExtensionClass::LegacyEager;
163    };
164    let tools = !deferred.tools.is_empty();
165    let providers = !deferred.providers.is_empty();
166    let context = has_context_provider(deferred);
167    let hooks = !manifest.hooks.is_empty() || deferred.lifecycle == Some(DeferredLifecycle::Hook);
168    let user = deferred.lifecycle == Some(DeferredLifecycle::User);
169    // With `context == false` this is EXACTLY the pre-context-provider
170    // matrix: existing manifests classify byte-for-byte as before.
171    match (tools, providers, context, hooks, user) {
172        (false, false, false, false, false) => ExtensionClass::LegacyEager,
173        (true, false, false, false, false) => ExtensionClass::ToolOnly,
174        (false, true, false, false, false) => ExtensionClass::Provider,
175        (false, false, true, false, false) => ExtensionClass::ContextProvider,
176        (false, false, false, true, false) => ExtensionClass::HookLifecycle,
177        (false, false, false, false, true) => ExtensionClass::UiSidecar,
178        _ => ExtensionClass::Mixed,
179    }
180}
181
182/// The earliest legitimately required activation trigger for a manifest
183/// (spec 7.5). Mixed extensions use the EARLIEST of their components —
184/// hook events fire passively during any turn, provider selection is an
185/// explicit routing choice, exact tool activation comes last; tool SEARCH
186/// alone never triggers a start.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ActivationTrigger {
189    Eager,
190    FirstAuthorizedHookEvent,
191    ProviderSelection,
192    ExactToolActivation,
193    UserAction,
194}
195
196pub fn earliest_trigger(manifest: &ExtensionManifest) -> ActivationTrigger {
197    match classify(manifest) {
198        ExtensionClass::LegacyEager => ActivationTrigger::Eager,
199        ExtensionClass::ToolOnly => ActivationTrigger::ExactToolActivation,
200        ExtensionClass::Provider => ActivationTrigger::ProviderSelection,
201        ExtensionClass::HookLifecycle => ActivationTrigger::FirstAuthorizedHookEvent,
202        ExtensionClass::UiSidecar => ActivationTrigger::UserAction,
203        // A context provider is dormant until an exact memory-context
204        // lease, which only an explicit host-authorized user action (e.g.
205        // `/memory on`, task A6) can grant — never a hook, provider
206        // selection, or tool search.
207        ExtensionClass::ContextProvider => ActivationTrigger::UserAction,
208        ExtensionClass::Mixed => {
209            let deferred = manifest.deferred.as_ref();
210            let hooks = !manifest.hooks.is_empty()
211                || deferred.and_then(|d| d.lifecycle) == Some(DeferredLifecycle::Hook);
212            let providers = deferred.map(|d| !d.providers.is_empty()).unwrap_or(false);
213            if hooks {
214                ActivationTrigger::FirstAuthorizedHookEvent
215            } else if providers {
216                ActivationTrigger::ProviderSelection
217            } else {
218                ActivationTrigger::ExactToolActivation
219            }
220        }
221    }
222}
223
224fn valid_name(name: &str) -> bool {
225    !name.is_empty() && name.len() <= DECLARED_NAME_MAX_BYTES && !name.chars().any(char::is_control)
226}
227
228/// Manifest-time validation for a name that BECOMES a runtime capability id
229/// (declared tool names, provider ids, model ids).
230///
231/// Deliberately delegates to [`validate_id_segment`] — the exact function the
232/// runtime applies at `initialize` — so the two checks cannot drift. They
233/// previously did: manifest-time `valid_name` allowed 128 bytes and embedded
234/// whitespace, while the runtime caps ids at
235/// [`MAX_ID_LENGTH`][super::validation::MAX_ID_LENGTH] (64) and rejects
236/// whitespace outright. A 65–128 byte or whitespace-bearing name passed
237/// manifest validation and then died at `initialize` with an unrelated error.
238///
239/// No working extension is affected by tightening this: any name that this
240/// now rejects was already guaranteed to fail at `initialize`. The only
241/// change is that the failure is early, local, and says what is wrong.
242fn valid_capability_id(name: &str) -> bool {
243    validate_id_segment(name).is_ok()
244}
245
246/// Bounded validation of a `deferred` block. Static reasons only; any
247/// violation fails the WHOLE manifest closed (validated before any spawn).
248pub fn validate_deferred(deferred: &DeferredDeclarations) -> Result<(), &'static str> {
249    if deferred.tools.len() > DECLARED_MAX_TOOLS {
250        return Err("too_many_declared_tools");
251    }
252    let mut seen: HashSet<&str> = HashSet::new();
253    for tool in &deferred.tools {
254        validate_tool_shape(&tool.name, &tool.description, &tool.input_schema)?;
255        if !seen.insert(tool.name.as_str()) {
256            return Err("duplicate_declared_tool_name");
257        }
258    }
259    if deferred.providers.len() > DECLARED_MAX_PROVIDERS {
260        return Err("too_many_declared_providers");
261    }
262    let mut provider_ids: HashSet<&str> = HashSet::new();
263    for provider in &deferred.providers {
264        if !valid_capability_id(&provider.id) {
265            return Err("invalid_declared_provider_id");
266        }
267        if !provider_ids.insert(provider.id.as_str()) {
268            return Err("duplicate_declared_provider_id");
269        }
270        if provider.display_name.is_empty()
271            || provider.display_name.len() > DECLARED_DISPLAY_MAX_BYTES
272            || provider.display_name.chars().any(char::is_control)
273        {
274            return Err("invalid_declared_provider_display_name");
275        }
276        if provider.description.is_empty()
277            || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
278        {
279            return Err("invalid_declared_provider_description");
280        }
281        if provider.models.is_empty() || provider.models.len() > DECLARED_MAX_PROVIDER_MODELS {
282            return Err("invalid_declared_provider_model_count");
283        }
284        let mut model_ids: HashSet<&str> = HashSet::new();
285        for model in &provider.models {
286            if !valid_capability_id(&model.id) {
287                return Err("invalid_declared_provider_model_id");
288            }
289            if !model_ids.insert(model.id.as_str()) {
290                return Err("duplicate_declared_provider_model_id");
291            }
292            if let Some(display) = &model.display_name {
293                if display.len() > DECLARED_DISPLAY_MAX_BYTES
294                    || display.chars().any(char::is_control)
295                {
296                    return Err("invalid_declared_provider_model_display_name");
297                }
298            }
299            // Capabilities: absent (null) or an object of boolean flags.
300            match &model.capabilities {
301                Value::Null => {}
302                Value::Object(map) => {
303                    for (key, value) in map {
304                        if !valid_name(key) || !value.is_boolean() {
305                            return Err("invalid_declared_provider_model_capabilities");
306                        }
307                    }
308                }
309                _ => return Err("invalid_declared_provider_model_capabilities"),
310            }
311        }
312        if let Some(config_schema) = &provider.config_schema {
313            let len = serde_json::to_vec(config_schema)
314                .map(|b| b.len())
315                .unwrap_or(usize::MAX);
316            if !config_schema.is_object() || len > DECLARED_SCHEMA_MAX_BYTES {
317                return Err("invalid_declared_provider_config_schema");
318            }
319        }
320    }
321    // Memory/context contribution providers (continuous-memory spec §7.1):
322    // bounded exactly like the other declared types; any violation fails
323    // the WHOLE manifest closed.
324    if deferred.context_providers.len() > DECLARED_MAX_CONTEXT_PROVIDERS {
325        return Err("too_many_declared_context_providers");
326    }
327    let mut context_provider_ids: HashSet<&str> = HashSet::new();
328    for provider in &deferred.context_providers {
329        // The validated newtype is the single id policy source: non-empty,
330        // bounded, ASCII-safe, no ':' (runtime address composability).
331        if super::context_provider::ContextProviderId::parse(&provider.id).is_err() {
332            return Err("invalid_declared_context_provider_id");
333        }
334        if !context_provider_ids.insert(provider.id.as_str()) {
335            return Err("duplicate_declared_context_provider_id");
336        }
337        if !valid_name(&provider.capability) {
338            return Err("invalid_declared_context_provider_capability");
339        }
340        if provider.description.is_empty()
341            || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
342        {
343            return Err("invalid_declared_context_provider_description");
344        }
345        if provider.schema_version == 0 {
346            return Err("invalid_declared_context_provider_schema_version");
347        }
348    }
349    // Lifecycle conflict policy: `user` means user-triggered ONLY — it must
350    // never silently cover active tool/provider capabilities.
351    if deferred.lifecycle == Some(DeferredLifecycle::User)
352        && (!deferred.tools.is_empty() || !deferred.providers.is_empty())
353    {
354        return Err("user_lifecycle_conflicts_with_active_capabilities");
355    }
356    Ok(())
357}
358
359/// Manifest-level coupling checks for a `deferred` block (review fix A1/
360/// A2). Passive declarations are CAPABILITY claims: cataloging a dormant
361/// tool descriptor (or provider metadata) grants the extension future
362/// runtime reach, so it MUST be backed by the exact permission the live
363/// registration would require — otherwise a manifest could obtain
364/// discoverable/activatable surface it was never authorized to register.
365/// Likewise `lifecycle = "hook"` without a real manifest hook subscription
366/// has NO authorized trigger and can never legitimately start: it fails
367/// closed here instead of lingering as unreachable-but-cataloged state.
368///
369/// Runs inside `ExtensionManifest::validate` — i.e. strictly BEFORE any
370/// spawn or catalog registration on every load path.
371pub fn validate_manifest_deferred(manifest: &ExtensionManifest) -> Result<(), &'static str> {
372    let Some(deferred) = &manifest.deferred else {
373        return Ok(());
374    };
375    validate_deferred(deferred)?;
376    let has_permission = |wire: &str| manifest.permissions.iter().any(|p| p == wire);
377    if !deferred.tools.is_empty() && !has_permission("tools.register") {
378        return Err("deferred_tools_require_tools_register_permission");
379    }
380    if !deferred.providers.is_empty() && !has_permission("providers.register") {
381        return Err("deferred_providers_require_providers_register_permission");
382    }
383    // Mirror of the provider gate above for memory/context contribution
384    // providers (continuous-memory spec §7.1): dormant descriptor metadata
385    // still grants future runtime reach, so it MUST be backed by the exact
386    // permission the eventual lease-backed registration would require.
387    if !deferred.context_providers.is_empty() && !has_permission("context_providers.register") {
388        return Err("deferred_context_providers_require_context_providers_register_permission");
389    }
390    if deferred.lifecycle == Some(DeferredLifecycle::Hook) && manifest.hooks.is_empty() {
391        return Err("hook_lifecycle_requires_hook_subscriptions");
392    }
393    Ok(())
394}
395
396/// Shared bounded shape check for a tool identity (declared OR runtime
397/// registered). Declared tool names additionally must not contain ':'
398/// so the `<plugin>:<tool>` runtime name stays reverse-parseable.
399fn validate_tool_shape(
400    name: &str,
401    description: &str,
402    input_schema: &Value,
403) -> Result<(), &'static str> {
404    if !valid_capability_id(name) {
405        return Err("invalid_declared_tool_name");
406    }
407    // Non-empty required: the runtime's initialize validation refuses
408    // empty descriptions, so an empty declaration could never match a
409    // live registration — fail closed at manifest time instead.
410    if description.trim().is_empty() || description.len() > DECLARED_DESCRIPTION_MAX_BYTES {
411        return Err("invalid_declared_tool_description");
412    }
413    if !input_schema.is_object() {
414        return Err("declared_tool_schema_not_object");
415    }
416    let schema_len = serde_json::to_vec(input_schema)
417        .map(|b| b.len())
418        .unwrap_or(usize::MAX);
419    if schema_len > DECLARED_SCHEMA_MAX_BYTES {
420        return Err("oversized_declared_tool_schema");
421    }
422    Ok(())
423}
424
425/// Strict initialize-time declaration check: the runtime's registered tool
426/// specs must match the manifest's passive declarations EXACTLY — same
427/// name set (no missing, no undeclared extras) and identical canonical
428/// schema digests. Static reasons only; callers shut the child down and
429/// fail closed on any mismatch, before any registration or grant.
430pub fn validate_runtime_tool_declarations(
431    declared: &[DeclaredExtensionTool],
432    registered: &[RegisteredExtensionToolSpec],
433) -> Result<(), &'static str> {
434    if declared.len() != registered.len() {
435        return Err("declared_and_registered_tool_counts_differ");
436    }
437    for spec in registered {
438        // The runtime's own declaration must pass the same bounded shape
439        // policy BEFORE any comparison is trusted.
440        validate_tool_shape(&spec.name, &spec.description, &spec.input_schema)?;
441    }
442    for decl in declared {
443        let Some(live) = registered.iter().find(|spec| spec.name == decl.name) else {
444            return Err("declared_tool_not_registered");
445        };
446        if SchemaDigest::of_schema(&live.input_schema)
447            != SchemaDigest::of_schema(&decl.input_schema)
448        {
449            return Err("registered_tool_schema_digest_mismatch");
450        }
451        // Full-match policy: descriptions are part of the declared
452        // contract (model-visible prompt surface), so they must be equal.
453        if live.description != decl.description {
454            return Err("registered_tool_description_mismatch");
455        }
456    }
457    // Equal counts + every declared found => no undeclared extras (names
458    // are unique per validate_deferred; duplicates in `registered` would
459    // fail the count/find pass above).
460    let mut live_names: HashSet<&str> = HashSet::new();
461    for spec in registered {
462        if !live_names.insert(spec.name.as_str()) {
463            return Err("registered_tool_names_duplicate");
464        }
465    }
466    Ok(())
467}
468
469/// Strict initialize-time PROVIDER declaration check (Task 20 Commit B):
470/// the runtime's registered provider specs must match the manifest's
471/// passive declarations EXACTLY — same id set, display names,
472/// descriptions, model id sets, per-model display/context-window/
473/// capability values, and config schemas as declared. A manifest that
474/// declares NO providers must see NO registered providers (a tool-only
475/// runtime sneaking provider metadata in is an undeclared capability).
476/// Static reasons only; callers shut the child down and fail closed.
477pub fn validate_runtime_provider_declarations(
478    declared: &[DeclaredExtensionProvider],
479    registered: &[crate::extensions::runtime::process::RegisteredProviderSpec],
480) -> Result<(), &'static str> {
481    if declared.len() != registered.len() {
482        return Err("declared_and_registered_provider_counts_differ");
483    }
484    let mut live_ids: HashSet<&str> = HashSet::new();
485    for spec in registered {
486        if !live_ids.insert(spec.id.as_str()) {
487            return Err("registered_provider_ids_duplicate");
488        }
489    }
490    for decl in declared {
491        let Some(live) = registered.iter().find(|spec| spec.id == decl.id) else {
492            return Err("declared_provider_not_registered");
493        };
494        if live.display_name != decl.display_name {
495            return Err("registered_provider_display_name_mismatch");
496        }
497        if live.description != decl.description {
498            return Err("registered_provider_description_mismatch");
499        }
500        if live.config_schema != decl.config_schema {
501            return Err("registered_provider_config_schema_mismatch");
502        }
503        if live.models.len() != decl.models.len() {
504            return Err("registered_provider_model_counts_differ");
505        }
506        let mut live_model_ids: HashSet<&str> = HashSet::new();
507        for model in &live.models {
508            if !live_model_ids.insert(model.id.as_str()) {
509                return Err("registered_provider_model_ids_duplicate");
510            }
511        }
512        for declared_model in &decl.models {
513            let Some(live_model) = live.models.iter().find(|m| m.id == declared_model.id) else {
514                return Err("declared_provider_model_not_registered");
515            };
516            if live_model.display_name != declared_model.display_name {
517                return Err("registered_provider_model_display_name_mismatch");
518            }
519            if live_model.context_window != declared_model.context_window {
520                return Err("registered_provider_model_context_window_mismatch");
521            }
522            if live_model.capabilities != declared_model.capabilities {
523                return Err("registered_provider_model_capabilities_mismatch");
524            }
525        }
526    }
527    Ok(())
528}
529
530impl DeclaredExtensionProvider {
531    /// Convert a VALIDATED passive declaration into the runtime's
532    /// `RegisteredProviderSpec` shape for metadata-only registration
533    /// (Task 20 Commit C). Field-for-field: routing later re-validates the
534    /// live runtime's registrations against these same declarations.
535    pub fn to_registered_spec(
536        &self,
537    ) -> crate::extensions::runtime::process::RegisteredProviderSpec {
538        crate::extensions::runtime::process::RegisteredProviderSpec {
539            id: self.id.clone(),
540            display_name: self.display_name.clone(),
541            description: self.description.clone(),
542            models: self
543                .models
544                .iter()
545                .map(
546                    |model| crate::extensions::runtime::process::RegisteredProviderModelSpec {
547                        id: model.id.clone(),
548                        display_name: model.display_name.clone(),
549                        capabilities: model.capabilities.clone(),
550                        context_window: model.context_window,
551                    },
552                )
553                .collect(),
554            config_schema: self.config_schema.clone(),
555        }
556    }
557}
558
559/// A dormant, manifest-declared extension tool. Registered like any live
560/// tool (stable `ext:<plugin>:<name>` identity, schema, digest — so wire
561/// resolution, discovery, exact grants, and the execution gate all work
562/// unchanged), but its implementation is DEFERRED: executing it requires
563/// the session extension-runtime lease capability (lease lifecycle
564/// commit), which starts only the OWNING extension after the execution
565/// gate has already authorized the call. Without that capability it fails
566/// typed and spawns nothing.
567pub struct DeferredExtensionTool {
568    plugin_id: String,
569    runtime_name: String,
570    tool_name: String,
571    description: String,
572    input_schema: Value,
573    expected_digest: SchemaDigest,
574}
575
576impl DeferredExtensionTool {
577    fn new(plugin_id: &str, declared: &DeclaredExtensionTool) -> Self {
578        Self {
579            plugin_id: plugin_id.to_string(),
580            // Naming parity with live registration ("<plugin>:<tool>").
581            runtime_name: format!("{}:{}", plugin_id, declared.name),
582            tool_name: declared.name.clone(),
583            description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
584                .text,
585            input_schema: declared.input_schema.clone(),
586            expected_digest: SchemaDigest::of_schema(&declared.input_schema),
587        }
588    }
589
590    pub fn plugin_id(&self) -> &str {
591        &self.plugin_id
592    }
593
594    pub fn tool_name(&self) -> &str {
595        &self.tool_name
596    }
597
598    /// Digest of the pinned declaration — equal by construction to this
599    /// tool's catalog record digest.
600    pub fn expected_digest(&self) -> &SchemaDigest {
601        &self.expected_digest
602    }
603}
604
605#[async_trait::async_trait]
606impl Tool for DeferredExtensionTool {
607    fn origin(&self) -> ToolOrigin {
608        ToolOrigin::Extension {
609            extension_id: self.plugin_id.clone(),
610        }
611    }
612
613    fn source_operation(&self) -> Option<&str> {
614        Some(&self.tool_name)
615    }
616
617    fn name(&self) -> &str {
618        &self.runtime_name
619    }
620
621    fn description(&self) -> &str {
622        &self.description
623    }
624
625    fn parameters(&self) -> Value {
626        self.input_schema.clone()
627    }
628
629    async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
630        // Runs strictly AFTER the ExecutionGate authorized this exact call.
631        let binding = ctx
632            .capabilities
633            .memory_backend
634            .clone()
635            .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
636        if binding.exclusive() && crate::memory_backend::competing_memory_tool(self) {
637            return Err(crate::RuntimeError::Tool("independent extension memory is disabled by the selected host backend; use short memory tools".into()));
638        }
639        // Without the typed session lease capability, a deferred extension
640        // tool NEVER starts a process.
641        let Some(leases) = ctx.capabilities.extension_leases.clone() else {
642            return Err(crate::RuntimeError::Tool(format!(
643                "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
644                 runtime lease capability is available in this context; no process was started",
645                self.runtime_name, self.plugin_id
646            )));
647        };
648        match leases
649            .call_exact_for(
650                &self.plugin_id,
651                &self.tool_name,
652                &self.expected_digest,
653                params,
654                ctx.capabilities.session_id.as_deref(),
655            )
656            .await
657        {
658            Ok(value) => Ok(render_tool_result(&value)),
659            Err(err) => {
660                // Grant invalidation (spec §7.5): record removal, manifest/
661                // permission re-validation failure, launch-record (config)
662                // drift, catalog drift, and runtime declaration mismatches
663                // poison the pinned declaration, so the EXACT session grant
664                // must fall with the lease. Transport/capacity/revocation-
665                // race and extension-reported tool errors are transient and
666                // must NOT revoke.
667                if err.revokes_exact_grant() {
668                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
669                        if activation.revoke_exact_extension_grant(
670                            leases.session(),
671                            &self.plugin_id,
672                            &self.tool_name,
673                        ) {
674                            tracing::debug!(
675                                "revoked exact extension activation after declaration invalidation"
676                            );
677                        }
678                    }
679                }
680                Err(crate::RuntimeError::Tool(err.to_string()))
681            }
682        }
683    }
684}
685
686/// Render an extension tool-call result value exactly like the eager
687/// `ExtensionTool` path: plain string, `content` string field, or the
688/// serialized value.
689fn render_tool_result(value: &Value) -> String {
690    if let Some(text) = value.as_str() {
691        text.to_string()
692    } else if let Some(text) = value.get("content").and_then(Value::as_str) {
693        text.to_string()
694    } else {
695        value.to_string()
696    }
697}
698
699impl DeclaredExtensionTool {
700    /// Stable canonical schema digest of this declaration. VISIBILITY-ONLY
701    /// metadata (diagnostics/inventory); grants and gate checks always
702    /// recompute from catalog records, never trust this accessor.
703    pub fn schema_digest(&self) -> SchemaDigest {
704        SchemaDigest::of_schema(&self.input_schema)
705    }
706}
707
708/// Build the dormant deferred tools for one manifest. Deterministic
709/// (declaration order); returns nothing for manifests without validated
710/// declarations — descriptors are never invented. Never spawns.
711pub fn dormant_extension_tools(
712    plugin_id: &str,
713    manifest: &ExtensionManifest,
714) -> Vec<Arc<dyn Tool>> {
715    // Plugin ids with ':' would break `<plugin>:<tool>` reverse identity.
716    if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
717        return Vec::new();
718    }
719    let Some(deferred) = &manifest.deferred else {
720        return Vec::new();
721    };
722    // Full manifest-level policy (bounds + permission/hook coupling), not
723    // just block bounds: dormant descriptors minted for a manifest that
724    // lacks `tools.register` would be an unauthorized catalog grant.
725    if validate_manifest_deferred(manifest).is_err() {
726        return Vec::new();
727    }
728    deferred
729        .tools
730        .iter()
731        .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
732        .collect()
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use serde_json::json;
739
740    fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
741        serde_json::from_value(json!({
742            "runtime": "process",
743            "command": "/bin/false",
744            "permissions": permissions,
745        }))
746        .unwrap()
747    }
748
749    fn declared_tool(name: &str) -> DeclaredExtensionTool {
750        DeclaredExtensionTool {
751            name: name.to_string(),
752            description: format!("declared {name}"),
753            input_schema: json!({"type": "object"}),
754        }
755    }
756
757    fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
758        DeclaredExtensionContextProvider {
759            id: id.to_string(),
760            capability: "project-memory".to_string(),
761            description: "Project memory context contributions".to_string(),
762            schema_version: 1,
763        }
764    }
765
766    fn with_context_providers(
767        permissions: &[&str],
768        context_providers: Vec<DeclaredExtensionContextProvider>,
769    ) -> ExtensionManifest {
770        let mut m = base_manifest(permissions);
771        m.deferred = Some(DeferredDeclarations {
772            context_providers,
773            ..DeferredDeclarations::default()
774        });
775        m
776    }
777
778    // ── regression proof: empty/absent context_providers change NOTHING ─
779
780    #[test]
781    fn a_deferred_extension_tool_names_its_registered_operation() {
782        let tool = DeferredExtensionTool::new("plug", &declared_tool("memory_fetch"));
783        assert_eq!(tool.name(), "plug:memory_fetch");
784        assert_eq!(tool.source_operation(), Some("memory_fetch"));
785    }
786
787    #[test]
788    fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
789        // No deferred block at all.
790        let legacy = base_manifest(&["tools.register"]);
791        assert!(legacy.deferred.is_none());
792        assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
793        assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
794        legacy.validate("legacy").unwrap();
795
796        // Empty deferred block (context_providers defaulted empty).
797        let mut empty = base_manifest(&["tools.register"]);
798        empty.deferred = Some(DeferredDeclarations::default());
799        assert!(empty
800            .deferred
801            .as_ref()
802            .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
803        assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
804        assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
805        empty.validate("empty").unwrap();
806
807        // A deferred block WITHOUT the context_providers key deserializes
808        // to the empty default (additive/byte-compatible) and does not
809        // reappear on serialization.
810        let d: DeferredDeclarations = serde_json::from_value(json!({
811            "tools": [], "providers": []
812        }))
813        .unwrap();
814        assert!(d.context_providers.is_empty());
815        let round = serde_json::to_value(&d).unwrap();
816        assert!(round.get("context_providers").is_none());
817
818        // Pre-existing classes are untouched when context_providers is
819        // empty: tool-only stays ToolOnly...
820        let mut tool_only = base_manifest(&["tools.register"]);
821        tool_only.deferred = Some(DeferredDeclarations {
822            tools: vec![declared_tool("t")],
823            ..DeferredDeclarations::default()
824        });
825        assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
826        // ...user lifecycle stays UiSidecar...
827        let mut sidecar = base_manifest(&["tools.register"]);
828        sidecar.deferred = Some(DeferredDeclarations {
829            lifecycle: Some(DeferredLifecycle::User),
830            ..DeferredDeclarations::default()
831        });
832        assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
833        // ...and hook subscriptions stay HookLifecycle.
834        let mut hooky: ExtensionManifest = serde_json::from_value(json!({
835            "runtime": "process",
836            "command": "/bin/false",
837            "permissions": ["tools.intercept"],
838            "hooks": [{"hook": "before_tool_call"}]
839        }))
840        .unwrap();
841        hooky.deferred = Some(DeferredDeclarations::default());
842        assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
843    }
844
845    // ── context-provider-only classification + permission gate ──────────
846
847    #[test]
848    fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
849        let m = with_context_providers(
850            &["context_providers.register"],
851            vec![declared_context_provider("project-memory")],
852        );
853        assert!(m.deferred.as_ref().is_some_and(has_context_provider));
854        assert_eq!(classify(&m), ExtensionClass::ContextProvider);
855        // Dormant until an explicit user/host action grants a lease —
856        // never eager, never tool-search/hook/provider triggered.
857        assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
858        m.validate("axel-memory-manager").unwrap();
859
860        // WITHOUT the exact permission the whole manifest fails closed.
861        let ungated = with_context_providers(
862            &["tools.register"],
863            vec![declared_context_provider("project-memory")],
864        );
865        let err = ungated.validate("axel-memory-manager").unwrap_err();
866        assert!(
867            err.contains(
868                "deferred_context_providers_require_context_providers_register_permission"
869            ),
870            "{err}"
871        );
872        // The model/LLM provider permission does NOT cover it.
873        let wrong_gate = with_context_providers(
874            &["providers.register"],
875            vec![declared_context_provider("project-memory")],
876        );
877        assert!(wrong_gate.validate("axel-memory-manager").is_err());
878    }
879
880    #[test]
881    fn mixed_combinations_with_context_providers_still_classify_mixed() {
882        // tools + context providers => Mixed (both permissions required).
883        let mut m = with_context_providers(
884            &["tools.register", "context_providers.register"],
885            vec![declared_context_provider("project-memory")],
886        );
887        m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
888        assert_eq!(classify(&m), ExtensionClass::Mixed);
889        m.validate("plug").unwrap();
890        // Tool activation stays the Mixed trigger — context providers
891        // never advance a Mixed extension's earliest trigger.
892        assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
893    }
894
895    // ── bounds: fail the WHOLE manifest closed ─────────────────────────
896
897    #[test]
898    fn too_many_declared_context_providers_are_rejected() {
899        let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
900            .map(|i| declared_context_provider(&format!("p{i}")))
901            .collect();
902        with_context_providers(&["context_providers.register"], at_cap)
903            .validate("plug")
904            .unwrap();
905
906        let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
907            .map(|i| declared_context_provider(&format!("p{i}")))
908            .collect();
909        let m = with_context_providers(&["context_providers.register"], over);
910        assert_eq!(
911            validate_deferred(m.deferred.as_ref().unwrap()),
912            Err("too_many_declared_context_providers")
913        );
914        assert!(m.validate("plug").is_err());
915    }
916
917    #[test]
918    fn oversized_or_hostile_context_provider_fields_are_rejected() {
919        let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
920            let m = with_context_providers(&["context_providers.register"], vec![p]);
921            assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
922            assert!(m.validate("plug").is_err());
923        };
924        // Oversized id (one byte over the declared-name budget).
925        check(
926            declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
927            "invalid_declared_context_provider_id",
928        );
929        // Empty and colon ids fail through the same newtype policy.
930        check(
931            declared_context_provider(""),
932            "invalid_declared_context_provider_id",
933        );
934        check(
935            declared_context_provider("has:colon"),
936            "invalid_declared_context_provider_id",
937        );
938        // Oversized / empty / control-char capability.
939        let mut p = declared_context_provider("p");
940        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
941        check(p, "invalid_declared_context_provider_capability");
942        let mut p = declared_context_provider("p");
943        p.capability = String::new();
944        check(p, "invalid_declared_context_provider_capability");
945        let mut p = declared_context_provider("p");
946        p.capability = "ctrl\u{7}".to_string();
947        check(p, "invalid_declared_context_provider_capability");
948        // Oversized / empty description.
949        let mut p = declared_context_provider("p");
950        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
951        check(p, "invalid_declared_context_provider_description");
952        let mut p = declared_context_provider("p");
953        p.description = String::new();
954        check(p, "invalid_declared_context_provider_description");
955        // Zero schema version.
956        let mut p = declared_context_provider("p");
957        p.schema_version = 0;
958        check(p, "invalid_declared_context_provider_schema_version");
959        // Duplicate ids.
960        let m = with_context_providers(
961            &["context_providers.register"],
962            vec![
963                declared_context_provider("dup"),
964                declared_context_provider("dup"),
965            ],
966        );
967        assert_eq!(
968            validate_deferred(m.deferred.as_ref().unwrap()),
969            Err("duplicate_declared_context_provider_id")
970        );
971        // Exact-cap fields pass (bounds are exact, not fuzzy).
972        let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
973        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
974        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
975        with_context_providers(&["context_providers.register"], vec![p])
976            .validate("plug")
977            .unwrap();
978    }
979
980    // ── dormant descriptors: fail closed without the permission ─────────
981
982    #[test]
983    fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
984        use super::super::context_provider::dormant_context_provider_descriptors;
985        let m = with_context_providers(
986            &["context_providers.register"],
987            vec![declared_context_provider("project-memory")],
988        );
989        let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
990        assert_eq!(descriptors.len(), 1);
991        assert_eq!(
992            descriptors[0].runtime_address(),
993            "extension:axel-memory-manager:project-memory"
994        );
995        assert_eq!(descriptors[0].schema_version(), 1);
996
997        // Missing permission => NOTHING is minted (unauthorized dormant
998        // metadata would still be future runtime reach).
999        let ungated = with_context_providers(
1000            &["tools.register"],
1001            vec![declared_context_provider("project-memory")],
1002        );
1003        assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
1004        // Colon plugin ids would break the composed runtime address.
1005        assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
1006        // No deferred block => nothing.
1007        assert!(
1008            dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
1009                .is_empty()
1010        );
1011    }
1012
1013    // ── T291 D4: manifest-time ids must be validated by the SAME function
1014    // the runtime uses at initialize, or names pass here and die there.
1015
1016    /// Every name that manifest validation accepts for a declared tool MUST
1017    /// also be accepted by the runtime's `validate_id_segment`, and vice
1018    /// versa. This is the anti-drift invariant; the two checks used to
1019    /// disagree on length (128 vs 64) and on whitespace.
1020    #[test]
1021    fn declared_capability_ids_agree_with_runtime_validation() {
1022        let cases = [
1023            "ok",
1024            "with-dash",
1025            "with_underscore",
1026            "MiXedCase123",
1027            "",
1028            "has space",
1029            "has\ttab",
1030            "colon:name",
1031            "trailing ",
1032            &"a".repeat(super::super::validation::MAX_ID_LENGTH),
1033            &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
1034            &"a".repeat(DECLARED_NAME_MAX_BYTES),
1035        ];
1036        for name in cases {
1037            let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
1038            let runtime_ok = validate_id_segment(name).is_ok();
1039            assert_eq!(
1040                manifest_ok, runtime_ok,
1041                "manifest/runtime disagree on declared tool name {name:?}: \
1042                 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
1043            );
1044        }
1045    }
1046
1047    /// The exact defect from the S287 audit: a 65-byte name sat in the gap
1048    /// between DECLARED_NAME_MAX_BYTES (128) and MAX_ID_LENGTH (64). It
1049    /// passed manifest validation and then failed at initialize with an
1050    /// unrelated error. It must now fail early, at the manifest.
1051    #[test]
1052    fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
1053        let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
1054        assert_eq!(
1055            validate_deferred(&DeferredDeclarations {
1056                tools: vec![declared_tool(&name)],
1057                ..Default::default()
1058            }),
1059            Err("invalid_declared_tool_name"),
1060        );
1061        // ...and it is genuinely inside the old accepted range, i.e. this
1062        // test would have failed before the fix.
1063        assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
1064    }
1065
1066    /// Whitespace was the second, unfiled half of the same defect.
1067    #[test]
1068    fn declared_capability_ids_reject_whitespace_at_manifest() {
1069        assert_eq!(
1070            validate_deferred(&DeferredDeclarations {
1071                tools: vec![declared_tool("my tool")],
1072                ..Default::default()
1073            }),
1074            Err("invalid_declared_tool_name"),
1075        );
1076    }
1077
1078    // ── an exclusive memory binding refuses aliased memory tools ────────
1079
1080    const MEMORY_REFUSAL: &str = "independent extension memory is disabled by the selected host backend; use short memory tools";
1081
1082    async fn call_deferred(
1083        plugin: &str,
1084        operation: &str,
1085        kind: agent_core::config::MemoryBackendKind,
1086    ) -> String {
1087        let dir = tempfile::tempdir().unwrap();
1088        let binding = crate::tools::test_helpers::memory_binding(kind, dir.path());
1089        let tool = DeferredExtensionTool::new(plugin, &declared_tool(operation));
1090        match tool
1091            .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
1092            .await
1093        {
1094            Err(crate::RuntimeError::Tool(message)) => message,
1095            other => panic!("expected a tool error, got {other:?}"),
1096        }
1097    }
1098
1099    /// Regression: `memory:search` is offered as `memory_search`, yet only
1100    /// its operation `search` was checked.
1101    #[tokio::test]
1102    async fn an_exclusive_binding_refuses_deferred_extension_memory_aliases() {
1103        use agent_core::config::MemoryBackendKind::Axel;
1104        for (plugin, operation) in [
1105            ("memory", "search"),
1106            ("memory", "fetch"),
1107            ("Memory", "Search"),
1108            ("plug", "memory.store"),
1109            ("plug", "MEMORY_STORE"),
1110            ("axel-memory-manager", "memory_search"),
1111            ("axel-memory-manager", "memory_forget"),
1112        ] {
1113            assert_eq!(
1114                call_deferred(plugin, operation, Axel).await,
1115                MEMORY_REFUSAL,
1116                "{plugin}:{operation}"
1117            );
1118        }
1119    }
1120
1121    /// The suffixed wire name the model sees beside the host's own tool
1122    /// reaches the deferred alias, which refuses.
1123    #[tokio::test]
1124    async fn the_suffixed_wire_name_of_a_deferred_alias_is_refused() {
1125        let dir = tempfile::tempdir().unwrap();
1126        let registry = crate::tools::ToolRegistry::from_tools_for_tests(vec![
1127            Arc::new(crate::tools::memory::MemorySearchTool),
1128            Arc::new(DeferredExtensionTool::new("memory", &declared_tool("search"))),
1129        ]);
1130        assert_eq!(registry.runtime_name_for_api("memory_search"), "memory_search");
1131        assert_eq!(registry.runtime_name_for_api("memory_search_2"), "memory:search");
1132        let alias = registry
1133            .get(registry.runtime_name_for_api("memory_search_2"))
1134            .expect("alias registered");
1135        let binding = crate::tools::test_helpers::memory_binding(
1136            agent_core::config::MemoryBackendKind::Axel,
1137            dir.path(),
1138        );
1139        match alias
1140            .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
1141            .await
1142        {
1143            Err(crate::RuntimeError::Tool(message)) => assert_eq!(message, MEMORY_REFUSAL),
1144            other => panic!("expected the memory refusal, got {other:?}"),
1145        }
1146    }
1147
1148    /// A nested `:` (`plug:memory:fetch`) cannot reach a deferred tool: the
1149    /// manifest refuses `:` in declared tool names, and a plugin id with `:`
1150    /// gets no deferred tools. The classifier still covers it for other
1151    /// routes (`memory_backend::competing`).
1152    #[test]
1153    fn a_nested_colon_name_never_becomes_a_deferred_tool() {
1154        assert_eq!(
1155            validate_deferred(&DeferredDeclarations {
1156                tools: vec![declared_tool("memory:fetch")],
1157                ..Default::default()
1158            }),
1159            Err("invalid_declared_tool_name"),
1160        );
1161        let mut manifest = base_manifest(&["tools.register"]);
1162        manifest.deferred = Some(DeferredDeclarations {
1163            tools: vec![declared_tool("fetch")],
1164            ..DeferredDeclarations::default()
1165        });
1166        assert_eq!(dormant_extension_tools("plug", &manifest).len(), 1);
1167        assert!(dormant_extension_tools("plug:memory", &manifest).is_empty());
1168    }
1169
1170    /// Other tools, and every tool without an exclusive binding, go on to
1171    /// the lease check exactly as before (no lease here, so nothing starts).
1172    #[tokio::test]
1173    async fn other_deferred_tools_and_legacy_bindings_reach_the_lease_check() {
1174        use agent_core::config::MemoryBackendKind::{Axel, Legacy};
1175        for (plugin, operation, kind) in [
1176            ("weather", "search", Axel),
1177            ("memory", "summarize", Axel),
1178            ("memory", "search", Legacy),
1179            ("axel-memory-manager", "memory_search", Legacy),
1180        ] {
1181            let message = call_deferred(plugin, operation, kind).await;
1182            assert!(
1183                message.contains("is activation-deferred"),
1184                "{plugin}:{operation}: {message}"
1185            );
1186        }
1187    }
1188}