test_plugin.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import importlib
  2. import importlib.util
  3. import os
  4. import shutil
  5. import sys
  6. from pathlib import Path
  7. import pytest
  8. # Point at the plugin directory
  9. _PLUGIN_DIR = os.path.abspath(
  10. os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
  11. )
  12. sys.path.insert(0, _PLUGIN_DIR)
  13. BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
  14. def _load_plugin():
  15. """Re-import plugin module fresh."""
  16. if "__init__" in sys.modules:
  17. del sys.modules["__init__"]
  18. return importlib.import_module("__init__")
  19. def _fire_pre_llm(ctx, **kwargs):
  20. hook = ctx._hooks["pre_llm_call"]
  21. defaults = {
  22. "session_id": "s1",
  23. "user_message": "hi",
  24. "conversation_history": [],
  25. "is_first_turn": False,
  26. "model": "test-model",
  27. "platform": "cli",
  28. }
  29. defaults.update(kwargs)
  30. return hook(**defaults)
  31. class TestPluginRegistration:
  32. def test_register_attaches_only_pre_llm_call_hook(self, mock_ctx):
  33. plugin = _load_plugin()
  34. plugin.register(mock_ctx)
  35. assert list(mock_ctx._hooks.keys()) == ["pre_llm_call"]
  36. def test_register_registers_every_stock_skill_as_path(self, mock_ctx):
  37. plugin = _load_plugin()
  38. plugin.register(mock_ctx)
  39. # The conftest mock raises on non-Path (mirroring hermes' real
  40. # register_skill), so reaching these asserts proves every
  41. # registration passed a pathlib.Path.
  42. assert "using-superpowers" in mock_ctx._skills
  43. assert "brainstorming" in mock_ctx._skills
  44. for name, path in mock_ctx._skills.items():
  45. assert isinstance(path, Path)
  46. assert path.name == "SKILL.md"
  47. assert path.parent.name == name
  48. assert path.is_file()
  49. def test_registered_skills_match_skill_directories(self, mock_ctx):
  50. plugin = _load_plugin()
  51. plugin.register(mock_ctx)
  52. skills_root = plugin._skills_dir()
  53. expected = {
  54. entry
  55. for entry in os.listdir(skills_root)
  56. if os.path.isfile(os.path.join(skills_root, entry, "SKILL.md"))
  57. }
  58. assert set(mock_ctx._skills.keys()) == expected
  59. class TestBootstrapInjection:
  60. def test_first_turn_returns_bootstrap_context(self, mock_ctx):
  61. plugin = _load_plugin()
  62. plugin.register(mock_ctx)
  63. result = _fire_pre_llm(mock_ctx, is_first_turn=True)
  64. assert isinstance(result, dict)
  65. content = result["context"]
  66. assert BOOTSTRAP_MARKER in content
  67. assert content.startswith("<EXTREMELY_IMPORTANT>")
  68. assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
  69. def test_later_turns_return_none(self, mock_ctx):
  70. plugin = _load_plugin()
  71. plugin.register(mock_ctx)
  72. assert _fire_pre_llm(mock_ctx, is_first_turn=False) is None
  73. assert _fire_pre_llm(mock_ctx, is_first_turn=None) is None
  74. def test_hook_tolerates_future_kwargs(self, mock_ctx):
  75. plugin = _load_plugin()
  76. plugin.register(mock_ctx)
  77. result = _fire_pre_llm(
  78. mock_ctx, is_first_turn=True, telemetry_schema_version=3
  79. )
  80. assert BOOTSTRAP_MARKER in result["context"]
  81. class TestLayoutResolution:
  82. def _stage(self, tmp_path, layout):
  83. """Copy the plugin module + a minimal skills tree in the given layout."""
  84. src_skills = Path(_PLUGIN_DIR).parent / "skills"
  85. if layout == "clone":
  86. plugdir = tmp_path / "superpowers" / ".hermes-plugin"
  87. else: # flat: module at the plugin dir root, skills nested inside it
  88. plugdir = tmp_path / "superpowers"
  89. skills = tmp_path / "superpowers" / "skills"
  90. plugdir.mkdir(parents=True, exist_ok=True)
  91. shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
  92. for skill in ("using-superpowers", "brainstorming"):
  93. shutil.copytree(src_skills / skill, skills / skill)
  94. return plugdir
  95. def _load_from(self, plugdir):
  96. spec = importlib.util.spec_from_file_location(
  97. f"hermes_plugin_test_{plugdir.parent.name}_{plugdir.name}",
  98. plugdir / "__init__.py",
  99. )
  100. mod = importlib.util.module_from_spec(spec)
  101. spec.loader.exec_module(mod)
  102. return mod
  103. def test_clone_layout_resolves_sibling_skills(self, tmp_path, mock_ctx):
  104. # git-clone install: .hermes-plugin/ and skills/ are siblings.
  105. plugdir = self._stage(tmp_path, "clone")
  106. mod = self._load_from(plugdir)
  107. mod.register(mock_ctx)
  108. assert "using-superpowers" in mock_ctx._skills
  109. def test_flat_layout_resolves_nested_skills(self, tmp_path, mock_ctx):
  110. # flattened install: module at the plugin dir root, skills/ inside it.
  111. plugdir = self._stage(tmp_path, "flat")
  112. mod = self._load_from(plugdir)
  113. mod.register(mock_ctx)
  114. assert "using-superpowers" in mock_ctx._skills
  115. def test_missing_skills_raises_loudly(self, tmp_path, mock_ctx):
  116. plugdir = tmp_path / "superpowers"
  117. plugdir.mkdir(parents=True)
  118. shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
  119. mod = self._load_from(plugdir)
  120. with pytest.raises(RuntimeError, match="cannot find the skills"):
  121. mod.register(mock_ctx)