test_trigger_detection.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. #!/usr/bin/env python3
  2. """Guard trigger detection in run_triggers.py.
  3. The stream-json init event lists every discovered skill by name, so any
  4. detection that substring-matches the whole transcript reports a 100% trigger
  5. rate. These tests pin the rule: only tool_use events (a Skill call naming the
  6. synthetic skill, or a Read inside its directory) count as a load, and
  7. substring-style load signals are rejected outright.
  8. Run with: python3 -m pytest test_trigger_detection.py
  9. (or plain `python3 test_trigger_detection.py` for a lightweight self-check).
  10. """
  11. import json
  12. import sys
  13. from pathlib import Path
  14. SCRIPTS_DIR = Path(__file__).resolve().parents[1]
  15. sys.path.insert(0, str(SCRIPTS_DIR))
  16. from run_triggers import detect_load, validate_load_signal # noqa: E402
  17. NAME = "my-skill-trig-abc12345"
  18. def line(obj) -> str:
  19. return json.dumps(obj)
  20. def init_event() -> str:
  21. # Claude Code's init event advertises every discovered skill.
  22. return line({"type": "system", "subtype": "init",
  23. "tools": ["Skill", "Read", "Bash"],
  24. "slash_commands": [], "skills": [NAME, "other-skill"]})
  25. def assistant(content) -> str:
  26. return line({"type": "assistant", "message": {"content": content}})
  27. def test_init_event_alone_is_not_a_load():
  28. transcript = "\n".join([
  29. init_event(),
  30. assistant([{"type": "text", "text": "I can't help with that."}]),
  31. line({"type": "result", "usage": {}}),
  32. ])
  33. assert detect_load(transcript, {}, NAME) is False
  34. def test_text_mention_is_not_a_load():
  35. transcript = assistant(
  36. [{"type": "text", "text": f"There is a skill called {NAME} available."}])
  37. assert detect_load(transcript, {}, NAME) is False
  38. def test_skill_tool_call_is_a_load():
  39. transcript = "\n".join([
  40. init_event(),
  41. assistant([{"type": "tool_use", "name": "Skill",
  42. "input": {"skill": NAME}}]),
  43. ])
  44. assert detect_load(transcript, {}, NAME) is True
  45. def test_read_of_synthetic_skill_md_is_a_load():
  46. transcript = "\n".join([
  47. init_event(),
  48. assistant([{"type": "tool_use", "name": "Read",
  49. "input": {"file_path":
  50. f"/tmp/stage/.claude/skills/{NAME}/SKILL.md"}}]),
  51. ])
  52. assert detect_load(transcript, {}, NAME) is True
  53. def test_unrelated_tool_calls_are_not_a_load():
  54. transcript = "\n".join([
  55. init_event(),
  56. assistant([{"type": "tool_use", "name": "Read",
  57. "input": {"file_path": "/tmp/stage/notes.md"}}]),
  58. assistant([{"type": "tool_use", "name": "Skill",
  59. "input": {"skill": "other-skill"}}]),
  60. assistant([{"type": "tool_use", "name": "Bash",
  61. "input": {"command": f"echo {NAME}"}}]),
  62. ])
  63. assert detect_load(transcript, {}, NAME) is False
  64. def test_custom_tool_names_from_load_signal():
  65. sig = {"skill_tool": "InvokeSkill", "read_tool": "OpenFile"}
  66. hit = assistant([{"type": "tool_use", "name": "InvokeSkill",
  67. "input": {"name": NAME}}])
  68. miss = assistant([{"type": "tool_use", "name": "Skill",
  69. "input": {"skill": NAME}}])
  70. assert detect_load(hit, sig, NAME) is True
  71. assert detect_load(miss, sig, NAME) is False, \
  72. "default tool name must not fire when the adapter renames it"
  73. def test_garbage_lines_do_not_crash():
  74. transcript = "not json\n\n{\"type\": 42}\n[1,2,3]\n"
  75. assert detect_load(transcript, {}, NAME) is False
  76. def test_string_load_signal_rejected():
  77. for fn, args in ((validate_load_signal, ({"type": "string"},)),
  78. (detect_load, ("", {"type": "string"}, NAME))):
  79. try:
  80. fn(*args)
  81. except ValueError:
  82. pass
  83. else:
  84. raise AssertionError(
  85. f"{fn.__name__} accepted a substring load_signal")
  86. if __name__ == "__main__":
  87. test_init_event_alone_is_not_a_load()
  88. test_text_mention_is_not_a_load()
  89. test_skill_tool_call_is_a_load()
  90. test_read_of_synthetic_skill_md_is_a_load()
  91. test_unrelated_tool_calls_are_not_a_load()
  92. test_custom_tool_names_from_load_signal()
  93. test_garbage_lines_do_not_crash()
  94. test_string_load_signal_rejected()
  95. print("ok: trigger detection counts tool calls only; substring rejected")