Skip to main content

everruns_core/capabilities/
skills.rs

1// Skills Capability (built-in)
2//
3// Generic mechanism for filesystem-based skill discovery and activation.
4// When enabled on an agent, provides:
5// - System prompt explaining the skills system
6// - `list_skills` tool: scans /.agents/skills/ in session VFS
7// - `activate_skill` tool: loads SKILL.md instructions from VFS
8//
9// This is the built-in "skills" capability. It does NOT ship with any skills.
10// Users upload SKILL.md files to /.agents/skills/{name}/SKILL.md in the
11// session filesystem, and the agent discovers them at runtime.
12//
13// Database-registered skills are attached via AttachSkillCapability, which
14// mounts skill files into the VFS so this capability discovers them.
15//
16// COMMAND-SUBSTITUTION TRUST GATE (see also `specs/skills-registry.md`
17// "Activation Substitution Pipeline" and threat-model entry TM-TOOL-020):
18// SKILL.md may contain ``!`command` `` placeholders that, when expanded by
19// `preprocess_command_injections`, spawn a shell on the worker host. That is
20// RCE if the SKILL.md was authored by an untrusted party. There is currently
21// no platform-controlled provenance signal on `SessionFile` that proves a
22// SKILL.md was placed by the capability/registry mount layer as opposed to by
23// a user-facing path (session-files API, agent/session `initial_files`,
24// runtime `write_file`). In particular, `SessionFile::is_readonly` is NOT
25// such a signal: both the session-files API and `InitialFile` accept
26// `is_readonly = true` from user input.
27//
28// Therefore the trust gate in `ActivateSkillFromVfsTool::execute_with_context`
29// is conservative: `is_trusted_source = false` for every source, so
30// `preprocess_command_injections` is never invoked at runtime. This preserves
31// PR #1449's RCE fix while keeping the execution pipeline wired up
32// (`ProcessCommandExecutor`, `preprocess_command_injections`, unit tests) so a
33// follow-up can flip the gate once a non-user-spoofable provenance signal
34// (e.g. a `mount_capability_id` column populated only by mount application
35// code) is added to `SessionFile` / `SessionFileSystem`. See EVE-388.
36//
37// Re-enable must ALSO replace `ProcessCommandExecutor` (which spawns worker-host
38// `bash -c`) with a session-sandbox-backed executor: execution MUST run against
39// the bashkit shell (managed session sandbox) and the session virtual
40// filesystem, not the worker host. Adding provenance alone would still be RCE
41// against the worker. See threat-model TM-TOOL-020 step 6.
42
43use super::{Capability, CapabilityLocalization, CapabilityStatus, SystemPromptContext};
44use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolDefinition, ToolHints, ToolPolicy};
45use crate::tools::{Tool, ToolExecutionResult};
46use crate::traits::ToolContext;
47use async_trait::async_trait;
48use serde_json::Value;
49
50/// Skills capability ID (built-in)
51pub const SKILLS_CAPABILITY_ID: &str = "skills";
52
53/// Path in session VFS where skills are discovered (reuse shared constant)
54use super::attach_skill::SKILLS_DISCOVERY_PATH as SKILLS_PATH;
55
56/// Kind tag used when registering activated skills in the session resource registry.
57/// Idempotence for `activate_skill` (EVE-337) is implemented by recording each activation
58/// under `resource_id = "skill_activation:{name}"` and reusing its metadata on re-entry.
59const SKILL_ACTIVATION_KIND: &str = "skill_activation";
60
61fn skill_activation_resource_id(name: &str) -> String {
62    format!("{SKILL_ACTIVATION_KIND}:{name}")
63}
64
65/// Max skills to include in the system prompt (rest via list_skills tool)
66const MAX_SKILLS_IN_PROMPT: usize = 15;
67
68/// Max skill directories to scan when building the system prompt.
69/// Bounds per-turn filesystem reads to avoid prompt-build DoS.
70const MAX_SKILLS_SCAN_IN_PROMPT: usize = 64;
71
72/// Max description length in system prompt (truncated with "…")
73const MAX_DESCRIPTION_CHARS: usize = 76;
74
75/// Workspace prefix for agent-facing paths (matches file_system capability convention)
76const WORKSPACE_PREFIX: &str = "/workspace";
77
78/// Truncate a string to `max_chars`, appending "…" if truncated.
79/// Splits on the nearest char boundary at or before `max_chars`.
80fn truncate_description(s: &str, max_chars: usize) -> String {
81    if s.chars().count() <= max_chars {
82        return s.to_string();
83    }
84    let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
85    format!("{}…", truncated.trim_end())
86}
87
88/// Add workspace prefix for display paths shown to the agent
89fn workspace_path(path: &str) -> String {
90    if path.starts_with('/') {
91        format!("{}{}", WORKSPACE_PREFIX, path)
92    } else {
93        format!("{}/{}", WORKSPACE_PREFIX, path)
94    }
95}
96
97/// Built-in Skills Discovery Capability
98///
99/// Provides the generic skills mechanism. No skills are bundled — users upload
100/// SKILL.md files to `/.agents/skills/{name}/SKILL.md` in the session VFS.
101pub struct SkillsCapability;
102
103/// Static skills system prompt (used by sync callers and as fallback)
104const SKILLS_SYSTEM_PROMPT: &str = "Skills location: `/workspace/.agents/skills/{skill-name}/SKILL.md`. \
105Only activate skills that are relevant to the current task.";
106
107#[async_trait]
108impl Capability for SkillsCapability {
109    fn id(&self) -> &str {
110        SKILLS_CAPABILITY_ID
111    }
112
113    fn name(&self) -> &str {
114        "Agent Skills"
115    }
116
117    fn description(&self) -> &str {
118        r#"Discover and activate skills from the session filesystem.
119
120Skills are instruction packages (SKILL.md files) that teach the agent new abilities. Upload skills to `/workspace/.agents/skills/{name}/SKILL.md` and the agent will discover them automatically.
121
122> [!TIP]
123> Use the `list_skills` tool to see available skills, then `activate_skill` to load one."#
124    }
125
126    fn localizations(&self) -> Vec<CapabilityLocalization> {
127        vec![CapabilityLocalization::text(
128            "uk",
129            "Навички агента",
130            r#"Виявляйте та активуйте навички з файлової системи сесії.
131
132Навички — це пакети інструкцій (файли SKILL.md), які навчають агента нових умінь. Завантажте навички до `/workspace/.agents/skills/{name}/SKILL.md`, і агент виявить їх автоматично.
133
134> [!TIP]
135> Використовуйте інструмент `list_skills`, щоб переглянути доступні навички, а потім `activate_skill`, щоб завантажити потрібну."#,
136        )]
137    }
138
139    fn status(&self) -> CapabilityStatus {
140        CapabilityStatus::Available
141    }
142
143    fn icon(&self) -> Option<&str> {
144        Some("wand")
145    }
146
147    fn category(&self) -> Option<&str> {
148        Some("Core")
149    }
150
151    fn system_prompt_addition(&self) -> Option<&str> {
152        Some(SKILLS_SYSTEM_PROMPT)
153    }
154
155    /// Dynamically discovers skills from the session filesystem and includes
156    /// them in the system prompt.
157    ///
158    /// When a file store is available, scans `/.agents/skills/` for SKILL.md
159    /// files and lists discovered skills directly in the prompt. Falls back
160    /// to the static prompt when no file store is available.
161    async fn system_prompt_contribution(&self, ctx: &SystemPromptContext) -> Option<String> {
162        let file_store = match ctx.file_store.as_ref() {
163            Some(fs) => fs,
164            None => {
165                // No file store — fall back to static prompt with capability wrapping
166                return Some(format!(
167                    "<capability id=\"{}\">\n{}\n</capability>",
168                    self.id(),
169                    SKILLS_SYSTEM_PROMPT
170                ));
171            }
172        };
173
174        // Scan /.agents/skills/ for SKILL.md files
175        let entries = match file_store.list_directory(ctx.session_id, SKILLS_PATH).await {
176            Ok(entries) => entries,
177            Err(_) => {
178                // Directory doesn't exist — use static prompt
179                return Some(format!(
180                    "<capability id=\"{}\">\n{}\n</capability>",
181                    self.id(),
182                    SKILLS_SYSTEM_PROMPT
183                ));
184            }
185        };
186
187        // Only scan a bounded number of skill directories for prompt generation.
188        // Full discovery remains available through list_skills tool.
189        let skill_dirs: Vec<_> = entries.iter().filter(|entry| entry.is_directory).collect();
190        let scan_truncated = skill_dirs.len() > MAX_SKILLS_SCAN_IN_PROMPT;
191
192        let mut discovered_skills = Vec::new();
193        for entry in skill_dirs.iter().take(MAX_SKILLS_SCAN_IN_PROMPT) {
194            let skill_md_path = format!("{}/SKILL.md", entry.path);
195            if let Ok(Some(file)) = file_store.read_file(ctx.session_id, &skill_md_path).await {
196                let content = file.content.as_deref().unwrap_or("");
197                if let Ok(parsed) = crate::skill::parse_skill_md(content) {
198                    discovered_skills.push((
199                        parsed.name,
200                        parsed.description,
201                        parsed.user_invocable,
202                        parsed.disable_model_invocation,
203                    ));
204                }
205            }
206        }
207
208        let mut prompt = String::from(SKILLS_SYSTEM_PROMPT);
209
210        if !discovered_skills.is_empty() {
211            // Filter out skills where disable_model_invocation is true
212            let model_visible_skills: Vec<_> = discovered_skills
213                .iter()
214                .filter(|(_, _, _, disable_model)| !disable_model)
215                .collect();
216            let total = model_visible_skills.len();
217            if total > 0 {
218                prompt.push_str("\n\nAvailable skills:\n");
219            }
220            for (name, description, user_invocable, _) in
221                model_visible_skills.iter().take(MAX_SKILLS_IN_PROMPT)
222            {
223                let desc = truncate_description(description, MAX_DESCRIPTION_CHARS);
224                let invocable_hint = if *user_invocable { " (/{name})" } else { "" };
225                prompt.push_str(&format!("- **{name}**: {desc}{invocable_hint}\n"));
226            }
227            if total > MAX_SKILLS_IN_PROMPT {
228                prompt.push_str(&format!(
229                    "\n({} more skills available — use `list_skills` to see all)\n",
230                    total - MAX_SKILLS_IN_PROMPT
231                ));
232            }
233            if scan_truncated {
234                prompt.push_str(
235                    "\n(Additional skills may exist — use `list_skills` to view the full list)\n",
236                );
237            }
238        }
239
240        Some(format!(
241            "<capability id=\"{}\">\n{}\n</capability>",
242            self.id(),
243            prompt
244        ))
245    }
246
247    fn tools(&self) -> Vec<Box<dyn Tool>> {
248        vec![Box::new(ListSkillsTool), Box::new(ActivateSkillFromVfsTool)]
249    }
250
251    fn tool_definitions(&self) -> Vec<ToolDefinition> {
252        vec![
253            ToolDefinition::Builtin(BuiltinTool {
254                name: "list_skills".to_string(),
255                display_name: Some("List Skills".to_string()),
256                description: "Discover available skills from the session filesystem. \
257                    Scans /workspace/.agents/skills/ for SKILL.md files and returns their names \
258                    and descriptions."
259                    .to_string(),
260                parameters: serde_json::json!({
261                    "type": "object",
262                    "properties": {},
263                    "required": []
264                }),
265                policy: ToolPolicy::Auto,
266                category: None,
267                deferrable: DeferrablePolicy::default(),
268                hints: ToolHints::default()
269                    .with_readonly(true)
270                    .with_idempotent(true),
271                full_parameters: None,
272            }),
273            ToolDefinition::Builtin(BuiltinTool {
274                name: "activate_skill".to_string(),
275                display_name: Some("Activate Skill".to_string()),
276                description: "Activate a skill by name to load its full instructions. \
277                    The skill must exist at /workspace/.agents/skills/{name}/SKILL.md in the \
278                    session filesystem."
279                    .to_string(),
280                parameters: serde_json::json!({
281                    "type": "object",
282                    "properties": {
283                        "name": {
284                            "type": "string",
285                            "description": "The skill directory name (e.g., 'pdf-processing')"
286                        },
287                        "arguments": {
288                            "type": "string",
289                            "description": "Optional arguments to pass to the skill for $ARGUMENTS substitution"
290                        }
291                    },
292                    "required": ["name"]
293                }),
294                policy: ToolPolicy::Auto,
295                category: None,
296                deferrable: DeferrablePolicy::default(),
297                hints: ToolHints::default()
298                    .with_readonly(true)
299                    .with_idempotent(true),
300                full_parameters: None,
301            }),
302        ]
303    }
304
305    fn dependencies(&self) -> Vec<&'static str> {
306        vec!["session_file_system"]
307    }
308}
309
310// ============================================================================
311// ListSkillsTool - Discovers skills from session VFS
312// ============================================================================
313
314/// Tool that scans `/.agents/skills/` in the session VFS for SKILL.md files.
315#[derive(Debug)]
316struct ListSkillsTool;
317
318#[async_trait]
319impl Tool for ListSkillsTool {
320    fn narrate(
321        &self,
322        tool_call: &crate::tool_types::ToolCall,
323        phase: crate::tool_narration::ToolNarrationPhase,
324        locale: Option<&str>,
325        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
326    ) -> Option<String> {
327        crate::tool_narration::narrate_skill(&tool_call.name, &tool_call.arguments, phase, locale)
328    }
329
330    fn name(&self) -> &str {
331        "list_skills"
332    }
333
334    fn display_name(&self) -> Option<&str> {
335        Some("List Skills")
336    }
337
338    fn description(&self) -> &str {
339        "Discover available skills from the session filesystem."
340    }
341
342    fn parameters_schema(&self) -> Value {
343        serde_json::json!({
344            "type": "object",
345            "properties": {},
346            "required": []
347        })
348    }
349
350    fn hints(&self) -> ToolHints {
351        ToolHints::default()
352            .with_readonly(true)
353            .with_idempotent(true)
354    }
355
356    fn requires_context(&self) -> bool {
357        true
358    }
359
360    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
361        ToolExecutionResult::tool_error(
362            "list_skills requires context. This tool must be executed with session context.",
363        )
364    }
365
366    async fn execute_with_context(
367        &self,
368        _arguments: Value,
369        context: &ToolContext,
370    ) -> ToolExecutionResult {
371        let file_store = match &context.file_store {
372            Some(fs) => fs,
373            None => {
374                return ToolExecutionResult::tool_error(
375                    "File store not available. The session_file_system capability is required.",
376                );
377            }
378        };
379
380        // List directories under /.agents/skills/
381        let entries = match file_store
382            .list_directory(context.session_id, SKILLS_PATH)
383            .await
384        {
385            Ok(entries) => entries,
386            Err(_) => {
387                // Directory doesn't exist yet — no skills available
388                return ToolExecutionResult::success(serde_json::json!({
389                    "skills": [],
390                    "message": "No skills found. Upload skills to /workspace/.agents/skills/{name}/SKILL.md"
391                }));
392            }
393        };
394
395        let mut skills = Vec::new();
396
397        for entry in &entries {
398            if !entry.is_directory {
399                continue;
400            }
401
402            let skill_md_path = format!("{}/SKILL.md", entry.path);
403            if let Ok(Some(file)) = file_store
404                .read_file(context.session_id, &skill_md_path)
405                .await
406            {
407                let content = file.content.as_deref().unwrap_or("");
408                match crate::skill::parse_skill_md(content) {
409                    Ok(parsed) => {
410                        skills.push(serde_json::json!({
411                            "name": parsed.name,
412                            "description": parsed.description,
413                            "path": workspace_path(&skill_md_path),
414                            "version": parsed.version,
415                            "user_invocable": parsed.user_invocable,
416                            "disable_model_invocation": parsed.disable_model_invocation,
417                        }));
418                    }
419                    Err(errors) => {
420                        skills.push(serde_json::json!({
421                            "name": entry.name,
422                            "path": workspace_path(&skill_md_path),
423                            "error": format!("Invalid SKILL.md: {}", errors.join(", ")),
424                        }));
425                    }
426                }
427            }
428        }
429
430        ToolExecutionResult::success(serde_json::json!({
431            "skills": skills,
432            "count": skills.len(),
433            "skills_path": workspace_path(SKILLS_PATH),
434        }))
435    }
436}
437
438// ============================================================================
439// ActivateSkillFromVfsTool - Loads skill instructions from session VFS
440// ============================================================================
441
442/// Tool that reads a SKILL.md from `/.agents/skills/{name}/SKILL.md` and
443/// returns its full instructions.
444#[derive(Debug)]
445struct ActivateSkillFromVfsTool;
446
447#[async_trait]
448impl Tool for ActivateSkillFromVfsTool {
449    fn narrate(
450        &self,
451        tool_call: &crate::tool_types::ToolCall,
452        phase: crate::tool_narration::ToolNarrationPhase,
453        locale: Option<&str>,
454        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
455    ) -> Option<String> {
456        crate::tool_narration::narrate_skill(&tool_call.name, &tool_call.arguments, phase, locale)
457    }
458
459    fn name(&self) -> &str {
460        "activate_skill"
461    }
462
463    fn display_name(&self) -> Option<&str> {
464        Some("Activate Skill")
465    }
466
467    fn description(&self) -> &str {
468        "Activate a skill by name to load its full instructions from the session filesystem."
469    }
470
471    fn parameters_schema(&self) -> Value {
472        serde_json::json!({
473            "type": "object",
474            "properties": {
475                "name": {
476                    "type": "string",
477                    "description": "The skill directory name (e.g., 'pdf-processing')"
478                },
479                "arguments": {
480                    "type": "string",
481                    "description": "Optional arguments to pass to the skill for $ARGUMENTS substitution"
482                }
483            },
484            "required": ["name"]
485        })
486    }
487
488    fn hints(&self) -> ToolHints {
489        ToolHints::default()
490            .with_readonly(true)
491            .with_idempotent(true)
492    }
493
494    fn requires_context(&self) -> bool {
495        true
496    }
497
498    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
499        ToolExecutionResult::tool_error(
500            "activate_skill requires context. This tool must be executed with session context.",
501        )
502    }
503
504    async fn execute_with_context(
505        &self,
506        arguments: Value,
507        context: &ToolContext,
508    ) -> ToolExecutionResult {
509        let name = match arguments.get("name").and_then(|v| v.as_str()) {
510            Some(n) => n,
511            None => {
512                return ToolExecutionResult::tool_error("Missing required parameter: name");
513            }
514        };
515
516        let skill_args = arguments
517            .get("arguments")
518            .and_then(|v| v.as_str())
519            .unwrap_or("");
520
521        // Validate name (prevent path traversal)
522        if name.contains("..") || name.contains('/') || name.contains('\\') {
523            return ToolExecutionResult::tool_error(
524                "Invalid skill name. Must be a simple directory name without path separators.",
525            );
526        }
527
528        // Validate name against the skill naming rules (lowercase letters, digits,
529        // single hyphens, 1-64 chars). This also rejects `:` and any other
530        // non-spec character so the session-resource key `skill_activation:{name}`
531        // is unambiguous — see `skill_activation_resource_id`.
532        if let Err(errors) = crate::skill::validate_skill_name(name) {
533            return ToolExecutionResult::tool_error(format!(
534                "Invalid skill name '{name}': {}",
535                errors.join(", ")
536            ));
537        }
538
539        // Idempotence (EVE-337): if this skill has already been activated in this
540        // session, return the cached result with `already_active: true` instead of
541        // replaying the full parse/expand/preprocess pipeline. Requires a session
542        // resource registry; when no registry is wired in (unit tests, embedded
543        // runtime without session state) we fall back to non-cached activation.
544        if let Some(registry) = &context.session_resource_registry {
545            let resource_id = skill_activation_resource_id(name);
546            match registry.get(context.session_id, &resource_id).await {
547                Ok(Some(entry))
548                    if entry.status == crate::session_resource::SessionResourceStatus::Active =>
549                {
550                    if entry.kind != SKILL_ACTIVATION_KIND {
551                        tracing::warn!(
552                            skill = name,
553                            resource_id = %resource_id,
554                            entry_kind = %entry.kind,
555                            expected_kind = SKILL_ACTIVATION_KIND,
556                            "activate_skill: registry entry collides with unexpected kind; falling back to non-cached activation"
557                        );
558                    } else if let Value::Object(mut map) = entry.metadata {
559                        map.insert("already_active".to_string(), Value::Bool(true));
560                        return ToolExecutionResult::success(Value::Object(map));
561                    } else {
562                        tracing::warn!(
563                            skill = name,
564                            resource_id = %resource_id,
565                            "activate_skill: cached entry has non-object metadata; falling back to non-cached activation"
566                        );
567                    }
568                }
569                Ok(_) => {}
570                Err(e) => {
571                    tracing::warn!(
572                        error = %e,
573                        skill = name,
574                        "activate_skill: failed to read session resource registry; falling back to non-cached activation"
575                    );
576                }
577            }
578        }
579
580        let file_store = match &context.file_store {
581            Some(fs) => fs,
582            None => {
583                return ToolExecutionResult::tool_error(
584                    "File store not available. The session_file_system capability is required.",
585                );
586            }
587        };
588
589        let skill_md_path = format!("{}/{}/SKILL.md", SKILLS_PATH, name);
590
591        let file = match file_store
592            .read_file(context.session_id, &skill_md_path)
593            .await
594        {
595            Ok(Some(f)) => f,
596            Ok(None) => {
597                return ToolExecutionResult::tool_error(format!(
598                    "Skill '{name}' not found at {}. \
599                     Use list_skills to see available skills.",
600                    workspace_path(&skill_md_path)
601                ));
602            }
603            Err(e) => {
604                return ToolExecutionResult::internal_error_msg(format!(
605                    "Failed to read skill file: {e}"
606                ));
607            }
608        };
609
610        // Parse the SKILL.md to extract instructions
611        let content = file.content.as_deref().unwrap_or("");
612        // Trust gate: `preprocess_command_injections` is gated OFF for every
613        // source. `SessionFile::is_readonly` is user-settable via the
614        // session-files API and `InitialFile`, so it cannot be used as the
615        // trust signal. See module-level comment and EVE-388.
616        //
617        // `file` is still read above so future trust checks can look at
618        // provenance metadata once it exists; for now the decision is fixed.
619        let _ = &file;
620        let is_trusted_source: bool = false;
621        match crate::skill::parse_skill_md(content) {
622            Ok(parsed) => {
623                // Apply substitution pipeline:
624                //   1. arguments ($ARGUMENTS, $N)
625                //   2. env vars (${SESSION_ID}, ${SKILL_DIR})
626                //   3. command injection (!`cmd`) — only for trusted sources
627                let expanded =
628                    crate::skill::expand_skill_arguments(&parsed.instructions, skill_args);
629                let skill_dir = format!("{}/{}", SKILLS_PATH, name);
630                let session_id_str = context.session_id.to_string();
631                let substituted = crate::skill::substitute_activation_vars(
632                    &expanded,
633                    &session_id_str,
634                    &skill_dir,
635                );
636                let preprocessed = if is_trusted_source {
637                    let executor = crate::skill::ProcessCommandExecutor::default();
638                    crate::skill::preprocess_command_injections(&substituted, &executor).await
639                } else {
640                    substituted
641                };
642                let instructions = format!(
643                    "<skill name=\"{}\">\n{}\n</skill>",
644                    parsed.name, preprocessed
645                );
646
647                let mut result = serde_json::json!({
648                    "skill": parsed.name,
649                    "instructions": instructions,
650                    "description": parsed.description,
651                });
652
653                // Include context and agent fields for fork-mode skills
654                if parsed.context == crate::skill::SkillContext::Fork {
655                    result["context"] = serde_json::json!("fork");
656                    result["agent"] =
657                        serde_json::json!(parsed.agent.as_deref().unwrap_or("general-purpose"));
658                    if let Some(ref model) = parsed.model {
659                        result["model"] = serde_json::json!(model);
660                    }
661                }
662
663                // Register this activation so subsequent `activate_skill` calls for the
664                // same skill in this session short-circuit (EVE-337). We store the full
665                // tool result as metadata; the cache hit path re-emits it verbatim and
666                // adds `already_active: true`.
667                if let Some(registry) = &context.session_resource_registry {
668                    // Key the registry entry on the tool-argument `name` (which is
669                    // also the skill directory name and matches the cache-lookup key
670                    // above). Using `parsed.name` here would diverge if the
671                    // frontmatter `name` ever drifts from the directory, breaking
672                    // the idempotence contract on the second activation.
673                    let entry = crate::session_resource::RegisterSessionResource {
674                        session_id: context.session_id,
675                        resource_id: skill_activation_resource_id(name),
676                        kind: SKILL_ACTIVATION_KIND.to_string(),
677                        display_name: format!("skill:{name}"),
678                        status: crate::session_resource::SessionResourceStatus::Active,
679                        metadata: result.clone(),
680                    };
681                    if let Err(e) = registry.register(entry).await {
682                        tracing::warn!(
683                            error = %e,
684                            skill = %parsed.name,
685                            "activate_skill: failed to record activation in session resource registry; skill still returned but re-activation will replay"
686                        );
687                    }
688                }
689
690                ToolExecutionResult::success(result)
691            }
692            Err(errors) => ToolExecutionResult::tool_error(format!(
693                "Invalid SKILL.md at {}: {}",
694                workspace_path(&skill_md_path),
695                errors.join(", ")
696            )),
697        }
698    }
699}
700
701// ============================================================================
702// Tests
703// ============================================================================
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::capabilities::Capability;
709    use crate::error::Result;
710    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
711    use crate::session_resource::{
712        RegisterSessionResource, SessionResourceEntry, SessionResourceFilter, SessionResourceStatus,
713    };
714    use crate::traits::{SessionFileSystem, SessionResourceRegistry};
715    use crate::typed_id::SessionId;
716    use std::collections::HashMap;
717    use std::sync::atomic::{AtomicUsize, Ordering};
718    use std::sync::{Arc, Mutex};
719
720    // ========================================================================
721    // MockFileStore for testing skill discovery
722    // ========================================================================
723
724    /// In-memory file store supporting both files and directories.
725    struct MockFileStore {
726        /// Files: (session_id, path) -> content
727        files: Mutex<HashMap<(SessionId, String), String>>,
728        /// Readonly flag per (session_id, path). Absent = writable.
729        readonly_files: Mutex<std::collections::HashSet<(SessionId, String)>>,
730        /// Directories: (session_id, path)
731        dirs: Mutex<std::collections::HashSet<(SessionId, String)>>,
732        /// Counter used by the idempotence test to prove the cache-hit path
733        /// does not re-read SKILL.md on the second activation.
734        read_count: AtomicUsize,
735    }
736
737    impl MockFileStore {
738        fn new() -> Self {
739            Self {
740                files: Mutex::new(HashMap::new()),
741                readonly_files: Mutex::new(std::collections::HashSet::new()),
742                dirs: Mutex::new(std::collections::HashSet::new()),
743                read_count: AtomicUsize::new(0),
744            }
745        }
746
747        fn read_count(&self) -> usize {
748            self.read_count.load(Ordering::SeqCst)
749        }
750
751        /// Add a file and auto-create parent directories
752        fn add_file(&self, session_id: SessionId, path: &str, content: &str) {
753            self.files
754                .lock()
755                .unwrap()
756                .insert((session_id, path.to_string()), content.to_string());
757
758            // Auto-create parent directories
759            let mut dir = path.to_string();
760            while let Some(idx) = dir.rfind('/') {
761                if idx == 0 {
762                    break;
763                }
764                dir = dir[..idx].to_string();
765                self.dirs.lock().unwrap().insert((session_id, dir.clone()));
766            }
767        }
768
769        /// Add a file and mark it as readonly (simulates a capability or
770        /// registry mount).
771        fn add_readonly_file(&self, session_id: SessionId, path: &str, content: &str) {
772            self.add_file(session_id, path, content);
773            self.readonly_files
774                .lock()
775                .unwrap()
776                .insert((session_id, path.to_string()));
777        }
778    }
779
780    #[async_trait]
781    impl SessionFileSystem for MockFileStore {
782        fn is_mount_resolver(&self) -> bool {
783            false
784        }
785
786        async fn read_file(
787            &self,
788            session_id: SessionId,
789            path: &str,
790        ) -> Result<Option<SessionFile>> {
791            self.read_count.fetch_add(1, Ordering::SeqCst);
792            let files = self.files.lock().unwrap();
793            let readonly_files = self.readonly_files.lock().unwrap();
794            if let Some(content) = files.get(&(session_id, path.to_string())) {
795                let is_readonly = readonly_files.contains(&(session_id, path.to_string()));
796                Ok(Some(SessionFile {
797                    id: uuid::Uuid::new_v4(),
798                    session_id: session_id.into(),
799                    path: path.to_string(),
800                    name: path.split('/').next_back().unwrap_or("").to_string(),
801                    is_directory: false,
802                    is_readonly,
803                    content: Some(content.clone()),
804                    encoding: "text".to_string(),
805                    size_bytes: content.len() as i64,
806                    created_at: chrono::Utc::now(),
807                    updated_at: chrono::Utc::now(),
808                }))
809            } else {
810                Ok(None)
811            }
812        }
813
814        async fn write_file(
815            &self,
816            session_id: SessionId,
817            path: &str,
818            content: &str,
819            _encoding: &str,
820        ) -> Result<SessionFile> {
821            self.add_file(session_id, path, content);
822            Ok(SessionFile {
823                id: uuid::Uuid::new_v4(),
824                session_id: session_id.into(),
825                path: path.to_string(),
826                name: path.split('/').next_back().unwrap_or("").to_string(),
827                is_directory: false,
828                is_readonly: false,
829                content: Some(content.to_string()),
830                encoding: "text".to_string(),
831                size_bytes: content.len() as i64,
832                created_at: chrono::Utc::now(),
833                updated_at: chrono::Utc::now(),
834            })
835        }
836
837        async fn delete_file(
838            &self,
839            _session_id: SessionId,
840            _path: &str,
841            _recursive: bool,
842        ) -> Result<bool> {
843            Ok(false)
844        }
845
846        async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
847            let files = self.files.lock().unwrap();
848            let dirs = self.dirs.lock().unwrap();
849            let mut entries = Vec::new();
850            let mut seen_dirs = std::collections::HashSet::new();
851
852            let prefix = if path.ends_with('/') {
853                path.to_string()
854            } else {
855                format!("{}/", path)
856            };
857
858            // Find files directly under this path
859            for ((sid, file_path), content) in files.iter() {
860                if *sid != session_id || !file_path.starts_with(&prefix) {
861                    continue;
862                }
863                let remainder = &file_path[prefix.len()..];
864                if !remainder.contains('/') {
865                    entries.push(FileInfo {
866                        id: uuid::Uuid::new_v4(),
867                        session_id: session_id.into(),
868                        path: file_path.clone(),
869                        name: remainder.to_string(),
870                        is_directory: false,
871                        is_readonly: false,
872                        size_bytes: content.len() as i64,
873                        created_at: chrono::Utc::now(),
874                        updated_at: chrono::Utc::now(),
875                    });
876                }
877            }
878
879            // Find subdirectories directly under this path
880            for (sid, dir_path) in dirs.iter() {
881                if *sid != session_id || !dir_path.starts_with(&prefix) {
882                    continue;
883                }
884                let remainder = &dir_path[prefix.len()..];
885                // Only direct children (no nested slashes)
886                if !remainder.contains('/')
887                    && !remainder.is_empty()
888                    && seen_dirs.insert(dir_path.clone())
889                {
890                    entries.push(FileInfo {
891                        id: uuid::Uuid::new_v4(),
892                        session_id: session_id.into(),
893                        path: dir_path.clone(),
894                        name: remainder.to_string(),
895                        is_directory: true,
896                        is_readonly: false,
897                        size_bytes: 0,
898                        created_at: chrono::Utc::now(),
899                        updated_at: chrono::Utc::now(),
900                    });
901                }
902            }
903
904            Ok(entries)
905        }
906
907        async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
908            Ok(None)
909        }
910
911        async fn grep_files(
912            &self,
913            _session_id: SessionId,
914            _pattern: &str,
915            _path_pattern: Option<&str>,
916        ) -> Result<Vec<GrepMatch>> {
917            Ok(vec![])
918        }
919
920        async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
921            self.dirs
922                .lock()
923                .unwrap()
924                .insert((session_id, path.to_string()));
925            Ok(FileInfo {
926                id: uuid::Uuid::new_v4(),
927                session_id: session_id.into(),
928                path: path.to_string(),
929                name: path.split('/').next_back().unwrap_or("").to_string(),
930                is_directory: true,
931                is_readonly: false,
932                size_bytes: 0,
933                created_at: chrono::Utc::now(),
934                updated_at: chrono::Utc::now(),
935            })
936        }
937    }
938
939    fn valid_skill_md(name: &str, desc: &str) -> String {
940        format!("---\nname: {name}\ndescription: {desc}\n---\n\n# Instructions\nDo the thing.")
941    }
942
943    fn make_context(file_store: Arc<MockFileStore>) -> ToolContext {
944        ToolContext::with_file_store(SessionId::new(), file_store)
945    }
946
947    // ========================================================================
948    // TestSessionResourceRegistry — in-memory registry for the idempotence test
949    // ========================================================================
950
951    #[derive(Default)]
952    struct TestSessionResourceRegistry {
953        entries: Mutex<HashMap<String, SessionResourceEntry>>,
954    }
955
956    #[async_trait]
957    impl SessionResourceRegistry for TestSessionResourceRegistry {
958        async fn register(&self, entry: RegisterSessionResource) -> Result<SessionResourceEntry> {
959            let stored = SessionResourceEntry {
960                resource_id: entry.resource_id.clone(),
961                session_id: entry.session_id,
962                kind: entry.kind,
963                display_name: entry.display_name,
964                status: entry.status,
965                metadata: entry.metadata,
966                created_at: chrono::Utc::now(),
967                updated_at: chrono::Utc::now(),
968            };
969            self.entries
970                .lock()
971                .unwrap()
972                .insert(entry.resource_id, stored.clone());
973            Ok(stored)
974        }
975
976        async fn update_status(
977            &self,
978            _session_id: SessionId,
979            resource_id: &str,
980            status: SessionResourceStatus,
981        ) -> Result<Option<SessionResourceEntry>> {
982            let mut entries = self.entries.lock().unwrap();
983            if let Some(entry) = entries.get_mut(resource_id) {
984                entry.status = status;
985                entry.updated_at = chrono::Utc::now();
986                return Ok(Some(entry.clone()));
987            }
988            Ok(None)
989        }
990
991        async fn get(
992            &self,
993            _session_id: SessionId,
994            resource_id: &str,
995        ) -> Result<Option<SessionResourceEntry>> {
996            Ok(self.entries.lock().unwrap().get(resource_id).cloned())
997        }
998
999        async fn list(
1000            &self,
1001            _session_id: SessionId,
1002            _filter: Option<&SessionResourceFilter>,
1003        ) -> Result<Vec<SessionResourceEntry>> {
1004            Ok(self.entries.lock().unwrap().values().cloned().collect())
1005        }
1006
1007        async fn deregister(&self, _session_id: SessionId, resource_id: &str) -> Result<bool> {
1008            Ok(self.entries.lock().unwrap().remove(resource_id).is_some())
1009        }
1010    }
1011
1012    // ========================================================================
1013    // Capability Trait Tests
1014    // ========================================================================
1015
1016    // Metadata, tool-list, dependency, and builtin-registration constants are
1017    // covered registry-wide by `builtin_capabilities_satisfy_registry_invariants`
1018    // in `capabilities::tests`; the per-capability constant mirrors were removed.
1019
1020    #[test]
1021    fn test_skills_has_system_prompt() {
1022        let cap = SkillsCapability;
1023        let prompt = cap.system_prompt_addition().unwrap();
1024
1025        assert!(prompt.contains("/workspace/.agents/skills/"));
1026    }
1027
1028    // ========================================================================
1029    // Tool Basics (no context)
1030    // ========================================================================
1031
1032    #[test]
1033    fn test_list_skills_requires_context() {
1034        let tool = ListSkillsTool;
1035        assert!(tool.requires_context());
1036    }
1037
1038    #[test]
1039    fn test_activate_skill_requires_context() {
1040        let tool = ActivateSkillFromVfsTool;
1041        assert!(tool.requires_context());
1042    }
1043
1044    #[tokio::test]
1045    async fn test_list_skills_without_context() {
1046        let tool = ListSkillsTool;
1047        let result = tool.execute(serde_json::json!({})).await;
1048        assert!(result.is_error());
1049    }
1050
1051    #[tokio::test]
1052    async fn test_activate_skill_without_context() {
1053        let tool = ActivateSkillFromVfsTool;
1054        let result = tool.execute(serde_json::json!({"name": "test"})).await;
1055        assert!(result.is_error());
1056    }
1057
1058    // ========================================================================
1059    // Tool Error Cases
1060    // ========================================================================
1061
1062    #[tokio::test]
1063    async fn test_activate_skill_missing_name() {
1064        let tool = ActivateSkillFromVfsTool;
1065        let context = ToolContext::new(SessionId::new());
1066        let result = tool
1067            .execute_with_context(serde_json::json!({}), &context)
1068            .await;
1069        match result {
1070            ToolExecutionResult::ToolError(msg) => {
1071                assert!(msg.contains("Missing required parameter"));
1072            }
1073            other => panic!("Expected ToolError, got: {:?}", other),
1074        }
1075    }
1076
1077    #[tokio::test]
1078    async fn test_activate_skill_path_traversal_blocked() {
1079        let tool = ActivateSkillFromVfsTool;
1080        let context = ToolContext::new(SessionId::new());
1081
1082        // ".." traversal
1083        let result = tool
1084            .execute_with_context(serde_json::json!({"name": "../etc/passwd"}), &context)
1085            .await;
1086        match result {
1087            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid skill name")),
1088            other => panic!("Expected ToolError, got: {:?}", other),
1089        }
1090
1091        // Forward slash
1092        let result = tool
1093            .execute_with_context(serde_json::json!({"name": "foo/bar"}), &context)
1094            .await;
1095        match result {
1096            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid skill name")),
1097            other => panic!("Expected ToolError, got: {:?}", other),
1098        }
1099
1100        // Backslash
1101        let result = tool
1102            .execute_with_context(serde_json::json!({"name": "foo\\bar"}), &context)
1103            .await;
1104        match result {
1105            ToolExecutionResult::ToolError(msg) => assert!(msg.contains("Invalid skill name")),
1106            other => panic!("Expected ToolError, got: {:?}", other),
1107        }
1108    }
1109
1110    // Regression for EVE-337 review: names are validated against the skill
1111    // naming rules before being used as part of the `skill_activation:{name}`
1112    // resource-registry key. Reject the delimiter character explicitly.
1113    #[tokio::test]
1114    async fn test_activate_skill_rejects_resource_id_delimiter() {
1115        let tool = ActivateSkillFromVfsTool;
1116        let context = ToolContext::new(SessionId::new());
1117        let result = tool
1118            .execute_with_context(
1119                serde_json::json!({"name": "skill_activation:evil"}),
1120                &context,
1121            )
1122            .await;
1123        match result {
1124            ToolExecutionResult::ToolError(msg) => {
1125                assert!(msg.contains("Invalid skill name"), "got: {msg}");
1126            }
1127            other => panic!("Expected ToolError, got: {:?}", other),
1128        }
1129    }
1130
1131    #[tokio::test]
1132    async fn test_list_skills_no_file_store() {
1133        let tool = ListSkillsTool;
1134        let context = ToolContext::new(SessionId::new());
1135        let result = tool
1136            .execute_with_context(serde_json::json!({}), &context)
1137            .await;
1138        match result {
1139            ToolExecutionResult::ToolError(msg) => {
1140                assert!(msg.contains("File store not available"));
1141            }
1142            other => panic!("Expected ToolError, got: {:?}", other),
1143        }
1144    }
1145
1146    #[tokio::test]
1147    async fn test_activate_skill_no_file_store() {
1148        let tool = ActivateSkillFromVfsTool;
1149        let context = ToolContext::new(SessionId::new());
1150        let result = tool
1151            .execute_with_context(serde_json::json!({"name": "test"}), &context)
1152            .await;
1153        match result {
1154            ToolExecutionResult::ToolError(msg) => {
1155                assert!(msg.contains("File store not available"));
1156            }
1157            other => panic!("Expected ToolError, got: {:?}", other),
1158        }
1159    }
1160
1161    // ========================================================================
1162    // list_skills with MockFileStore
1163    // ========================================================================
1164
1165    #[tokio::test]
1166    async fn test_list_skills_empty_directory() {
1167        let fs = Arc::new(MockFileStore::new());
1168        let context = make_context(fs);
1169        let tool = ListSkillsTool;
1170
1171        let result = tool
1172            .execute_with_context(serde_json::json!({}), &context)
1173            .await;
1174        match result {
1175            ToolExecutionResult::Success(val) => {
1176                let skills = val["skills"].as_array().unwrap();
1177                assert!(skills.is_empty());
1178                assert_eq!(val["count"], 0);
1179            }
1180            other => panic!("Expected Success, got: {:?}", other),
1181        }
1182    }
1183
1184    #[tokio::test]
1185    async fn test_list_skills_discovers_valid_skill() {
1186        let fs = Arc::new(MockFileStore::new());
1187        let session_id = SessionId::new();
1188        fs.add_file(
1189            session_id,
1190            "/.agents/skills/pdf-tool/SKILL.md",
1191            &valid_skill_md("pdf-tool", "Extract text from PDFs"),
1192        );
1193
1194        let context = ToolContext::with_file_store(session_id, fs);
1195        let tool = ListSkillsTool;
1196
1197        let result = tool
1198            .execute_with_context(serde_json::json!({}), &context)
1199            .await;
1200        match result {
1201            ToolExecutionResult::Success(val) => {
1202                let skills = val["skills"].as_array().unwrap();
1203                assert_eq!(skills.len(), 1);
1204                assert_eq!(skills[0]["name"], "pdf-tool");
1205                assert_eq!(skills[0]["description"], "Extract text from PDFs");
1206                assert_eq!(val["count"], 1);
1207            }
1208            other => panic!("Expected Success, got: {:?}", other),
1209        }
1210    }
1211
1212    #[tokio::test]
1213    async fn test_list_skills_discovers_multiple_skills() {
1214        let fs = Arc::new(MockFileStore::new());
1215        let session_id = SessionId::new();
1216        fs.add_file(
1217            session_id,
1218            "/.agents/skills/pdf-tool/SKILL.md",
1219            &valid_skill_md("pdf-tool", "Extract text from PDFs"),
1220        );
1221        fs.add_file(
1222            session_id,
1223            "/.agents/skills/data-analysis/SKILL.md",
1224            &valid_skill_md("data-analysis", "Analyze datasets"),
1225        );
1226
1227        let context = ToolContext::with_file_store(session_id, fs);
1228        let tool = ListSkillsTool;
1229
1230        let result = tool
1231            .execute_with_context(serde_json::json!({}), &context)
1232            .await;
1233        match result {
1234            ToolExecutionResult::Success(val) => {
1235                let skills = val["skills"].as_array().unwrap();
1236                assert_eq!(skills.len(), 2);
1237                assert_eq!(val["count"], 2);
1238                let names: Vec<&str> = skills.iter().map(|s| s["name"].as_str().unwrap()).collect();
1239                assert!(names.contains(&"pdf-tool"));
1240                assert!(names.contains(&"data-analysis"));
1241            }
1242            other => panic!("Expected Success, got: {:?}", other),
1243        }
1244    }
1245
1246    #[tokio::test]
1247    async fn test_list_skills_reports_invalid_skill_md() {
1248        let fs = Arc::new(MockFileStore::new());
1249        let session_id = SessionId::new();
1250        fs.add_file(
1251            session_id,
1252            "/.agents/skills/bad-skill/SKILL.md",
1253            "not valid frontmatter",
1254        );
1255
1256        let context = ToolContext::with_file_store(session_id, fs);
1257        let tool = ListSkillsTool;
1258
1259        let result = tool
1260            .execute_with_context(serde_json::json!({}), &context)
1261            .await;
1262        match result {
1263            ToolExecutionResult::Success(val) => {
1264                let skills = val["skills"].as_array().unwrap();
1265                assert_eq!(skills.len(), 1);
1266                // Should report the error but still include the entry
1267                assert!(
1268                    skills[0]["error"]
1269                        .as_str()
1270                        .unwrap()
1271                        .contains("Invalid SKILL.md")
1272                );
1273                assert_eq!(skills[0]["name"], "bad-skill");
1274            }
1275            other => panic!("Expected Success, got: {:?}", other),
1276        }
1277    }
1278
1279    // ========================================================================
1280    // activate_skill with MockFileStore
1281    // ========================================================================
1282
1283    #[tokio::test]
1284    async fn test_activate_skill_success() {
1285        let fs = Arc::new(MockFileStore::new());
1286        let session_id = SessionId::new();
1287        fs.add_file(
1288            session_id,
1289            "/.agents/skills/pdf-tool/SKILL.md",
1290            &valid_skill_md("pdf-tool", "Extract text from PDFs"),
1291        );
1292
1293        let context = ToolContext::with_file_store(session_id, fs);
1294        let tool = ActivateSkillFromVfsTool;
1295
1296        let result = tool
1297            .execute_with_context(serde_json::json!({"name": "pdf-tool"}), &context)
1298            .await;
1299        match result {
1300            ToolExecutionResult::Success(val) => {
1301                assert_eq!(val["skill"], "pdf-tool");
1302                assert_eq!(val["description"], "Extract text from PDFs");
1303                let instructions = val["instructions"].as_str().unwrap();
1304                assert!(instructions.contains("<skill name=\"pdf-tool\">"));
1305                assert!(instructions.contains("# Instructions"));
1306                assert!(instructions.contains("</skill>"));
1307            }
1308            other => panic!("Expected Success, got: {:?}", other),
1309        }
1310    }
1311
1312    // Regression test for EVE-337: activating the same skill twice within a
1313    // session must short-circuit on the second call (cached handle, no VFS
1314    // re-read) and annotate the response with `already_active: true`.
1315    #[tokio::test]
1316    async fn test_activate_skill_is_idempotent_within_session() {
1317        let fs = Arc::new(MockFileStore::new());
1318        let session_id = SessionId::new();
1319        fs.add_file(
1320            session_id,
1321            "/.agents/skills/pdf-tool/SKILL.md",
1322            &valid_skill_md("pdf-tool", "Extract text from PDFs"),
1323        );
1324
1325        let registry: Arc<dyn SessionResourceRegistry> =
1326            Arc::new(TestSessionResourceRegistry::default());
1327        let context = ToolContext::with_file_store(session_id, fs.clone())
1328            .with_session_resource_registry(registry.clone());
1329        let tool = ActivateSkillFromVfsTool;
1330
1331        let first = tool
1332            .execute_with_context(serde_json::json!({"name": "pdf-tool"}), &context)
1333            .await;
1334        let first_val = match first {
1335            ToolExecutionResult::Success(val) => val,
1336            other => panic!("Expected Success, got: {:?}", other),
1337        };
1338        assert_eq!(first_val["skill"], "pdf-tool");
1339        assert!(
1340            first_val.get("already_active").is_none(),
1341            "first activation must not carry already_active"
1342        );
1343        let reads_after_first = fs.read_count();
1344        assert!(reads_after_first >= 1, "first call must read SKILL.md");
1345
1346        let second = tool
1347            .execute_with_context(serde_json::json!({"name": "pdf-tool"}), &context)
1348            .await;
1349        let second_val = match second {
1350            ToolExecutionResult::Success(val) => val,
1351            other => panic!("Expected Success, got: {:?}", other),
1352        };
1353        assert_eq!(second_val["already_active"], serde_json::Value::Bool(true));
1354        assert_eq!(second_val["skill"], first_val["skill"]);
1355        assert_eq!(second_val["description"], first_val["description"]);
1356        assert_eq!(second_val["instructions"], first_val["instructions"]);
1357        assert_eq!(
1358            fs.read_count(),
1359            reads_after_first,
1360            "cache hit must not re-read SKILL.md from the VFS"
1361        );
1362
1363        // Registry holds exactly one entry, under the documented resource_id.
1364        let entry = registry
1365            .get(session_id, "skill_activation:pdf-tool")
1366            .await
1367            .unwrap()
1368            .expect("registry should contain the activation entry");
1369        assert_eq!(entry.kind, "skill_activation");
1370        assert_eq!(entry.status, SessionResourceStatus::Active);
1371    }
1372
1373    #[tokio::test]
1374    async fn test_activate_skill_not_found() {
1375        let fs = Arc::new(MockFileStore::new());
1376        let session_id = SessionId::new();
1377
1378        let context = ToolContext::with_file_store(session_id, fs);
1379        let tool = ActivateSkillFromVfsTool;
1380
1381        let result = tool
1382            .execute_with_context(serde_json::json!({"name": "nonexistent"}), &context)
1383            .await;
1384        match result {
1385            ToolExecutionResult::ToolError(msg) => {
1386                assert!(msg.contains("not found"));
1387                assert!(msg.contains("nonexistent"));
1388                assert!(msg.contains("list_skills"));
1389            }
1390            other => panic!("Expected ToolError, got: {:?}", other),
1391        }
1392    }
1393
1394    #[tokio::test]
1395    async fn test_activate_skill_invalid_skill_md() {
1396        let fs = Arc::new(MockFileStore::new());
1397        let session_id = SessionId::new();
1398        fs.add_file(
1399            session_id,
1400            "/.agents/skills/bad-skill/SKILL.md",
1401            "no frontmatter here",
1402        );
1403
1404        let context = ToolContext::with_file_store(session_id, fs);
1405        let tool = ActivateSkillFromVfsTool;
1406
1407        let result = tool
1408            .execute_with_context(serde_json::json!({"name": "bad-skill"}), &context)
1409            .await;
1410        match result {
1411            ToolExecutionResult::ToolError(msg) => {
1412                assert!(msg.contains("Invalid SKILL.md"));
1413            }
1414            other => panic!("Expected ToolError, got: {:?}", other),
1415        }
1416    }
1417
1418    #[tokio::test]
1419    async fn test_activate_skill_with_context_fork() {
1420        let fs = Arc::new(MockFileStore::new());
1421        let session_id = SessionId::new();
1422        fs.add_file(
1423            session_id,
1424            "/.agents/skills/research/SKILL.md",
1425            "---\nname: research\ndescription: Deep research.\ncontext: fork\nagent: Explore\n---\n\nResearch the topic.",
1426        );
1427
1428        let context = ToolContext::with_file_store(session_id, fs);
1429        let tool = ActivateSkillFromVfsTool;
1430
1431        let result = tool
1432            .execute_with_context(serde_json::json!({"name": "research"}), &context)
1433            .await;
1434        match result {
1435            ToolExecutionResult::Success(val) => {
1436                assert_eq!(val["skill"], "research");
1437                assert_eq!(val["context"], "fork");
1438                assert_eq!(val["agent"], "Explore");
1439                // Instructions are still included (caller decides how to use them)
1440                let instructions = val["instructions"].as_str().unwrap();
1441                assert!(instructions.contains("Research the topic"));
1442            }
1443            other => panic!("Expected Success, got: {:?}", other),
1444        }
1445    }
1446
1447    #[tokio::test]
1448    async fn test_activate_skill_fork_default_agent() {
1449        let fs = Arc::new(MockFileStore::new());
1450        let session_id = SessionId::new();
1451        fs.add_file(
1452            session_id,
1453            "/.agents/skills/analyze/SKILL.md",
1454            "---\nname: analyze\ndescription: Analyze code.\ncontext: fork\n---\n\nAnalyze the code.",
1455        );
1456
1457        let context = ToolContext::with_file_store(session_id, fs);
1458        let tool = ActivateSkillFromVfsTool;
1459
1460        let result = tool
1461            .execute_with_context(serde_json::json!({"name": "analyze"}), &context)
1462            .await;
1463        match result {
1464            ToolExecutionResult::Success(val) => {
1465                assert_eq!(val["context"], "fork");
1466                assert_eq!(val["agent"], "general-purpose");
1467            }
1468            other => panic!("Expected Success, got: {:?}", other),
1469        }
1470    }
1471
1472    #[tokio::test]
1473    async fn test_activate_skill_inline_no_context_field() {
1474        let fs = Arc::new(MockFileStore::new());
1475        let session_id = SessionId::new();
1476        fs.add_file(
1477            session_id,
1478            "/.agents/skills/inline-skill/SKILL.md",
1479            &valid_skill_md("inline-skill", "An inline skill"),
1480        );
1481
1482        let context = ToolContext::with_file_store(session_id, fs);
1483        let tool = ActivateSkillFromVfsTool;
1484
1485        let result = tool
1486            .execute_with_context(serde_json::json!({"name": "inline-skill"}), &context)
1487            .await;
1488        match result {
1489            ToolExecutionResult::Success(val) => {
1490                assert_eq!(val["skill"], "inline-skill");
1491                // No context or agent fields for inline skills
1492                assert!(val.get("context").is_none());
1493                assert!(val.get("agent").is_none());
1494            }
1495            other => panic!("Expected Success, got: {:?}", other),
1496        }
1497    }
1498
1499    #[tokio::test]
1500    async fn test_activate_skill_fork_with_model() {
1501        let fs = Arc::new(MockFileStore::new());
1502        let session_id = SessionId::new();
1503        fs.add_file(
1504            session_id,
1505            "/.agents/skills/quick-lint/SKILL.md",
1506            "---\nname: quick-lint\ndescription: Fast lint.\ncontext: fork\nmodel: claude-haiku-4-5-20251001\n---\n\nLint check.",
1507        );
1508
1509        let context = ToolContext::with_file_store(session_id, fs);
1510        let tool = ActivateSkillFromVfsTool;
1511
1512        let result = tool
1513            .execute_with_context(serde_json::json!({"name": "quick-lint"}), &context)
1514            .await;
1515        match result {
1516            ToolExecutionResult::Success(val) => {
1517                assert_eq!(val["context"], "fork");
1518                assert_eq!(val["agent"], "general-purpose");
1519                assert_eq!(val["model"], "claude-haiku-4-5-20251001");
1520            }
1521            other => panic!("Expected Success, got: {:?}", other),
1522        }
1523    }
1524
1525    #[tokio::test]
1526    async fn test_activate_skill_inline_no_model_in_result() {
1527        let fs = Arc::new(MockFileStore::new());
1528        let session_id = SessionId::new();
1529        fs.add_file(
1530            session_id,
1531            "/.agents/skills/my-skill/SKILL.md",
1532            "---\nname: my-skill\ndescription: A skill.\nmodel: gpt-4o\n---\n\nBody.",
1533        );
1534
1535        let context = ToolContext::with_file_store(session_id, fs);
1536        let tool = ActivateSkillFromVfsTool;
1537
1538        let result = tool
1539            .execute_with_context(serde_json::json!({"name": "my-skill"}), &context)
1540            .await;
1541        match result {
1542            ToolExecutionResult::Success(val) => {
1543                // Inline skill: no model field in result (model only applies with fork)
1544                assert!(val.get("context").is_none());
1545                assert!(val.get("model").is_none());
1546            }
1547            other => panic!("Expected Success, got: {:?}", other),
1548        }
1549    }
1550
1551    // ========================================================================
1552    // Integration: CapabilityInfo DTO
1553    // ========================================================================
1554
1555    #[test]
1556    fn test_capability_info_from_core_marks_is_skill() {
1557        use crate::capability_dto::CapabilityInfo;
1558        let cap = SkillsCapability;
1559        let info = CapabilityInfo::from_core(&cap);
1560
1561        assert_eq!(info.id.as_str(), "skills");
1562        assert!(info.is_skill, "skills capability should have is_skill=true");
1563        assert!(!info.is_mcp);
1564        assert_eq!(info.category, Some("Core".to_string()));
1565        assert!(!info.tool_definitions.is_empty());
1566        assert!(!info.dependencies.is_empty());
1567    }
1568
1569    // ========================================================================
1570    // Integration: Dependency Resolution
1571    // ========================================================================
1572
1573    #[test]
1574    fn test_skills_dependency_resolution() {
1575        use crate::capabilities::resolve_dependencies;
1576
1577        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
1578        let resolved = resolve_dependencies(&["skills".to_string()], &registry).unwrap();
1579
1580        // Should auto-include session_file_system as dependency
1581        assert!(
1582            resolved
1583                .resolved_ids
1584                .contains(&"session_file_system".to_string()),
1585            "skills should pull in session_file_system dependency"
1586        );
1587        assert!(resolved.resolved_ids.contains(&"skills".to_string()));
1588        assert!(
1589            resolved
1590                .added_as_dependencies
1591                .contains(&"session_file_system".to_string()),
1592            "session_file_system should be marked as auto-added"
1593        );
1594    }
1595
1596    // ========================================================================
1597    // Integration: apply_capabilities
1598    // ========================================================================
1599
1600    #[tokio::test]
1601    async fn test_apply_capabilities_with_skills() {
1602        use crate::capabilities::SystemPromptContext;
1603        use crate::runtime_agent::RuntimeAgentBuilder;
1604
1605        let registry = crate::capabilities::CapabilityRegistry::with_builtins();
1606        let ctx = SystemPromptContext::without_file_store(crate::typed_id::SessionId::new());
1607        // Use builder pattern which resolves dependencies automatically
1608        let runtime_agent = RuntimeAgentBuilder::new()
1609            .system_prompt("Base prompt.")
1610            .with_capabilities(&["skills".to_string()], &registry, &ctx)
1611            .await
1612            .model("gpt-5.2")
1613            .build();
1614
1615        // System prompt should include skills capability section
1616        assert!(
1617            runtime_agent
1618                .system_prompt
1619                .contains("/workspace/.agents/skills/"),
1620            "System prompt should mention skills path"
1621        );
1622        assert!(
1623            runtime_agent
1624                .system_prompt
1625                .contains("/workspace/.agents/skills/"),
1626            "System prompt should mention skills path with workspace prefix"
1627        );
1628        assert!(
1629            runtime_agent
1630                .system_prompt
1631                .contains("<capability id=\"skills\">"),
1632            "Should include skills capability in XML tags"
1633        );
1634
1635        // Should include file system tools (from dependency) + skills tools
1636        let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
1637        assert!(tool_names.contains(&"list_skills"));
1638        assert!(tool_names.contains(&"activate_skill"));
1639        // Dependency tools (from session_file_system)
1640        assert!(tool_names.contains(&"read_file"));
1641        assert!(tool_names.contains(&"write_file"));
1642    }
1643
1644    // ========================================================================
1645    // Dynamic system_prompt_contribution tests
1646    // ========================================================================
1647
1648    #[tokio::test]
1649    async fn test_contribution_includes_discovered_skills() {
1650        let cap = SkillsCapability;
1651        let store = Arc::new(MockFileStore::new());
1652        let session_id = SessionId::new();
1653
1654        store.add_file(
1655            session_id,
1656            "/.agents/skills/pdf-processor/SKILL.md",
1657            &valid_skill_md("pdf-processor", "Process PDF files"),
1658        );
1659        store.add_file(
1660            session_id,
1661            "/.agents/skills/data-analysis/SKILL.md",
1662            &valid_skill_md("data-analysis", "Analyze datasets"),
1663        );
1664
1665        let ctx = SystemPromptContext {
1666            session_id,
1667            locale: None,
1668            file_store: Some(store),
1669            model: None,
1670        };
1671
1672        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1673        assert!(result.contains("<capability id=\"skills\">"));
1674        assert!(result.contains("pdf-processor"));
1675        assert!(result.contains("data-analysis"));
1676        assert!(result.contains("Available skills:"));
1677    }
1678
1679    #[tokio::test]
1680    async fn test_contribution_static_when_no_file_store() {
1681        let cap = SkillsCapability;
1682        let ctx = SystemPromptContext::without_file_store(SessionId::new());
1683
1684        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1685        assert!(result.contains("<capability id=\"skills\">"));
1686        assert!(result.contains("/workspace/.agents/skills/"));
1687        // No "Available skills:" section
1688        assert!(!result.contains("Available skills:"));
1689    }
1690
1691    #[tokio::test]
1692    async fn test_contribution_static_when_no_skills_dir() {
1693        let cap = SkillsCapability;
1694        let store = Arc::new(MockFileStore::new());
1695
1696        let ctx = SystemPromptContext {
1697            session_id: SessionId::new(),
1698            locale: None,
1699            file_store: Some(store),
1700            model: None,
1701        };
1702
1703        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1704        assert!(result.contains("<capability id=\"skills\">"));
1705        assert!(result.contains("/workspace/.agents/skills/"));
1706        // No "Available skills:" section (dir doesn't exist)
1707        assert!(!result.contains("Available skills:"));
1708    }
1709
1710    // ========================================================================
1711    // truncate_description tests
1712    // ========================================================================
1713
1714    #[test]
1715    fn test_truncate_short_description() {
1716        assert_eq!(truncate_description("Short desc", 76), "Short desc");
1717    }
1718
1719    #[test]
1720    fn test_truncate_exact_limit() {
1721        let s = "a".repeat(76);
1722        assert_eq!(truncate_description(&s, 76), s);
1723    }
1724
1725    #[test]
1726    fn test_truncate_long_description() {
1727        let s = "a".repeat(100);
1728        let result = truncate_description(&s, 76);
1729        assert!(result.ends_with('…'));
1730        // 75 chars + "…" = 76 display chars
1731        assert_eq!(result.chars().count(), 76);
1732    }
1733
1734    #[test]
1735    fn test_truncate_preserves_words_trimming() {
1736        let s = "Extract text and tables from PDF files, fill forms, merge documents, and do other cool things too";
1737        let result = truncate_description(s, 76);
1738        assert!(result.ends_with('…'));
1739        assert!(result.chars().count() <= 76);
1740    }
1741
1742    // ========================================================================
1743    // System prompt caps at MAX_SKILLS_IN_PROMPT
1744    // ========================================================================
1745
1746    #[tokio::test]
1747    async fn test_contribution_caps_at_max_skills() {
1748        let cap = SkillsCapability;
1749        let store = Arc::new(MockFileStore::new());
1750        let session_id = SessionId::new();
1751
1752        // Add 20 skills (exceeds MAX_SKILLS_IN_PROMPT = 15)
1753        for i in 0..20 {
1754            let name = format!("skill-{:02}", i);
1755            store.add_file(
1756                session_id,
1757                &format!("/.agents/skills/{}/SKILL.md", name),
1758                &valid_skill_md(&name, &format!("Description for skill {}", i)),
1759            );
1760        }
1761
1762        let ctx = SystemPromptContext {
1763            session_id,
1764            locale: None,
1765            file_store: Some(store),
1766            model: None,
1767        };
1768
1769        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1770
1771        // Should contain "Available skills:"
1772        assert!(result.contains("Available skills:"));
1773
1774        // Count how many skill entries appear (lines starting with "- **skill-")
1775        let skill_lines: Vec<&str> = result
1776            .lines()
1777            .filter(|l| l.starts_with("- **skill-"))
1778            .collect();
1779        assert_eq!(skill_lines.len(), MAX_SKILLS_IN_PROMPT);
1780
1781        // Should contain overflow message
1782        assert!(result.contains("5 more skills available"));
1783        assert!(result.contains("list_skills"));
1784    }
1785
1786    #[tokio::test]
1787    async fn test_contribution_no_overflow_at_limit() {
1788        let cap = SkillsCapability;
1789        let store = Arc::new(MockFileStore::new());
1790        let session_id = SessionId::new();
1791
1792        // Add exactly MAX_SKILLS_IN_PROMPT skills
1793        for i in 0..MAX_SKILLS_IN_PROMPT {
1794            let name = format!("skill-{:02}", i);
1795            store.add_file(
1796                session_id,
1797                &format!("/.agents/skills/{}/SKILL.md", name),
1798                &valid_skill_md(&name, &format!("Description for skill {}", i)),
1799            );
1800        }
1801
1802        let ctx = SystemPromptContext {
1803            session_id,
1804            locale: None,
1805            file_store: Some(store),
1806            model: None,
1807        };
1808
1809        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1810
1811        let skill_lines: Vec<&str> = result
1812            .lines()
1813            .filter(|l| l.starts_with("- **skill-"))
1814            .collect();
1815        assert_eq!(skill_lines.len(), MAX_SKILLS_IN_PROMPT);
1816
1817        // No overflow message
1818        assert!(!result.contains("more skills available"));
1819    }
1820
1821    #[tokio::test]
1822    async fn test_contribution_limits_skill_scan_reads() {
1823        let cap = SkillsCapability;
1824        let store = Arc::new(MockFileStore::new());
1825        let session_id = SessionId::new();
1826
1827        for i in 0..(MAX_SKILLS_SCAN_IN_PROMPT + 20) {
1828            let name = format!("scan-skill-{:03}", i);
1829            store.add_file(
1830                session_id,
1831                &format!("/.agents/skills/{name}/SKILL.md"),
1832                &valid_skill_md(&name, "Scan limit test"),
1833            );
1834        }
1835
1836        let ctx = SystemPromptContext {
1837            session_id,
1838            locale: None,
1839            file_store: Some(store.clone()),
1840            model: None,
1841        };
1842
1843        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
1844
1845        assert_eq!(store.read_count(), MAX_SKILLS_SCAN_IN_PROMPT);
1846        assert!(result.contains("Additional skills may exist"));
1847    }
1848
1849    // ========================================================================
1850    // Integration: AttachSkillCapability mount → SkillsCapability discovery
1851    // ========================================================================
1852
1853    /// Simulate the runtime flow: AttachSkillCapability produces a mount,
1854    /// we materialize it into MockFileStore, then SkillsCapability discovers it.
1855    fn materialize_mount_into_store(
1856        store: &MockFileStore,
1857        session_id: SessionId,
1858        mount: &crate::capability_types::MountPoint,
1859    ) {
1860        use crate::capability_types::MountSource;
1861        fn walk(store: &MockFileStore, session_id: SessionId, base: &str, source: &MountSource) {
1862            match source {
1863                MountSource::InlineFile { content, .. } => {
1864                    store.add_file(session_id, base, content);
1865                }
1866                MountSource::InlineDirectory { entries } => {
1867                    for (name, entry) in entries {
1868                        let path = format!("{}/{}", base, name);
1869                        walk(store, session_id, &path, &entry.source);
1870                    }
1871                }
1872                MountSource::Virtual { .. } => {
1873                    // Virtual mounts are not materialized in tests
1874                }
1875            }
1876        }
1877        walk(store, session_id, &mount.path, &mount.source);
1878    }
1879
1880    #[tokio::test]
1881    async fn test_attach_skill_mount_discovered_by_list_skills() {
1882        use crate::capabilities::attach_skill::AttachSkillCapability;
1883
1884        let skill_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1885        let cap = AttachSkillCapability::from_registry(
1886            skill_id,
1887            "pdf-tool".to_string(),
1888            "Extract text from PDFs".to_string(),
1889            "# Instructions\nUse pdfplumber to extract.".to_string(),
1890            vec![],
1891        );
1892
1893        // Materialize mount into VFS mock
1894        let store = Arc::new(MockFileStore::new());
1895        let session_id = SessionId::new();
1896        for mount in cap.mounts() {
1897            materialize_mount_into_store(&store, session_id, &mount);
1898        }
1899
1900        // list_skills should discover it
1901        let context = ToolContext::with_file_store(session_id, store);
1902        let tool = ListSkillsTool;
1903        let result = tool
1904            .execute_with_context(serde_json::json!({}), &context)
1905            .await;
1906
1907        match result {
1908            ToolExecutionResult::Success(val) => {
1909                let skills = val["skills"].as_array().unwrap();
1910                assert_eq!(skills.len(), 1);
1911                assert_eq!(skills[0]["name"], "pdf-tool");
1912                assert_eq!(skills[0]["description"], "Extract text from PDFs");
1913            }
1914            other => panic!("Expected Success, got: {:?}", other),
1915        }
1916    }
1917
1918    #[tokio::test]
1919    async fn test_attach_skill_mount_activatable_by_skills_capability() {
1920        use crate::capabilities::attach_skill::AttachSkillCapability;
1921
1922        let skill_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1923        let cap = AttachSkillCapability::from_registry(
1924            skill_id,
1925            "code-review".to_string(),
1926            "Review code for issues".to_string(),
1927            "# Instructions\nReview the code carefully.\n\n## Steps\n1. Check style\n2. Check logic"
1928                .to_string(),
1929            vec![],
1930        );
1931
1932        let store = Arc::new(MockFileStore::new());
1933        let session_id = SessionId::new();
1934        for mount in cap.mounts() {
1935            materialize_mount_into_store(&store, session_id, &mount);
1936        }
1937
1938        // activate_skill should load instructions
1939        let context = ToolContext::with_file_store(session_id, store);
1940        let tool = ActivateSkillFromVfsTool;
1941        let result = tool
1942            .execute_with_context(serde_json::json!({"name": "code-review"}), &context)
1943            .await;
1944
1945        match result {
1946            ToolExecutionResult::Success(val) => {
1947                assert_eq!(val["skill"], "code-review");
1948                assert_eq!(val["description"], "Review code for issues");
1949                let instructions = val["instructions"].as_str().unwrap();
1950                assert!(instructions.contains("<skill name=\"code-review\">"));
1951                assert!(instructions.contains("Review the code carefully"));
1952                assert!(instructions.contains("Check logic"));
1953                assert!(instructions.contains("</skill>"));
1954            }
1955            other => panic!("Expected Success, got: {:?}", other),
1956        }
1957    }
1958
1959    #[tokio::test]
1960    async fn test_attach_skill_with_files_discovered() {
1961        use crate::capabilities::attach_skill::AttachSkillCapability;
1962
1963        let skill_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1964        let cap = AttachSkillCapability::from_registry(
1965            skill_id,
1966            "data-pipeline".to_string(),
1967            "Build data pipelines".to_string(),
1968            "# Instructions\nUse the bundled script.".to_string(),
1969            vec![
1970                ("run.py".to_string(), "import pandas as pd".to_string()),
1971                ("README.md".to_string(), "# Reference docs".to_string()),
1972            ],
1973        );
1974
1975        let store = Arc::new(MockFileStore::new());
1976        let session_id = SessionId::new();
1977        for mount in cap.mounts() {
1978            materialize_mount_into_store(&store, session_id, &mount);
1979        }
1980
1981        // list_skills discovers it
1982        let context = ToolContext::with_file_store(session_id, store.clone());
1983        let tool = ListSkillsTool;
1984        let result = tool
1985            .execute_with_context(serde_json::json!({}), &context)
1986            .await;
1987        match result {
1988            ToolExecutionResult::Success(val) => {
1989                assert_eq!(val["skills"][0]["name"], "data-pipeline");
1990            }
1991            other => panic!("Expected Success, got: {:?}", other),
1992        }
1993    }
1994
1995    #[tokio::test]
1996    async fn test_multiple_attach_skills_all_discovered() {
1997        use crate::capabilities::attach_skill::AttachSkillCapability;
1998
1999        let store = Arc::new(MockFileStore::new());
2000        let session_id = SessionId::new();
2001
2002        // Mount 3 different skills from "registry"
2003        for (i, (name, desc)) in [
2004            ("pdf-tool", "PDF processing"),
2005            ("csv-analyzer", "CSV analysis"),
2006            ("code-review", "Code review"),
2007        ]
2008        .iter()
2009        .enumerate()
2010        {
2011            let skill_id =
2012                uuid::Uuid::parse_str(&format!("550e8400-e29b-41d4-a716-44665544000{}", i))
2013                    .unwrap();
2014            let cap = AttachSkillCapability::from_registry(
2015                skill_id,
2016                name.to_string(),
2017                desc.to_string(),
2018                format!("# {name} Instructions"),
2019                vec![],
2020            );
2021            for mount in cap.mounts() {
2022                materialize_mount_into_store(&store, session_id, &mount);
2023            }
2024        }
2025
2026        // list_skills discovers all 3
2027        let context = ToolContext::with_file_store(session_id, store);
2028        let tool = ListSkillsTool;
2029        let result = tool
2030            .execute_with_context(serde_json::json!({}), &context)
2031            .await;
2032
2033        match result {
2034            ToolExecutionResult::Success(val) => {
2035                let skills = val["skills"].as_array().unwrap();
2036                assert_eq!(skills.len(), 3);
2037                let names: Vec<&str> = skills.iter().map(|s| s["name"].as_str().unwrap()).collect();
2038                assert!(names.contains(&"pdf-tool"));
2039                assert!(names.contains(&"csv-analyzer"));
2040                assert!(names.contains(&"code-review"));
2041            }
2042            other => panic!("Expected Success, got: {:?}", other),
2043        }
2044    }
2045
2046    #[tokio::test]
2047    async fn test_attach_skill_prompt_contribution_includes_mounted_skill() {
2048        use crate::capabilities::attach_skill::AttachSkillCapability;
2049
2050        let skill_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2051        let cap = AttachSkillCapability::from_registry(
2052            skill_id,
2053            "pdf-tool".to_string(),
2054            "Extract text from PDFs".to_string(),
2055            "# Instructions".to_string(),
2056            vec![],
2057        );
2058
2059        let store = Arc::new(MockFileStore::new());
2060        let session_id = SessionId::new();
2061        for mount in cap.mounts() {
2062            materialize_mount_into_store(&store, session_id, &mount);
2063        }
2064
2065        // SkillsCapability dynamic prompt should include the mounted skill
2066        let skills_cap = SkillsCapability;
2067        let ctx = SystemPromptContext {
2068            session_id,
2069            locale: None,
2070            file_store: Some(store),
2071            model: None,
2072        };
2073        let result = skills_cap.system_prompt_contribution(&ctx).await.unwrap();
2074        assert!(result.contains("pdf-tool"));
2075        assert!(result.contains("Extract text from PDFs"));
2076        assert!(result.contains("Available skills:"));
2077    }
2078
2079    #[tokio::test]
2080    async fn test_attach_skill_description_with_special_chars_roundtrips() {
2081        use crate::capabilities::attach_skill::AttachSkillCapability;
2082
2083        let skill_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2084        let cap = AttachSkillCapability::from_registry(
2085            skill_id,
2086            "tricky-skill".to_string(),
2087            "Description with: colons, #hashtags, and \"quotes\"".to_string(),
2088            "# Instructions\nDo the thing.".to_string(),
2089            vec![],
2090        );
2091
2092        let store = Arc::new(MockFileStore::new());
2093        let session_id = SessionId::new();
2094        for mount in cap.mounts() {
2095            materialize_mount_into_store(&store, session_id, &mount);
2096        }
2097
2098        // list_skills should parse the YAML correctly
2099        let context = ToolContext::with_file_store(session_id, store);
2100        let tool = ListSkillsTool;
2101        let result = tool
2102            .execute_with_context(serde_json::json!({}), &context)
2103            .await;
2104
2105        match result {
2106            ToolExecutionResult::Success(val) => {
2107                let skills = val["skills"].as_array().unwrap();
2108                assert_eq!(skills.len(), 1);
2109                assert_eq!(skills[0]["name"], "tricky-skill");
2110                assert_eq!(
2111                    skills[0]["description"],
2112                    "Description with: colons, #hashtags, and \"quotes\""
2113                );
2114            }
2115            other => panic!("Expected Success, got: {:?}", other),
2116        }
2117    }
2118
2119    // ========================================================================
2120    // activate_skill env var substitution tests
2121    // ========================================================================
2122
2123    #[tokio::test]
2124    async fn test_activate_skill_substitutes_session_id() {
2125        let fs = Arc::new(MockFileStore::new());
2126        let session_id = SessionId::new();
2127        let skill_md =
2128            "---\nname: test-env\ndescription: Test env vars.\n---\n\nSession: ${SESSION_ID}";
2129        fs.add_file(session_id, "/.agents/skills/test-env/SKILL.md", skill_md);
2130
2131        let context = ToolContext::with_file_store(session_id, fs);
2132        let tool = ActivateSkillFromVfsTool;
2133
2134        let result = tool
2135            .execute_with_context(serde_json::json!({"name": "test-env"}), &context)
2136            .await;
2137        match result {
2138            ToolExecutionResult::Success(val) => {
2139                let instructions = val["instructions"].as_str().unwrap();
2140                let expected_id = session_id.to_string();
2141                assert!(
2142                    instructions.contains(&expected_id),
2143                    "Instructions should contain session ID '{}', got: {}",
2144                    expected_id,
2145                    instructions
2146                );
2147                assert!(
2148                    !instructions.contains("${SESSION_ID}"),
2149                    "Raw placeholder should be replaced"
2150                );
2151            }
2152            other => panic!("Expected Success, got: {:?}", other),
2153        }
2154    }
2155
2156    #[tokio::test]
2157    async fn test_activate_skill_substitutes_skill_dir() {
2158        let fs = Arc::new(MockFileStore::new());
2159        let session_id = SessionId::new();
2160        let skill_md =
2161            "---\nname: test-dir\ndescription: Test skill dir.\n---\n\nDir: ${SKILL_DIR}";
2162        fs.add_file(session_id, "/.agents/skills/test-dir/SKILL.md", skill_md);
2163
2164        let context = ToolContext::with_file_store(session_id, fs);
2165        let tool = ActivateSkillFromVfsTool;
2166
2167        let result = tool
2168            .execute_with_context(serde_json::json!({"name": "test-dir"}), &context)
2169            .await;
2170        match result {
2171            ToolExecutionResult::Success(val) => {
2172                let instructions = val["instructions"].as_str().unwrap();
2173                assert!(
2174                    instructions.contains("/.agents/skills/test-dir"),
2175                    "Instructions should contain skill dir path, got: {}",
2176                    instructions
2177                );
2178                assert!(
2179                    !instructions.contains("${SKILL_DIR}"),
2180                    "Raw placeholder should be replaced"
2181                );
2182            }
2183            other => panic!("Expected Success, got: {:?}", other),
2184        }
2185    }
2186
2187    #[tokio::test]
2188    async fn test_activate_skill_substitutes_both_env_vars() {
2189        let fs = Arc::new(MockFileStore::new());
2190        let session_id = SessionId::new();
2191        let skill_md = "---\nname: test-both\ndescription: Both vars.\n---\n\n${SKILL_DIR}/run.sh --session ${SESSION_ID}";
2192        fs.add_file(session_id, "/.agents/skills/test-both/SKILL.md", skill_md);
2193
2194        let context = ToolContext::with_file_store(session_id, fs);
2195        let tool = ActivateSkillFromVfsTool;
2196
2197        let result = tool
2198            .execute_with_context(serde_json::json!({"name": "test-both"}), &context)
2199            .await;
2200        match result {
2201            ToolExecutionResult::Success(val) => {
2202                let instructions = val["instructions"].as_str().unwrap();
2203                let expected_id = session_id.to_string();
2204                assert!(instructions.contains("/.agents/skills/test-both/run.sh"));
2205                assert!(instructions.contains(&format!("--session {}", expected_id)));
2206                assert!(!instructions.contains("${SESSION_ID}"));
2207                assert!(!instructions.contains("${SKILL_DIR}"));
2208            }
2209            other => panic!("Expected Success, got: {:?}", other),
2210        }
2211    }
2212
2213    // Regression test for PR #1449 / EVE-388: SKILL.md written via the VFS
2214    // at runtime must leave `!`command`` placeholders literal — no shell
2215    // execution on the worker host.
2216    #[tokio::test]
2217    async fn test_activate_skill_does_not_execute_commands_for_writable_skill() {
2218        let fs = Arc::new(MockFileStore::new());
2219        let session_id = SessionId::new();
2220        let skill_md = "---\nname: test-no-exec\ndescription: Leaves command placeholders literal.\n---\n\nLiteral: !`echo pwned`";
2221        // add_file — writable (user-authored) source.
2222        fs.add_file(
2223            session_id,
2224            "/.agents/skills/test-no-exec/SKILL.md",
2225            skill_md,
2226        );
2227
2228        let context = ToolContext::with_file_store(session_id, fs);
2229        let tool = ActivateSkillFromVfsTool;
2230
2231        let result = tool
2232            .execute_with_context(serde_json::json!({"name": "test-no-exec"}), &context)
2233            .await;
2234        match result {
2235            ToolExecutionResult::Success(val) => {
2236                let instructions = val["instructions"].as_str().unwrap();
2237                assert!(
2238                    instructions.contains("Literal: !`echo pwned`"),
2239                    "writable SKILL.md must not execute commands; got: {}",
2240                    instructions
2241                );
2242                assert!(
2243                    !instructions.contains("pwned\n") && !instructions.contains("\npwned"),
2244                    "command output must not appear in skill instructions; got: {}",
2245                    instructions
2246                );
2247            }
2248            other => panic!("Expected Success, got: {:?}", other),
2249        }
2250    }
2251
2252    // Regression test for the Copilot review of EVE-388: a SKILL.md whose
2253    // `is_readonly = true` does NOT earn trust. Users can set `is_readonly`
2254    // via the session-files API and `InitialFile`, so it is not a valid
2255    // provenance signal. The trust gate must remain off until a
2256    // platform-controlled signal (e.g. `mount_capability_id`) exists.
2257    #[tokio::test]
2258    async fn test_activate_skill_does_not_execute_commands_for_readonly_user_skill() {
2259        let fs = Arc::new(MockFileStore::new());
2260        let session_id = SessionId::new();
2261        let skill_md = "---\nname: test-readonly-no-exec\ndescription: is_readonly alone must not unlock exec.\n---\n\nLiteral: !`echo pwned`";
2262        // add_readonly_file simulates a user marking a SKILL.md is_readonly=true
2263        // via the session-files API or InitialFile configuration.
2264        fs.add_readonly_file(
2265            session_id,
2266            "/.agents/skills/test-readonly-no-exec/SKILL.md",
2267            skill_md,
2268        );
2269
2270        let context = ToolContext::with_file_store(session_id, fs);
2271        let tool = ActivateSkillFromVfsTool;
2272
2273        let result = tool
2274            .execute_with_context(
2275                serde_json::json!({"name": "test-readonly-no-exec"}),
2276                &context,
2277            )
2278            .await;
2279        match result {
2280            ToolExecutionResult::Success(val) => {
2281                let instructions = val["instructions"].as_str().unwrap();
2282                assert!(
2283                    instructions.contains("Literal: !`echo pwned`"),
2284                    "is_readonly=true must not bypass the command-substitution gate; got: {}",
2285                    instructions
2286                );
2287                assert!(
2288                    !instructions.contains("pwned\n") && !instructions.contains("\npwned"),
2289                    "command output must not appear in skill instructions; got: {}",
2290                    instructions
2291                );
2292            }
2293            other => panic!("Expected Success, got: {:?}", other),
2294        }
2295    }
2296
2297    #[tokio::test]
2298    async fn test_contribution_excludes_disable_model_invocation_skills() {
2299        let cap = SkillsCapability;
2300        let store = Arc::new(MockFileStore::new());
2301        let session_id = SessionId::new();
2302
2303        // Normal skill
2304        store.add_file(
2305            session_id,
2306            "/.agents/skills/normal-skill/SKILL.md",
2307            &valid_skill_md("normal-skill", "A normal skill"),
2308        );
2309
2310        // Skill with disable-model-invocation: true
2311        store.add_file(
2312            session_id,
2313            "/.agents/skills/manual-skill/SKILL.md",
2314            "---\nname: manual-skill\ndescription: Manual only skill\ndisable-model-invocation: true\n---\n\n# Instructions\nManual only.",
2315        );
2316
2317        let ctx = SystemPromptContext {
2318            session_id,
2319            locale: None,
2320            file_store: Some(store),
2321            model: None,
2322        };
2323
2324        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
2325        assert!(
2326            result.contains("normal-skill"),
2327            "Normal skill should appear"
2328        );
2329        assert!(
2330            !result.contains("manual-skill"),
2331            "Skill with disable-model-invocation should not appear in system prompt"
2332        );
2333    }
2334
2335    #[tokio::test]
2336    async fn test_contribution_truncates_long_descriptions() {
2337        let cap = SkillsCapability;
2338        let store = Arc::new(MockFileStore::new());
2339        let session_id = SessionId::new();
2340
2341        let long_desc = "a".repeat(200);
2342        store.add_file(
2343            session_id,
2344            "/.agents/skills/long-desc/SKILL.md",
2345            &valid_skill_md("long-desc", &long_desc),
2346        );
2347
2348        let ctx = SystemPromptContext {
2349            session_id,
2350            locale: None,
2351            file_store: Some(store),
2352            model: None,
2353        };
2354
2355        let result = cap.system_prompt_contribution(&ctx).await.unwrap();
2356
2357        // The full 200-char description should NOT appear
2358        assert!(!result.contains(&long_desc));
2359        // Should contain truncated version with ellipsis
2360        assert!(result.contains('…'));
2361    }
2362
2363    #[test]
2364    fn localized_name_differs_from_default() {
2365        let cap = SkillsCapability;
2366        assert_ne!(cap.localized_name(Some("uk")), cap.name());
2367    }
2368}