"""Tests for the DialogueMemory conversation-scoped scratch cache and the ``is_tool_message`` helper. The cache is a per-conversation primitive used by the reply engine to memoise idempotent per-turn work (warm profile, memory extractor, tool router). Entries persist for the lifetime of the active conversation and are wiped on ``clear_hot_cache()``; the warm profile entry can also be invalidated on demand via ``invalidate_warm_profile()``. """ import time import pytest from src.jarvis.memory.conversation import DialogueMemory, is_tool_message @pytest.mark.unit class TestHotCachePrimitives: def test_get_returns_none_for_missing_key(self): dm = DialogueMemory() assert dm.hot_cache_get("nope") is None def test_put_then_get_roundtrips(self): dm = DialogueMemory() dm.hot_cache_put("k", {"v": 1}) assert dm.hot_cache_get("k") == {"v": 1} def test_entries_persist_past_recent_window_age(self): """Cache entries are conversation-scoped, not bounded by RECENT_WINDOW_SEC. A long active conversation must keep the cache hot even when the original write is older than the window. """ dm = DialogueMemory(inactivity_timeout=300.0) dm.hot_cache_put("k", "v") with dm._lock: ts, value = dm._hot_cache["k"] dm._hot_cache["k"] = (ts - (dm.RECENT_WINDOW_SEC + 10), value) # Age alone must NOT cause the value to disappear; only explicit # invalidation should drop it. assert dm.hot_cache_get("k") == "v" def test_invalidate_warm_profile_drops_only_that_key(self): dm = DialogueMemory() dm.hot_cache_put(dm.WARM_PROFILE_CACHE_KEY, "warm-block") dm.hot_cache_put("router:abc", ["webSearch"]) dm.invalidate_warm_profile() assert dm.hot_cache_get(dm.WARM_PROFILE_CACHE_KEY) is None assert dm.hot_cache_get("router:abc") == ["webSearch"] def test_clear_hot_cache_drops_all_entries(self): dm = DialogueMemory() dm.hot_cache_put("a", 1) dm.hot_cache_put("b", 2) dm.clear_hot_cache() assert dm.hot_cache_get("a") is None assert dm.hot_cache_get("b") is None def test_put_overwrites_existing_value(self): dm = DialogueMemory() dm.hot_cache_put("k", "old") dm.hot_cache_put("k", "new") assert dm.hot_cache_get("k") == "new" @pytest.mark.unit class TestHotCacheLRUCap: """The hot cache must not grow without bound. Per-query keys (router output, enrichment extractor output) are unique per turn, so a long session would otherwise accumulate one entry per unique query. """ def test_size_never_exceeds_cap(self): dm = DialogueMemory() cap = dm.HOT_CACHE_MAX_ENTRIES for i in range(cap + 50): dm.hot_cache_put(f"key:{i}", i) assert len(dm._hot_cache) == cap def test_least_recently_used_entry_evicted_first(self): dm = DialogueMemory() cap = dm.HOT_CACHE_MAX_ENTRIES # Fill exactly to cap. for i in range(cap): dm.hot_cache_put(f"k{i}", i) # Touch the oldest entry so it becomes most-recently-used. assert dm.hot_cache_get("k0") == 0 # Inserting one more entry should evict the next-oldest (k1), # NOT k0 since we just touched it. dm.hot_cache_put("new", "v") assert dm.hot_cache_get("k0") == 0 assert dm.hot_cache_get("k1") is None assert dm.hot_cache_get("new") == "v" def test_overwriting_existing_key_does_not_evict(self): dm = DialogueMemory() cap = dm.HOT_CACHE_MAX_ENTRIES for i in range(cap): dm.hot_cache_put(f"k{i}", i) # Overwrite an existing entry — size should stay at cap, no # entry should disappear. dm.hot_cache_put("k0", "updated") assert len(dm._hot_cache) == cap assert dm.hot_cache_get("k0") == "updated" # The other keys are still present. assert dm.hot_cache_get(f"k{cap - 1}") == cap - 1 @pytest.mark.unit class TestNextTsMonotonic: """``_next_ts`` exists because ``time.time()`` has ~16ms granularity on Windows and consecutive calls can return identical values. Without the epsilon bump, text/tool messages recorded in the same tick would collide and break interleave ordering downstream. """ def test_consecutive_calls_strictly_increase(self): dm = DialogueMemory() with dm._lock: t1 = dm._next_ts() t2 = dm._next_ts() t3 = dm._next_ts() assert t1 < t2 < t3 def test_advances_past_artificially_high_last_ts(self): """Even if ``_last_ts`` is ahead of the wall clock (clock skew, manual seed), the next call must still advance. """ dm = DialogueMemory() future = time.time() + 100.0 with dm._lock: dm._last_ts = future nxt = dm._next_ts() assert nxt > future assert nxt - future < 0.01 # only an epsilon bump, not a wall jump @pytest.mark.unit class TestToolTurnsStorageCap: def test_tool_turns_capped_to_max_storage(self): dm = DialogueMemory() # Push more entries than the cap; each call appends one turn. for i in range(dm._tool_turns_max_storage + 5): dm.record_tool_turn([ {"role": "tool", "tool_call_id": f"c{i}", "content": f"r{i}"}, ]) assert len(dm._tool_turns) == dm._tool_turns_max_storage # The oldest entries are dropped — last one survives. last_msg = dm._tool_turns[-1][1][0]["content"] assert last_msg.endswith(str(dm._tool_turns_max_storage + 4)) @pytest.mark.unit class TestIsToolMessage: def test_native_tool_role(self): assert is_tool_message({"role": "tool", "content": "x"}) is True def test_assistant_with_tool_calls(self): assert is_tool_message({ "role": "assistant", "content": "", "tool_calls": [{"id": "c1"}], }) is True def test_assistant_without_tool_calls(self): assert is_tool_message({"role": "assistant", "content": "hi"}) is False def test_text_tool_user_with_tool_name(self): assert is_tool_message({ "role": "user", "content": "result", "tool_name": "webSearch", }) is True def test_plain_user_message(self): assert is_tool_message({"role": "user", "content": "hi"}) is False def test_non_dict_returns_false(self): assert is_tool_message("tool") is False assert is_tool_message(None) is False