Bolt Assistant: wake-phrase voice assistant for Bolt
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""Bolt Assistant without a mic, speaker or server: synthetic audio frames,
|
||||
a fake transcriber, fake converse/tts/playback. Covers wake matching, the
|
||||
voice-activity detector, the listen -> ask -> speak flow, and the command
|
||||
rules."""
|
||||
|
||||
import array
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_assistant import config, server
|
||||
from bolt_assistant.assistant import Assistant, annotate
|
||||
from bolt_assistant.listener import Segmenter
|
||||
from bolt_assistant.stt import Transcription, parse_result
|
||||
from bolt_assistant.tools import Tools
|
||||
from bolt_assistant.wake import find_wake
|
||||
|
||||
LOUD, QUIET = 3000, 20
|
||||
|
||||
|
||||
def frame(level, samples=1280):
|
||||
return array.array("h", [level, -level] * (samples // 2)).tobytes()
|
||||
|
||||
|
||||
# ── wake phrases ────────────────────────────────────────────────────────────
|
||||
|
||||
W = config.WAKE_WORDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("said, expected", [
|
||||
("Hey Bolt, what's the weather?", (True, "what's the weather?")),
|
||||
("Bolt.", (True, "")),
|
||||
("Thunderbolt turn on the lights", (True, "turn on the lights")),
|
||||
("hey thunderbolt!", (True, "")),
|
||||
("um, hey Bolt what time is it", (True, "what time is it")),
|
||||
("Hey assistant. Set a timer", (True, "Set a timer")),
|
||||
("Hey volt, play music", (True, "play music")), # a common mishearing, after "hey"
|
||||
("okay so bolt, hi", (True, "hi")),
|
||||
("tighten that bolt", (False, "")),
|
||||
("the bolt on the door", (False, "")),
|
||||
("I was bold", (False, "")), # the mishearing alone isn't a wake word
|
||||
("assistant set a timer", (False, "")), # "assistant" needs the "hey"
|
||||
("boltage is high", (False, "")),
|
||||
("", (False, "")),
|
||||
])
|
||||
def test_find_wake(said, expected):
|
||||
assert find_wake(said, W) == expected
|
||||
|
||||
|
||||
# ── voice-activity detection ────────────────────────────────────────────────
|
||||
|
||||
def seg(**kw):
|
||||
args = dict(threshold=500, start_frames=3, silence_end_sec=0.4, min_sec=0.3, max_sec=2.0)
|
||||
args.update(kw)
|
||||
return Segmenter(**args)
|
||||
|
||||
|
||||
def feed(s, frames):
|
||||
events = []
|
||||
for f in frames:
|
||||
events += s.feed(f)
|
||||
return events
|
||||
|
||||
|
||||
def test_silence_never_starts_an_utterance():
|
||||
assert feed(seg(), [frame(QUIET)] * 50) == []
|
||||
|
||||
|
||||
def test_speech_starts_with_pre_roll_and_ends_after_silence():
|
||||
s = seg()
|
||||
events = feed(s, [frame(QUIET)] * 6 + [frame(LOUD)] * 6 + [frame(QUIET)] * 5)
|
||||
kinds = [e.kind for e in events]
|
||||
assert kinds[0] == "start" and kinds[-1] == "end"
|
||||
start = events[0]
|
||||
assert len(start.frames) == 4 + 3 # pre-roll kept the first syllable, plus the trigger
|
||||
end = events[-1]
|
||||
assert len(end.audio) == (4 + 6 + 5) * 2560 # pre-roll + speech + trailing silence
|
||||
|
||||
|
||||
def test_a_click_is_not_speech_and_a_short_burst_is_dropped():
|
||||
s = seg(min_sec=0.5) # needs 6+ loud frames
|
||||
assert feed(s, [frame(LOUD), frame(QUIET)] * 5) == [] # isolated clicks
|
||||
events = feed(s, [frame(LOUD)] * 3 + [frame(QUIET)] * 5)
|
||||
assert [e.kind for e in events if e.kind != "frame"] == ["start", "dropped"]
|
||||
|
||||
|
||||
def test_long_speech_is_cut_off_at_the_cap():
|
||||
events = feed(seg(max_sec=1.0), [frame(LOUD)] * 30)
|
||||
ends = [e for e in events if e.kind == "end"]
|
||||
assert ends and ends[0].seconds <= 1.0 + 1e-6
|
||||
|
||||
|
||||
# ── the flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeTranscription:
|
||||
"""Each utterance returns the next scripted transcript."""
|
||||
|
||||
script = []
|
||||
|
||||
def __init__(self):
|
||||
self.frames = 0
|
||||
self.cancelled = False
|
||||
|
||||
def add(self, frames):
|
||||
self.frames += len(frames)
|
||||
|
||||
def finish(self, audio):
|
||||
return FakeTranscription.script.pop(0)
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def utterance():
|
||||
return [frame(LOUD)] * 8 + [frame(QUIET)] * 12
|
||||
|
||||
|
||||
def make(replies, commands="read", streaming=False, **kw):
|
||||
asked, played, logs = [], [], []
|
||||
|
||||
def converse(message, on_command, on_say=None):
|
||||
asked.append(message)
|
||||
reply = replies.pop(0)
|
||||
if callable(reply):
|
||||
return reply(on_command)
|
||||
return server.Reply(reply)
|
||||
|
||||
clock = Clock()
|
||||
a = Assistant(transcription=FakeTranscription, converse=converse,
|
||||
converse_stream=None if not streaming else kw.pop("stream"),
|
||||
tts=lambda text, voice: [text.encode()], play=lambda chunks: played.extend(chunks),
|
||||
tools=Tools(mode=commands, log=logs.append), log=logs.append, clock=clock, **kw)
|
||||
return a, asked, played, logs, clock
|
||||
|
||||
|
||||
def say(a, transcript):
|
||||
FakeTranscription.script.append(transcript)
|
||||
for f in utterance():
|
||||
a.process(f)
|
||||
|
||||
|
||||
def spoken(played):
|
||||
return [p.decode() for p in played if not p.startswith(b"\x00") and len(p) < 2000]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _config(monkeypatch):
|
||||
monkeypatch.setattr(config, "STREAMING_REPLIES", False)
|
||||
monkeypatch.setattr(config, "VAD_RMS_THRESHOLD", 500)
|
||||
FakeTranscription.script = []
|
||||
|
||||
|
||||
def test_ignores_speech_without_the_wake_phrase():
|
||||
a, asked, played, logs, _ = make([])
|
||||
say(a, "so anyway I was telling him about the bolt")
|
||||
assert asked == [] and played == []
|
||||
|
||||
|
||||
def test_wake_phrase_and_request_in_one_breath():
|
||||
a, asked, played, logs, _ = make(["It's three o'clock."])
|
||||
say(a, "Hey Bolt, what time is it?")
|
||||
assert asked[0].startswith("what time is it?\n\n[Bolt Assistant: a voice-only assistant")
|
||||
assert "It's three o'clock." in spoken(played)
|
||||
assert a.state == "idle"
|
||||
|
||||
|
||||
def test_bare_wake_phrase_chimes_then_takes_the_next_utterance():
|
||||
a, asked, played, logs, clock = make(["Sure."])
|
||||
say(a, "Hey Bolt")
|
||||
assert a.state == "command" and asked == []
|
||||
assert played # the chime
|
||||
clock.t += 1
|
||||
say(a, "turn it up") # no wake phrase needed now
|
||||
assert asked[0].startswith("turn it up")
|
||||
|
||||
|
||||
def test_the_command_window_closes():
|
||||
a, asked, played, logs, clock = make([])
|
||||
say(a, "Thunderbolt")
|
||||
clock.t += config.COMMAND_WAIT_SEC + 5
|
||||
say(a, "so then we left")
|
||||
assert asked == [] and a.state == "idle"
|
||||
|
||||
|
||||
def test_a_question_keeps_listening_for_the_answer():
|
||||
a, asked, played, logs, clock = make(["Which room, the kitchen or the bedroom?", "Done."])
|
||||
say(a, "Hey Bolt, turn on the lights")
|
||||
assert a.state == "follow_up"
|
||||
clock.t += 2
|
||||
say(a, "the kitchen")
|
||||
assert asked[1].startswith("the kitchen") and a.state == "idle"
|
||||
|
||||
|
||||
def test_mic_audio_during_speech_is_discarded():
|
||||
a, asked, played, logs, clock = make(["Hello there."])
|
||||
a.on_mic_frame(frame(LOUD)) # queued while... (simulating backlog)
|
||||
say(a, "hey bolt hello")
|
||||
assert a.frames.empty() # what the mic heard while Bolt talked is gone
|
||||
assert not a.segmenter.in_speech
|
||||
|
||||
|
||||
def test_streamed_sentences_are_spoken_as_they_arrive():
|
||||
def stream(message, on_say, on_command):
|
||||
on_say("First.")
|
||||
on_say("Second.")
|
||||
return server.Reply("First. Second.", streamed=True)
|
||||
|
||||
a, asked, played, logs, _ = make([], streaming=True, stream=stream)
|
||||
a._stream = stream
|
||||
say(a, "hey bolt tell me two things")
|
||||
assert spoken(played)[-2:] == ["First.", "Second."]
|
||||
|
||||
|
||||
def test_server_failure_chimes_and_goes_back_to_listening():
|
||||
def boom(on_command):
|
||||
raise server.ServerError("down")
|
||||
|
||||
a, asked, played, logs, _ = make([boom])
|
||||
say(a, "hey bolt anything")
|
||||
assert any("down" in line for line in logs) and a.state == "idle"
|
||||
|
||||
|
||||
def test_markdown_is_not_read_aloud():
|
||||
a, asked, played, logs, _ = make(["**Sure** — see https://example.com for `details`."])
|
||||
say(a, "hey bolt help")
|
||||
text = spoken(played)[-1]
|
||||
assert "**" not in text and "https" not in text and "`" not in text
|
||||
|
||||
|
||||
def test_the_note_describes_what_bolt_may_do():
|
||||
assert "can't run shell commands" in annotate("x", "read")
|
||||
assert "`command: <shell command>`" in annotate("x", "all")
|
||||
assert "can't act on this computer" in annotate("x", "off")
|
||||
|
||||
|
||||
# ── commands relayed by the server ──────────────────────────────────────────
|
||||
|
||||
def filectl(**payload):
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
def test_read_mode_lists_files_but_refuses_shell_and_writes(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi")
|
||||
ran = []
|
||||
tools = Tools(mode="read", log=lambda _m: None, shell=lambda c: ran.append(c) or "[exit 0]\n")
|
||||
assert "a.txt" in tools(filectl(op="list", path=str(tmp_path)))
|
||||
assert "hi" in tools(filectl(op="read", path=str(tmp_path / "a.txt")))
|
||||
assert "Not run" in tools("rm -rf /")
|
||||
assert "Not run" in tools(filectl(op="write", path=str(tmp_path / "b.txt"), content="x"))
|
||||
assert ran == [] and not (tmp_path / "b.txt").exists()
|
||||
|
||||
|
||||
def test_all_mode_runs_commands_and_off_runs_nothing(tmp_path):
|
||||
ran = []
|
||||
shell = lambda c: ran.append(c) or "[exit 0]\n" # noqa: E731
|
||||
assert Tools(mode="all", log=lambda _m: None, shell=shell)("command: echo hi") == "[exit 0]\n"
|
||||
assert ran == ["echo hi"] # the stray "command:" label was stripped
|
||||
assert "Not run" in Tools(mode="off", log=lambda _m: None, shell=shell)(filectl(op="list", path="."))
|
||||
assert "isn't available" in Tools(mode="all", log=lambda _m: None)("petctl emote wave")
|
||||
|
||||
|
||||
def test_unknown_mode_falls_back_to_read():
|
||||
assert Tools(mode="everything", log=lambda _m: None).mode == "read"
|
||||
|
||||
|
||||
def test_a_relayed_command_goes_through_the_rules(tmp_path):
|
||||
(tmp_path / "notes.txt").write_text("buy milk")
|
||||
|
||||
def reply_with_tool(on_command):
|
||||
output = on_command(filectl(op="read", path=str(tmp_path / "notes.txt")))
|
||||
assert "buy milk" in output
|
||||
return server.Reply("Your note says buy milk.")
|
||||
|
||||
a, asked, played, logs, _ = make([reply_with_tool])
|
||||
say(a, "hey bolt read my notes")
|
||||
assert "Your note says buy milk." in spoken(played)
|
||||
|
||||
|
||||
# ── server pieces ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_whole_samples_carries_an_odd_byte_across_chunks():
|
||||
out = b"".join(server.whole_samples([b"\x01", b"\x02\x03", b"\x04\x05"]))
|
||||
assert out == b"\x01\x02\x03\x04" # the lone trailing byte waits for its partner
|
||||
|
||||
|
||||
def test_unsaid_drops_what_was_already_spoken():
|
||||
reply = server.unsaid(server.Reply("One. Two.\n\nThree."), ["One.", "Two."])
|
||||
assert reply == server.Reply("Three.", streamed=False)
|
||||
|
||||
|
||||
def test_tts_needs_a_voice(monkeypatch):
|
||||
monkeypatch.setattr(config, "VOICE_ID", "")
|
||||
with pytest.raises(server.TtsError, match="ELEVENLABS_VOICE_ID"):
|
||||
server.tts_stream("hi")
|
||||
|
||||
|
||||
def test_tts_request_shape(monkeypatch):
|
||||
monkeypatch.setattr(config, "SERVER_URL", "http://s")
|
||||
monkeypatch.setattr(config, "API_KEY", "k")
|
||||
monkeypatch.setattr(config, "VOICE_ID", "v1")
|
||||
response = MagicMock()
|
||||
response.iter_content.return_value = [b"\x00\x01"]
|
||||
with patch.object(server.requests, "post", return_value=response) as post:
|
||||
assert list(server.tts_stream("hello", voice_id="v2")) == [b"\x00\x01"]
|
||||
assert post.call_args.args[0] == "http://s/desk/tts"
|
||||
assert post.call_args.kwargs["json"]["voice_id"] == "v2" # a speak_as pick wins
|
||||
assert post.call_args.kwargs["headers"] == {"X-Desk-Api-Key": "k"}
|
||||
|
||||
|
||||
def test_parse_result():
|
||||
msg = json.dumps({"type": "Results", "is_final": True, "channel": {"alternatives": [{"transcript": "hey bolt"}]}})
|
||||
assert parse_result(msg) == ("hey bolt", True)
|
||||
assert parse_result("junk") == ("", False)
|
||||
|
||||
|
||||
def test_transcription_falls_back_to_one_upload():
|
||||
class Sock:
|
||||
def __init__(self):
|
||||
self.audio, self.replies = [], [json.dumps({"is_final": True, "channel": {
|
||||
"alternatives": [{"transcript": "hey bolt"}]}}), ""]
|
||||
|
||||
def send_binary(self, data):
|
||||
self.audio.append(data)
|
||||
|
||||
def send(self, _text):
|
||||
pass
|
||||
|
||||
def recv(self):
|
||||
return self.replies.pop(0)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
late = Sock()
|
||||
calls = iter([ConnectionError("down"), late])
|
||||
|
||||
def connect(_rate):
|
||||
item = next(calls)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
t = Transcription(connect)
|
||||
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
|
||||
@@ -0,0 +1,343 @@
|
||||
"""filectl parsing + execution — the pseudo-commands the server can relay to
|
||||
read/write/edit local files instead of a raw shell heredoc.
|
||||
|
||||
filectl {"op": ...} is a single-line JSON envelope (not a multi-line
|
||||
marker block) because it rides the "command" tool marker, which the main
|
||||
repo's tool-call extractor only captures up to the next newline — see the
|
||||
module docstring in bolt_assistant/file_ops.py."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_assistant import file_ops
|
||||
|
||||
|
||||
def _cmd(payload: dict) -> str:
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_non_file_commands_are_left_alone():
|
||||
assert file_ops.parse("ls -la") is None
|
||||
assert file_ops.parse("systemctl restart nginx") is None
|
||||
assert file_ops.parse("") is None
|
||||
# "filed" must not be mistaken for the "file" prefix
|
||||
assert file_ops.parse("filed --list") is None
|
||||
|
||||
|
||||
def test_list_parses_defaults():
|
||||
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp"})) == {
|
||||
"action": "list", "path": "/tmp", "pattern": "*", "recursive": False,
|
||||
}
|
||||
|
||||
|
||||
def test_list_parses_pattern_and_recursive():
|
||||
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": "*.py", "recursive": True})) == {
|
||||
"action": "list", "path": "/tmp", "pattern": "*.py", "recursive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_list_requires_a_path():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "list"}))
|
||||
|
||||
|
||||
def test_list_rejects_empty_pattern():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": ""}))
|
||||
|
||||
|
||||
def test_read_parses_path_and_optional_line_range():
|
||||
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt"})) == {
|
||||
"action": "read", "path": "/tmp/a.txt", "start": None, "end": None,
|
||||
}
|
||||
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt", "start": 10, "end": 40})) == {
|
||||
"action": "read", "path": "/tmp/a.txt", "start": 10, "end": 40,
|
||||
}
|
||||
|
||||
|
||||
def test_read_requires_a_path():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "read"}))
|
||||
|
||||
|
||||
def test_read_rejects_non_numeric_line_args():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt", "start": "start"}')
|
||||
|
||||
|
||||
def test_write_parses_path_and_content():
|
||||
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": "hello\nworld"})) == {
|
||||
"action": "write", "path": "/tmp/a.txt", "content": "hello\nworld",
|
||||
}
|
||||
|
||||
|
||||
def test_write_allows_empty_content():
|
||||
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": ""})) == {
|
||||
"action": "write", "path": "/tmp/a.txt", "content": "",
|
||||
}
|
||||
|
||||
|
||||
def test_write_without_content_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
def test_edit_parses_old_and_new():
|
||||
assert file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz"})) == {
|
||||
"action": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz",
|
||||
}
|
||||
|
||||
|
||||
def test_edit_rejects_identical_old_and_new():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "same", "new": "same"}))
|
||||
|
||||
|
||||
def test_edit_missing_fields_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
def test_content_with_embedded_quotes_and_shell_metacharacters_survives():
|
||||
payload = 'echo "hi $USER" `whoami` && rm -rf /'
|
||||
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": payload})
|
||||
assert "\n" not in command # stays on one line, as the command marker requires
|
||||
assert file_ops.parse(command) == {"action": "write", "path": "/tmp/a.txt", "content": payload}
|
||||
|
||||
|
||||
def test_multiline_content_stays_on_one_physical_line():
|
||||
content = "line one\nline two\nline three with \"quotes\" and \\backslashes\\"
|
||||
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": content})
|
||||
assert "\n" not in command
|
||||
assert file_ops.parse(command)["content"] == content
|
||||
|
||||
|
||||
def test_help():
|
||||
assert file_ops.parse("filectl help") == {"action": "help"}
|
||||
assert file_ops.parse("filectl") == {"action": "help"}
|
||||
assert file_ops.parse(_cmd({"op": "help"})) == {"action": "help"}
|
||||
|
||||
|
||||
def test_invalid_json_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse("filectl {not valid json")
|
||||
|
||||
|
||||
def test_non_object_json_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse("filectl [1, 2, 3]")
|
||||
|
||||
|
||||
def test_unknown_op_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "frobnicate", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
# ── execution ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_shows_files_and_subdirectories(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "a.txt\t2B" in output
|
||||
assert "sub/" in output
|
||||
assert "b.txt" not in output # non-recursive: nested file not shown
|
||||
|
||||
|
||||
def test_list_recursive_finds_nested_files(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "recursive": True}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "sub/b.txt" in output or "sub\\b.txt" in output # os-dependent separator
|
||||
|
||||
|
||||
def test_list_pattern_filters_entries(tmp_path):
|
||||
(tmp_path / "a.py").write_text("x")
|
||||
(tmp_path / "b.txt").write_text("y")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "pattern": "*.py"}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "a.py" in output
|
||||
assert "b.txt" not in output
|
||||
|
||||
|
||||
def test_list_empty_directory_says_so(tmp_path):
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
assert "no entries" in output
|
||||
|
||||
|
||||
def test_list_missing_directory_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "list", "path": "/no/such/dir", "pattern": "*", "recursive": False})
|
||||
|
||||
|
||||
def test_list_rejects_a_file_path(tmp_path):
|
||||
target = tmp_path / "a.txt"
|
||||
target.write_text("hi")
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "list", "path": str(target), "pattern": "*", "recursive": False})
|
||||
|
||||
|
||||
def test_list_truncates_past_the_entry_cap(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(file_ops, "_MAX_LIST_ENTRIES", 3)
|
||||
for i in range(5):
|
||||
(tmp_path / f"f{i}.txt").write_text("x")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "truncated" in output
|
||||
assert output.count(".txt") == 3
|
||||
|
||||
|
||||
def test_write_then_read_round_trips(tmp_path):
|
||||
target = tmp_path / "notes.txt"
|
||||
write_action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "line one\nline two"}))
|
||||
result = file_ops.execute(write_action)
|
||||
assert target.read_text() == "line one\nline two"
|
||||
assert str(target) in result
|
||||
|
||||
read_action = file_ops.parse(_cmd({"op": "read", "path": str(target)}))
|
||||
output = file_ops.execute(read_action)
|
||||
assert "line one" in output
|
||||
assert "line two" in output
|
||||
|
||||
|
||||
def test_read_missing_file_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "read", "path": "/no/such/file.txt", "start": None, "end": None})
|
||||
|
||||
|
||||
def test_read_respects_line_range(tmp_path):
|
||||
target = tmp_path / "a.txt"
|
||||
target.write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
||||
action = file_ops.parse(_cmd({"op": "read", "path": str(target), "start": 3, "end": 5}))
|
||||
output = file_ops.execute(action)
|
||||
assert "line3" in output and "line5" in output
|
||||
assert "line1" not in output and "line6" not in output
|
||||
|
||||
|
||||
def test_edit_replaces_a_unique_match(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("def foo():\n return 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "return 1", "new": "return 2"}))
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "def foo():\n return 2\n"
|
||||
|
||||
|
||||
def test_edit_fails_when_text_not_found(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("def foo():\n return 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "nope", "new": "x"}))
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "def foo():\n return 1\n" # untouched
|
||||
|
||||
|
||||
def test_edit_fails_when_text_is_ambiguous(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("x = 1\nx = 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}))
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "x = 1\nx = 1\n" # untouched
|
||||
|
||||
|
||||
def test_write_creates_parent_directories(tmp_path):
|
||||
target = tmp_path / "nested" / "dir" / "a.txt"
|
||||
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "hi"
|
||||
|
||||
|
||||
# ── lenient JSON (see relay_json) ───────────────────────────────────────────
|
||||
# Machine-written JSON fails in a small, repeatable set of ways. Observed live
|
||||
# 2026-07-30: a stray quote after `false` cost a whole desk turn — rejected,
|
||||
# re-sent identically, rejected again, then abandoned with a promise to the
|
||||
# user that nothing fulfilled.
|
||||
|
||||
def test_the_stray_quote_that_cost_a_live_turn_now_parses():
|
||||
action = file_ops.parse(
|
||||
'filectl {"op":"list","path":"/home/maji/Documents","pattern":"*","recursive":false"}'
|
||||
)
|
||||
assert action["action"] == "list"
|
||||
assert action["path"] == "/home/maji/Documents"
|
||||
assert action["recursive"] is False
|
||||
assert action["_repairs"] == ["removed a stray quote after a bare value"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload,expected", [
|
||||
('{"op": "read", "path": "/tmp/a.txt",}', "removed a trailing comma"),
|
||||
("{'op': 'read', 'path': '/tmp/a.txt'}", "converted single-quoted strings to double-quoted"),
|
||||
('{“op”: “read”, “path”: “/tmp/a.txt”}', "replaced smart quotes with straight ones"),
|
||||
('```json {"op": "read", "path": "/tmp/a.txt"} ```', "stripped a markdown code fence"),
|
||||
])
|
||||
def test_common_model_json_mistakes_are_repaired(payload, expected):
|
||||
action = file_ops.parse("filectl " + payload)
|
||||
assert action["action"] == "read"
|
||||
assert action["path"] == "/tmp/a.txt"
|
||||
assert expected in action["_repairs"]
|
||||
|
||||
|
||||
def test_python_literals_are_converted():
|
||||
action = file_ops.parse('filectl {"op": "list", "path": "/tmp", "recursive": True}')
|
||||
assert action["recursive"] is True
|
||||
|
||||
|
||||
def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
|
||||
"""Silently fixing it would work today and guarantee the same broken call
|
||||
tomorrow — the model has to be told while it still has the turn."""
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
action = file_ops.parse(
|
||||
'filectl {"op":"list","path":%s,"recursive":false"}' % json.dumps(str(tmp_path))
|
||||
)
|
||||
output = file_ops.execute(action)
|
||||
assert "a.txt" in output
|
||||
assert "your JSON was malformed" in output
|
||||
assert "stray quote" in output
|
||||
|
||||
|
||||
def test_valid_json_gets_no_repair_note(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
action = file_ops.parse('filectl {"op": "list", "path": %s}' % json.dumps(str(tmp_path)))
|
||||
assert "_repairs" not in action
|
||||
assert "malformed" not in file_ops.execute(action)
|
||||
|
||||
|
||||
def test_genuinely_unparseable_json_points_at_the_character():
|
||||
""""Expecting ',' delimiter: char 74" is not something a model can act on."""
|
||||
with pytest.raises(file_ops.FileOpError) as excinfo:
|
||||
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt" "extra": 1}')
|
||||
message = str(excinfo.value)
|
||||
assert "^" in message # caret under the offending character
|
||||
assert '"extra"' in message # ...and the fragment around it
|
||||
|
||||
|
||||
def test_a_raw_windows_path_is_repaired_and_reported():
|
||||
r"""Models type Windows paths with single backslashes. `\U` isn't a JSON
|
||||
escape, so strict parsing would reject the whole call."""
|
||||
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\Users\sam\data.txt"}')
|
||||
assert action["path"] == r"C:\Users\sam\data.txt"
|
||||
assert any("backslash" in repair for repair in action["_repairs"])
|
||||
|
||||
|
||||
def test_the_backslash_repair_leaves_valid_escapes_alone():
|
||||
# The escaped pair and the valid \n survive; only the lone \U is doubled.
|
||||
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\\ok\Users\n.txt"}')
|
||||
assert action["path"] == "C:\\ok\\Users\n.txt"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Sanitizing chat-formatted replies into speakable prose. Pure string logic
|
||||
— no audio hardware, no Qt (see the testing conventions in CLAUDE.md)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_assistant.speech_text import for_display, for_speech, is_question
|
||||
|
||||
|
||||
def test_bold_markers_are_not_spoken():
|
||||
assert "*" not in for_speech("Here's the **maji-desktop** snapshot")
|
||||
assert for_speech("Here's the **maji-desktop** snapshot") == "Here's the maji-desktop snapshot"
|
||||
|
||||
|
||||
def test_italics_and_underscores_dropped():
|
||||
assert for_speech("that is _really_ odd") == "that is really odd"
|
||||
assert for_speech("***everything*** is fine") == "everything is fine"
|
||||
|
||||
|
||||
def test_bullet_list_becomes_sentences():
|
||||
spoken = for_speech(
|
||||
"System status:\n"
|
||||
"* **OS:** Linux 7.0.0\n"
|
||||
"* **Uptime:** 1 day\n"
|
||||
)
|
||||
assert "*" not in spoken
|
||||
assert spoken == "System status: OS: Linux 7.0.0. Uptime: 1 day."
|
||||
|
||||
|
||||
def test_emoji_and_symbols_removed():
|
||||
assert for_speech("✅ Done 🚀 — all good") == "Done all good"
|
||||
assert for_speech("→ next step") == "to next step"
|
||||
|
||||
|
||||
def test_urls_and_code_are_not_read_out_character_by_character():
|
||||
assert for_speech("see https://example.com/x?y=1 for docs") == "see link for docs"
|
||||
assert for_speech("run `sudo reboot` now") == "run sudo reboot now"
|
||||
assert for_speech("here:\n```\nls -la\n```\n") == "here: (code)."
|
||||
|
||||
|
||||
def test_headings_quotes_and_rules_stripped():
|
||||
assert for_speech("## Summary\n---\n> quoted bit") == "Summary. quoted bit."
|
||||
|
||||
|
||||
def test_ampersand_and_percent_are_spoken_as_words():
|
||||
assert for_speech("R&D at 50% capacity") == "R and D at 50 percent capacity"
|
||||
|
||||
|
||||
def test_blank_and_symbol_only_input():
|
||||
assert for_speech("") == ""
|
||||
assert for_speech(None) == ""
|
||||
assert for_speech("***") == ""
|
||||
|
||||
|
||||
def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("**Done** ✅") == "Done ✅"
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
assert is_question("Ready to go? 🚀")
|
||||
assert is_question('Shall I continue?"')
|
||||
assert is_question("Want me to fix it? **")
|
||||
|
||||
|
||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
# The '?' here is inside a URL query string, which for_speech strips.
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
|
||||
# ── ElevenLabs v3 delivery tags (observed live 2026-07-31) ──────────────────
|
||||
# "[laughing] That one came through clean" was spoken as "laughing That one
|
||||
# came through clean": the tags mean something to the dialogue model inside a
|
||||
# dialoguectl scene, and nothing at all to the ordinary reply voice.
|
||||
|
||||
def test_delivery_tags_are_never_spoken_aloud():
|
||||
spoken = for_speech("[laughing] That one came through clean.")
|
||||
assert "laughing" not in spoken
|
||||
assert spoken.startswith("That one came through clean")
|
||||
|
||||
|
||||
def test_the_bubble_does_not_caption_a_laugh_nobody_heard():
|
||||
assert "[laughing]" not in for_display("[laughing] All good.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", [
|
||||
"[whispering]", "[cheerfully]", "[sighs]", "[laughs]", "[clears throat]",
|
||||
"[nervously]", "[excited]", "[pause]", "[shouting]",
|
||||
])
|
||||
def test_the_common_tags_are_all_covered(tag):
|
||||
assert "" == for_speech(tag).strip()
|
||||
|
||||
|
||||
def test_ordinary_bracketed_text_survives():
|
||||
"""The tags are matched narrowly on purpose — real bracketed content is
|
||||
part of what the user asked to hear."""
|
||||
assert "1" in for_speech("See reference [1] for details.")
|
||||
assert "docs" in for_speech("It's in [the docs] somewhere.")
|
||||
Reference in New Issue
Block a user