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