diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 77241ea485b7..dd5b921d1695 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -53,6 +53,26 @@ class TestSentenceChunker: ] +# ── Interruption latch ─────────────────────────────────────────────────── + + +class TestSpeechInterruptedLatch: + def test_take_pops_and_reports_recent_barge(self): + ts.mark_speech_interrupted() + assert ts.take_speech_interrupted() is True + assert ts.take_speech_interrupted() is False # one-shot + + def test_untouched_latch_is_false(self): + ts._interrupted_at = None + assert ts.take_speech_interrupted() is False + + def test_stale_barge_expires(self, monkeypatch): + ts.mark_speech_interrupted() + at = ts._interrupted_at + monkeypatch.setattr(ts.time, "monotonic", lambda: at + ts._INTERRUPT_TTL_S + 1) + assert ts.take_speech_interrupted() is False + + # ── Registry + resolver ────────────────────────────────────────────────── diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 8d773d954f59..746886ccce3f 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -23,6 +23,7 @@ from __future__ import annotations import logging import re +import time from abc import ABC, abstractmethod from typing import Callable, Dict, Iterator, List, Optional @@ -30,6 +31,34 @@ from tools.tts_tool import _get_provider, get_env_value logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Interruption latch — lets the model know it was cut off mid-speech +# --------------------------------------------------------------------------- +# When the user barges in on a spoken reply (talks over it, types, hits the +# record key), the surface marks the latch; the next turn's submit path takes +# it and prepends SPEECH_INTERRUPTED_NOTE to the model-bound message (API-call +# local — never persisted, same as the CLI's model-switch notes). The TTL +# keeps a stale barge from annotating an unrelated message minutes later. + +SPEECH_INTERRUPTED_NOTE = ( + "[Note: the user interrupted your previous spoken reply before it finished.]" +) +_INTERRUPT_TTL_S = 120.0 +_interrupted_at: Optional[float] = None + + +def mark_speech_interrupted() -> None: + global _interrupted_at + _interrupted_at = time.monotonic() + + +def take_speech_interrupted() -> bool: + """Pop the latch; True when a barge happened within the TTL.""" + global _interrupted_at + at, _interrupted_at = _interrupted_at, None + return at is not None and time.monotonic() - at < _INTERRUPT_TTL_S + # Sentence boundary: after .!? followed by whitespace, or a blank line. SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])(?:\s|\n)|(?:\n\n)") _THINK_BLOCK_RE = re.compile(r"].*?", flags=re.DOTALL)