"""Tests for the after_turn hook (stdin/stdout interface).""" import importlib.util import json import subprocess import sys import tempfile from pathlib import Path HOOK = Path(__file__).parent.parent / "hooks" / "after_turn.py" def run_hook(payload: dict, env=None) -> dict: import os run_env = os.environ.copy() if env: run_env.update(env) result = subprocess.run( [sys.executable, str(HOOK)], input=json.dumps(payload), capture_output=True, text=True, env=run_env, ) return json.loads(result.stdout.strip()) def _load_module(env_overrides=None): import os saved = {} if env_overrides: for k, v in env_overrides.items(): saved[k] = os.environ.get(k) os.environ[k] = v spec = importlib.util.spec_from_file_location("after_turn", HOOK) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) for k, v in saved.items(): if v is None: os.environ.pop(k, None) else: os.environ[k] = v return mod _mod = _load_module() # --------------------------------------------------------------------------- # Skip cases # --------------------------------------------------------------------------- def test_empty_messages_skipped(): out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": []}) assert out["status"] == "skip" assert out["reason"] == "no messages" def test_bad_json_skipped(): result = subprocess.run( [sys.executable, str(HOOK)], input="not json", capture_output=True, text=True, ) out = json.loads(result.stdout.strip()) assert out["status"] == "skip" assert out["reason"] == "bad input" def test_short_exchange_skipped(): messages = [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) assert out["status"] == "skip" assert out["reason"] == "too short" def test_irrelevant_exchange_skipped(): messages = [ {"role": "user", "content": "What is the capital of France and why is it historically significant?"}, {"role": "assistant", "content": "Paris has been the capital since the 10th century and is the cultural hub."}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) assert out["status"] == "skip" assert out["reason"] == "not relevant" def test_agent_mempalace_tool_call_skipped(): messages = [ {"role": "user", "content": "Save this to my memory please."}, {"role": "tool", "content": '{"tool": "mcp_mempalace_add_drawer", "result": "ok"}'}, {"role": "assistant", "content": "Done, I saved it using mcp_mempalace."}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) assert out["status"] == "skip" assert out["reason"] == "agent used mcp_mempalace" def test_user_mentioning_mempalace_not_skipped(): """User saying 'mcp_mempalace' should NOT trigger the dedup skip.""" messages = [ {"role": "user", "content": "Can you use mcp_mempalace to save my dentist appointment on April 15th?"}, {"role": "assistant", "content": "I will remember your dentist appointment on April 15th."}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) assert out.get("reason") != "agent used mcp_mempalace" # --------------------------------------------------------------------------- # Code block stripping # --------------------------------------------------------------------------- def test_noise_re_does_not_match_common_prose(): """'exception' and 'traceback' as normal words must not trigger noise filter.""" assert not _mod.NOISE_RE.search("There's no exception to this rule.") assert not _mod.NOISE_RE.search("Let me traceback the history of this decision.") def test_noise_re_matches_python_traceback(): assert _mod.NOISE_RE.search("Traceback (most recent call last):\n File foo.py") def test_noise_re_matches_error_line(): assert _mod.NOISE_RE.search("Exception: something went wrong") assert _mod.NOISE_RE.search("Error: connection refused") def test_code_block_stripped_not_whole_message_skipped(): """A message with a code block but meaningful surrounding text should not be skipped.""" text = "My email is alice@example.com. Here is the script:\n```bash\necho hello\n```\nRun it daily." result = _mod._strip_code_blocks(text) assert "alice@example.com" in result assert "```" not in result assert "[code]" in result def test_pure_code_block_skipped_after_strip(): """A message that is only a code block becomes empty after stripping → too short → skip.""" messages = [ {"role": "user", "content": "here's the script"}, {"role": "assistant", "content": "```python\nfor i in range(10):\n print(i)\n```"}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) # Should be skipped due to length or irrelevance after stripping assert out["status"] == "skip" # --------------------------------------------------------------------------- # Multi-room classification # --------------------------------------------------------------------------- def test_classify_single_room(): assert _mod._classify_rooms("Her email is alice@example.com") == [("people", "contacts")] def test_classify_multiple_rooms(): # "meeting" → calendar, "email" → contacts — both should match rooms = _mod._classify_rooms("Schedule a meeting with alice@example.com next Tuesday") assert ("people", "contacts") in rooms assert ("time", "calendar") in rooms def test_classify_finance(): assert _mod._classify_rooms("Invoice for $500 is due next week") == [("finance", "transactions")] def test_classify_logistics(): assert _mod._classify_rooms("The shipment arrived today") == [("logistics", "orders")] def test_classify_decisions(): assert _mod._classify_rooms("We decided to use Postgres going forward") == [("knowledge", "decisions")] def test_classify_default_fallback(): assert _mod._classify_rooms("The sky is blue and the grass is green.") == [("default", "sessions")] # --------------------------------------------------------------------------- # Content deduplication # --------------------------------------------------------------------------- def test_duplicate_detection(): with tempfile.TemporaryDirectory() as tmpdir: mod = _load_module({"MEMPALACE_PALACE_PATH": tmpdir}) text = "I have a dentist appointment on April 15th with Dr. Smith." assert mod._is_duplicate(text, "agent-1") is False # first time: not a duplicate assert mod._is_duplicate(text, "agent-1") is True # second time: duplicate def test_different_content_not_duplicate(): with tempfile.TemporaryDirectory() as tmpdir: mod = _load_module({"MEMPALACE_PALACE_PATH": tmpdir}) assert mod._is_duplicate("dentist appointment April 15th", "agent-1") is False assert mod._is_duplicate("meeting with Bob on Friday afternoon", "agent-1") is False def test_dedup_is_per_agent(): """Two different agents should have independent dedup stores.""" with tempfile.TemporaryDirectory() as tmpdir: mod = _load_module({"MEMPALACE_PALACE_PATH": tmpdir}) text = "I have a dentist appointment on April 15th with Dr. Smith." mod._is_duplicate(text, "agent-1") # agent-1 saves it assert mod._is_duplicate(text, "agent-2") is False # agent-2 has not seen it def test_dedup_rolling_max(): with tempfile.TemporaryDirectory() as tmpdir: mod = _load_module({"MEMPALACE_PALACE_PATH": tmpdir, "MEMPALACE_DEDUP_MAX": "3"}) for i in range(5): mod._is_duplicate(f"unique content number {i} with enough chars to matter", "agent-1") seen = json.loads((Path(tmpdir) / ".after_turn_seen_agent-1.json").read_text()) assert len(seen) == 3 # capped at DEDUP_MAX # --------------------------------------------------------------------------- # Configurable parameters # --------------------------------------------------------------------------- def test_custom_min_chars_env(): mod = _load_module({"MEMPALACE_MIN_CHARS": "200"}) assert mod.MIN_CONTENT_LENGTH == 200 def test_custom_window_size_env(): mod = _load_module({"MEMPALACE_WINDOW_SIZE": "10"}) assert mod.WINDOW_SIZE == 10 # --------------------------------------------------------------------------- # Language detection # --------------------------------------------------------------------------- def test_detect_language_returns_string(): lang = _mod._detect_language("I have a meeting with the client tomorrow afternoon.") assert isinstance(lang, str) assert len(lang) > 0 def test_is_english_known_english(): assert _mod._is_english("en") is True def test_is_english_unknown_treated_as_english(): # unknown = langdetect unavailable or failed → don't block on keyword check assert _mod._is_english("unknown") is True def test_is_english_other_language(): assert _mod._is_english("zh") is False assert _mod._is_english("ja") is False assert _mod._is_english("fr") is False def test_lang_detect_disabled_returns_unknown(): mod = _load_module({"MEMPALACE_LANG_DETECT": "0"}) lang = mod._detect_language("Ich habe morgen einen Termin beim Zahnarzt.") assert lang == "unknown" def test_non_english_skips_relevance_check(): """A non-English exchange long enough to pass length check should not be blocked by the English-only RELEVANCE_RE.""" # This text is in Chinese and contains no English keywords from RELEVANCE_RE, # so without language detection it would be skipped as "not relevant". messages = [ {"role": "user", "content": "我明天下午三点有个牙医预约,在城市医院,请帮我记住这件事。"}, {"role": "assistant", "content": "好的,我已经记住了您明天下午三点在城市医院的牙医预约。"}, ] out = run_hook({"type": "after_turn", "agent_id": "a1", "messages": messages}) # Should NOT be skipped for "not relevant" — may fail at mempalace import (error) or dedup assert out.get("reason") != "not relevant"