main
py 774 lines 25.7 KB
Raw
1 import importlib.util
2 import sys
3 import types
4 from pathlib import Path
5
6 import pytest
7
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 SKILLS_HELPER_PATH = PROJECT_ROOT / "helpers" / "skills.py"
11 HELPER_STUB_MODULES = (
12 "helpers",
13 "helpers.files",
14 "helpers.projects",
15 "helpers.plugins",
16 "helpers.subagents",
17 "helpers.file_tree",
18 "helpers.runtime",
19 )
20
21
22 def _register_helpers_stubs():
23 helpers_pkg = types.ModuleType("helpers")
24 helpers_pkg.__path__ = []
25
26 files = types.ModuleType("helpers.files")
27 files.normalize_a0_path = lambda path: str(path).replace("\\", "/")
28 files.fix_dev_path = lambda path: str(path).replace("\\", "/")
29 files.get_abs_path = lambda *parts: "/" + "/".join(str(part).strip("/") for part in parts if part)
30 files.exists = lambda path: False
31 files.is_in_dir = lambda path, root: str(path).startswith(str(root))
32 files.find_existing_paths_by_pattern = lambda pattern: []
33 files.read_file = lambda path: ""
34
35 projects = types.ModuleType("helpers.projects")
36 projects.get_context_project_name = lambda context: context.get_data("project")
37 projects.get_project_meta = lambda project_name, *parts: (
38 f"/projects/{project_name}/" + "/".join(str(part).strip("/") for part in parts if part)
39 if project_name
40 else ""
41 )
42
43 plugins = types.ModuleType("helpers.plugins")
44 plugins.get_plugin_config = lambda *args, **kwargs: {}
45 plugins.get_enabled_plugin_paths = lambda *args, **kwargs: []
46
47 subagents = types.ModuleType("helpers.subagents")
48 subagents.get_paths = lambda agent, *parts: []
49
50 file_tree = types.ModuleType("helpers.file_tree")
51 file_tree.file_tree = lambda *args, **kwargs: ""
52
53 runtime = types.ModuleType("helpers.runtime")
54 runtime.is_development = lambda: False
55
56 helpers_pkg.files = files
57 helpers_pkg.projects = projects
58 helpers_pkg.plugins = plugins
59 helpers_pkg.subagents = subagents
60 helpers_pkg.file_tree = file_tree
61 helpers_pkg.runtime = runtime
62
63 sys.modules["helpers"] = helpers_pkg
64 sys.modules["helpers.files"] = files
65 sys.modules["helpers.projects"] = projects
66 sys.modules["helpers.plugins"] = plugins
67 sys.modules["helpers.subagents"] = subagents
68 sys.modules["helpers.file_tree"] = file_tree
69 sys.modules["helpers.runtime"] = runtime
70
71
72 def _load_skills_helper_module():
73 missing = object()
74 original_modules = {name: sys.modules.get(name, missing) for name in HELPER_STUB_MODULES}
75 _register_helpers_stubs()
76 try:
77 spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH)
78 module = importlib.util.module_from_spec(spec)
79 assert spec and spec.loader
80 sys.modules[spec.name] = module
81 spec.loader.exec_module(module)
82 return module
83 finally:
84 for name, original in original_modules.items():
85 if original is missing:
86 sys.modules.pop(name, None)
87 else:
88 sys.modules[name] = original
89
90
91 runtime = _load_skills_helper_module()
92
93
94 class DummyContext:
95 def __init__(self):
96 self.data = {}
97 self.agent = None
98
99 def get_data(self, key, recursive=True):
100 return self.data.get(key)
101
102 def set_data(self, key, value, recursive=True):
103 self.data[key] = value
104
105 def get_agent(self):
106 return self.agent
107
108
109 class DummyAgent:
110 def __init__(self):
111 self.context = DummyContext()
112 self.context.agent = self
113 self.data = {}
114
115
116 def _scope_config(entries=None, *, hidden_entries=None, max_active_skills=None):
117 config = {}
118 if entries is not None:
119 config["active_skills"] = entries
120 if hidden_entries is not None:
121 config["hidden_skills"] = hidden_entries
122 if max_active_skills is not None:
123 config["max_active_skills"] = max_active_skills
124 return config
125
126
127 def _write_skill_catalog(root: Path, *names: str) -> Path:
128 skills_root = root / "skills"
129 for name in names:
130 skill_dir = skills_root / name
131 skill_dir.mkdir(parents=True)
132 (skill_dir / "SKILL.md").write_text(
133 f"---\nname: {name}\ndescription: {name} description\n---\nBody\n",
134 encoding="utf-8",
135 )
136 return skills_root
137
138
139 def test_active_skills_cap_is_twenty():
140 assert runtime.MAX_ACTIVE_SKILLS == 20
141 assert runtime.get_max_active_skills() == 20
142
143
144 def test_skills_config_can_raise_active_cap_above_default():
145 config = runtime.normalize_skills_config(
146 {
147 "max_active_skills": 25,
148 "active_skills": [{"name": f"Skill {index}"} for index in range(25)],
149 }
150 )
151
152 assert config["max_active_skills"] == 25
153 assert len(config["active_skills"]) == 25
154
155
156 def test_hidden_skills_are_not_capped_like_active_skills():
157 agent = DummyAgent()
158 entries = [{"name": f"Hidden {index}"} for index in range(25)]
159 agent.context.set_data(runtime.CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS, entries)
160
161 assert len(runtime.get_chat_disabled_skills(agent.context)) == 25
162 assert len(runtime.get_hidden_skills(agent)) == 25
163
164
165 def test_active_skill_prompt_protocol_is_disabled(monkeypatch):
166 monkeypatch.setattr(
167 runtime.plugin_helpers,
168 "get_plugin_config",
169 lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
170 )
171 agent = DummyAgent()
172
173 assert runtime.get_active_skills(agent) == [{"name": "Pinned"}]
174 assert runtime.build_active_skills_prompt(agent) == ""
175
176
177 def test_hiding_skill_does_not_unload_history_loaded_skill():
178 agent = DummyAgent()
179 agent.context.set_data(
180 runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
181 ["history-skill"],
182 )
183
184 runtime.hide_chat_skill(agent, {"name": "history-skill"})
185
186 assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
187 "history-skill"
188 ]
189 assert runtime.get_chat_disabled_skills(agent.context) == [
190 {"name": "history-skill"}
191 ]
192
193
194 def test_chat_activation_can_override_scope_defaults(monkeypatch):
195 monkeypatch.setattr(
196 runtime.plugin_helpers,
197 "get_plugin_config",
198 lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
199 )
200 agent = DummyAgent()
201
202 assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
203 "Pinned"
204 ]
205
206 runtime.activate_chat_skill(agent, {"name": "Extra"})
207 assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
208 "Pinned",
209 "Extra",
210 ]
211 assert [entry["name"] for entry in runtime.get_chat_active_skills(agent.context)] == [
212 "Extra"
213 ]
214
215 runtime.deactivate_chat_skill(agent, {"name": "Pinned"})
216 assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
217 "Extra"
218 ]
219 assert [entry["name"] for entry in runtime.get_chat_disabled_skills(agent.context)] == [
220 "Pinned"
221 ]
222
223 runtime.activate_chat_skill(agent, {"name": "Pinned"})
224 assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
225 "Pinned",
226 "Extra",
227 ]
228 assert runtime.get_chat_disabled_skills(agent.context) == []
229
230
231 def test_chat_deactivation_hides_name_only_scope_default_by_path(monkeypatch):
232 monkeypatch.setattr(
233 runtime.plugin_helpers,
234 "get_plugin_config",
235 lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
236 )
237 agent = DummyAgent()
238
239 runtime.deactivate_chat_skill(
240 agent,
241 {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
242 )
243
244 assert runtime.get_active_skills(agent) == []
245 assert runtime.get_chat_disabled_skills(agent.context) == [
246 {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"}
247 ]
248
249
250 def test_reactivating_name_only_scope_default_by_path_clears_hidden_override(monkeypatch):
251 monkeypatch.setattr(
252 runtime.plugin_helpers,
253 "get_plugin_config",
254 lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
255 )
256 agent = DummyAgent()
257
258 runtime.deactivate_chat_skill(
259 agent,
260 {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
261 )
262 runtime.activate_chat_skill(
263 agent,
264 {"name": "Pinned", "path": "/a0/usr/skills/custom/pinned"},
265 )
266
267 assert runtime.get_active_skills(agent) == [{"name": "Pinned"}]
268 assert runtime.get_chat_active_skills(agent.context) == []
269 assert runtime.get_chat_disabled_skills(agent.context) == []
270
271
272 def test_loaded_skill_entries_come_from_context_data():
273 agent = DummyAgent()
274 agent.context.set_data(
275 runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
276 [
277 "host-computer-use",
278 "",
279 "a0-development",
280 ],
281 )
282
283 assert runtime.get_loaded_skill_entries(agent) == [
284 {"name": "host-computer-use"},
285 {"name": "a0-development"},
286 ]
287
288
289 def test_loaded_skill_entries_migrate_legacy_agent_data():
290 agent = DummyAgent()
291 agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
292 "host-computer-use",
293 "",
294 "a0-development",
295 ]
296
297 assert runtime.get_loaded_skill_entries(agent) == [
298 {"name": "host-computer-use"},
299 {"name": "a0-development"},
300 ]
301 assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
302 "host-computer-use",
303 "a0-development",
304 ]
305 assert runtime.AGENT_DATA_NAME_LOADED_SKILLS not in agent.data
306
307
308 def test_unloading_last_migrated_skill_does_not_restore_legacy_agent_data():
309 agent = DummyAgent()
310 agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = ["host-computer-use"]
311
312 assert runtime.unload_agent_skill(agent, {"name": "host-computer-use"}) is True
313 assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) is None
314 assert runtime.get_loaded_skill_entries(agent) == []
315
316
317 def test_skill_runtime_does_not_alias_old_office_skill_references():
318 entries = runtime.normalize_active_skills(
319 [
320 "office-artifacts",
321 {"name": "word-documents"},
322 {"path": "/a0/plugins/_office/skills/excel-workbooks"},
323 {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"},
324 "presentation-decks",
325 ]
326 )
327
328 assert entries == [
329 {"name": "office-artifacts"},
330 {"name": "word-documents"},
331 {"path": "/a0/plugins/_office/skills/excel-workbooks"},
332 {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"},
333 {"name": "presentation-decks"},
334 ]
335
336 agent = DummyAgent()
337 agent.context.set_data(
338 runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
339 [
340 "office-artifacts",
341 "word-documents",
342 "excel-workbooks",
343 "presentation-decks",
344 ],
345 )
346
347 assert runtime.get_loaded_skill_entries(agent) == [
348 {"name": "office-artifacts"},
349 {"name": "word-documents"},
350 {"name": "excel-workbooks"},
351 {"name": "presentation-decks"},
352 ]
353
354 assert runtime.unload_agent_skill(agent, {"name": "office-artifacts"}) is True
355 assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
356 "word-documents",
357 "excel-workbooks",
358 "presentation-decks",
359 ]
360
361
362 def test_builtin_plugin_skill_delete_is_rejected_before_filesystem_delete():
363 with pytest.raises(PermissionError, match="Built-in plugin skills cannot be deleted"):
364 runtime.delete_skill("/a0/plugins/_office/skills/office-artifacts")
365
366
367 def test_invalid_skill_frontmatter_reports_yaml_errors():
368 frontmatter, errors = runtime.parse_frontmatter("name: [unterminated\n")
369
370 assert frontmatter == {}
371 assert errors
372 assert errors[0].startswith("Invalid YAML frontmatter")
373
374
375 def test_invalid_skill_frontmatter_warns_when_skill_is_skipped(monkeypatch, tmp_path: Path):
376 skills_root = tmp_path / "skills"
377 broken = skills_root / "broken-skill"
378 broken.mkdir(parents=True)
379 (broken / "SKILL.md").write_text(
380 "---\nname: broken-skill\ndescription: missing closing fence\nBody\n",
381 encoding="utf-8",
382 )
383
384 warnings: list[str] = []
385 runtime._WARNED_SKILL_PARSE_PATHS.clear()
386 monkeypatch.setattr(runtime, "get_skill_roots", lambda agent=None: [str(skills_root)])
387 monkeypatch.setattr(runtime, "_emit_skill_scan_warning", warnings.append)
388
389 assert runtime.list_skills() == []
390 assert warnings == [
391 "skill broken-skill skipped: invalid frontmatter at line 4: "
392 "Unterminated YAML frontmatter"
393 ]
394
395 assert runtime.list_skills() == []
396 assert len(warnings) == 1
397
398
399 def test_a0_manage_plugin_skill_frontmatter_is_valid_yaml():
400 text = (PROJECT_ROOT / "skills" / "a0-manage-plugin" / "SKILL.md").read_text(
401 encoding="utf-8"
402 )
403
404 frontmatter, body, errors = runtime.split_frontmatter(text)
405
406 assert errors == []
407 assert frontmatter["name"] == "a0-manage-plugin"
408 assert "Agent Zero Plugin Management" in body
409
410
411 def test_renamed_skills_use_standard_frontmatter_only():
412 skill_paths = [
413 PROJECT_ROOT / "skills" / "build-skill" / "SKILL.md",
414 PROJECT_ROOT / "skills" / "scheduled-tasks" / "SKILL.md",
415 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-code-execution" / "SKILL.md",
416 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use" / "SKILL.md",
417 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use-macos" / "SKILL.md",
418 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use-windows" / "SKILL.md",
419 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-file-editing" / "SKILL.md",
420 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "setup-a0-cli" / "SKILL.md",
421 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-automation" / "SKILL.md",
422 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-extension-control" / "SKILL.md",
423 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-form-workflows" / "SKILL.md",
424 ]
425
426 for path in skill_paths:
427 frontmatter, body, errors = runtime.split_frontmatter(path.read_text(encoding="utf-8"))
428 assert errors == []
429 expected_keys = {"name", "description"}
430 if path.parent.name == "host-computer-use":
431 expected_keys.update({"tags", "triggers"})
432 if path.parent.name in {"browser-automation", "browser-form-workflows"}:
433 expected_keys.add("triggers")
434 assert set(frontmatter) == expected_keys
435 assert frontmatter["name"] == path.parent.name
436 assert frontmatter["description"]
437 assert body
438
439
440 def test_browser_skills_rank_for_browser_trigger_phrases(monkeypatch):
441 browser_automation = runtime.skill_from_markdown(
442 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-automation" / "SKILL.md"
443 )
444 browser_forms = runtime.skill_from_markdown(
445 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-form-workflows" / "SKILL.md"
446 )
447 document_query = runtime.skill_from_markdown(
448 PROJECT_ROOT / "plugins" / "_document_query" / "skills" / "document-query" / "SKILL.md"
449 )
450 host_computer = runtime.skill_from_markdown(
451 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use" / "SKILL.md"
452 )
453 assert browser_automation is not None
454 assert browser_forms is not None
455 assert document_query is not None
456 assert host_computer is not None
457 monkeypatch.setattr(
458 runtime,
459 "list_skills",
460 lambda *args, **kwargs: [
461 document_query,
462 host_computer,
463 browser_forms,
464 browser_automation,
465 ],
466 )
467
468 browser_queries = (
469 "open this URL in my browser and take a screenshot",
470 "interact with a JavaScript page and verify it visually",
471 "use the host browser for multi-tab browsing",
472 )
473 for query in browser_queries:
474 results = runtime.search_skills(query, limit=3)
475 assert results[0].name == "browser-automation"
476
477 form_results = [
478 skill.name
479 for skill in runtime.search_skills(
480 "fill a web form with a checkbox and file upload",
481 limit=3,
482 )
483 ]
484 assert "browser-automation" in form_results
485 assert "browser-form-workflows" in form_results
486
487
488 def test_skill_search_does_not_score_description_terms_alone(monkeypatch):
489 browser_automation = runtime.Skill(
490 name="browser-automation",
491 description=(
492 "Use for browser automation, screenshots, forms, uploads, "
493 "and complex tool workflows."
494 ),
495 path=Path("/skills/browser-automation"),
496 skill_md_path=Path("/skills/browser-automation/SKILL.md"),
497 )
498 monkeypatch.setattr(runtime, "list_skills", lambda *args, **kwargs: [browser_automation])
499
500 rendered_user_message = (
501 'user: {"user_message": "Please reply with exactly OK. Do not use tools.", '
502 '"attachments": []}'
503 )
504
505 assert runtime.search_skills(rendered_user_message) == []
506 assert runtime.search_skills("Open a browser screenshot?", limit=1) == [browser_automation]
507
508
509 def test_host_computer_use_ranks_before_linux_desktop_for_host_screen_queries(monkeypatch):
510 host_skill = runtime.skill_from_markdown(
511 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use" / "SKILL.md"
512 )
513 linux_skill = runtime.skill_from_markdown(
514 PROJECT_ROOT / "plugins" / "_desktop" / "skills" / "linux-desktop" / "SKILL.md"
515 )
516 assert host_skill is not None
517 assert linux_skill is not None
518 monkeypatch.setattr(runtime, "list_skills", lambda *args, **kwargs: [linux_skill, host_skill])
519
520 host_queries = (
521 "hide a window on my host computer screen with computer use",
522 "take screenshot of my local Ubuntu Wayland desktop",
523 "minimize a window on my screen",
524 )
525 for query in host_queries:
526 results = runtime.search_skills(query, limit=2)
527 assert results[0].name == "host-computer-use"
528
529 xpra_results = runtime.search_skills(
530 "operate Agent Zero built-in Xpra Desktop LibreOffice GUI",
531 limit=2,
532 )
533 assert xpra_results[0].name == "linux-desktop"
534
535
536 def test_unload_agent_skill_removes_loaded_skill_by_name():
537 agent = DummyAgent()
538 agent.context.set_data(
539 runtime.CONTEXT_DATA_NAME_LOADED_SKILLS,
540 [
541 "host-computer-use",
542 "a0-development",
543 ],
544 )
545
546 removed = runtime.unload_agent_skill(
547 agent,
548 {
549 "name": "host-computer-use",
550 "path": "/a0/plugins/_a0_connector/skills/host-computer-use",
551 },
552 )
553
554 assert removed is True
555 assert agent.context.get_data(runtime.CONTEXT_DATA_NAME_LOADED_SKILLS) == [
556 "a0-development"
557 ]
558
559
560 def test_clearing_chat_overrides_restores_scope_defaults(monkeypatch):
561 monkeypatch.setattr(
562 runtime.plugin_helpers,
563 "get_plugin_config",
564 lambda *args, **kwargs: _scope_config([{"name": "Pinned"}]),
565 )
566 agent = DummyAgent()
567 runtime.activate_chat_skill(agent, {"name": "Extra"})
568 runtime.deactivate_chat_skill(agent, {"name": "Pinned"})
569
570 runtime.clear_chat_skill_overrides(agent)
571
572 assert [entry["name"] for entry in runtime.get_active_skills(agent)] == [
573 "Pinned"
574 ]
575 assert runtime.get_chat_active_skills(agent.context) == []
576 assert runtime.get_chat_disabled_skills(agent.context) == []
577 assert runtime.get_chat_visible_skills(agent.context) == []
578
579
580 def test_activating_new_skill_fails_once_limit_is_full(monkeypatch):
581 monkeypatch.setattr(
582 runtime.plugin_helpers,
583 "get_plugin_config",
584 lambda *args, **kwargs: _scope_config(
585 [{"name": f"Pinned {index}"} for index in range(20)]
586 ),
587 )
588 agent = DummyAgent()
589
590 with pytest.raises(ValueError, match="at most 20"):
591 runtime.activate_chat_skill(agent, {"name": "Overflow"})
592
593 assert len(runtime.get_active_skills(agent)) == 20
594
595
596 def test_chat_activation_respects_scope_configured_cap(monkeypatch):
597 monkeypatch.setattr(
598 runtime.plugin_helpers,
599 "get_plugin_config",
600 lambda *args, **kwargs: _scope_config(max_active_skills=25),
601 )
602 agent = DummyAgent()
603
604 for index in range(21):
605 runtime.activate_chat_skill(agent, {"name": f"Extra {index}"})
606
607 assert len(runtime.get_chat_active_skills(agent.context)) == 21
608 assert len(runtime.get_active_skills(agent)) == 21
609
610
611 def test_activating_new_skill_uses_scope_configured_limit(monkeypatch):
612 monkeypatch.setattr(
613 runtime.plugin_helpers,
614 "get_plugin_config",
615 lambda *args, **kwargs: _scope_config(
616 [{"name": f"Pinned {index}"} for index in range(3)],
617 max_active_skills=3,
618 ),
619 )
620 agent = DummyAgent()
621
622 with pytest.raises(ValueError, match="at most 3"):
623 runtime.activate_chat_skill(agent, {"name": "Overflow"})
624
625 assert len(runtime.get_active_skills(agent)) == 3
626
627
628 def test_hidden_skills_filter_agent_visible_skill_catalog(monkeypatch, tmp_path: Path):
629 skills_root = _write_skill_catalog(tmp_path, "alpha-skill", "beta-skill")
630
631 monkeypatch.setattr(
632 runtime.subagents,
633 "get_paths",
634 lambda agent, *parts: [str(skills_root)],
635 )
636 monkeypatch.setattr(runtime.files, "exists", lambda path: Path(str(path)) == skills_root)
637 monkeypatch.setattr(
638 runtime.plugin_helpers,
639 "get_plugin_config",
640 lambda *args, **kwargs: {"hidden_skills": [{"name": "beta-skill"}]},
641 )
642
643 agent = DummyAgent()
644
645 assert [skill.name for skill in runtime.list_skills(agent)] == ["alpha-skill"]
646 assert [skill.name for skill in runtime.list_skills(agent, include_hidden=True)] == [
647 "alpha-skill",
648 "beta-skill",
649 ]
650 assert runtime.search_skills("beta", agent=agent) == []
651 assert [skill.name for skill in runtime.search_skills("beta", agent=agent, include_hidden=True)] == [
652 "beta-skill"
653 ]
654 assert runtime.find_skill("beta-skill", agent=agent) is None
655 assert runtime.find_skill("beta-skill", agent=agent, include_hidden=True).name == "beta-skill"
656
657 catalog = runtime.list_skill_catalog(agent=agent)
658 hidden_by_name = {item["name"]: item["hidden"] for item in catalog}
659 assert hidden_by_name == {
660 "alpha-skill": False,
661 "beta-skill": True,
662 }
663
664
665 def test_chat_visible_override_restores_scope_hidden_skill(monkeypatch):
666 monkeypatch.setattr(
667 runtime.plugin_helpers,
668 "get_plugin_config",
669 lambda *args, **kwargs: {"hidden_skills": [{"name": "beta-skill"}]},
670 )
671 agent = DummyAgent()
672
673 assert runtime.get_hidden_skills(agent) == [{"name": "beta-skill"}]
674
675 runtime.show_chat_skill(agent, {"name": "beta-skill"})
676 assert runtime.get_hidden_skills(agent) == []
677 assert runtime.get_chat_visible_skills(agent.context) == [{"name": "beta-skill"}]
678
679 runtime.hide_chat_skill(agent, {"name": "beta-skill"})
680 assert runtime.get_hidden_skills(agent) == [{"name": "beta-skill"}]
681 assert runtime.get_chat_visible_skills(agent.context) == []
682
683
684 def test_visibility_policy_is_absent_until_explicitly_configured():
685 normalized = runtime.normalize_skills_config({"hidden_skills": []})
686
687 assert "visibility_policy" not in normalized
688 assert runtime.normalize_visibility_policy(
689 {
690 "mode": "custom",
691 "default": "block",
692 "allowed": ["alpha", "alpha", {"name": "missing"}],
693 "blocked": [],
694 }
695 ) == {
696 "mode": "custom",
697 "default": "block",
698 "allowed": ["alpha", "missing"],
699 "blocked": [],
700 }
701
702
703 def test_allow_only_visibility_blocks_new_skills_without_pinning(
704 monkeypatch, tmp_path: Path
705 ):
706 skills_root = _write_skill_catalog(tmp_path, "alpha-skill", "new-skill")
707
708 monkeypatch.setattr(
709 runtime.subagents,
710 "get_paths",
711 lambda agent, *parts: [str(skills_root)],
712 )
713 monkeypatch.setattr(runtime.files, "exists", lambda path: Path(str(path)) == skills_root)
714 monkeypatch.setattr(
715 runtime.plugin_helpers,
716 "get_plugin_config",
717 lambda *args, **kwargs: {
718 "visibility_policy": {
719 "mode": "custom",
720 "default": "block",
721 "allowed": ["alpha-skill"],
722 "blocked": [],
723 }
724 },
725 )
726 agent = DummyAgent()
727
728 assert [skill.name for skill in runtime.list_skills(agent)] == ["alpha-skill"]
729 assert runtime.find_skill("new-skill", agent=agent) is None
730 assert runtime.load_skill_for_agent("new-skill", agent=agent) == (
731 "Error: skill 'new-skill' not found"
732 )
733 assert runtime.get_active_skills(agent) == []
734
735 catalog = {item["name"]: item for item in runtime.list_skill_catalog(agent=agent)}
736 assert catalog["alpha-skill"]["hidden"] is False
737 assert catalog["new-skill"]["hidden"] is True
738
739
740 def test_profile_blocked_skill_cannot_be_reenabled_by_chat_override(monkeypatch):
741 monkeypatch.setattr(
742 runtime.plugin_helpers,
743 "get_plugin_config",
744 lambda *args, **kwargs: {
745 "visibility_policy": {
746 "mode": "custom",
747 "default": "allow",
748 "allowed": [],
749 "blocked": ["beta-skill"],
750 }
751 },
752 )
753 agent = DummyAgent()
754
755 with pytest.raises(ValueError, match='Skill "beta-skill" is blocked'):
756 runtime.activate_chat_skill(agent, {"name": "beta-skill"})
757 with pytest.raises(ValueError, match='Skill "beta-skill" is blocked'):
758 runtime.show_chat_skill(agent, {"name": "beta-skill"})
759 with pytest.raises(ValueError, match="is blocked"):
760 runtime.activate_chat_skill(
761 agent, {"path": "/a0/skills/beta-skill"}
762 )
763 with pytest.raises(ValueError, match="is blocked"):
764 runtime.show_chat_skill(agent, {"path": "/a0/skills/beta-skill"})
765
766 agent.context.set_data(
767 runtime.CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
768 [{"name": "beta-skill"}],
769 )
770 agent.context.set_data(
771 runtime.CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS,
772 [{"path": "/a0/skills/beta-skill"}],
773 )
774 assert runtime.get_active_skills(agent) == []