from __future__ import annotations import importlib.util import json import sys import tempfile import unittest from pathlib import Path from unittest import mock SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" def load(name: str): spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module hook = load("xkb_agent_hook") installer = load("xkb_install_agent_hook") class SessionIdentityTests(unittest.TestCase): def test_session_id_is_preferred_and_cwd_is_the_fallback(self) -> None: self.assertEqual(hook.session_key({"session_id": "abc", "cwd": "/tmp"}), "abc") self.assertEqual(hook.session_key({"cwd": "/work/project"}), "/work/project") self.assertEqual(hook.session_key({}), "default") def test_turn_id_is_stable_for_the_same_prompt(self) -> None: first = hook.turn_id("session", "same prompt") self.assertEqual(first, hook.turn_id("session", "same prompt")) self.assertNotEqual(first, hook.turn_id("session", "other prompt")) class FailOpenTests(unittest.TestCase): def test_service_being_down_never_blocks_the_agent(self) -> None: """A knowledge base that cannot be reached must not stop the user working.""" payload = json.dumps({"hook_event_name": "UserPromptSubmit", "prompt": "hi", "session_id": "s"}) with mock.patch.object(hook, "call", side_effect=OSError("connection refused")), \ mock.patch("sys.stdin", new=__import__("io").StringIO(payload)): self.assertEqual(hook.main(), 0) def test_malformed_event_is_ignored(self) -> None: with mock.patch("sys.stdin", new=__import__("io").StringIO("{not json")): self.assertEqual(hook.main(), 0) class EncodingTests(unittest.TestCase): def test_output_is_utf8_regardless_of_console_codepage(self) -> None: """Chinese output must not depend on the console encoding. print() would encode with the console codepage (cp950 on a zh-TW Windows box) and raise UnicodeEncodeError, which — being a ValueError — the fail-open handler swallows, silently disabling the whole hook. """ captured = __import__("io").BytesIO() stdout = mock.Mock() stdout.buffer = captured with mock.patch.object(hook.sys, "stdout", stdout): hook.emit({"text": "語意召回"}) self.assertEqual(json.loads(captured.getvalue().decode("utf-8"))["text"], "語意召回") class TranscriptTests(unittest.TestCase): def test_reads_the_last_assistant_message(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "t.jsonl" path.write_text("\n".join([ json.dumps({"message": {"role": "user", "content": "q"}}), json.dumps({"message": {"role": "assistant", "content": "first"}}), json.dumps({"message": {"role": "assistant", "content": [{"type": "text", "text": "final"}]}}), ]), encoding="utf-8") self.assertEqual(hook.last_assistant_message(str(path)), "final") def test_missing_transcript_is_not_an_error(self) -> None: self.assertEqual(hook.last_assistant_message("/no/such/file"), "") class RenderTests(unittest.TestCase): def test_delivery_distinguishes_quiet_from_degraded_without_injecting_unknown_claims(self) -> None: for state in ("ready", "degraded"): for candidates in ([], [{"id": "unverified", "summary": "UNVERIFIED_CLAIM"}]): packet = {"records": candidates, "quality": {"status": state}, "delivery": {"records": [], "status": "ready"}} with self.subTest(state=state, candidates=bool(candidates)), \ tempfile.TemporaryDirectory() as tmp, \ mock.patch.object(hook, "STATE_DIR", Path(tmp)), \ mock.patch.object(hook, "call", side_effect=[{"session_id": "fixture"}, {"retrieval": packet}]), \ mock.patch.object(hook, "emit") as emit: hook.on_prompt({"session_id": "fixture", "prompt": "Prepare the release"}, {"source": "test"}) if state == "ready": emit.assert_not_called() else: context = emit.call_args.args[0]["hookSpecificOutput"]["additionalContext"] self.assertIn("degraded", context) self.assertNotIn("UNVERIFIED_CLAIM", context) def test_prompt_event_injects_conversation_evidence_and_provenance(self) -> None: record = {"record_type": "conversation_trace", "trace_id": "trace:fixture", "query": "角色參考圖如何分工?", "answer": "人物、場景、構圖使用各自的參考圖。"} with tempfile.TemporaryDirectory() as tmp, \ mock.patch.object(hook, "STATE_DIR", Path(tmp)), \ mock.patch.object(hook, "call", side_effect=[ {"session_id": "session-fixture"}, {"retrieval": {"records": [record]}}]), \ mock.patch.object(hook, "emit") as emit: hook.on_prompt({"session_id": "session-fixture", "prompt": "角色參考圖如何分工?"}, {"source": "test"}) output = emit.call_args.args[0]["hookSpecificOutput"] self.assertEqual(output["hookEventName"], "UserPromptSubmit") for text in (record["query"], record["answer"], record["trace_id"], "可能已經過時"): self.assertIn(text, output["additionalContext"]) def test_recalled_knowledge_is_labelled_as_history(self) -> None: rendered = hook.render([{"record_type": "knowledge_card", "title": "T", "summary": "S", "source_url": "u"}]) self.assertIn("xkb_recalled_knowledge", rendered) self.assertIn("可能已經過時", rendered) self.assertIn("T", rendered) def test_complete_delivery_keeps_late_conditions_and_budget(self) -> None: body = 'Step details. ' * 55 + 'Do not proceed unless the review is approved.' rendered = hook.render([{'title': 'Procedure', 'summary': body, 'source_url': 'source'}]) self.assertIn(body, rendered) self.assertTrue(rendered.endswith('')) records = [{'title': 'Too long', 'summary': 'x' * (hook.MAX_CONTEXT_CHARS + 1)}, {'title': 'Useful', 'summary': body}] rendered = hook.render(records) self.assertIn(body, rendered) self.assertNotIn('Too long', rendered) self.assertLessEqual(len(rendered), hook.MAX_CONTEXT_CHARS) self.assertEqual(hook.render(records[:1]), '') class InstallerTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.home = Path(self.tmp.name) patcher = mock.patch.object(installer, "agent_home", return_value=self.home) patcher.start() self.addCleanup(patcher.stop) self.config = self.home / "hook-config.json" config_patcher = mock.patch.object(installer, "HOOK_CONFIG", self.config) config_patcher.start() self.addCleanup(config_patcher.stop) self.addCleanup(self.tmp.cleanup) def settings(self) -> dict: return json.loads((self.home / "settings.json").read_text(encoding="utf-8")) def test_install_wires_both_events(self) -> None: installer.install("http://127.0.0.1:18972", "", "claude-code") hooks = self.settings()["hooks"] for event in installer.EVENTS: commands = [h["command"] for g in hooks[event] for h in g["hooks"]] self.assertTrue(any(installer.HOOK_SCRIPT.name in c for c in commands)) def test_reinstall_replaces_rather_than_duplicates(self) -> None: installer.install("http://a", "", "claude-code") installer.install("http://b", "", "claude-code") hooks = self.settings()["hooks"]["UserPromptSubmit"] ours = [h for g in hooks for h in g["hooks"] if installer.HOOK_SCRIPT.name in h["command"]] self.assertEqual(len(ours), 1) self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["url"], "http://b") def test_existing_unrelated_hooks_are_preserved(self) -> None: (self.home / "settings.json").write_text(json.dumps({ "hooks": {"UserPromptSubmit": [{"matcher": "", "hooks": [{"type": "command", "command": "other-tool"}]}]}, "model": "keep-me", }), encoding="utf-8") installer.install("http://127.0.0.1:18972", "", "claude-code") settings = self.settings() self.assertEqual(settings["model"], "keep-me") commands = [h["command"] for g in settings["hooks"]["UserPromptSubmit"] for h in g["hooks"]] self.assertIn("other-tool", commands) def test_uninstall_removes_only_ours(self) -> None: (self.home / "settings.json").write_text(json.dumps({ "hooks": {"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "other-tool"}]}]}, }), encoding="utf-8") installer.install("http://127.0.0.1:18972", "", "claude-code") installer.uninstall() settings = self.settings() commands = [h["command"] for g in settings.get("hooks", {}).get("Stop", []) for h in g["hooks"]] self.assertEqual(commands, ["other-tool"]) self.assertNotIn("UserPromptSubmit", settings.get("hooks", {})) self.assertFalse(self.config.exists()) def test_corrupt_settings_are_never_overwritten(self) -> None: (self.home / "settings.json").write_text("{not json", encoding="utf-8") with self.assertRaises(SystemExit): installer.install("http://127.0.0.1:18972", "", "claude-code") if __name__ == "__main__": unittest.main()