Plan 3: backend WS client + dispatch + tool round-trip
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from client.session import Session
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
"""Minimal stand-in for a `websockets` client connection."""
|
||||
def __init__(self, inbox: list):
|
||||
# inbox is a list of either str (text frame) or bytes (binary frame).
|
||||
self._inbox = list(inbox)
|
||||
self.sent: list = []
|
||||
|
||||
async def send(self, frame):
|
||||
self.sent.append(frame)
|
||||
|
||||
async def recv(self):
|
||||
if not self._inbox:
|
||||
await asyncio.sleep(3600) # block forever; tests cancel
|
||||
return self._inbox.pop(0)
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCallbacks:
|
||||
"""Implements client.session.Callbacks via duck-typing."""
|
||||
def __init__(self):
|
||||
self.started: list = []
|
||||
self.audio: list = []
|
||||
self.assistant_done = 0
|
||||
self.session_ended: list = []
|
||||
|
||||
def on_session_started(self, conversation_id: str) -> None:
|
||||
self.started.append(conversation_id)
|
||||
|
||||
def on_assistant_audio(self, pcm16: bytes) -> None:
|
||||
self.audio.append(pcm16)
|
||||
|
||||
def on_assistant_done(self) -> None:
|
||||
self.assistant_done += 1
|
||||
|
||||
def on_session_ended(self, reason: str) -> None:
|
||||
self.session_ended.append(reason)
|
||||
|
||||
|
||||
async def _run_pump_briefly(session, ws, settle: float = 0.05):
|
||||
task = asyncio.create_task(session._pump(ws))
|
||||
await asyncio.sleep(settle)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, BaseException):
|
||||
pass
|
||||
|
||||
|
||||
async def test_hello_is_sent_on_connect_and_hello_ack_logged():
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {"voice": "alloy"}}),
|
||||
])
|
||||
session = Session(
|
||||
backend_ws="wss://x/device",
|
||||
device_token="T",
|
||||
device_id="00000000-0000-0000-0000-000000000001",
|
||||
callbacks=_FakeCallbacks(),
|
||||
)
|
||||
await _run_pump_briefly(session, ws)
|
||||
|
||||
first = json.loads(ws.sent[0])
|
||||
assert first["type"] == "hello"
|
||||
assert first["device_id"] == "00000000-0000-0000-0000-000000000001"
|
||||
assert first["client_version"]
|
||||
|
||||
|
||||
async def test_session_started_calls_callback_with_conversation_id():
|
||||
cb = _FakeCallbacks()
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
json.dumps({
|
||||
"type": "session_started",
|
||||
"conversation_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
}),
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
await _run_pump_briefly(session, ws)
|
||||
assert cb.started == ["cccccccc-cccc-cccc-cccc-cccccccccccc"]
|
||||
|
||||
|
||||
async def test_binary_frame_goes_to_audio_callback():
|
||||
cb = _FakeCallbacks()
|
||||
payload = bytes(range(256)) * 15 # 3840 bytes
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
payload,
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
await _run_pump_briefly(session, ws)
|
||||
assert cb.audio == [payload]
|
||||
|
||||
|
||||
async def test_assistant_done_and_session_ended_callbacks():
|
||||
cb = _FakeCallbacks()
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
json.dumps({"type": "assistant_done"}),
|
||||
json.dumps({"type": "session_ended", "reason": "idle"}),
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
await _run_pump_briefly(session, ws)
|
||||
assert cb.assistant_done == 1
|
||||
assert cb.session_ended == ["idle"]
|
||||
|
||||
|
||||
async def test_tool_call_runs_handler_and_sends_tool_result():
|
||||
cb = _FakeCallbacks()
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
json.dumps({
|
||||
"type": "tool_call",
|
||||
"call_id": "c1",
|
||||
"name": "set_volume",
|
||||
"arguments": {"level": 42},
|
||||
}),
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
captured = {}
|
||||
|
||||
def handler(args):
|
||||
captured["args"] = args
|
||||
return {"ok": True, "level": args["level"]}
|
||||
|
||||
session.register_tool("set_volume", handler)
|
||||
await _run_pump_briefly(session, ws)
|
||||
|
||||
assert captured["args"] == {"level": 42}
|
||||
reply = json.loads(ws.sent[-1])
|
||||
assert reply["type"] == "tool_result"
|
||||
assert reply["call_id"] == "c1"
|
||||
assert reply["ok"] is True
|
||||
assert reply["result"] == {"ok": True, "level": 42}
|
||||
|
||||
|
||||
async def test_tool_call_with_exception_sends_ok_false():
|
||||
cb = _FakeCallbacks()
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
json.dumps({
|
||||
"type": "tool_call",
|
||||
"call_id": "c2",
|
||||
"name": "broken",
|
||||
"arguments": {},
|
||||
}),
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
|
||||
def handler(args):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
session.register_tool("broken", handler)
|
||||
await _run_pump_briefly(session, ws)
|
||||
|
||||
reply = json.loads(ws.sent[-1])
|
||||
assert reply["type"] == "tool_result"
|
||||
assert reply["call_id"] == "c2"
|
||||
assert reply["ok"] is False
|
||||
assert "kaboom" in reply["error"]
|
||||
|
||||
|
||||
async def test_unknown_tool_sends_ok_false():
|
||||
cb = _FakeCallbacks()
|
||||
ws = FakeWebSocket(inbox=[
|
||||
json.dumps({"type": "hello_ack", "config": {}}),
|
||||
json.dumps({
|
||||
"type": "tool_call",
|
||||
"call_id": "c3",
|
||||
"name": "ghost",
|
||||
"arguments": {},
|
||||
}),
|
||||
])
|
||||
session = Session("wss://x/device", "T", "did", cb)
|
||||
await _run_pump_briefly(session, ws)
|
||||
reply = json.loads(ws.sent[-1])
|
||||
assert reply["ok"] is False
|
||||
assert "ghost" in reply["error"]
|
||||
Reference in New Issue
Block a user