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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user