Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,118 @@
|
||||
"""Offscreen functional check for the GUI retheme registry.
|
||||
|
||||
Single thread (Qt widgets must be driven on their creating thread):
|
||||
- Patch the modal startup dialog to return the config immediately.
|
||||
- Patch ``qasync.QEventLoop.run_forever`` to grab the top-level window and raise
|
||||
a sentinel BEFORE the GUI loop actually runs.
|
||||
- Call ``run_gui``, catch the sentinel (window is now fully constructed), then
|
||||
read a registered themed widget's style in dark, trigger the REAL light/dark
|
||||
toggle (``_theme_act`` -> ``_on_theme_toggle`` -> ``_apply_theme`` ->
|
||||
``retheme``), and assert the style changed and round-trips.
|
||||
|
||||
Run: QT_QPA_PLATFORM=offscreen .\\.venv\\Scripts\\python.exe tests\\_retheme_check.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6 import QtWidgets # noqa: E402
|
||||
import qasync # noqa: E402
|
||||
|
||||
from javis.core.config import load_config # noqa: E402
|
||||
import javis.gui.main_window as mw # noqa: E402
|
||||
|
||||
|
||||
class _Stop(Exception):
|
||||
pass
|
||||
|
||||
|
||||
STATE: dict = {"win": None}
|
||||
|
||||
|
||||
def _probe(win) -> None:
|
||||
"""Drive the window: capture dark, toggle to light, assert it changed."""
|
||||
if not getattr(win, "_themed", None):
|
||||
raise AssertionError("retheme registry (_themed) is empty!")
|
||||
|
||||
sample = None
|
||||
for w, factory in win._themed:
|
||||
try:
|
||||
factory()
|
||||
except Exception:
|
||||
continue
|
||||
sample = (w, factory)
|
||||
break
|
||||
if sample is None:
|
||||
w, factory = win._themed[0]
|
||||
sample = (w, factory)
|
||||
|
||||
w, factory = sample
|
||||
app = QtWidgets.QApplication.instance()
|
||||
app.processEvents()
|
||||
dark_style = w.styleSheet()
|
||||
print(f"[probe] themed widget: {type(w).__name__}")
|
||||
print(f"[probe] dark style = {dark_style!r}")
|
||||
|
||||
act = win._theme_act
|
||||
act.setChecked(True) # -> light
|
||||
app.processEvents()
|
||||
light_style = w.styleSheet()
|
||||
print(f"[probe] light style = {light_style!r}")
|
||||
|
||||
assert light_style != dark_style, (
|
||||
"BUG: toggling theme did not change the registered widget's stylesheet. "
|
||||
f"dark={dark_style!r} light={light_style!r}"
|
||||
)
|
||||
|
||||
act.setChecked(False) # -> dark
|
||||
app.processEvents()
|
||||
back_dark = w.styleSheet()
|
||||
print(f"[probe] dark (again) = {back_dark!r}")
|
||||
assert back_dark == dark_style, (
|
||||
f"BUG: toggling back did not restore the dark style. "
|
||||
f"{back_dark!r} != {dark_style!r}"
|
||||
)
|
||||
|
||||
print("\n[probe] OK — retheme registry is theme-reactive (dark->light->dark round-trip).")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||
|
||||
config = load_config(Path("config.yaml"))
|
||||
mw.show_startup_dialog = lambda cfg: cfg # type: ignore[assignment]
|
||||
|
||||
def _run_forever(self):
|
||||
tops = [w for w in QtWidgets.QApplication.topLevelWidgets()
|
||||
if isinstance(w, QtWidgets.QMainWindow) and getattr(w, "retheme", None)]
|
||||
if tops:
|
||||
STATE["win"] = tops[-1]
|
||||
raise _Stop()
|
||||
|
||||
qasync.QEventLoop.run_forever = _run_forever # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
mw.run_gui(config)
|
||||
except _Stop:
|
||||
pass
|
||||
|
||||
if STATE["win"] is None:
|
||||
print("[FAIL] could not capture the main window")
|
||||
return 1
|
||||
|
||||
try:
|
||||
_probe(STATE["win"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[FAIL] {e!r}")
|
||||
return 1
|
||||
|
||||
print("\nRESULT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Regression tests for the correctness fixes applied to Javis.
|
||||
|
||||
These target concrete, previously-broken behaviours and use only
|
||||
dependency-light modules (no torch / audio / MCP) so they run fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from javis.core.config import Config, PathsConfig, find_project_root
|
||||
from javis.llm.conversation import Conversation, count_tokens
|
||||
from javis.voices.profile import VoiceProfile
|
||||
|
||||
|
||||
class FindProjectRootTests(unittest.TestCase):
|
||||
def test_finds_pyproject_marker(self) -> None:
|
||||
root = find_project_root()
|
||||
# The repository root holds pyproject.toml.
|
||||
self.assertTrue((root / "pyproject.toml").is_file())
|
||||
|
||||
def test_falls_back_to_cwd_without_marker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
deep = Path(td) / "a" / "b"
|
||||
deep.mkdir(parents=True)
|
||||
# No pyproject.toml anywhere up the temp tree → cwd fallback, no raise.
|
||||
result = find_project_root(deep)
|
||||
self.assertIsInstance(result, Path)
|
||||
|
||||
|
||||
class VoiceProfileCorruptionTests(unittest.TestCase):
|
||||
def test_corrupt_profile_json_does_not_raise(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d = Path(td)
|
||||
(d / "profile.json").write_text("{ this is not valid json", encoding="utf-8")
|
||||
prof = VoiceProfile.from_dir(d) # must not raise
|
||||
self.assertEqual(prof.name, d.name)
|
||||
self.assertIsNone(prof.speed)
|
||||
|
||||
def test_non_numeric_speed_falls_back_to_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d = Path(td)
|
||||
(d / "profile.json").write_text(
|
||||
json.dumps({"name": "x", "speed": "fast"}), encoding="utf-8"
|
||||
)
|
||||
prof = VoiceProfile.from_dir(d)
|
||||
self.assertIsNone(prof.speed)
|
||||
|
||||
def test_valid_speed_is_parsed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d = Path(td)
|
||||
(d / "profile.json").write_text(
|
||||
json.dumps({"name": "x", "speed": 1.25}), encoding="utf-8"
|
||||
)
|
||||
prof = VoiceProfile.from_dir(d)
|
||||
self.assertEqual(prof.speed, 1.25)
|
||||
|
||||
|
||||
class ConversationTests(unittest.TestCase):
|
||||
def test_history_property_is_a_readonly_snapshot(self) -> None:
|
||||
conv = Conversation(system_prompt="sys", max_turns=20)
|
||||
conv.add_user("hello")
|
||||
snap = conv.history
|
||||
snap.append({"role": "user", "content": "mutated"})
|
||||
# Mutating the snapshot must not affect the real history.
|
||||
self.assertEqual(len(conv.history), 1)
|
||||
|
||||
def test_trim_keeps_tool_pairs_and_bounds_size(self) -> None:
|
||||
conv = Conversation(system_prompt="sys", max_turns=2, max_chars=200)
|
||||
for i in range(10):
|
||||
conv.add_user("u" * 50)
|
||||
conv.add_assistant("a" * 50)
|
||||
total = sum(len(m.get("content") or "") for m in conv.history)
|
||||
self.assertLessEqual(total, 200)
|
||||
# History must never start with an orphaned tool/assistant message.
|
||||
if conv.history:
|
||||
self.assertEqual(conv.history[0]["role"], "user")
|
||||
|
||||
def test_count_tokens_fallback_is_nonzero_for_text(self) -> None:
|
||||
self.assertEqual(count_tokens(""), 0)
|
||||
self.assertGreater(count_tokens("hello world this is text"), 0)
|
||||
|
||||
|
||||
class QuickEngineTests(unittest.TestCase):
|
||||
def test_load_skips_malformed_command_but_keeps_valid(self) -> None:
|
||||
from javis.agent.quick_engine import QuickEngine
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "quick_commands.json"
|
||||
path.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"commands": {
|
||||
"good": {
|
||||
"id": "good", "trigger": "lights on", "match_mode": "exact",
|
||||
"actions": [{"tool": "homey__x", "args": {}}],
|
||||
},
|
||||
"bad": {
|
||||
"id": "bad", "trigger": "broken", "match_mode": "exact",
|
||||
"actions": [{"not_a_valid_field": 1}],
|
||||
},
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
eng = QuickEngine(path)
|
||||
eng.load() # must not raise
|
||||
ids = {c.id for c in eng.list_all()}
|
||||
self.assertIn("good", ids)
|
||||
self.assertNotIn("bad", ids)
|
||||
|
||||
|
||||
class DeviceStoreDebounceTests(unittest.TestCase):
|
||||
def test_touch_last_seen_debounces_disk_writes(self) -> None:
|
||||
from javis.api.devices import SlaveDeviceStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
store = SlaveDeviceStore(Path(td) / "slaves.json")
|
||||
store.register("dev1", "Device One")
|
||||
with patch.object(store, "_save", wraps=store._save) as spy:
|
||||
# Same IP, rapid calls → at most one flush within the interval.
|
||||
for _ in range(5):
|
||||
store.touch_last_seen("dev1", ip_address="10.0.0.1")
|
||||
self.assertLessEqual(spy.call_count, 1)
|
||||
|
||||
def test_touch_last_seen_flushes_on_ip_change(self) -> None:
|
||||
from javis.api.devices import SlaveDeviceStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
store = SlaveDeviceStore(Path(td) / "slaves.json")
|
||||
store.register("dev1", "Device One")
|
||||
store.touch_last_seen("dev1", ip_address="10.0.0.1")
|
||||
with patch.object(store, "_save", wraps=store._save) as spy:
|
||||
store.touch_last_seen("dev1", ip_address="10.0.0.2") # IP changed
|
||||
self.assertEqual(spy.call_count, 1)
|
||||
|
||||
|
||||
class TimerPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncNoop(self, timer_id: str, label: str) -> None:
|
||||
return None
|
||||
|
||||
async def test_set_persists_and_cancel_updates_file(self) -> None:
|
||||
from javis.core.timer import TimerService
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "timers.json"
|
||||
svc = TimerService(self.asyncNoop, persist_path=path)
|
||||
tid = svc.set_timer("test", 3600)
|
||||
try:
|
||||
self.assertTrue(path.exists())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(data["timers"]), 1)
|
||||
self.assertTrue(svc.cancel_timer(tid))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(data["timers"]), 0)
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
async def test_restore_reschedules_pending_and_drops_expired(self) -> None:
|
||||
import time as _time
|
||||
from javis.core.timer import TimerService
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "timers.json"
|
||||
now = _time.time()
|
||||
path.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"timers": [
|
||||
{"id": "pending", "label": "future", "duration_s": 3600,
|
||||
"fire_at": now + 3600},
|
||||
{"id": "expired", "label": "past", "duration_s": 60,
|
||||
"fire_at": now - 10},
|
||||
],
|
||||
}), encoding="utf-8")
|
||||
svc = TimerService(self.asyncNoop, persist_path=path)
|
||||
await svc.restore()
|
||||
try:
|
||||
active = svc.list_timers()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["id"], "pending")
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
class FinetuneSignatureTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_run_pipeline_receives_profile_dir_not_config(self) -> None:
|
||||
from javis.voices.manager import VoiceManager
|
||||
import javis.voices.piper_train as pt
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
config = Config()
|
||||
config.paths = PathsConfig(base_dir=Path(td))
|
||||
config.paths.ensure()
|
||||
mgr = VoiceManager(config)
|
||||
prof = mgr.create("tv")
|
||||
assert prof.dir is not None
|
||||
(prof.dir / "training").mkdir(parents=True, exist_ok=True)
|
||||
(prof.dir / "training" / "metadata.csv").write_text(
|
||||
"clip_0|hallo|hallo\n", encoding="utf-8")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_run_pipeline(voice_dir, **kwargs):
|
||||
captured["voice_dir"] = voice_dir
|
||||
captured["kwargs"] = kwargs
|
||||
return 0
|
||||
|
||||
from javis.voices import finetune
|
||||
with patch.object(pt, "run_pipeline", fake_run_pipeline):
|
||||
rc = await finetune.finetune_voice(config, "tv", language="de")
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(captured["voice_dir"], prof.dir)
|
||||
# The old bug passed the Config as voice_dir and voice_name as language.
|
||||
self.assertNotIsInstance(captured["voice_dir"], Config)
|
||||
self.assertEqual(captured["kwargs"].get("language"), "de")
|
||||
|
||||
|
||||
# ── wake-word barge-in (interrupt TTS playback with the wake phrase) ──────
|
||||
|
||||
|
||||
class BargeInConfigTests(unittest.TestCase):
|
||||
"""The new WakeWordConfig.barge_in_enabled flag defaults to True."""
|
||||
|
||||
def test_barge_in_enabled_defaults_true(self) -> None:
|
||||
from javis.core.config import WakeWordConfig
|
||||
|
||||
self.assertTrue(WakeWordConfig().barge_in_enabled)
|
||||
|
||||
|
||||
class BargeInListenerTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""_barge_in_listener: on wake-word trigger it cancels playback, resets
|
||||
the detector, sets the barge-in flag, and publishes the event."""
|
||||
|
||||
async def test_trigger_cancels_playback_and_sets_flag(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
from javis.core.state import AgentState
|
||||
|
||||
orch = object.__new__(Orchestrator)
|
||||
|
||||
class FakeWake:
|
||||
def __init__(self) -> None:
|
||||
self.feed_calls = 0
|
||||
self.reset_calls = 0
|
||||
|
||||
def feed(self, chunk):
|
||||
self.feed_calls += 1
|
||||
return 0.9 if self.feed_calls == 1 else 0.1
|
||||
|
||||
def triggered(self, score):
|
||||
return score >= 0.5
|
||||
|
||||
def reset(self):
|
||||
self.reset_calls += 1
|
||||
|
||||
class FakeMic:
|
||||
def __init__(self) -> None:
|
||||
self.drain_calls = 0
|
||||
|
||||
def drain(self):
|
||||
self.drain_calls += 1
|
||||
|
||||
async def chunks(self, stale_timeout=None):
|
||||
yield b"\x00"
|
||||
|
||||
class FakeBus:
|
||||
def __init__(self) -> None:
|
||||
self.events = []
|
||||
|
||||
async def publish(self, name, payload=None):
|
||||
self.events.append((name, payload))
|
||||
|
||||
orch.wake = FakeWake()
|
||||
orch.mic = FakeMic()
|
||||
orch.bus = FakeBus()
|
||||
orch._stop = asyncio.Event()
|
||||
orch._barge_in_requested = False
|
||||
orch._barge_in_task = None
|
||||
|
||||
interrupt_calls = []
|
||||
state_calls = []
|
||||
with patch.object(Orchestrator, "interrupt",
|
||||
lambda self: interrupt_calls.append(1)), \
|
||||
patch.object(Orchestrator, "set_state",
|
||||
lambda self, s: state_calls.append(s) or asyncio.sleep(0)), \
|
||||
patch.object(Orchestrator, "_play_wake_chime",
|
||||
lambda self: asyncio.sleep(0)):
|
||||
await orch._barge_in_listener()
|
||||
|
||||
# Trigger fired on the first chunk: listener must have exited.
|
||||
self.assertTrue(orch._barge_in_requested)
|
||||
self.assertEqual(len(interrupt_calls), 1)
|
||||
self.assertEqual(state_calls, [AgentState.WAKE])
|
||||
# reset() is called once at arming and once after the trigger.
|
||||
self.assertEqual(orch.wake.reset_calls, 2)
|
||||
self.assertEqual(orch.mic.drain_calls, 1)
|
||||
self.assertIn(("barge_in", 0.9), orch.bus.events)
|
||||
# Chime was scheduled via ensure_future — let it finish.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class BargeInRunConsumptionTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""run(): when _barge_in_requested is set, the main loop must skip
|
||||
_wait_for_wake, drain the mic tail, and start the turn with followup
|
||||
grace instead of the default 1500 ms."""
|
||||
|
||||
async def test_run_consumes_barge_in_flag(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
|
||||
orch = object.__new__(Orchestrator)
|
||||
orch.stt = object()
|
||||
orch.mic = object()
|
||||
orch.wake = object()
|
||||
orch._stop = asyncio.Event()
|
||||
orch._turn_lock = asyncio.Lock()
|
||||
orch._barge_in_requested = True
|
||||
orch._barge_in_task = None
|
||||
orch._followup_pending = False
|
||||
orch.config = Config()
|
||||
orch.profiler = None
|
||||
|
||||
class FakeMic:
|
||||
def __init__(self) -> None:
|
||||
self.drain_calls = 0
|
||||
|
||||
def drain(self):
|
||||
self.drain_calls += 1
|
||||
|
||||
orch.mic = FakeMic()
|
||||
|
||||
class FakeBus:
|
||||
def __init__(self) -> None:
|
||||
self.events = []
|
||||
|
||||
async def publish(self, name, payload=None):
|
||||
self.events.append((name, payload))
|
||||
|
||||
orch.bus = FakeBus()
|
||||
|
||||
wake_calls = []
|
||||
turn_calls = []
|
||||
|
||||
async def fake_handle_turn(self, initial_grace_ms=1500):
|
||||
turn_calls.append(initial_grace_ms)
|
||||
self._stop.set() # end the run() loop after the first turn
|
||||
|
||||
with patch.object(Orchestrator, "_wait_for_wake",
|
||||
lambda self: wake_calls.append(1)), \
|
||||
patch.object(Orchestrator, "_handle_turn", fake_handle_turn), \
|
||||
patch.object(Orchestrator, "stop",
|
||||
lambda self: asyncio.sleep(0)), \
|
||||
patch.object(Orchestrator, "set_state",
|
||||
lambda self, s: asyncio.sleep(0)):
|
||||
await orch.run()
|
||||
|
||||
self.assertFalse(orch._barge_in_requested)
|
||||
self.assertEqual(orch.mic.drain_calls, 1)
|
||||
self.assertEqual(wake_calls, [])
|
||||
self.assertEqual(turn_calls, [int(orch.config.wake_word.followup_grace_s * 1000)])
|
||||
self.assertIn(("barge_in_listen", {}), orch.bus.events)
|
||||
|
||||
|
||||
class BargeInEmptyUtteranceTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""_handle_turn: an empty capture must publish stt_done(empty) + stt_empty
|
||||
and reset the state to IDLE (previously the GUI stayed stuck on
|
||||
'listening')."""
|
||||
|
||||
async def test_empty_utterance_resets_to_idle(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
from javis.core.state import AgentState
|
||||
|
||||
orch = object.__new__(Orchestrator)
|
||||
orch.stt = object()
|
||||
orch.mic = object()
|
||||
orch.vad = object()
|
||||
orch.config = Config()
|
||||
orch._last_response_is_question = False
|
||||
orch.profiler = None
|
||||
|
||||
class FakeBus:
|
||||
def __init__(self) -> None:
|
||||
self.events = []
|
||||
|
||||
async def publish(self, name, payload=None):
|
||||
self.events.append((name, payload))
|
||||
|
||||
orch.bus = FakeBus()
|
||||
|
||||
state_calls = []
|
||||
with patch("javis.agent.orchestrator.record_utterance",
|
||||
new=lambda *a, **kw: asyncio.sleep(0, result=None)), \
|
||||
patch.object(Orchestrator, "set_state",
|
||||
lambda self, s: state_calls.append(s) or asyncio.sleep(0)):
|
||||
await orch._handle_turn(initial_grace_ms=1500)
|
||||
|
||||
self.assertIn(("stt_done", {"text": "", "audio_s": 0.0, "latency_s": 0.0, "empty": True}),
|
||||
orch.bus.events)
|
||||
self.assertIn(("stt_empty", {}), orch.bus.events)
|
||||
self.assertEqual(state_calls, [AgentState.LISTENING, AgentState.IDLE])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
from javis.agent.middleware import MemoryMiddleware, TurnContext
|
||||
from javis.core.config import Config, PathsConfig
|
||||
from javis.llm.hermes_client import HermesClient
|
||||
from javis.llm.ollama_client import ChatResponse
|
||||
|
||||
|
||||
class HermesClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_chat_uses_bearer_auth_and_openai_payload(self) -> None:
|
||||
config = Config()
|
||||
config.memory.provider = "hermes"
|
||||
config.memory.base_url = "http://hermes.test:30432"
|
||||
config.memory.model = "hermes-agent"
|
||||
config.memory.api_key_env = "TEST_HERMES_KEY"
|
||||
captured: dict = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["url"] = str(request.url)
|
||||
captured["authorization"] = request.headers.get("Authorization")
|
||||
captured["payload"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": "[]"}}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
client = HermesClient(config)
|
||||
await client._client.aclose()
|
||||
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with patch.dict(os.environ, {"TEST_HERMES_KEY": "test-secret"}):
|
||||
response = await client.chat(
|
||||
[{"role": "user", "content": "Extract memories"}],
|
||||
temperature=0.0,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(captured["url"], "http://hermes.test:30432/v1/chat/completions")
|
||||
self.assertEqual(captured["authorization"], "Bearer test-secret")
|
||||
self.assertEqual(captured["payload"]["model"], "hermes-agent")
|
||||
self.assertFalse(captured["payload"]["stream"])
|
||||
self.assertEqual(response.content, "[]")
|
||||
|
||||
async def test_list_models_is_authenticated(self) -> None:
|
||||
config = Config()
|
||||
config.memory.provider = "hermes"
|
||||
config.memory.base_url = "http://hermes.test:30432"
|
||||
config.memory.api_key_env = "TEST_HERMES_KEY"
|
||||
authorization = ""
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal authorization
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
return httpx.Response(200, json={"data": [{"id": "hermes-agent"}]})
|
||||
|
||||
client = HermesClient(config)
|
||||
await client._client.aclose()
|
||||
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with patch.dict(os.environ, {"TEST_HERMES_KEY": "test-secret"}):
|
||||
models = await client.list_models()
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(authorization, "Bearer test-secret")
|
||||
self.assertEqual(models, ["hermes-agent"])
|
||||
|
||||
|
||||
class FakeMemoryClient:
|
||||
async def chat(self, messages: list[dict], **kwargs) -> ChatResponse:
|
||||
return ChatResponse(content=json.dumps([
|
||||
{
|
||||
"key": "music_preference",
|
||||
"value": "Florian prefers jazz music",
|
||||
"category": "preferences",
|
||||
}
|
||||
]))
|
||||
|
||||
|
||||
class HermesMemoryMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_short_explicit_request_is_extracted_and_persisted(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
config = Config(paths=PathsConfig(base_dir=Path(temporary_directory)))
|
||||
config.memory.provider = "hermes"
|
||||
config.memory.model = "hermes-agent"
|
||||
middleware = MemoryMiddleware(FakeMemoryClient(), config)
|
||||
context = TurnContext(turn_id="turn-1", speaker_name="Florian")
|
||||
|
||||
await middleware.on_finish(
|
||||
context,
|
||||
"Merke dir: Ich mag Jazz.",
|
||||
"Das merke ich mir.",
|
||||
)
|
||||
|
||||
self.assertEqual(len(context._deferred), 1)
|
||||
await context._deferred[0]
|
||||
personal_file = Path(temporary_directory) / "memory_florian.json"
|
||||
stored = json.loads(personal_file.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
stored["entries"]["music_preference"]["value"],
|
||||
"Florian prefers jazz music",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user