1use 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
47pub const SKILLS_CAPABILITY_ID: &str = "skills";
49
50#[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
60use super::attach_skill::SKILLS_DISCOVERY_PATH as SKILLS_PATH;
62
63const SKILL_ACTIVATION_KIND: &str = "skill_activation";
67
68fn skill_activation_resource_id(name: &str) -> String {
69 format!("{SKILL_ACTIVATION_KIND}:{name}")
70}
71
72const MAX_SKILLS_IN_PROMPT: usize = 15;
74
75const MAX_SKILLS_SCAN_IN_PROMPT: usize = 64;
78
79const MAX_DESCRIPTION_CHARS: usize = 76;
81
82const WORKSPACE_PREFIX: &str = "/workspace";
84
85fn 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
95fn 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
104pub struct SkillsCapability;
109
110const 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 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 return Some(format!(
174 "<capability id=\"{}\">\n{}\n</capability>",
175 self.id(),
176 SKILLS_SYSTEM_PROMPT
177 ));
178 }
179 };
180
181 let entries = match file_store.list_directory(ctx.session_id, SKILLS_PATH).await {
183 Ok(entries) => entries,
184 Err(_) => {
185 return Some(format!(
187 "<capability id=\"{}\">\n{}\n</capability>",
188 self.id(),
189 SKILLS_SYSTEM_PROMPT
190 ));
191 }
192 };
193
194 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 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#[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 let entries = match file_store
389 .list_directory(context.session_id, SKILLS_PATH)
390 .await
391 {
392 Ok(entries) => entries,
393 Err(_) => {
394 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#[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 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 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 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 let content = file.content.as_deref().unwrap_or("");
619 let _ = &file;
627 match crate::skill::parse_skill_md(content) {
628 Ok(parsed) => {
629 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 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 if let Some(registry) = &context.session_resource_registry {
669 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#[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 struct MockFileStore {
742 files: Mutex<HashMap<(SessionId, String), String>>,
744 readonly_files: Mutex<std::collections::HashSet<(SessionId, String)>>,
746 dirs: Mutex<std::collections::HashSet<(SessionId, String)>>,
748 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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()], ®istry).unwrap();
1597
1598 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 #[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 let runtime_agent = RuntimeAgentBuilder::new()
1629 .system_prompt("Base prompt.")
1630 .with_capabilities(&["skills".to_string()], ®istry, &ctx)
1631 .await
1632 .model("gpt-5.2")
1633 .build();
1634
1635 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 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 #[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 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 assert!(!result.contains("Available skills:"));
1726 }
1727
1728 #[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 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 #[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 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 assert!(result.contains("Available skills:"));
1791
1792 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 assert!(!result.contains(&long_desc));
2388 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}