Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""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
|