feat: Enhance local command handling and introduce local intents

- Refactor `run_local_command` to manage subprocesses more effectively, ensuring child processes are terminated on timeout.
- Introduce `_terminate` function to handle process group termination and capture output.
- Implement `_command_output` to format command results with a character limit.
- Add local intent recognition in `intents.py` to handle commands like "stop", "go to sleep", and "come here" without server interaction.
- Normalize user input to match local intents while stripping filler words.
- Update tests to cover new local intent functionality and ensure proper command handling.
- Enhance speech processing to handle abbreviations and improve spoken output clarity.
This commit is contained in:
2026-08-05 18:31:02 -06:00
parent 8d4751d80f
commit 3ee67cb4d6
14 changed files with 1107 additions and 59 deletions
+197 -5
View File
@@ -221,10 +221,13 @@ def test_a_statement_does_not_keep_listening(spoke, ctrl):
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_a_question_anywhere_in_the_reply_keeps_listening(spoke, ctrl):
"""Bolt often asks and then keeps talking ("Want me to fix it? I'd start
with the config."), so the question mark doesn't have to be last. An
unwanted extra listen ends itself on VAD_GRACE_SECONDS of silence; a missed
one costs you a wake word, which is the more expensive mistake."""
ctrl._speak("Want me to restart it? It's been up for 40 days.")
assert ctrl._talk_now.is_set()
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
@@ -382,6 +385,148 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
assert spoken == ["always"]
# ── local intents ───────────────────────────────────────────────────────────
@pytest.fixture
def heard(monkeypatch):
"""A turn where you said something, with the server and TTS recorded.
Returns (utterance_setter, sent, spoken)."""
said = {"text": ""}
sent, spoken = [], []
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["text"])
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or Reply("from the server"))
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None, voice_id=None:
spoken.append(text) or True)
return said, sent, spoken
def test_a_local_intent_never_reaches_the_server(heard, ctrl):
said, sent, spoken = heard
said["text"] = "come here"
actions = _capture(ctrl.action)
ctrl._handle_conversation_turn()
assert sent == [] # no round trip at all
assert actions == [{"action": "move", "anchor": "cursor"}]
assert spoken == [] # walking over is the reply
assert ctrl._state.state == PetState.IDLE
def test_stop_is_answered_with_silence(heard, ctrl):
said, sent, spoken = heard
said["text"] = "be quiet"
ctrl._handle_conversation_turn()
assert (sent, spoken) == ([], [])
def test_a_request_that_merely_starts_with_an_intent_word_goes_to_the_server(heard, ctrl):
said, sent, spoken = heard
said["text"] = "stop the docker container"
ctrl._handle_conversation_turn()
assert sent and "stop the docker container" in sent[0]
assert spoken == ["from the server"]
def test_local_intents_are_skipped_while_answering_a_question(heard, ctrl):
"""Bolt asked something; "never mind" is an answer to him, not a body
command. Swallowing it locally would leave the server holding a question it
never got a reply to."""
said, sent, spoken = heard
said["text"] = "never mind"
ctrl._pending_follow_up = True
ctrl._handle_conversation_turn()
assert sent and "never mind" in sent[0]
def test_local_intents_can_be_turned_off(monkeypatch, heard, ctrl):
monkeypatch.setattr(controller_mod.config, "LOCAL_INTENTS", False)
said, sent, spoken = heard
said["text"] = "come here"
ctrl._handle_conversation_turn()
assert sent and "come here" in sent[0]
def test_say_that_again_replays_the_last_line_without_duplicating_history(heard, ctrl):
said, sent, spoken = heard
ctrl.history.add(controller_mod.history_mod.PET, "it's 7:15 AM", 0.0)
said["text"] = "what did you say?"
ctrl._handle_conversation_turn()
assert spoken == ["it's 7:15 AM"]
assert sent == []
pet_lines = [e.text for e in ctrl.history.entries()
if e.role == controller_mod.history_mod.PET]
assert pet_lines == ["it's 7:15 AM"] # replayed, not re-recorded
def test_repeat_with_nothing_to_repeat_says_so(heard, ctrl):
said, sent, spoken = heard
said["text"] = "say that again"
ctrl._handle_conversation_turn()
assert spoken == ["I haven't said anything yet."]
def test_going_back_to_the_normal_voice_needs_no_server_prompt_support(heard, ctrl):
"""The server can only offer `petctl voice reset` if its prompt happens to
advertise the verb; recognising the phrase here works regardless."""
said, sent, spoken = heard
ctrl._voice_id, ctrl._voice_name = "voice-123", "Brian"
changed = _capture(ctrl.voice_changed)
said["text"] = "go back to your normal voice"
ctrl._handle_conversation_turn()
assert ctrl._voice_id == ""
assert changed == [""]
assert spoken == ["Back to my own voice."]
assert sent == []
def test_go_to_sleep_overrides_the_quiet_hours_schedule(heard, ctrl):
said, sent, spoken = heard
said["text"] = "go to sleep"
ctrl._handle_conversation_turn()
assert ctrl._napping is True
assert ctrl._nap_forced is True # not undone by the next schedule check
assert spoken == ["Night."]
# ── failure containment ─────────────────────────────────────────────────────
def test_one_bad_turn_does_not_end_the_session(monkeypatch, ctrl):
"""A turn raising something unforeseen used to unwind _loop and kill the
thread — the pet would go deaf until it was restarted by hand."""
logs = _capture(ctrl.log)
def explode():
raise RuntimeError("numpy said no")
assert ctrl._guarded(explode, "conversation turn") is False
assert ctrl._state.state == PetState.IDLE
assert any("Recovered from a conversation turn failure" in m for m in logs)
def test_a_command_handler_crash_is_reported_up_the_relay(monkeypatch, ctrl):
"""The server is blocked on /desk/tool_result while this runs. Raising would
leave it waiting out its own timeout on a turn that can never finish."""
monkeypatch.setattr(controller_mod.pet_actions, "parse",
lambda command: (_ for _ in ()).throw(KeyError("boom")))
output = ctrl._handle_command("petctl move top-left")
assert output.startswith("[error]") and "boom" in output
# ── notification bridge ─────────────────────────────────────────────────────
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
@@ -404,7 +549,54 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
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 == []
assert not ctrl._pending_notifications
def test_the_notification_queue_is_bounded(monkeypatch, ctrl):
"""An overnight nap can't grow the queue without limit — the drain only runs
from the heartbeat, and the heartbeat doesn't run while napping."""
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_QUEUE_LIMIT", 3)
ctrl = controller_mod.PetController()
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
for index in range(10):
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
queued = [notification.summary for _stamp, notification in ctrl._pending_notifications]
assert queued == ["build 7", "build 8", "build 9"] # oldest dropped
def test_stale_notifications_are_dropped_instead_of_read_out(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MAX_AGE_SECONDS", 900)
sent = []
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or Reply(""))
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
ctrl._queue_notification(Notification(app="CI", summary="fresh", body=""))
# Backdate it past the age limit, as an overnight backlog would be.
stamp, notification = ctrl._pending_notifications.pop()
ctrl._pending_notifications.append((stamp - 4000, notification))
ctrl._drain_notifications()
assert sent == []
def test_a_nap_starting_mid_drain_keeps_the_rest_queued(monkeypatch, ctrl):
"""The old code swapped the queue out and returned, losing the remainder."""
def converse(text, on_command=None):
ctrl._napping = True # e.g. quiet hours began, or a fullscreen app opened
return Reply("")
monkeypatch.setattr(controller_mod.server_client, "converse", converse)
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
for index in range(3):
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
ctrl._drain_notifications()
remaining = [notification.summary for _stamp, notification in ctrl._pending_notifications]
assert remaining == ["build 1", "build 2"]
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
+112
View File
@@ -0,0 +1,112 @@
"""Local intent recognition — pure string logic, no hardware or display.
The interesting tests are the negative ones: this feature's whole risk is
swallowing something that was meant for the server.
"""
import sys
from pathlib import Path
import pytest
from bolt_pet import intents
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@pytest.mark.parametrize("said, expected", [
("stop", "stop"),
("Stop!", "stop"),
("never mind", "stop"),
("be quiet", "stop"),
("go to sleep", "nap"),
("take a nap", "nap"),
("goodnight", "nap"),
("wake up", "wake"),
("come here", "come"),
("follow my cursor", "come"),
("get out of the way", "go_away"),
("hide", "go_away"),
("say that again", "repeat"),
("what did you say?", "repeat"),
("go for a walk", "wander_on"),
("stay put", "wander_off"),
("sit", "wander_off"),
("use your normal voice", "voice_reset"),
("go back to your normal voice", "voice_reset"),
("be yourself again", "voice_reset"),
])
def test_recognized_phrases(said, expected):
intent = intents.recognize(said)
assert intent is not None and intent.name == expected
@pytest.mark.parametrize("said", [
# Each of these starts with (or contains) an intent phrase, and every one is
# a real request. A substring match would eat all of them.
"stop the docker container",
"stop the deploy and tell me what broke",
"can you hide the window that's covering my terminal",
"come up with a name for this branch",
"repeat the last command but with sudo",
"what did you say the disk usage was on the server",
"move the config file to the backup directory",
"sit down and write me a haiku about kubernetes",
"what time is it",
"go to sleep mode on the server",
"",
" ",
# Pure filler leaves an empty string, which must not match anything.
"hey bolt",
"okay bolt please",
])
def test_real_requests_are_left_for_the_server(said):
assert intents.recognize(said) is None
def test_filler_is_stripped_from_both_ends():
assert intents.normalize("Hey Bolt, could you please just stop now?") == "stop"
assert intents.normalize("okay, come here buddy") == "come here"
def test_normalize_returns_empty_for_pure_filler():
assert intents.normalize("hey bolt") == ""
assert intents.normalize("...") == ""
def test_intents_carry_ui_actions_in_the_pet_actions_shape():
"""The action dicts go straight to PetWindow.apply_action, so they have to
match the vocabulary pet_actions.parse produces — no new UI cases."""
assert intents.recognize("come here").action == {"action": "move", "anchor": "cursor"}
assert intents.recognize("stay put").action == {"action": "wander", "enabled": False}
assert intents.recognize("go to sleep").action == {"action": "nap", "enabled": True}
def test_stop_says_nothing():
"""Answering "okay!" when told to be quiet defeats the purpose."""
intent = intents.recognize("be quiet")
assert intent.speak == "" and intent.action is None
def test_a_phrase_claimed_by_two_intents_fails_at_import(monkeypatch):
"""Without this guard the phrase would silently bind to whichever intent was
declared last — a table edit that looks fine and misbehaves on a mic."""
monkeypatch.setattr(intents, "_TABLE", (
(intents.Intent("stop"), ("enough",)),
(intents.Intent("nap"), ("enough",)),
))
with pytest.raises(ValueError, match="claimed by both"):
intents._build()
def test_a_phrase_of_pure_filler_fails_at_import(monkeypatch):
"""It would normalise to "" and then match any all-filler utterance."""
monkeypatch.setattr(intents, "_TABLE", ((intents.Intent("stop"), ("please bolt",)),))
with pytest.raises(ValueError, match="normalises to nothing"):
intents._build()
def test_every_table_phrase_round_trips():
for phrase, intent in intents._BY_PHRASE.items():
assert phrase, "a phrase normalised to nothing"
assert intents.recognize(phrase) is intent
+48
View File
@@ -112,3 +112,51 @@ def test_pcm_to_wav_bytes_round_trips_via_wave_module():
assert wf.getframerate() == 16000
frames = wf.readframes(wf.getnframes())
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
# ── flushing buffered audio ─────────────────────────────────────────────────
class _BufferedStream:
"""A stream with a backlog, like PortAudio's ring buffer after the reader
was blocked on a network call for a while."""
def __init__(self, available):
self.read_available = available
self.reads = []
def read(self, frames):
self.reads.append(frames)
self.read_available = max(0, self.read_available - frames)
return np.zeros((frames, 1), dtype=np.int16), False
def test_flush_drops_exactly_what_was_buffered():
stream = _BufferedStream(4096)
assert mic.flush(stream) == 4096
assert stream.reads == [4096]
assert stream.read_available == 0
def test_flush_is_bounded_so_it_cannot_chase_a_live_stream():
"""A stream filling as fast as it drains must not spin forever."""
stream = _BufferedStream(10 ** 9)
dropped = mic.flush(stream, max_seconds=1.0, sample_rate=16000)
assert dropped == 16000
def test_flush_is_a_noop_on_an_empty_or_fake_stream():
stream = _BufferedStream(0)
assert mic.flush(stream) == 0
assert stream.reads == []
assert mic.flush(_ScriptedStream([])) == 0 # no read_available at all
assert mic.flush(None) == 0
def test_flush_swallows_a_device_error():
class _Broken:
read_available = 1024
def read(self, frames):
raise RuntimeError("device disappeared")
assert mic.flush(_Broken()) == 0
+51
View File
@@ -1,4 +1,6 @@
import os
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -142,3 +144,52 @@ def test_download_outbox_file_raises_server_error_on_http_failure():
get.return_value = _mock_response({}, ok=False)
with pytest.raises(server_client.ServerError, match="abc"):
server_client.download_outbox_file("abc")
def test_converse_reports_a_relay_that_never_produced_a_reply():
"""Hitting the hop cap used to surface as "unknown server response", which
sent everyone looking at the payload shape instead of at a model that kept
calling tools and never answered."""
command = {"type": "command", "command": "echo hi", "token": "t"}
with patch.object(server_client.requests, "post") as post:
post.return_value = _mock_response(command)
with pytest.raises(server_client.ServerError, match="hop cap"):
server_client.converse("hi", on_command=lambda cmd: "ok")
# ── relayed shell commands ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _no_sudo_prompt(monkeypatch):
monkeypatch.setattr(server_client.config, "SUDO_ASKPASS_PROMPT", False)
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
def test_a_successful_command_returns_its_output_and_exit_code():
output = server_client.run_local_command("echo hello; exit 3")
assert output.startswith("[exit 3]")
assert "hello" in output
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
def test_a_timed_out_command_still_reports_what_it_printed():
"""A bare "timed out" tells the model nothing; the last line of output
usually says exactly what it was stuck waiting for."""
output = server_client.run_local_command("echo working on it; sleep 30", timeout=1)
assert "timed out after 1s" in output
assert "working on it" in output
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
def test_a_timed_out_command_takes_its_children_with_it(tmp_path):
"""subprocess.run() would only kill the `sh`, leaving whatever it spawned
running for the rest of the session with no parent watching."""
marker = tmp_path / "ticks"
server_client.run_local_command(
f"(while true; do echo tick >> {marker}; sleep 0.05; done) & sleep 30",
timeout=1,
)
settled = marker.stat().st_size if marker.exists() else 0
time.sleep(0.4)
grew = (marker.stat().st_size if marker.exists() else 0) - settled
assert grew == 0, "a grandchild survived the timeout and is still writing"
+26
View File
@@ -78,3 +78,29 @@ def test_is_question_ignores_question_marks_that_are_not_spoken():
assert not is_question("Docs are at https://example.com/x?y=1")
assert not is_question("")
assert not is_question(None)
def test_abbreviations_are_worded_instead_of_spelled_out():
"""The periods make these look like sentence boundaries, so the voice reads
them letter by letter ("eee gee")."""
assert for_speech("Use a flag, e.g. --force") == "Use a flag, for example force"
assert for_speech("i.e. the config file") == "that is the config file"
assert for_speech("logs, configs, etc.") == "logs, configs, and so on"
assert for_speech("docker vs. podman") == "docker versus podman"
assert for_speech("Fixed in PR #42") == "Fixed in PR number 42"
def test_abbreviation_wording_is_word_bounded():
""""vs" inside a word or filename isn't an abbreviation."""
assert "versus" not in for_speech("the vscode window")
assert "versus" not in for_speech("revs per minute")
# A markdown heading has no digit after the hashes, so it's still a heading.
assert for_speech("## Results") == "Results"
def test_long_option_dashes_are_dropped_but_hyphens_survive():
assert for_speech("run it with --force") == "run it with force"
assert for_speech("check bolt-pet is up-to-date") == "check bolt-pet is up-to-date"
# The rule line is gone; the full stops are _bullets_to_sentences giving the
# voice a pause where the eye saw a line break.
assert for_speech("one\n---\ntwo") == "one. two."