fix: ChatContext is not a sequence — use .items for len/list; add test suite
Bug fix: - GemmaLLM.chat(): len(chat_ctx) → len(chat_ctx.items) - GemmaLLM._compact_context(): list(chat_ctx) → list(chat_ctx.items) ChatContext in livekit-agents 1.7 is not iterable or sized directly; it exposes an .items list. The TypeError was silently killing all LLM responses (agent heard user but never spoke back). Test suite (tests/): - test_web_ui.py: static assets, token endpoint, LiveKit WS proxy - test_llm_api.py: completion, tool calling, thinking-disabled latency, streaming - test_mcp_tools.py: weather (Fahrenheit), time, memory, skills — all via docker exec + in-container MCP client - test_agent_integration.py: process alive, worker registered, supervisord, container health, LiveKit API - test_compaction.py: size-triggered compaction, system prompt preservation, short-context no-op - conftest.py: shared fixtures (HTTPS client, token, LLM, docker exec helpers) - Run with: .venv-tests/bin/python -m pytest tests/ -v
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Shared fixtures for the Hope voice-assistant test suite.
|
||||
|
||||
Tests run on the HOST machine against the live "voice" container:
|
||||
- HTTPS web UI -> https://localhost:8090 (self-signed cert, verify=False)
|
||||
- Token server -> http://127.0.0.1:8091 (host network mode)
|
||||
- LLM API -> GEMMA_BASE_URL (default http://192.168.86.2:8023/v1)
|
||||
- In-container -> docker exec voice ...
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
CONTAINER = "voice"
|
||||
WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:8090")
|
||||
TOKEN_URL = os.environ.get("VOICE_TOKEN_URL", "http://127.0.0.1:8091/token")
|
||||
GEMMA_BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1").rstrip("/")
|
||||
GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "gemma-4-e4b")
|
||||
|
||||
# Path to the agent's venv python inside the container (has mcp, livekit.agents)
|
||||
CONTAINER_PYTHON = "/opt/voice-agent/.venv/bin/python"
|
||||
AGENT_DIR_IN_CONTAINER = "/opt/voice-agent"
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "slow: marks tests as slow (LLM / network calls)")
|
||||
|
||||
|
||||
# ── docker helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def docker_exec(*args, timeout=60, env=None, check=True) -> subprocess.CompletedProcess:
|
||||
"""Run a command inside the voice container. Returns CompletedProcess."""
|
||||
cmd = ["docker", "exec"]
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
cmd += ["-e", f"{k}={v}"]
|
||||
cmd += [CONTAINER, *args]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def docker_exec_python(
|
||||
code: str, *, timeout=120, cwd=None, env=None, check=True
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a python snippet inside the container with the agent venv."""
|
||||
return docker_exec(CONTAINER_PYTHON, "-c", code, timeout=timeout, env=env, check=check)
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url() -> str:
|
||||
"""Base URL of the web UI (HTTPS, self-signed)."""
|
||||
return WEB_BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client(base_url):
|
||||
"""httpx client for the HTTPS web UI with TLS verification disabled."""
|
||||
with httpx.Client(base_url=base_url, verify=False, timeout=15) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def token() -> dict:
|
||||
"""Fetch a fresh LiveKit token from the token endpoint (direct, no proxy)."""
|
||||
resp = httpx.post(TOKEN_URL, json={}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
assert "token" in data, f"token endpoint did not return a token: {data.keys()}"
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def llm_client():
|
||||
"""httpx client pointed at the Gemma LLM API (same one Hope uses)."""
|
||||
with httpx.Client(base_url=GEMMA_BASE_URL, timeout=60) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mcp_client_factory():
|
||||
"""Factory that runs an MCP stdio tool call inside the container.
|
||||
|
||||
Returns (result_text, is_error). Spawns the MCP server script via the
|
||||
agent venv python and drives it with a raw JSON-RPC session using the
|
||||
mcp client library (available in the container, not on the host).
|
||||
"""
|
||||
|
||||
def _call(script: str, tool_name: str, args: dict | None = None, env: dict | None = None) -> tuple[str, bool]:
|
||||
import os as host_os
|
||||
|
||||
merged_env = {**host_os.environ, **(env or {})}
|
||||
code = f"""
|
||||
import asyncio, json
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
async def main():
|
||||
params = StdioServerParameters(
|
||||
command={CONTAINER_PYTHON!r},
|
||||
args=[{AGENT_DIR_IN_CONTAINER + "/" + script!r}],
|
||||
env={json.dumps(merged_env)},
|
||||
)
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool({tool_name!r}, {json.dumps(args or {})})
|
||||
text = ""
|
||||
for block in result.content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
text += block.text
|
||||
print(json.dumps({{"text": text, "isError": bool(result.isError)}}))
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
proc = docker_exec_python(code, timeout=120, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"MCP call {tool_name} failed (rc={proc.returncode}): "
|
||||
f"stderr={proc.stderr[-800:]}"
|
||||
)
|
||||
data = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
return data["text"], data["isError"]
|
||||
|
||||
return _call
|
||||
Reference in New Issue
Block a user