Add heartbeat: reminders and delivered files reach Bolt Assistant
The client never called /desk/report_status, so Bolt's reminders and any files he delivered to this session sat in the server's outbox forever - the same dead-air failure the pet client showed when its heartbeat stopped. Now a heartbeat thread (HEARTBEAT_SECONDS, default 60; 0 disables) reports in, speaks announcements through the assistant's queue when the mic is idle, and downloads the outbox to BOLT_DOWNLOAD_DIR (default ~/Downloads/Bolt) via .part-then-rename, never overwriting. A startup health probe warns loudly when the server refuses the DESK_API_KEY. Also: add the .env.example the README's setup step references (it was missing, so `cp .env.example .env` failed for everyone), and gitignore .claude/ so Claude Code's worktree directory stays out of the repo. Tests: tests/test_heartbeat.py (12) plus announcement tests in tests/test_assistant.py (6); full suite 135 passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
a8c7db9520
commit
751d1c8b69
@@ -411,3 +411,55 @@ def test_transcription_falls_back_to_one_upload():
|
||||
t.add([frame(LOUD)]) # nowhere to go: the live socket never opened
|
||||
assert t.finish(frame(LOUD) * 2) == "hey bolt"
|
||||
assert b"".join(late.audio) == frame(LOUD) * 2
|
||||
|
||||
|
||||
# ── heartbeat announcements ──────────────────────────────────────────────────
|
||||
|
||||
def test_announcements_are_spoken_when_idle():
|
||||
a, asked, played, logs, _ = make([])
|
||||
a.announce("You have a reminder.")
|
||||
a._drain_announcements()
|
||||
assert "You have a reminder." in spoken(played)
|
||||
assert a.state == "idle"
|
||||
|
||||
|
||||
def test_announcements_wait_for_idle():
|
||||
a, asked, played, logs, _ = make(["It's three o'clock."])
|
||||
a.state = "busy"
|
||||
a.announce("You have a reminder.")
|
||||
a._drain_announcements()
|
||||
assert spoken(played) == [] # held back while Bolt is talking
|
||||
a.state = "idle"
|
||||
a._drain_announcements()
|
||||
assert "You have a reminder." in spoken(played)
|
||||
|
||||
|
||||
def test_announcements_hold_while_a_transcript_is_in_flight():
|
||||
a, asked, played, logs, _ = make([])
|
||||
a._pending.append(object()) # an utterance is still at the server
|
||||
a.announce("You have a reminder.")
|
||||
a._drain_announcements()
|
||||
assert spoken(played) == []
|
||||
a._pending.clear()
|
||||
a._drain_announcements()
|
||||
assert "You have a reminder." in spoken(played)
|
||||
|
||||
|
||||
def test_announcements_hold_while_someone_is_speaking():
|
||||
a, asked, played, logs, _ = make([])
|
||||
FakeTranscription.script.append("") # the start event still mints a transcriber
|
||||
for f in [frame(LOUD)] * 3:
|
||||
a.process(f) # the segmenter is mid-utterance
|
||||
a.announce("You have a reminder.")
|
||||
a._drain_announcements()
|
||||
assert spoken(played) == []
|
||||
a.segmenter.reset()
|
||||
a._drain_announcements()
|
||||
assert "You have a reminder." in spoken(played)
|
||||
|
||||
|
||||
def test_blank_announcements_are_dropped():
|
||||
a, asked, played, logs, _ = make([])
|
||||
a.announce(" ")
|
||||
a._drain_announcements()
|
||||
assert spoken(played) == []
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""The heartbeat that brings reminders and delivered files in — with fakes
|
||||
for the network. Covers the report_status POST, the outbox list, the
|
||||
download rules (.part first, never overwrite, clean up on failure) and the
|
||||
loop's stop/disable behaviour."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_assistant import config, heartbeat
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload=None, data=b"", status_code=200):
|
||||
self._payload = payload
|
||||
self._data = data
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def iter_content(self, chunk_size=65536):
|
||||
yield self._data
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _quiet(monkeypatch):
|
||||
monkeypatch.setattr(heartbeat, "log", lambda msg: None)
|
||||
|
||||
|
||||
def _files_get(data=b"hello", files=None, status_code=200):
|
||||
def get(url, params=None, headers=None, timeout=None, stream=None):
|
||||
if url.endswith("/desk/files"):
|
||||
return FakeResponse(payload={"files": files or []})
|
||||
return FakeResponse(data=data, status_code=status_code)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
# ── one beat ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_heartbeat_once_reports_in_and_returns_ok(tmp_path):
|
||||
seen = {}
|
||||
|
||||
def post(url, json=None, headers=None, timeout=None):
|
||||
seen["url"], seen["json"], seen["headers"] = url, json, headers
|
||||
return FakeResponse(payload={"ok": True})
|
||||
|
||||
result = heartbeat.heartbeat_once(tmp_path, post=post, get=_files_get())
|
||||
assert result == {"ok": True, "announcement": "", "saved": []}
|
||||
assert seen["url"].endswith("/desk/report_status")
|
||||
assert seen["json"] == {"session_id": config.SESSION_ID, "download_dir": str(tmp_path)}
|
||||
assert seen["headers"] == {"X-Desk-Api-Key": config.API_KEY}
|
||||
|
||||
|
||||
def test_heartbeat_once_carries_the_announcement(tmp_path):
|
||||
def post(url, json=None, headers=None, timeout=None):
|
||||
return FakeResponse(payload={"reply": " You have a reminder. "})
|
||||
|
||||
result = heartbeat.heartbeat_once(tmp_path, post=post, get=_files_get())
|
||||
assert result["announcement"] == "You have a reminder."
|
||||
|
||||
|
||||
def test_heartbeat_once_downloads_the_outbox(tmp_path):
|
||||
seen = {}
|
||||
|
||||
def get(url, params=None, headers=None, timeout=None, stream=None):
|
||||
if url.endswith("/desk/files"):
|
||||
seen["list"] = params
|
||||
return FakeResponse(payload={"files": [{"id": "abc", "name": "note.txt", "size": 5}]})
|
||||
seen["download"] = params
|
||||
return FakeResponse(data=b"hello")
|
||||
|
||||
def post(url, json=None, headers=None, timeout=None):
|
||||
return FakeResponse(payload={"ok": True})
|
||||
|
||||
result = heartbeat.heartbeat_once(tmp_path, post=post, get=get)
|
||||
assert seen["list"] == seen["download"] == {"session_id": config.SESSION_ID}
|
||||
assert len(result["saved"]) == 1
|
||||
target = result["saved"][0]
|
||||
assert target.parent == tmp_path and target.name == "note.txt"
|
||||
assert target.read_bytes() == b"hello"
|
||||
assert not list(tmp_path.glob("*.part"))
|
||||
|
||||
|
||||
def test_heartbeat_once_network_failure_is_not_fatal(tmp_path):
|
||||
def post(url, json=None, headers=None, timeout=None):
|
||||
raise OSError("no network")
|
||||
|
||||
result = heartbeat.heartbeat_once(tmp_path, post=post, get=_files_get())
|
||||
assert result == {"ok": False, "announcement": "", "saved": []}
|
||||
|
||||
|
||||
def test_heartbeat_once_refused_does_not_touch_the_outbox(tmp_path):
|
||||
got = []
|
||||
|
||||
def get(url, params=None, headers=None, timeout=None, stream=None):
|
||||
got.append(url)
|
||||
return FakeResponse(payload={"files": []})
|
||||
|
||||
def post(url, json=None, headers=None, timeout=None):
|
||||
return FakeResponse(payload={"error": "unauthorized"}, status_code=401)
|
||||
|
||||
result = heartbeat.heartbeat_once(tmp_path, post=post, get=get)
|
||||
assert result == {"ok": False, "announcement": "", "saved": []}
|
||||
assert got == []
|
||||
|
||||
|
||||
# ── download rules ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_download_never_overwrites(tmp_path):
|
||||
(tmp_path / "note.txt").write_text("old")
|
||||
target = heartbeat._download("abc", "note.txt", tmp_path, get=_files_get(data=b"new"))
|
||||
assert target.name == "note (1).txt"
|
||||
assert (tmp_path / "note.txt").read_text() == "old"
|
||||
assert target.read_bytes() == b"new"
|
||||
|
||||
|
||||
def test_download_refused(tmp_path):
|
||||
assert heartbeat._download("abc", "note.txt", tmp_path,
|
||||
get=_files_get(status_code=500)) is None
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_download_cleans_up_the_part_file_on_failure(tmp_path):
|
||||
class Exploding(FakeResponse):
|
||||
def iter_content(self, chunk_size=65536):
|
||||
yield b"part of"
|
||||
raise OSError("connection reset")
|
||||
|
||||
assert heartbeat._download("abc", "note.txt", tmp_path,
|
||||
get=lambda **kw: Exploding()) is None
|
||||
assert list(tmp_path.iterdir()) == [] # no .part left behind
|
||||
|
||||
|
||||
def test_safe_filename_strips_paths_and_control_characters():
|
||||
assert heartbeat._safe_filename("a/b\\c:d.txt") == "a_b_c_d.txt"
|
||||
assert heartbeat._safe_filename("..") == "download"
|
||||
assert heartbeat._safe_filename("") == "download"
|
||||
assert heartbeat._safe_filename(" fine name.txt ") == "fine name.txt"
|
||||
|
||||
|
||||
# ── health probe and the loop ────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_server_health_warns_loudly_on_a_refused_key(monkeypatch):
|
||||
lines = []
|
||||
monkeypatch.setattr(heartbeat, "log", lines.append)
|
||||
|
||||
def get(url, headers=None, timeout=None):
|
||||
return FakeResponse(payload={"key_ok": False})
|
||||
|
||||
heartbeat.fetch_server_health(get=get)
|
||||
assert any("DESK_API_KEY" in line for line in lines)
|
||||
|
||||
|
||||
def test_fetch_server_health_logs_a_healthy_server(monkeypatch):
|
||||
lines = []
|
||||
monkeypatch.setattr(heartbeat, "log", lines.append)
|
||||
|
||||
def get(url, headers=None, timeout=None):
|
||||
return FakeResponse(payload={"key_ok": True, "version": "1"})
|
||||
|
||||
heartbeat.fetch_server_health(get=get)
|
||||
assert lines and lines[0].startswith("server:")
|
||||
|
||||
|
||||
def test_heartbeat_loop_reports_each_beat(monkeypatch):
|
||||
monkeypatch.setattr(config, "HEARTBEAT_SECONDS", 60)
|
||||
stop = threading.Event()
|
||||
results = []
|
||||
monkeypatch.setattr(heartbeat, "heartbeat_once",
|
||||
lambda: {"ok": True, "announcement": "hi", "saved": []})
|
||||
|
||||
def on_result(result):
|
||||
results.append(result)
|
||||
stop.set()
|
||||
|
||||
heartbeat.heartbeat_loop(stop, on_result)
|
||||
assert results == [{"ok": True, "announcement": "hi", "saved": []}]
|
||||
|
||||
|
||||
def test_heartbeat_loop_disabled_when_heartbeat_seconds_is_zero(monkeypatch):
|
||||
monkeypatch.setattr(config, "HEARTBEAT_SECONDS", 0)
|
||||
called = []
|
||||
monkeypatch.setattr(heartbeat, "heartbeat_once", lambda: called.append(1))
|
||||
heartbeat.heartbeat_loop(threading.Event(), None)
|
||||
assert called == []
|
||||
Reference in New Issue
Block a user