"""`dialoguectl` — multi-voice scene parsing, voice resolution, API limits, and the request the ElevenLabs Text to Dialogue endpoint actually gets. Pure logic plus one mocked HTTP call: no audio device, no network, no display. """ import sys from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from bolt_pet import dialogue from bolt_pet.audio import tts SELF_ID = "aaorr6ZHIL88gEexu7dC" NARRATOR_ID = "9BWtsMINqrJLrRacOk9x" VILLAIN_ID = "IKne3meq5aSn9XLyUdCD" VOICES = {"narrator": NARRATOR_ID, "villain": VILLAIN_ID} def _scene(*lines): return '{"lines": [' + ", ".join(lines) + "]}" # ── parsing ───────────────────────────────────────────────────────────────── def test_non_dialogue_commands_are_left_alone(): assert dialogue.parse("ls -la") is None assert dialogue.parse('filectl {"op": "list"}') is None assert dialogue.parse("") is None # "dialogues" must not be mistaken for the "dialogue" prefix assert dialogue.parse("dialogues --list") is None def test_a_scene_parses_into_lines(): action = dialogue.parse( 'dialoguectl ' + _scene( '{"voice": "self", "text": "[cheerfully] Hello, how are you?"}', '{"voice": "villain", "text": "[stuttering] I am... fine."}', ) ) assert action["action"] == "dialogue" assert [line["voice"] for line in action["lines"]] == ["self", "villain"] assert action["lines"][0]["text"].startswith("[cheerfully]") def test_the_elevenlabs_field_names_are_accepted_too(): """The model has read that API; copying its shape is the obvious thing to try, so 'inputs'/'voice_id' work as well as 'lines'/'voice'.""" action = dialogue.parse( 'dialoguectl {"inputs": [{"voice_id": "%s", "text": "hi"}]}' % NARRATOR_ID ) assert action["lines"] == [{"voice": NARRATOR_ID, "text": "hi"}] def test_a_line_with_no_voice_defaults_to_the_pet_itself(): action = dialogue.parse('dialoguectl {"lines": [{"text": "just me talking"}]}') assert action["lines"][0]["voice"] == "self" def test_truncated_json_explains_the_one_line_rule(): """The real failure mode: the server's command extractor stops at the first newline, so a multi-line payload arrives cut in half. The error has to name the cause, since Bolt is the one who has to fix it.""" with pytest.raises(dialogue.DialogueError, match="one line"): dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"') def test_an_empty_or_shapeless_payload_is_rejected(): with pytest.raises(dialogue.DialogueError, match="needs a JSON argument"): dialogue.parse("dialoguectl") with pytest.raises(dialogue.DialogueError, match="non-empty"): dialogue.parse('dialoguectl {"lines": []}') with pytest.raises(dialogue.DialogueError, match="no text"): dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": " "}]}') def test_optional_model_and_stability_ride_along(): action = dialogue.parse( 'dialoguectl {"model_id": "eleven_v3", "stability": 0.8, ' '"lines": [{"text": "hi"}]}' ) assert action["model"] == "eleven_v3" assert action["stability"] == 0.8 # ── voice resolution ──────────────────────────────────────────────────────── def test_named_voices_resolve_from_the_configured_cast(): action = dialogue.parse('dialoguectl ' + _scene( '{"voice": "narrator", "text": "Once upon a time."}', '{"voice": "villain", "text": "Not this again."}', )) inputs = dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID) assert [entry["voice_id"] for entry in inputs] == [NARRATOR_ID, VILLAIN_ID] def test_self_tracks_the_voice_the_pet_is_currently_using(): """A scene featuring Bolt should sound like whoever Bolt currently is — including a voice the server picked mid-conversation with speak_as.""" action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}') picked = "VOICEfromSPEAKas1234" assert dialogue.resolve(action, self_voice=picked)[0]["voice_id"] == picked def test_a_raw_voice_id_passes_straight_through(): action = dialogue.parse('dialoguectl {"lines": [{"voice": "%s", "text": "hi"}]}' % NARRATOR_ID) assert dialogue.resolve(action, self_voice=SELF_ID)[0]["voice_id"] == NARRATOR_ID def test_an_unknown_name_lists_what_is_available(): action = dialogue.parse('dialoguectl {"lines": [{"voice": "wizard", "text": "hi"}]}') with pytest.raises(dialogue.DialogueError) as excinfo: dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID) message = str(excinfo.value) assert "wizard" in message and "narrator" in message and "villain" in message def test_self_without_a_configured_voice_says_so(): action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}') with pytest.raises(dialogue.DialogueError, match="ELEVENLABS_VOICE_ID"): dialogue.resolve(action, self_voice="") def test_the_voice_map_parser_skips_typos_instead_of_dying(): voices = dialogue.parse_voice_map(f"narrator:{NARRATOR_ID}, broken-entry, villain:{VILLAIN_ID}") assert voices == {"narrator": NARRATOR_ID, "villain": VILLAIN_ID} assert dialogue.parse_voice_map("") == {} # ── API limits, enforced before the request goes out ──────────────────────── def test_too_many_distinct_voices_is_refused_locally(): inputs = [{"text": "hi", "voice_id": f"voice{index:015d}"} for index in range(11)] with pytest.raises(dialogue.DialogueError, match="limit is 10"): dialogue.check_limits(inputs) def test_an_over_long_scene_is_refused_with_advice(): inputs = [{"text": "x" * 1100, "voice_id": SELF_ID} for _ in range(2)] with pytest.raises(dialogue.DialogueError) as excinfo: dialogue.check_limits(inputs) assert "Split it" in str(excinfo.value) # actionable, since Bolt reads this # ── display / reporting ───────────────────────────────────────────────────── def test_delivery_tags_are_stripped_from_what_the_bubble_shows(): action = dialogue.parse('dialoguectl ' + _scene( '{"voice": "self", "text": "[cheerfully] Hello there!"}', '{"voice": "narrator", "text": "[whispering] He is lying."}', )) assert dialogue.spoken_text(action) == "Hello there! He is lying." def test_the_relay_report_names_the_cast(): action = dialogue.parse('dialoguectl ' + _scene( '{"voice": "self", "text": "one"}', '{"voice": "narrator", "text": "two"}', )) assert dialogue.describe(action) == "[dialogue] played 2 lines in 2 voices: narrator, self" # ── the HTTP request ──────────────────────────────────────────────────────── def _pcm_response(samples=(1, 2, 3, 4)): response = MagicMock() response.content = np.array(samples, dtype=np.int16).tobytes() response.raise_for_status = MagicMock() return response def test_the_request_matches_the_text_to_dialogue_api(monkeypatch): monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") monkeypatch.setattr(tts.config, "TTS_SAMPLE_RATE", 24000) monkeypatch.setattr(tts.config, "DIALOGUE_MODEL_ID", "eleven_v3") inputs = [ {"text": "[cheerfully] Hello", "voice_id": NARRATOR_ID}, {"text": "[stuttering] H-hi", "voice_id": VILLAIN_ID}, ] with patch.object(tts.requests, "post", return_value=_pcm_response()) as post: pcm, rate = tts.synthesize_dialogue(inputs) assert rate == 24000 and pcm.tolist() == [1, 2, 3, 4] args, kwargs = post.call_args assert args[0] == "https://api.elevenlabs.io/v1/text-to-dialogue" assert kwargs["params"] == {"output_format": "pcm_24000"} assert kwargs["headers"] == {"xi-api-key": "test-key"} assert kwargs["json"]["inputs"] == inputs assert kwargs["json"]["model_id"] == "eleven_v3" assert "settings" not in kwargs["json"] # omitted unless asked for def test_stability_is_only_sent_when_given(monkeypatch): monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") with patch.object(tts.requests, "post", return_value=_pcm_response()) as post: tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}], stability=0.3) assert post.call_args.kwargs["json"]["settings"] == {"stability": 0.3} def test_a_rejected_request_surfaces_what_the_api_said(monkeypatch): """The API explains refusals in the body; Bolt reads this through the tool relay, so it has to reach him rather than being flattened to '422'.""" monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") failure = MagicMock() failure.text = '{"detail": "voice_id not found"}' error = Exception("422 Client Error") error.response = failure response = MagicMock() response.raise_for_status = MagicMock(side_effect=error) with patch.object(tts.requests, "post", return_value=response): with pytest.raises(tts.TtsError, match="voice_id not found"): tts.synthesize_dialogue([{"text": "hi", "voice_id": "nope"}]) def test_no_api_key_fails_before_the_request(monkeypatch): monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "") with patch.object(tts.requests, "post") as post: with pytest.raises(tts.TtsError): tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}]) post.assert_not_called()