from __future__ import annotations import asyncio import threading from types import SimpleNamespace from unittest.mock import MagicMock import numpy as np import pytest from jarvis.presence import State from jarvis.runtime_conversation import _semantic_turn_should_commit, respond_and_speak @pytest.mark.asyncio async def test_respond_and_speak_streams_sentences_to_tts_queue() -> None: runtime = SimpleNamespace() runtime._barge_in = asyncio.Event() runtime._clear_tts_queue = MagicMock() runtime._response_id = 0 runtime._active_response_id = 0 runtime._response_started = False runtime._first_sentence_at = None runtime._first_audio_at = None runtime._response_start_at = None runtime._tts_gain = 1.0 runtime._filler_task = None runtime.tts = object() runtime._thinking_filler = MagicMock(return_value=asyncio.sleep(3600)) runtime._lock = threading.Lock() runtime._speaking = False runtime._telemetry = { "llm_first_sentence_total_ms": 0.0, "llm_first_sentence_count": 0.0, "llm_prompt_tokens_total": 0.0, "llm_completion_tokens_total": 0.0, "llm_total_tokens_total": 0.0, "llm_cost_usd_total": 0.0, "llm_usage_samples": 0.0, } runtime._tts_queue = asyncio.Queue() runtime._confidence_pause = lambda sentence: 0.0 runtime._flush_output = MagicMock() runtime.robot = SimpleNamespace(stop_sequence=MagicMock()) runtime.presence = SimpleNamespace(signals=SimpleNamespace(state=State.THINKING)) runtime._voice_controller = lambda: SimpleNamespace(continue_listening=MagicMock()) runtime._publish_voice_status = MagicMock() async def _responses(): yield "First sentence" runtime.brain = SimpleNamespace( respond=lambda _: _responses(), latest_llm_usage=lambda: { "prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200, "cost_usd": 0.04, }, ) await respond_and_speak(runtime, "hello") queued = runtime._tts_queue.get_nowait() assert queued[1] == "First sentence" assert runtime._response_started is True assert runtime._telemetry["llm_first_sentence_count"] == 1.0 assert runtime._telemetry["llm_total_tokens_total"] == 200.0 assert runtime._telemetry["llm_usage_samples"] == 1.0 runtime._publish_voice_status.assert_called_once() @pytest.mark.asyncio async def test_respond_and_speak_honors_barge_in_mid_stream() -> None: runtime = SimpleNamespace() runtime._barge_in = asyncio.Event() runtime._clear_tts_queue = MagicMock() runtime._response_id = 0 runtime._active_response_id = 0 runtime._response_started = False runtime._first_sentence_at = None runtime._first_audio_at = None runtime._response_start_at = None runtime._tts_gain = 1.0 runtime._filler_task = None runtime.tts = object() runtime._thinking_filler = MagicMock(return_value=asyncio.sleep(3600)) runtime._lock = threading.Lock() runtime._speaking = False runtime._telemetry = { "llm_first_sentence_total_ms": 0.0, "llm_first_sentence_count": 0.0, } runtime._tts_queue = asyncio.Queue() runtime._confidence_pause = lambda sentence: 0.0 runtime._flush_output = MagicMock() runtime.robot = SimpleNamespace(stop_sequence=MagicMock()) runtime.presence = SimpleNamespace(signals=SimpleNamespace(state=State.THINKING)) runtime._voice_controller = lambda: SimpleNamespace(continue_listening=MagicMock()) runtime._publish_voice_status = MagicMock() async def _responses(): yield "First sentence" runtime._barge_in.set() yield "Second sentence" runtime.brain = SimpleNamespace(respond=lambda _: _responses()) await respond_and_speak(runtime, "hello") queued = runtime._tts_queue.get_nowait() assert queued[1] == "First sentence" runtime._flush_output.assert_called_once() runtime.robot.stop_sequence.assert_called_once() @pytest.mark.asyncio async def test_semantic_turn_should_commit_returns_true_when_disabled() -> None: runtime = SimpleNamespace( config=SimpleNamespace(semantic_turn_enabled=False), _telemetry={}, ) decision = await _semantic_turn_should_commit( runtime, audio=np.ones(32, dtype=np.float32), assistant_busy=False, silence_elapsed_sec=0.9, utterance_duration_sec=1.2, ) assert decision is True @pytest.mark.asyncio async def test_semantic_turn_should_commit_respects_brain_wait_decision() -> None: class _Brain: async def semantic_turn_decision(self, **_kwargs): return SimpleNamespace(action="wait", route_confidence=0.9) def latest_semantic_turn_trace(self): return { "action": "wait", "route_confidence": 0.9, "route_source": "router", } runtime = SimpleNamespace( config=SimpleNamespace( semantic_turn_enabled=True, semantic_turn_max_transcript_chars=220, ), brain=_Brain(), _transcribe_with_fallback=lambda _audio: "turn on the office and", _telemetry={ "semantic_turn_decisions_total": 0.0, "semantic_turn_waits": 0.0, "semantic_turn_commits": 0.0, "semantic_turn_fallbacks": 0.0, }, _last_semantic_turn_route={}, ) decision = await _semantic_turn_should_commit( runtime, audio=np.ones(32, dtype=np.float32), assistant_busy=False, silence_elapsed_sec=0.85, utterance_duration_sec=1.15, ) assert decision is False assert runtime._telemetry["semantic_turn_decisions_total"] == 1.0 assert runtime._telemetry["semantic_turn_waits"] == 1.0 assert runtime._last_semantic_turn_route["action"] == "wait"