3a0959f55d
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON endpoint, so the wait is time-to-first-sentence rather than the whole model call, and Deepgram's live websocket transcribes while you're still talking instead of uploading the WAV afterwards. Both fall back invisibly — a stream that fails before anything was said drops to converse(), and a socket that never opens just means the old one-shot path. Speaking lived in four near-copies in the controller (a reply, a holding line, a streamed sentence, a dialogue scene) that had already drifted: one didn't arm barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an Utterance describing the policy differences, with collaborators injected so the whole of it tests without Qt or audio. The mouth follows the audio rather than a timer: tts.level_of reduces each PCM frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a linear map leaves the mouth barely open during normal talking) and that indexes the talking frames, which the sprite script now draws as an openness ramp. Offline pyttsx3 has no waveform, so stale levels hand control back to the timed loop instead of freezing the mouth mid-syllable. Also: the pet starts where you left it (ignoring positions on monitors that are no longer connected, since restoring those faithfully is how it ends up somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that says what to do about each problem rather than only what's wrong. tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit test passed all week while notifications sat unspoken for minutes, the pet said things twice and [laughing] got read aloud — each an interaction between two individually-correct units. It drives whole turns against a real HTTP server on a loopback port, faking only the mic and the speakers. It found a NameError in the paint path that would have fired on every repaint while talking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""End-to-end: microphone in, speech out, over real HTTP.
|
|
|
|
Every other test in this suite injects a fake at the seam it cares about, and
|
|
every one of them passed all week while these got through to production:
|
|
|
|
- notifications sitting unspoken for minutes (a clock stamped in the wrong
|
|
order, two correct units)
|
|
- the pet saying the same thing twice (server discarded prose, model repeated
|
|
it — both sides behaving as written)
|
|
- `[laughing]` read out loud (a tag that means something to one model and
|
|
nothing to the next one down the pipe)
|
|
- a device command written as prose (extractor fine, prompt fine, no marker)
|
|
|
|
They were all *interaction* bugs. So this one runs the actual pipeline against
|
|
a real socket: a threaded HTTP server that speaks the desk protocol, the real
|
|
`server_client` doing real requests (including NDJSON streaming), the real
|
|
controller loop and state machine. The only fakes are where the hardware is —
|
|
the mic stream and the speakers — because those are the two things a test
|
|
genuinely cannot have.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet import controller as controller_mod
|
|
from bolt_pet.notifications import Notification
|
|
from bolt_pet.state import PetState
|
|
|
|
_app = QApplication.instance() or QApplication(["test"])
|
|
|
|
|
|
# ── a desk server that actually listens on a port ───────────────────────────
|
|
|
|
class FakeDesk:
|
|
"""Scripted responses, real HTTP. Set `.script` per test."""
|
|
|
|
def __init__(self):
|
|
self.script = {}
|
|
self.requests = []
|
|
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
self._thread.start()
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
host, port = self._server.server_address[:2]
|
|
return f"http://{host}:{port}"
|
|
|
|
def stop(self) -> None:
|
|
self._server.shutdown()
|
|
self._server.server_close()
|
|
|
|
def _handler(self):
|
|
desk = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *_args):
|
|
pass # the test output is not an access log
|
|
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0]
|
|
desk.requests.append(("GET", path))
|
|
self._json(desk.script.get(path, {"files": []}))
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
path = self.path.split("?")[0]
|
|
desk.requests.append(("POST", path, body))
|
|
response = desk.script.get(path)
|
|
if callable(response):
|
|
response = response(body)
|
|
if path.endswith("converse_stream"):
|
|
self._ndjson(response or [])
|
|
else:
|
|
self._json(response if response is not None else {"type": "reply", "text": "ok"})
|
|
|
|
def _json(self, payload):
|
|
data = json.dumps(payload).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def _ndjson(self, events):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/x-ndjson")
|
|
self.end_headers()
|
|
for event in events:
|
|
self.wfile.write((json.dumps(event) + "\n").encode())
|
|
self.wfile.flush()
|
|
|
|
return Handler
|
|
|
|
|
|
class FakeMic:
|
|
"""Loud frames then quiet ones, so the VAD ends the utterance on its own."""
|
|
|
|
def __init__(self, loud=8, quiet=60):
|
|
self.frames = ([np.full((320, 1), 4000, dtype=np.int16)] * loud
|
|
+ [np.zeros((320, 1), dtype=np.int16)] * quiet)
|
|
|
|
def read(self, _n):
|
|
return (self.frames.pop(0) if self.frames
|
|
else np.zeros((320, 1), dtype=np.int16)), None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_a):
|
|
return False
|
|
|
|
|
|
@pytest.fixture
|
|
def pipeline(monkeypatch):
|
|
"""A controller wired to a real local server, with fake ears and mouth."""
|
|
desk = FakeDesk()
|
|
spoken: list[str] = []
|
|
levels: list[float] = []
|
|
|
|
monkeypatch.setattr(controller_mod.config, "SERVER_URL", desk.url)
|
|
monkeypatch.setattr(controller_mod.config, "API_KEY", "test-key")
|
|
monkeypatch.setattr(controller_mod.config, "SESSION_ID", "pet-smoke")
|
|
monkeypatch.setattr(controller_mod.config, "SILENCE_END_SEC", 0.2)
|
|
monkeypatch.setattr(controller_mod.config, "MIN_UTTERANCE_S", 0.0)
|
|
monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False)
|
|
# Off by default so each test picks its own path; the streaming tests opt in.
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
|
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
|
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
|
# Deepgram and the speakers are the two things a test can't have.
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's on my disk?")
|
|
monkeypatch.setattr(controller_mod.stt_stream.StreamingTranscriber, "open",
|
|
classmethod(lambda cls, **kw: None))
|
|
|
|
def fake_speak(text, on_error=None, should_stop=None, voice_id=None, on_level=None):
|
|
spoken.append(text)
|
|
if on_level is not None:
|
|
on_level(0.8) # the mouth opens while a word plays...
|
|
levels.append(0.8)
|
|
return True
|
|
|
|
monkeypatch.setattr(controller_mod.tts, "speak", fake_speak)
|
|
|
|
ctrl = controller_mod.PetController()
|
|
ctrl._stream = FakeMic()
|
|
try:
|
|
yield ctrl, desk, spoken, levels
|
|
finally:
|
|
desk.stop()
|
|
|
|
|
|
# ── the whole path ──────────────────────────────────────────────────────────
|
|
|
|
def test_a_plain_turn_goes_mic_to_speaker(pipeline):
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
desk.script["/desk/converse"] = {"type": "reply", "text": "About sixty percent full."}
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["About sixty percent full."]
|
|
assert ctrl._state.state == PetState.IDLE
|
|
posted = [r for r in desk.requests if r[0] == "POST"]
|
|
assert posted[0][1] == "/desk/converse"
|
|
assert "what's on my disk?" in posted[0][2]["text"]
|
|
assert ctrl.history.entries()[-1].text == "About sixty percent full."
|
|
|
|
|
|
def test_a_streamed_turn_speaks_each_sentence_as_it_lands(pipeline, monkeypatch):
|
|
"""The NDJSON is parsed by the real client over a real socket — the layer
|
|
that a mocked `converse` can never exercise."""
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
|
desk.script["/desk/converse_stream"] = [
|
|
{"type": "say", "text": "The disk is fine."},
|
|
{"type": "say", "text": "About sixty percent used."},
|
|
{"type": "reply", "text": "The disk is fine. About sixty percent used.",
|
|
"already_spoken": True},
|
|
]
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["The disk is fine.", "About sixty percent used."]
|
|
# The final reply must not be spoken a third time.
|
|
assert len(spoken) == 2
|
|
|
|
|
|
def test_a_tool_turn_says_give_me_a_sec_then_the_answer(pipeline, monkeypatch):
|
|
"""Holding line, relayed command, and the real answer — in that order."""
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
lambda cmd: ran.append(cmd) or "[exit 0]\n60% used")
|
|
desk.script["/desk/converse"] = {
|
|
"type": "command", "command": "df -h /", "token": "tok",
|
|
"say": "Let me check that for you.",
|
|
}
|
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Sixty percent used."}
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["Let me check that for you.", "Sixty percent used."]
|
|
assert ran == ["df -h /"]
|
|
relayed = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/tool_result")
|
|
assert relayed[2]["output"].endswith("60% used")
|
|
|
|
|
|
def test_a_device_command_never_reaches_the_shell(pipeline, monkeypatch):
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
actions = []
|
|
ctrl.action.connect(actions.append)
|
|
desk.script["/desk/converse"] = {
|
|
"type": "command", "command": "petctl emote wave", "token": "tok"}
|
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "There you go."}
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert ran == []
|
|
assert actions == [{"action": "emote", "emote": "wave"}]
|
|
assert spoken == ["There you go."]
|
|
|
|
|
|
def test_the_mouth_moves_with_the_audio(pipeline):
|
|
"""Lip-sync is only real if the level actually reaches the window."""
|
|
ctrl, desk, _spoken, levels = pipeline
|
|
mouth = []
|
|
ctrl.mouth.connect(mouth.append)
|
|
desk.script["/desk/converse"] = {"type": "reply", "text": "Talking now."}
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert levels, "TTS was never given a level callback"
|
|
assert 0.8 in mouth # opened while speaking...
|
|
assert mouth[-1] == 0.0 # ...and closed at the end
|
|
|
|
|
|
def test_a_notification_is_forwarded_and_spoken(pipeline):
|
|
"""The path that was silently sitting for five to ten minutes."""
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
desk.script["/desk/converse"] = {"type": "reply", "text": "Harry says he's around."}
|
|
|
|
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
|
ctrl._maybe_heartbeat()
|
|
|
|
assert spoken == ["Harry says he's around."]
|
|
forwarded = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/converse")
|
|
assert "Harry" in forwarded[2]["text"]
|
|
|
|
|
|
def test_streaming_falls_back_when_the_server_is_older(pipeline, monkeypatch):
|
|
"""A server without /desk/converse_stream (or one that answers with nothing)
|
|
must not cost a turn — the client drops to the plain endpoint. This is how
|
|
the pet keeps working against a container that hasn't been updated yet."""
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
|
desk.script["/desk/converse_stream"] = [] # nothing streamed back
|
|
desk.script["/desk/converse"] = {"type": "reply", "text": "Still here."}
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["Still here."]
|
|
paths = [r[1] for r in desk.requests if r[0] == "POST"]
|
|
assert paths == ["/desk/converse_stream", "/desk/converse"]
|
|
|
|
|
|
def test_a_dead_server_leaves_the_pet_usable(pipeline):
|
|
"""It should flash an error and go back to listening, not wedge."""
|
|
ctrl, desk, spoken, _levels = pipeline
|
|
desk.stop() # the server disappears mid-session
|
|
states = []
|
|
ctrl.state_changed.connect(states.append)
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert states[-2:] == ["error", "idle"]
|
|
assert spoken == []
|