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:
vh
2026-08-01 19:38:37 -07:00
parent ef76a03bcd
commit 56dce00b2b
7 changed files with 192 additions and 21 deletions
+31 -2
View File
@@ -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",
)