LiveKit — Testing & Evals¶
A LiveKit voice agent has four things to test: the agent logic (prompts, tools, handoffs, tasks) in text-only sessions, tokens and webhooks (plain functions), the worker and dispatch path against a real server, and the audio quality of the full pipeline. The Agents framework ships test helpers for the first; the rest uses the Python SDKs, a local server and lk.
Test Layers¶
| Layer | Tool | Speed | Needs |
|---|---|---|---|
| Agent logic | AgentSession + session.run(user_input=...) + result.expect |
~1 s | A fake OpenAI-compatible server (or a real LLM) |
| Tool code, turn hooks, nodes | Plain pytest, mock_tools, call the hook or node directly |
ms | Nothing |
| Tokens, webhooks | livekit-api + PyJWT |
ms | Nothing |
| Behaviour | judge() and JudgeGroup (livekit.agents.evals) |
seconds | A judge LLM: scripted offline, real in a nightly run |
| Worker, dispatch, room I/O | A local livekit-server --dev, a worker process, livekit.rtc client |
~20 s | Docker |
| Load | lk perf load-test, lk perf agent-load-test |
minutes | A running server (and agent) |
| Voice quality | DeepEval voice metrics on recordings | seconds | See DeepEval Part 8b |
| Real providers | A few smoke tests with real keys | slow, costs money | Provider keys, nightly |
Agent Simulations (lk agent simulate) run on LiveKit Cloud and are not covered here.
Setup¶
uv add --dev pytest pytest-asyncio aiohttp "livekit-agents[openai]"
# pytest.ini
[pytest]
asyncio_mode = auto
markers =
integration: needs a running LiveKit server
AgentSession in Tests¶
Create a session with only an LLM (no STT, TTS, VAD, room), start it with the agent and drive it with text:
async with AgentSession(llm=llm, userdata=CallState()) as session:
await session.start(SupportAgent())
result = await session.run(user_input="Where is ord-42?")
result.expect.next_event().is_function_call(name="lookup_order", arguments={"order_id": "ord-42"})
result.expect.next_event().is_function_call_output()
result.expect.next_event().is_message(role="assistant")
result.expect.no_more_events()
session.run(user_input=..., output_type=None) returns a RunResult for one turn and can be awaited; call it repeatedly for a multi-turn conversation (history accumulates). Without an llm argument the agent's own llm is used.
result.expect |
Meaning |
|---|---|
next_event() |
Move to the next event and return an assertion object; next_event(type="message") filters by type |
.is_message(role="assistant") |
The event is a chat message; chain await ....judge(llm, intent="...") |
.is_function_call(name=..., arguments={...}) |
A tool call with these arguments (compared as parsed JSON) |
.is_function_call_output(output=..., is_error=...) |
The tool result; event().item.output, event().item.is_error |
.is_agent_handoff(new_agent_type=...) |
A handoff event |
contains_message(role=...), contains_function_call(name=..., arguments=...), contains_function_call_output(...), contains_agent_handoff(new_agent_type=...) |
Search the whole turn; preferred when the model may take several steps |
skip_next(n), skip_next_event_if(type=..., role=...) |
Skip events |
no_more_events() |
The turn is complete |
expect[0], expect[1:3] |
Index or slice the events |
A failed assertion prints the events of the turn; LIVEKIT_EVALS_VERBOSE=1 uv run pytest -s -o log_cli=true prints judge verdicts.
A Fake OpenAI-Compatible Server¶
The openai plugin takes base_url, so the real LLM class, tool schema generation and tool loop run against a scripted local server. conftest.py:
import json
import pytest
from aiohttp import web
def chunk(delta, finish=None) -> bytes:
body = {"id": "c", "object": "chat.completion.chunk", "created": 0, "model": "fake",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
return f"data: {json.dumps(body)}\n\n".encode()
class FakeLLM:
"""Scripted chat server. The first matching rule wins; every request body is recorded."""
def __init__(self):
self.requests: list[dict] = []
self.rules: list = []
def when(self, matcher, *, text="", call=None): # call = (tool name, arguments dict)
self.rules.append((matcher, text, call))
@pytest.fixture
async def fake_llm():
fake = FakeLLM()
async def completions(request: web.Request) -> web.StreamResponse:
body = await request.json()
fake.requests.append(body)
text, call = "No scripted answer.", None
for matcher, t, c in fake.rules:
if matcher(body["messages"]):
text, call = t, c
break
resp = web.StreamResponse(headers={"Content-Type": "text/event-stream"})
await resp.prepare(request)
if call:
fn = {"name": call[0], "arguments": json.dumps(call[1])}
await resp.write(chunk({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": fn}]}))
await resp.write(chunk({}, "tool_calls"))
else:
for word in text.split(" "):
await resp.write(chunk({"role": "assistant", "content": word + " "}))
await resp.write(chunk({}, "stop"))
await resp.write(b"data: [DONE]\n\n")
return resp
app = web.Application()
app.add_routes([web.post("/v1/chat/completions", completions)])
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
fake.url = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/v1"
yield fake
await runner.cleanup()
Rules are predicates over the OpenAI messages list: the tool-result round trip is lambda m: m[-1]["role"] == "tool"; a handoff or task has a different system prompt in m[0], which a matcher can use to script each agent separately. Use the agents from 03 (SupportAgent, Intake):
from livekit.agents import AgentSession, mock_tools
from livekit.plugins import openai
from my_agent import CallState, SupportAgent # the agents from 03
def last_role(m): return m[-1]["role"]
def last_user_text(m): return next(x["content"] for x in reversed(m) if x["role"] == "user")
@pytest.fixture
def llm(fake_llm):
return openai.LLM(model="fake", base_url=fake_llm.url, api_key="sk-test")
async def test_tool_error_reaches_model(fake_llm, llm):
fake_llm.when(lambda m: last_role(m) == "tool", text="That order number looks wrong.")
fake_llm.when(lambda m: "bad" in last_user_text(m), call=("lookup_order", {"order_id": "bad-1"}))
async with AgentSession(llm=llm, userdata=CallState()) as session:
await session.start(SupportAgent())
result = await session.run(user_input="Check bad-1")
result.expect.next_event().is_function_call(name="lookup_order")
out = result.expect.next_event().is_function_call_output()
assert out.event().item.is_error and "Unknown order id" in out.event().item.output
result.expect.next_event().is_message(role="assistant")
async def test_mocked_tool(fake_llm, llm):
fake_llm.when(lambda m: last_role(m) == "tool", text="Our order system is down.")
fake_llm.when(lambda m: True, call=("lookup_order", {"order_id": "ord-42"}))
with mock_tools(SupportAgent, {"lookup_order": lambda: RuntimeError("order service is down")}):
async with AgentSession(llm=llm, userdata=CallState()) as session:
await session.start(SupportAgent())
result = await session.run(user_input="Where is ord-42?")
result.expect.next_event().is_function_call(name="lookup_order")
result.expect.next_event().is_function_call_output(is_error=True)
assert session.userdata.handled == [] # the real tool never ran
Handoffs and tasks use the same pattern: script each agent through its system prompt in m[0] (lambda m: "billing team" in str(m[-1]["content"])), then assert result.expect.contains_agent_handoff(new_agent_type=BillingAgent) and session.current_agent; for an AgentTask, script its tool call (record_consent) and assert contains_function_call(name="record_consent", arguments={"agreed": True}).
mock_tools(AgentClass, {"tool": fn}) replaces tool execution only; the model still sees the real schema. A mock may declare any subset of the parameters; returning an exception instance makes the tool fail. mock_tools(..., session=session) keeps mocks for the session's life. Assert on the requests the server recorded (fake_llm.requests[-1]["messages"]) to check what the model was sent: system prompt, injected facts, tool results.
Unit-testing the turn hook¶
session.run(user_input=...) and text on lk.chat skip on_user_turn_completed, so test the hook directly:
import pytest
from livekit.agents import ChatContext, ChatMessage, StopResponse
import my_agent
from my_agent import Guarded
async def test_turn_hook(monkeypatch):
async def fake_search(query: str) -> str:
return "the store opens at 9am"
monkeypatch.setattr(my_agent, "search_kb", fake_search) # my_agent: the module that defines Guarded and search_kb (see 03)
agent = Guarded()
ctx = ChatContext()
await agent.on_user_turn_completed(ctx, ChatMessage(role="user", content=["When do you open?"]))
assert "opens at 9am" in ctx.items[-1].text_content
with pytest.raises(StopResponse):
await agent.on_user_turn_completed(ChatContext(), ChatMessage(role="user", content=[" "]))
Code that calls get_job_context() needs a job: wrap it in fake_job_context(job_metadata='{"user_id": "12345"}') from livekit.agents.testing (yields a JobContext; no worker or server).
Judges and Behavioural Evals¶
judge() asks an LLM whether one message fulfils an intent; JudgeGroup grades a whole conversation with several judges.
await result.expect.next_event().is_message(role="assistant").judge(judge_llm, intent="reports the shipping status")
from livekit.agents.evals import Judge, JudgeGroup, JudgmentResult, task_completion_judge
class ShippedMentionJudge(Judge):
"""Deterministic judge: no LLM call."""
def __init__(self):
super().__init__(name="mentions_shipping")
async def evaluate(self, *, chat_ctx, reference=None, llm=None):
ok = any("shipped" in (m.text_content or "") for m in chat_ctx.messages() if m.role == "assistant")
return JudgmentResult(verdict="pass" if ok else "fail", reasoning="found" if ok else "missing")
async def test_conversation_passes_judges(fake_llm, llm):
fake_llm.when(lambda m: "evaluator for conversational" in m[0]["content"],
call=("submit_verdict", {"verdict": "pass", "reasoning": "task done"})) # scripted judge LLM
fake_llm.when(lambda m: True, text="Your order has shipped.")
async with AgentSession(llm=llm, userdata=CallState()) as session:
await session.start(SupportAgent())
await session.run(user_input="Where is my order?")
result = await JudgeGroup(llm=llm, judges=[task_completion_judge(), ShippedMentionJudge()]).evaluate(session.history)
assert result.all_passed and result.score == 1.0
| Item | Detail |
|---|---|
| Built-in judges | task_completion_judge, handoff_judge, accuracy_judge, tool_use_judge, safety_judge, relevancy_judge, coherence_judge, conciseness_judge (each takes an optional llm) |
| Verdicts | pass, fail, maybe; EvaluationResult has judgments (by name), score (pass=1, maybe=0.5, fail=0), all_passed, any_passed, majority_passed, none_failed |
| Custom judges | Subclass Judge, implement evaluate(*, chat_ctx, reference=None, llm=None); deterministic checks need no LLM |
| Reference | evaluate(chat_ctx, reference=expected_conversation) compares against a reference |
| Judge model | A different, stronger model than the agent's; temperature 0; pin it |
Offline, script the judge with the fake server as above to test the wiring (grouping, thresholds, reports). For real quality gates run a nightly job with a real judge (openai.LLM(model="gpt-4o-mini")), a fixed set of scripted user turns (pytest.mark.parametrize), several samples per case, and a pass-rate threshold instead of an exact match. Use deterministic judges for hard rules (no PII spoken, tool called before answer, handoff happened) and LLM judges for tone and task completion. Voice metrics such as word error rate and latency percentiles on recorded calls are covered in DeepEval voice metrics; the same ideas for a frame-based stack are in Pipecat testing.
Tokens and Webhooks¶
Both are plain functions: test them with negative cases (PyJWT test patterns).
import base64
import datetime
import hashlib
import jwt
import pytest
from livekit import api
KEY, SECRET = "APIexample", "secret-at-least-32-characters-long"
def token(secret=SECRET, ttl=datetime.timedelta(minutes=5)) -> str:
return (api.AccessToken(KEY, secret).with_identity("a").with_ttl(ttl)
.with_grants(api.VideoGrants(room_join=True, room="r")).to_jwt())
@pytest.mark.parametrize(
"bad, error",
[(token(secret="x" * 40), jwt.InvalidSignatureError), (token(ttl=datetime.timedelta(minutes=-10)), jwt.ExpiredSignatureError)],
)
def test_verifier_rejects_bad_tokens(bad, error):
with pytest.raises(error):
api.TokenVerifier(KEY, SECRET).verify(bad)
def signed_webhook(body: str) -> str:
digest = base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
return api.AccessToken(KEY, SECRET).with_sha256(digest).to_jwt()
def test_webhook_rejects_tampered_body():
body = '{"event": "participant_joined", "room": {"name": "r"}, "participant": {"identity": "alice"}}'
receiver = api.WebhookReceiver(api.TokenVerifier(KEY, SECRET))
assert receiver.receive(body, signed_webhook(body)).event == "participant_joined"
with pytest.raises(Exception, match="hash mismatch"):
receiver.receive(body.replace("alice", "mallory"), signed_webhook(body))
TokenVerifier also checks the issuer (InvalidIssuerError) and requires exp. Also assert grants (can_publish, room) on the tokens your backend mints.
Worker and Dispatch Against a Real Server¶
Start livekit/livekit-server --dev, run the worker as a subprocess, dispatch, join with livekit.rtc and exchange text. The worker (agent_text.py) is text-only and takes its LLM URL from the environment:
import os
from livekit import agents
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, room_io
from livekit.plugins import openai
server = AgentServer(port=0, num_idle_processes=1, load_threshold=float("inf")) # random health port, no load shedding in tests
@server.rtc_session(agent_name="support-test")
async def entrypoint(ctx: JobContext) -> None:
session = AgentSession(llm=openai.LLM(model="fake", base_url=os.environ["LLM_BASE_URL"], api_key="sk-test"))
await session.start(
agent=Agent(instructions="You are a support agent."),
room=ctx.room,
room_options=room_io.RoomOptions(audio_input=False, audio_output=False),
)
if __name__ == "__main__":
agents.cli.run_app(server)
The test:
import asyncio
import os
import subprocess
import sys
import time
import pytest
from livekit import api, rtc
URL, KEY, SECRET = "ws://localhost:7880", "devkey", "secret"
@pytest.fixture
def worker(fake_llm):
env = {**os.environ, "LIVEKIT_URL": URL, "LIVEKIT_API_KEY": KEY, "LIVEKIT_API_SECRET": SECRET, "LLM_BASE_URL": fake_llm.url}
proc = subprocess.Popen([sys.executable, "agent_text.py", "start"], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
deadline = time.time() + 30
for line in proc.stdout: # wait until the worker registered
if "registered worker" in line:
break
assert time.time() < deadline and proc.poll() is None, "worker did not start"
yield proc
proc.terminate()
proc.wait(timeout=30)
@pytest.mark.integration
async def test_dispatched_agent_answers_text(worker, fake_llm):
fake_llm.when(lambda m: True, text="Your order has shipped.")
room_name = f"it-{int(time.time())}"
token = api.AccessToken(KEY, SECRET).with_identity("tester").with_grants(api.VideoGrants(room_join=True, room=room_name)).to_jwt()
replies: asyncio.Queue[str] = asyncio.Queue()
room = rtc.Room()
async def read(reader, _identity):
text = await reader.read_all()
if reader.info.attributes.get("lk.transcription_final") == "true":
await replies.put(text)
room.register_text_stream_handler("lk.transcription", lambda r, p: asyncio.create_task(read(r, p)))
await room.connect(URL, token)
try:
async with api.LiveKitAPI("http://localhost:7880", KEY, SECRET) as lk:
await lk.agent_dispatch.create_dispatch(api.CreateAgentDispatchRequest(agent_name="support-test", room=room_name))
while not any(p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT for p in room.remote_participants.values()):
await asyncio.sleep(0.2) # wrap in asyncio.wait_for in real code
await asyncio.sleep(3) # let the session link to the participant
await room.local_participant.send_text("Where is my order?", topic="lk.chat")
assert "shipped" in await asyncio.wait_for(replies.get(), 30)
finally:
await room.disconnect()
This checks registration, explicit dispatch, room I/O, the text channel and the LLM round trip with no audio and no provider keys. Extend it for RPC (perform_rpc to the agent), metadata (CreateAgentDispatchRequest(metadata=...)) and SIP dispatch rules.
Load Testing¶
lk --dev perf load-test --room lt --audio-publishers 20 --subscribers 20 --duration 2m # the SFU
lk --dev perf agent-load-test --rooms 10 --agent-name support --duration 5m # a running agent worker
load-test flags: --video-publishers, --audio-publishers, --subscribers, --num-per-second (join rate, default 5), --video-resolution, --video-codec, --layout, --no-simulcast, --duration; the report lists tracks, bitrate and packet loss per subscriber. agent-load-test opens --rooms, dispatches --agent-name and drives each room with an echo track (--echo-speech-delay: pause between the echo speaking and the agent starting to speak, default 5 s). Watch lk_agents_worker_load, job counts and per-turn latency (04) and find the job count where load crosses load_threshold.
CI¶
name: voice-agent-tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync
- run: uv run pytest -m "not integration"
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: docker run -d -p 7880:7880 -p 7881:7881 -p 7882:7882/udp livekit/livekit-server:v1.13.10 --dev --bind 0.0.0.0
- run: uv sync && uv run pytest -m integration
Run unit tests on every push, integration on pull requests, real-provider smoke and judge-based evals nightly with keys from the CI secret store. lk agent debugger (text mode, no LiveKit project needed) is handy for ad-hoc scripted conversations: lk agent debugger start, lk agent debugger say --json "What are your hours?".
Quick Rules¶
- Test logic in text-only sessions (
AgentSession(llm=...), no audio, no room) with a fake OpenAI-compatible server. - Use
contains_*assertions for multi-step turns andnext_event()chains for exact flows; end withno_more_events()when the turn must be complete. - Mock tool execution with
mock_tools, and assert on the requests the model received. - Call hooks and nodes directly;
session.runbypasseson_user_turn_completed. - Script judges offline, use a real judge nightly, with thresholds and several samples, not exact output matching.
- Test token and webhook code with negative cases (bad signature, wrong issuer, expired, tampered body).
- Keep one worker-to-room integration test against
--devin Docker; mark itintegration. - Load test the SFU and the agent separately, then derive worker capacity from the agent test.
See also¶
- Digital Garden: Knowledge Base
- LiveKit — Open-Source WebRTC Server and Agents Framework
- LiveKit — Tools, Handoffs & Workflows
- Pipecat — Testing & Evals
- DeepEval Guide — Part 8b: Voice Evaluation
- Pytest
- PyJWT — JSON Web Tokens
- Docker