fix: heid-bug-hunt fixups — donut voiced-interview slices 2+3
Triaged the heid-bug-hunt panel (Gróa+Hulda+Regin+Kimi, 11 distinct findings).
Fixed the real ones; the 3-arm "memory_context unverifiable" alarm was refuted
(tests + live SDK verify), and caller-supplied agent_id is accepted under the
LAN/no-auth debug-tool trust model (documented, not fixed).
Constructible crashes (were uncaught HTTP 500s from wire input):
- _tts_endpoint: coerce non-str / unhashable agent_id -> None before the voice-map
lookup (matches the submit path's guard); an unhashable {} / [] TypeError'd -> 500.
- PadState.from_obj: catch ArithmeticError — float() of a huge-int JSON literal
raises OverflowError, absent from the except tuple -> 500; now a neutral read.
- _submit_turn_endpoint: require a non-blank STR content — a truthy non-str crashed
pin_kb_context's question.strip() mid-stream instead of a deterministic 400.
pin_kb_context also isinstance-guards the question defensively.
Robustness:
- kb_bridge: delete the throwaway Mimir consult session (SDK sessions.delete) on
success/error/timeout via a caller-owned holder so cleanup survives a mid-stream
timeout — consults no longer accumulate server-side under the fixed partition.
- _stream_turn_endpoint: emit a ": keepalive" SSE comment BEFORE the (<=20s) KB
consult so a reverse proxy / EventSource doesn't drop the silent connection into
a false "WIRE LOST" before the turn starts.
- _tts_endpoint: cap text at 8000 chars (413) before the process-global lock;
gateway timeout 120s->60s — one huge/stalled body can't starve all TTS.
- tts_synthesize: validate the WAVE form tag (bytes 8:12), not just the RIFF magic.
- index.html: revoke the audio blob URL in cancelTts (removeAttribute+load fires
neither ended nor error, so the src's own revoke never ran -> per-turn blob leak).
TDD: +11 tests (543 green). Live-smoked on :8765: all five constructible adversarial
inputs now return 200/413/400, never 500.
This commit is contained in:
+31
-2
@@ -22,8 +22,9 @@ from ratatoskr.tts import (
|
||||
tts_synthesize,
|
||||
)
|
||||
|
||||
# A minimally-valid wav body: the DEC-3 guard only checks the RIFF magic.
|
||||
_WAV = b"RIFF" + b"\x00" * 40
|
||||
# A minimally-valid wav body: the DEC-3 guard checks the RIFF magic (0:4) + WAVE
|
||||
# form tag (8:12).
|
||||
_WAV = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32
|
||||
|
||||
|
||||
class TestPadState:
|
||||
@@ -48,6 +49,12 @@ class TestPadState:
|
||||
def test_from_obj_non_numeric_is_none(self) -> None:
|
||||
assert PadState.from_obj({"pleasure": "hot", "arousal": 0.1}) is None
|
||||
|
||||
def test_from_obj_huge_int_overflow_is_none(self) -> None:
|
||||
# float() of a 400-digit JSON integer raises OverflowError (an ArithmeticError,
|
||||
# not a ValueError); from_obj must still degrade to None, not propagate a 500.
|
||||
huge = int("9" * 400)
|
||||
assert PadState.from_obj({"pleasure": huge, "arousal": 0}) is None
|
||||
|
||||
|
||||
class TestPadToDials:
|
||||
def test_none_pad_is_neutral_disabled(self) -> None:
|
||||
@@ -73,6 +80,14 @@ class TestPadToDials:
|
||||
assert d.emotion_arousal == 1.0 # +inf clamps to the ceiling
|
||||
assert not math.isnan(d.emotion_valence)
|
||||
|
||||
def test_non_padstate_input_degrades_to_neutral(self) -> None:
|
||||
# The declared surface is PadState | None, but the function must be total over
|
||||
# ANY object (a raw dict / str / arbitrary object) → neutral, never raise.
|
||||
for bad in ({}, "bad", [0.1, 0.2], object(), 42):
|
||||
d = pad_to_dials(bad)
|
||||
assert d.emotion_enabled is False
|
||||
assert d.to_body() == {}
|
||||
|
||||
|
||||
class TestEmotionDialsToBody:
|
||||
def test_disabled_emits_no_params(self) -> None:
|
||||
@@ -166,3 +181,17 @@ class TestTtsSynthesize:
|
||||
"hi", voice="Cora", dials=pad_to_dials(None), client=client,
|
||||
url="http://tts.example/v1/audio/speech",
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_riff_but_not_wave_raises_tts_unavailable(self) -> None:
|
||||
# A body with the RIFF magic but a non-WAVE form tag (e.g. RIFF/AVI, or a
|
||||
# truncated/garbage container) is not playable audio — reject it too.
|
||||
respx.post("http://tts.example/v1/audio/speech").mock(
|
||||
return_value=httpx.Response(200, content=b"RIFF\x00\x00\x00\x00AVI LIST")
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(TtsUnavailable):
|
||||
await tts_synthesize(
|
||||
"hi", voice="Cora", dials=pad_to_dials(None), client=client,
|
||||
url="http://tts.example/v1/audio/speech",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user