Skip to main content

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