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>
1043 lines
46 KiB
Python
1043 lines
46 KiB
Python
"""Controller-level wiring for the newer behaviours: petctl routing,
|
|
barge-in follow-up, quiet hours, screen context, notification forwarding and
|
|
the live wake threshold.
|
|
|
|
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
from bolt_pet import controller as controller_mod
|
|
from bolt_pet.notifications import Notification
|
|
from bolt_pet.server_client import Reply
|
|
from bolt_pet.state import PetState
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
_app = QApplication.instance() or QApplication(["test"])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def no_screen_probes(monkeypatch):
|
|
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
|
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_streaming(monkeypatch):
|
|
"""Most tests drive the plain request/response path; leaving streaming on
|
|
would have them attempt a real HTTP call, fail, and fall back — passing,
|
|
slowly, for the wrong reason. The streaming path has its own tests."""
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
|
|
|
|
|
@pytest.fixture
|
|
def ctrl():
|
|
return controller_mod.PetController()
|
|
|
|
|
|
def _capture(signal):
|
|
events = []
|
|
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
|
return events
|
|
|
|
|
|
# ── petctl routing ──────────────────────────────────────────────────────────
|
|
|
|
def test_petctl_commands_never_reach_the_shell(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
actions = _capture(ctrl.action)
|
|
|
|
output = ctrl._handle_command("petctl move top-left")
|
|
|
|
assert ran == []
|
|
assert actions == [{"action": "move", "anchor": "top-left"}]
|
|
assert "top-left" in output
|
|
|
|
|
|
def test_ordinary_commands_still_run_locally(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
lambda cmd: ran.append(cmd) or "[exit 0]\n")
|
|
actions = _capture(ctrl.action)
|
|
|
|
ctrl._handle_command("df -h /")
|
|
|
|
assert ran == ["df -h /"]
|
|
assert actions == []
|
|
|
|
|
|
def test_bad_petctl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
output = ctrl._handle_command("petctl move sideways")
|
|
assert ran == []
|
|
assert "[pet]" in output
|
|
|
|
|
|
def test_petctl_nap_also_flips_the_controller_state(ctrl):
|
|
napping = _capture(ctrl.napping)
|
|
ctrl._handle_command("petctl nap on")
|
|
assert napping == [True]
|
|
assert ctrl._napping is True
|
|
|
|
|
|
# ── filectl routing ──────────────────────────────────────────────────────────
|
|
# filectl's wire format is a single-line JSON envelope (see file_ops.py's
|
|
# module docstring for why) — build commands with json.dumps so the tests
|
|
# don't hardcode escaping by hand.
|
|
|
|
def _filectl(payload: dict) -> str:
|
|
return "filectl " + json.dumps(payload)
|
|
|
|
|
|
def test_filectl_commands_never_reach_the_shell(monkeypatch, ctrl, tmp_path):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
target = tmp_path / "a.txt"
|
|
|
|
output = ctrl._handle_command(_filectl({"op": "write", "path": str(target), "content": "hello"}))
|
|
|
|
assert ran == []
|
|
assert target.read_text() == "hello"
|
|
assert str(target) in output
|
|
|
|
|
|
def test_filectl_edit_round_trips_through_the_relay(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
lambda cmd: (_ for _ in ()).throw(AssertionError("should not shell out")))
|
|
target = tmp_path / "a.py"
|
|
target.write_text("x = 1\n")
|
|
|
|
output = ctrl._handle_command(
|
|
_filectl({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"})
|
|
)
|
|
|
|
assert target.read_text() == "x = 2\n"
|
|
assert str(target) in output
|
|
|
|
|
|
def test_bad_filectl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
output = ctrl._handle_command("filectl {not valid json")
|
|
assert ran == []
|
|
assert "[filectl]" in output
|
|
|
|
|
|
def test_filectl_execution_failure_is_reported_back_not_raised(monkeypatch, ctrl):
|
|
output = ctrl._handle_command(_filectl({"op": "read", "path": "/no/such/file.txt"}))
|
|
assert "[filectl]" in output
|
|
|
|
|
|
def test_ordinary_commands_still_run_locally_alongside_filectl(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
lambda cmd: ran.append(cmd) or "[exit 0]\n")
|
|
ctrl._handle_command("df -h /")
|
|
assert ran == ["df -h /"]
|
|
|
|
|
|
# ── barge-in ────────────────────────────────────────────────────────────────
|
|
|
|
def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
|
|
"""_speak resets the detector after playback so the pet's own voice
|
|
doesn't linger in the wake model's window. Reading the stats after that
|
|
reset reports 0.000 at frame 0 for every interruption, which is worse
|
|
than no instrumentation — it looks like hard evidence and isn't."""
|
|
from bolt_pet.audio import barge_in as barge_in_mod
|
|
|
|
class Detector(barge_in_mod.WakeWordBargeIn):
|
|
def __init__(self):
|
|
self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5
|
|
self._frame_len = 1280
|
|
self.reset_calls = 0
|
|
|
|
def reset(self):
|
|
self.reset_calls += 1
|
|
self._frames, self._peak, self._last = 0, 0.0, 0.0
|
|
|
|
detector = Detector()
|
|
ctrl._barge_in = detector
|
|
logs = _capture(ctrl.log)
|
|
|
|
def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None, on_level=None):
|
|
# What really happens: frames get scored during playback, then one
|
|
# clears the threshold and playback aborts.
|
|
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
|
return False
|
|
|
|
monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback)
|
|
|
|
ctrl._speak("a very long explanation")
|
|
|
|
interrupted = next(m for m in logs if "Interrupted" in m)
|
|
assert "0.810" in interrupted and "frame 7" in interrupted
|
|
# Once before playback (clear the window) and once after (drop the pet's
|
|
# own voice) — the point is that the *read* happens between them.
|
|
assert detector.reset_calls == 2
|
|
|
|
|
|
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
|
logs = _capture(ctrl.log)
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) # interrupted
|
|
|
|
ctrl._speak("a very long explanation")
|
|
|
|
assert ctrl._talk_now.is_set() # loop picks the next turn up without a wake word
|
|
assert any("Interrupted" in message for message in logs)
|
|
|
|
|
|
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
ctrl._speak("short answer")
|
|
assert not ctrl._talk_now.is_set()
|
|
|
|
|
|
# ── follow-up listening ─────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def spoke(monkeypatch):
|
|
"""Playback that always completes, so only the follow-up rule decides
|
|
whether another turn is queued."""
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
|
|
|
|
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
|
logs = _capture(ctrl.log)
|
|
|
|
ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?")
|
|
|
|
assert ctrl._talk_now.is_set() # no wake word needed for the answer
|
|
assert ctrl._pending_follow_up # and the next turn knows it's an answer
|
|
assert any("listening for your answer" in message for message in logs)
|
|
|
|
|
|
def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
|
ctrl._speak("It's 7:15 AM on July 23, 2026.")
|
|
assert not ctrl._talk_now.is_set()
|
|
assert not ctrl._pending_follow_up
|
|
|
|
|
|
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
|
"""Only a reply that *ends* on a question is waiting for an answer."""
|
|
ctrl._speak("What time is it? It's 7:15 AM.")
|
|
assert not ctrl._talk_now.is_set()
|
|
|
|
|
|
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2)
|
|
logs = _capture(ctrl.log)
|
|
|
|
for _ in range(2):
|
|
ctrl._speak("Want me to keep going?")
|
|
ctrl._talk_now.clear()
|
|
assert ctrl._follow_ups == 2
|
|
|
|
ctrl._speak("Want me to keep going?")
|
|
|
|
assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it
|
|
assert any("Follow-up limit reached" in message for message in logs)
|
|
|
|
|
|
def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1)
|
|
ctrl._follow_ups = 1
|
|
logs = _capture(ctrl.log)
|
|
|
|
ctrl._speak("Done — the file is saved.")
|
|
|
|
assert not any("Follow-up limit" in message for message in logs)
|
|
|
|
|
|
def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **kw: None) # you said nothing
|
|
ctrl._follow_ups = 3
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert ctrl._follow_ups == 0
|
|
|
|
|
|
def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl):
|
|
"""You were just asked something — you get longer to think than when you
|
|
deliberately said the wake word."""
|
|
grace = []
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **kw: grace.append(kw.get("grace_s")) or None)
|
|
|
|
ctrl._handle_conversation_turn() # you started this one
|
|
ctrl._pending_follow_up = True
|
|
ctrl._handle_conversation_turn() # this one answers a question
|
|
|
|
assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS]
|
|
|
|
|
|
def test_muting_stops_follow_ups(spoke, ctrl):
|
|
ctrl._muted = True
|
|
ctrl._speak("Shall I continue?")
|
|
assert not ctrl._talk_now.is_set() # mute means don't listen, question or not
|
|
|
|
|
|
def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False)
|
|
ctrl._speak("Shall I continue?")
|
|
assert not ctrl._talk_now.is_set()
|
|
|
|
|
|
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
|
|
ctrl._follow_ups = 3
|
|
|
|
ctrl._speak("a very long explanation")
|
|
|
|
assert ctrl._follow_ups == 0 # you're clearly engaged
|
|
assert ctrl._talk_now.is_set()
|
|
|
|
|
|
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
ctrl._speak("**bold** reply")
|
|
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
|
|
|
|
|
# ── screen context ──────────────────────────────────────────────────────────
|
|
|
|
def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's this error?")
|
|
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
|
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
sent = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("that's a KeyError"))
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert "[on screen right now: app.py]" in sent[0]
|
|
# ...but the *history* keeps what you actually said, not the annotation.
|
|
assert ctrl.history.entries()[0].text == "what's this error?"
|
|
|
|
|
|
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
|
|
|
|
def test_quiet_hours_suppress_the_heartbeat(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
|
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "00:00-23:59")
|
|
called = {"n": 0}
|
|
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
|
lambda: called.__setitem__("n", called["n"] + 1))
|
|
|
|
ctrl._maybe_heartbeat()
|
|
|
|
assert called["n"] == 0
|
|
assert ctrl._napping is True
|
|
|
|
|
|
def test_fullscreen_triggers_do_not_disturb(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
|
|
monkeypatch.setattr(controller_mod.config, "DND_ON_FULLSCREEN", True)
|
|
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: True)
|
|
napping = _capture(ctrl.napping)
|
|
|
|
ctrl._refresh_nap_state()
|
|
|
|
assert napping == [True]
|
|
|
|
|
|
def test_a_manual_nap_overrides_the_schedule(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
|
|
ctrl.set_napping(True)
|
|
ctrl._last_nap_check = 0.0
|
|
ctrl._refresh_nap_state()
|
|
assert ctrl._napping is True # the schedule doesn't wake it back up
|
|
|
|
ctrl.set_napping(None) # back on schedule
|
|
ctrl._last_nap_check = 0.0
|
|
ctrl._refresh_nap_state()
|
|
assert ctrl._napping is False
|
|
|
|
|
|
def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: Reply("always"))
|
|
spoken = []
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True)
|
|
ctrl.set_napping(True)
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["always"]
|
|
|
|
|
|
# ── notification bridge ─────────────────────────────────────────────────────
|
|
|
|
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
sent, spoken = [], []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("your build is green"))
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True)
|
|
|
|
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
|
ctrl._drain_notifications()
|
|
|
|
assert "CI: Build finished" in sent[0]
|
|
assert spoken == ["your build is green"]
|
|
assert ctrl._state.state == PetState.IDLE
|
|
|
|
|
|
def test_filtered_out_notifications_are_never_queued(ctrl):
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
|
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
|
assert ctrl._pending_notifications == []
|
|
|
|
|
|
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
called = {"n": 0}
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1))
|
|
ctrl.set_napping(True)
|
|
|
|
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
|
ctrl._drain_notifications()
|
|
|
|
assert called["n"] == 0
|
|
|
|
|
|
# ── wake threshold ──────────────────────────────────────────────────────────
|
|
|
|
def test_threshold_is_live_and_clamped(ctrl):
|
|
ctrl.set_wake_threshold(0.72)
|
|
assert ctrl.wake_threshold() == pytest.approx(0.72)
|
|
ctrl.set_wake_threshold(5)
|
|
assert ctrl.wake_threshold() == pytest.approx(0.99)
|
|
ctrl.set_wake_threshold(-1)
|
|
assert ctrl.wake_threshold() == pytest.approx(0.01)
|
|
|
|
|
|
def test_near_misses_are_recorded_for_the_tuner(ctrl):
|
|
ctrl.set_wake_threshold(0.5)
|
|
ctrl._observe_wake_score(0.42, 0.5) # near miss
|
|
ctrl._observe_wake_score(0.01, 0.5) # background noise, not interesting
|
|
stats = ctrl.wake_stats()
|
|
assert len(stats["near_misses"]) == 1
|
|
assert stats["peak"] == pytest.approx(0.42)
|
|
|
|
ctrl.reset_wake_stats()
|
|
assert ctrl.wake_stats()["near_misses"] == []
|
|
|
|
|
|
# ── file delivery ────────────────────────────────────────────────────────────
|
|
|
|
def test_check_deliveries_downloads_and_saves_queued_files(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
|
lambda: [{"id": "abc", "name": "report.pdf", "size": 5}])
|
|
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
|
lambda file_id: b"hello" if file_id == "abc" else b"")
|
|
|
|
ctrl._check_deliveries()
|
|
|
|
assert (tmp_path / "report.pdf").read_bytes() == b"hello"
|
|
|
|
|
|
def test_check_deliveries_is_a_noop_when_nothing_queued(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [])
|
|
downloaded = []
|
|
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
|
lambda file_id: downloaded.append(file_id))
|
|
|
|
ctrl._check_deliveries()
|
|
|
|
assert downloaded == []
|
|
|
|
|
|
def test_check_deliveries_can_be_disabled(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False)
|
|
called = {"n": 0}
|
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
|
lambda: called.__setitem__("n", called["n"] + 1))
|
|
|
|
ctrl._check_deliveries()
|
|
|
|
assert called["n"] == 0
|
|
|
|
|
|
def test_check_deliveries_logs_and_continues_on_download_failure(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [
|
|
{"id": "bad", "name": "a.txt", "size": 1},
|
|
{"id": "good", "name": "b.txt", "size": 1},
|
|
])
|
|
|
|
def fake_download(file_id):
|
|
if file_id == "bad":
|
|
raise controller_mod.server_client.ServerError("gone")
|
|
return b"ok"
|
|
|
|
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", fake_download)
|
|
logs = _capture(ctrl.log)
|
|
|
|
ctrl._check_deliveries()
|
|
|
|
assert (tmp_path / "b.txt").read_bytes() == b"ok"
|
|
assert not (tmp_path / "a.txt").exists()
|
|
assert any("bad" in msg or "a.txt" in msg for msg in logs)
|
|
|
|
|
|
def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: Reply("it's on the way"))
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
|
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
|
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
|
lambda file_id: b"hi")
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
|
|
|
|
|
|
# ── server-picked voice (the desk API's speak_as marker) ────────────────────
|
|
|
|
def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"):
|
|
"""Run one full conversation turn whose reply is *reply*, returning the
|
|
voice_id each tts.speak() call was given."""
|
|
voices = []
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said)
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: reply)
|
|
monkeypatch.setattr(
|
|
controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
voices.append(voice_id) or True,
|
|
)
|
|
ctrl._handle_conversation_turn()
|
|
return voices
|
|
|
|
|
|
def test_a_speak_as_reply_is_spoken_in_that_voice(monkeypatch, ctrl):
|
|
voices = _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
assert voices == ["VOICE1"]
|
|
assert ctrl.current_voice() == "Terence"
|
|
|
|
|
|
def test_the_picked_voice_sticks_for_later_replies(monkeypatch, ctrl):
|
|
"""The server tags one reply and doesn't keep the id in its history, so
|
|
it can't re-request the voice when you say "keep talking like that"."""
|
|
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", True)
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
voices = _voice_turn(monkeypatch, ctrl, Reply("Still me."), said="and now?")
|
|
assert voices == ["VOICE1"]
|
|
|
|
|
|
def test_voice_stickiness_can_be_turned_off(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", False)
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
voices = _voice_turn(monkeypatch, ctrl, Reply("Back to normal."), said="and now?")
|
|
assert voices == [None]
|
|
assert ctrl.current_voice() == ""
|
|
|
|
|
|
def test_a_new_pick_replaces_the_old_one(monkeypatch, ctrl):
|
|
changes = _capture(ctrl.voice_changed)
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
voices = _voice_turn(monkeypatch, ctrl, Reply("こんにちは。", "VOICE2", "Asahi"),
|
|
said="say that in Japanese")
|
|
assert voices == ["VOICE2"]
|
|
assert changes == ["Terence", "Asahi"]
|
|
|
|
|
|
def test_resetting_the_voice_goes_back_to_the_default(monkeypatch, ctrl):
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
changes = _capture(ctrl.voice_changed)
|
|
|
|
ctrl.reset_voice()
|
|
|
|
assert ctrl.current_voice() == ""
|
|
assert changes == [""] # the tray's menu entry follows this signal
|
|
voices = _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi")
|
|
assert voices == [None]
|
|
|
|
|
|
def test_an_unnamed_voice_still_reports_something_resettable(monkeypatch, ctrl):
|
|
"""voice_name is optional server-side — falling back to the id keeps the
|
|
tray entry from reading "now: " with nothing after it."""
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1"))
|
|
assert ctrl.current_voice() == "VOICE1"
|
|
|
|
|
|
def test_petctl_voice_reset_returns_bolt_to_his_own_voice(monkeypatch, ctrl):
|
|
"""The server can pick a voice but can't ask for the default back — it
|
|
was never told what Bolt's own voice id is. This is how it asks."""
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
|
|
|
output = ctrl._handle_command("petctl voice reset")
|
|
|
|
assert ran == [] # never reaches a shell, like every other petctl verb
|
|
assert "Terence" in output # the server can't see the voice; tell it what changed
|
|
assert ctrl.current_voice() == ""
|
|
assert _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi") == [None]
|
|
|
|
|
|
def test_petctl_voice_reset_says_so_when_there_was_nothing_to_reset(ctrl):
|
|
assert "already" in ctrl._handle_command("petctl voice reset")
|
|
|
|
|
|
# ── dialoguectl (multi-voice scenes) ────────────────────────────────────────
|
|
|
|
def _dialogue_command(*lines):
|
|
import json
|
|
return "dialoguectl " + json.dumps({"lines": list(lines)})
|
|
|
|
|
|
def test_dialoguectl_never_reaches_the_shell(monkeypatch, ctrl):
|
|
ran = []
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
|
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
|
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
|
lambda pcm, rate, should_stop=None, on_level=None: True)
|
|
|
|
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "[cheerfully] hi"}))
|
|
|
|
assert ran == []
|
|
assert "[dialogue] played 1 line" in output
|
|
|
|
|
|
def test_a_scene_shows_in_the_bubble_with_the_delivery_tags_stripped(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
|
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
|
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
|
said = _capture(ctrl.said)
|
|
|
|
ctrl._handle_command(_dialogue_command(
|
|
{"voice": "self", "text": "[cheerfully] Hello there!"},
|
|
{"voice": "narrator", "text": "[whispering] He is lying."},
|
|
))
|
|
|
|
assert said == ["Hello there! He is lying."]
|
|
assert ctrl.history.last().text == "Hello there! He is lying."
|
|
|
|
|
|
def test_a_mid_turn_scene_returns_to_thinking_not_idle(monkeypatch, ctrl):
|
|
"""The server is still waiting on the tool result, so the pet talks and
|
|
goes back to waiting — dropping to IDLE would look like the turn ended."""
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
|
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
|
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
|
ctrl._state.transition(PetState.LISTENING)
|
|
ctrl._state.transition(PetState.THINKING)
|
|
states = _capture(ctrl.state_changed)
|
|
|
|
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
|
|
|
assert states == ["talking", "thinking"]
|
|
assert ctrl._state.state == PetState.THINKING
|
|
|
|
|
|
def test_the_scene_uses_a_voice_the_server_picked_with_speak_as(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
seen = {}
|
|
|
|
def capture(inputs, model_id=None, stability=None):
|
|
seen["inputs"] = inputs
|
|
return np.zeros(4, dtype=np.int16), 24000
|
|
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", capture)
|
|
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
|
ctrl._apply_voice(controller_mod.server_client.Reply("ok", "PICKEDvoice123456789", "Terence"))
|
|
|
|
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
|
|
|
assert seen["inputs"][0]["voice_id"] == "PICKEDvoice123456789"
|
|
|
|
|
|
def test_a_synthesis_failure_is_reported_back_for_bolt_to_retry(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
|
|
def boom(inputs, model_id=None, stability=None):
|
|
raise controller_mod.tts.TtsError("voice_id not found")
|
|
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", boom)
|
|
|
|
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
|
|
|
assert "couldn't synthesize" in output and "voice_id not found" in output
|
|
assert ctrl._state.state == PetState.IDLE # nothing left half-transitioned
|
|
|
|
|
|
def test_a_bad_voice_name_comes_back_as_advice_not_an_exception(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
|
output = ctrl._handle_command(_dialogue_command({"voice": "wizard", "text": "hi"}))
|
|
assert "unknown voice" in output and "narrator" in output
|
|
|
|
|
|
def test_dialogue_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "DIALOGUE", False)
|
|
called = []
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
|
lambda *a, **k: called.append(1))
|
|
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
|
assert "disabled" in output and called == []
|
|
|
|
|
|
def test_talking_over_a_scene_is_reported_up_the_relay(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
|
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
|
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
|
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
|
lambda pcm, rate, should_stop=None, on_level=None: False) # barge-in
|
|
|
|
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
|
|
|
assert "interrupted" in output
|
|
|
|
|
|
# ── petctl self_restart ─────────────────────────────────────────────────────
|
|
|
|
def test_self_restart_arms_after_the_turn_rather_than_dying_mid_relay(monkeypatch, ctrl, tmp_path):
|
|
"""Restarting inline would kill the HTTP tool relay before the result was
|
|
posted, and the server would wait out its timeout on a turn that can never
|
|
finish. So the command returns, the turn completes, *then* the pet dies."""
|
|
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
|
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
|
restarts = _capture(ctrl.restart_requested)
|
|
|
|
output = ctrl._handle_command("petctl self_restart check the new dialogue code")
|
|
|
|
assert "restarting as soon as this turn finishes" in output
|
|
assert restarts == [] # nothing has happened yet
|
|
|
|
assert ctrl._maybe_self_restart() is True
|
|
assert restarts and "check the new dialogue code" in restarts[0]
|
|
|
|
|
|
def test_a_broken_edit_is_reported_instead_of_leaving_nothing_running(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise controller_mod.self_restart.RestartError(
|
|
"the current code does not import, so restarting would leave you with "
|
|
"nothing running. Fix this first:\nSyntaxError: invalid syntax"
|
|
)
|
|
|
|
monkeypatch.setattr(controller_mod.self_restart, "preflight", boom)
|
|
restarts = _capture(ctrl.restart_requested)
|
|
|
|
output = ctrl._handle_command("petctl self_restart try the new code")
|
|
|
|
assert "SyntaxError" in output and "refused" in output
|
|
assert ctrl._maybe_self_restart() is False
|
|
assert restarts == []
|
|
|
|
|
|
def test_a_second_restart_request_in_one_turn_is_a_no_op(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
|
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
|
ctrl._handle_command("petctl self_restart first")
|
|
assert "already armed" in ctrl._handle_command("petctl self_restart second")
|
|
|
|
|
|
def test_self_restart_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "SELF_RESTART", False)
|
|
checked = []
|
|
monkeypatch.setattr(controller_mod.self_restart, "preflight",
|
|
lambda *a, **k: checked.append(1))
|
|
assert "disabled" in ctrl._handle_command("petctl self_restart go")
|
|
assert checked == []
|
|
|
|
|
|
def test_coming_back_up_reports_to_the_server_and_speaks_the_reply(monkeypatch, ctrl, tmp_path):
|
|
"""The half that makes it a loop: the new process tells Bolt it's back and
|
|
why, and his answer is spoken like any other turn."""
|
|
state = tmp_path / "ctx.json"
|
|
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", state)
|
|
controller_mod.self_restart.arm("check the walk cycle", version="0.2.3",
|
|
path=state, now=1000.0)
|
|
sent, spoken = [], []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("Good, it's up."))
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
spoken.append(text) or True)
|
|
|
|
ctrl._report_self_restart()
|
|
|
|
assert "[pet self-restart]" in sent[0] and "check the walk cycle" in sent[0]
|
|
assert spoken == ["Good, it's up."]
|
|
# Consumed, so the next start doesn't announce the same restart again.
|
|
assert controller_mod.self_restart.load(state) is None
|
|
|
|
|
|
def test_an_ordinary_start_reports_nothing(monkeypatch, ctrl, tmp_path):
|
|
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "none.json")
|
|
called = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: called.append(text))
|
|
|
|
ctrl._report_self_restart()
|
|
|
|
assert called == []
|
|
|
|
|
|
# ── holding line: "give me a sec" while a tool runs ────────────────────────
|
|
# The server used to discard whatever the model wrote alongside a tool call,
|
|
# so the whole round trip was silence — and the model, with no evidence its
|
|
# sentence landed, said it again in the final reply.
|
|
|
|
def test_a_holding_line_is_spoken_before_the_tool_runs(monkeypatch, ctrl):
|
|
order = []
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
order.append(("spoke", text)) or True)
|
|
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
lambda cmd: order.append(("ran", cmd)) or "[exit 0]")
|
|
|
|
ctrl._speak_holding("Give me a sec.")
|
|
ctrl._handle_command("df -h /")
|
|
|
|
assert order == [("spoke", "Give me a sec."), ("ran", "df -h /")]
|
|
|
|
|
|
def test_the_holding_line_shows_in_the_bubble_but_not_the_transcript(monkeypatch, ctrl):
|
|
"""It's filler. The transcript should keep the actual answer."""
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
said = _capture(ctrl.said)
|
|
|
|
ctrl._speak_holding("I'll check on that — one moment.")
|
|
|
|
assert said == ["I'll check on that — one moment."]
|
|
assert ctrl.history.entries() == []
|
|
|
|
|
|
def test_a_holding_line_returns_to_waiting_not_idle(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
ctrl._state.transition(PetState.LISTENING)
|
|
ctrl._state.transition(PetState.THINKING)
|
|
states = _capture(ctrl.state_changed)
|
|
|
|
ctrl._speak_holding("one sec")
|
|
|
|
assert states == ["talking", "thinking"]
|
|
|
|
|
|
def test_the_relay_speaks_whatever_the_server_attaches(monkeypatch, ctrl):
|
|
spoken = []
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
spoken.append(text) or True)
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's in that folder?")
|
|
|
|
def fake_converse(text, on_command=None, on_say=None):
|
|
on_say("Let me check that for you.") # what the server now sends
|
|
on_command("filectl {\"op\": \"list\", \"path\": \"/tmp\"}")
|
|
return Reply("It's got three files in it.")
|
|
|
|
monkeypatch.setattr(controller_mod.server_client, "converse", fake_converse)
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["Let me check that for you.", "It's got three files in it."]
|
|
|
|
|
|
# ── streamed replies ───────────────────────────────────────────────────────
|
|
# Without streaming the pet waits out the entire model call before saying a
|
|
# word. With it, the wait is time-to-first-sentence.
|
|
|
|
def test_each_sentence_is_spoken_as_it_arrives(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
|
spoken = []
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
spoken.append(text) or True)
|
|
|
|
def fake_stream(text, on_say, on_command=None, timeout=180.0):
|
|
on_say("The disk is fine.")
|
|
on_say("About sixty percent used.")
|
|
return controller_mod.server_client.Reply(
|
|
"The disk is fine. About sixty percent used.", spoken=True)
|
|
|
|
monkeypatch.setattr(controller_mod.server_client, "converse_stream", fake_stream)
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "how's the disk?")
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert spoken == ["The disk is fine.", "About sixty percent used."]
|
|
# ...and the empty final reply is not spoken as an extra blank utterance.
|
|
assert ctrl.history.entries()[-1].text == "About sixty percent used."
|
|
|
|
|
|
def test_a_stream_that_fails_before_speaking_falls_back_silently(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
|
spoken = []
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
|
spoken.append(text) or True)
|
|
|
|
def broken_stream(text, on_say, on_command=None, timeout=180.0):
|
|
raise controller_mod.server_client.ServerError("no streaming endpoint")
|
|
|
|
monkeypatch.setattr(controller_mod.server_client, "converse_stream", broken_stream)
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: Reply("Fell back fine."))
|
|
|
|
assert ctrl._ask_server("hello").text == "Fell back fine."
|
|
assert spoken == [] # nothing was said twice
|
|
|
|
|
|
def test_streaming_can_be_switched_off(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
|
called = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse_stream",
|
|
lambda *a, **k: called.append(1))
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: Reply("plain path"))
|
|
|
|
assert ctrl._ask_server("hi").text == "plain path"
|
|
assert called == []
|
|
|
|
|
|
def test_talking_over_a_streamed_reply_still_interrupts(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
|
|
|
|
ctrl._speak_stream_chunk("A long explanation you cut short.")
|
|
|
|
assert ctrl._talk_now.is_set()
|
|
|
|
|
|
# ── notification latency (reported 2026-08-02: 5-10 minutes) ───────────────
|
|
# Draining was wired to the heartbeat's 60s interval, and a heartbeat that
|
|
# landed mid-conversation stamped its clock before noticing — so it burned the
|
|
# slot and waited another full interval. Several of those in a row is minutes.
|
|
|
|
def test_a_notification_goes_out_on_the_next_tick_not_the_next_heartbeat(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
sent = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None:
|
|
sent.append(text) or Reply("noted"))
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
|
# A heartbeat has just run, so the next one is a full interval away.
|
|
ctrl._last_heartbeat = controller_mod.time.monotonic()
|
|
|
|
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
|
ctrl._maybe_heartbeat()
|
|
|
|
assert sent and "Harry" in sent[0]
|
|
|
|
|
|
def test_a_heartbeat_skipped_mid_conversation_does_not_burn_its_slot(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
|
ctrl._last_heartbeat = 0.0
|
|
ctrl._state.transition(PetState.LISTENING)
|
|
ctrl._state.transition(PetState.THINKING)
|
|
|
|
ctrl._maybe_heartbeat() # due, but the pet is busy
|
|
assert ctrl._last_heartbeat == 0.0, "the clock must not advance on a skipped tick"
|
|
|
|
ctrl._state.transition(PetState.IDLE)
|
|
ctrl._maybe_heartbeat() # free now — runs immediately
|
|
assert ctrl._last_heartbeat > 0.0
|
|
|
|
|
|
def test_notifications_wait_while_the_pet_is_mid_turn(monkeypatch, ctrl):
|
|
"""Speaking over the answer they're already getting would be worse than
|
|
waiting a couple of seconds."""
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
sent = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None: sent.append(text))
|
|
ctrl._state.transition(PetState.LISTENING)
|
|
|
|
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
|
ctrl._maybe_heartbeat()
|
|
|
|
assert sent == []
|
|
assert len(ctrl._pending_notifications) == 1 # kept, not dropped
|
|
|
|
|
|
def test_a_second_message_inside_the_rate_limit_is_no_longer_lost(monkeypatch, ctrl):
|
|
"""The gate used to drop it. With NOTIFICATION_MIN_INTERVAL_SECONDS=60,
|
|
two texts a minute apart meant you heard about one of them."""
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 60)
|
|
sent = []
|
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
lambda text, on_command=None, on_say=None:
|
|
sent.append(text) or Reply("ok"))
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
|
|
|
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
|
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="it's urgent"))
|
|
ctrl._drain_notifications()
|
|
|
|
assert len(sent) == 1 # one round trip...
|
|
assert "you there?" in sent[0] and "it's urgent" in sent[0] # ...both messages
|
|
assert "2 desktop notifications" in sent[0]
|
|
|
|
|
|
def test_the_filter_still_applies(ctrl):
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
|
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
|
assert ctrl._pending_notifications == []
|
|
|
|
|
|
def test_a_notification_storm_cannot_become_an_unbounded_backlog(ctrl):
|
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
for index in range(40):
|
|
ctrl._queue_notification(Notification(app="Spam", summary=f"#{index}", body=""))
|
|
|
|
assert len(ctrl._pending_notifications) == controller_mod._MAX_PENDING_NOTIFICATIONS
|
|
assert "#39" in ctrl._pending_notifications[-1].as_text() # newest kept
|