Files
watch_sceen_ai/tests/test_monitor.py
EJClaw 356d1128fa fix(voice): stop backtick TTS crash and actually count error turns
Claude replies with markdown/backticks by default; MeloTTS's Korean text
normaliser has no entry for '`' and dies with KeyError: '`', so any reply
mentioning a command/code block crashed the whole voice turn (500 on
/api/voice-turn). Fix at the shared synth() choke point with
normalize_for_speech(), which flattens code fences/inline code/links/markdown
and guarantees no backtick reaches the worker — covering both the dashboard
voice turn and the Discord speak() bridge. Also add a PERSONA line asking the
model to avoid markdown (belt-and-suspenders; the code strip is the real fix).

errors_total never moved for turn-level failures: it was only bumped by
log("error") events, and the dashboard voice path calls turn.finish(error=...)
without logging. Emit one error-level log event from Turn.finish() when a turn
ends in error, so both the server counter and the browser SSE mirror stay
consistent, guarded to count at most once. Drop the now-redundant pipeline
log("error") to avoid double counting and remove the dead _publish stub.

Verified: raw backtick -> worker KeyError '`' reproduced; after fix real
MeloTTS synth of a backtick+fenced reply succeeds; /api/voice-turn returns 200
with a wav body on a backtick reply and errors_total stays 0, and an induced
synth failure returns 500 with errors_total incrementing to exactly 1. Full
suite 18 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 23:29:13 +09:00

120 lines
4.0 KiB
Python

"""The monitor must record step-by-step turns (heard / thought / answered,
per-step timing, ok vs error) and stream them to subscribers — that data is
exactly what the status dashboard renders."""
import asyncio
import json
from wsai.backends.mock import MockBrain, MockSTT, MockTTS
from wsai.monitor import Monitor
from wsai.pipeline import Pipeline
def test_monitor_records_turn_with_timed_steps():
async def go():
mon = Monitor()
pipe = Pipeline(
brain=MockBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
await asyncio.wait_for(pipe.run(), timeout=5)
return mon
mon = asyncio.run(go())
snap = mon.snapshot()
assert snap["status"]["turns_total"] == 1
assert snap["status"]["running"] is False # cleaned up after run
assert len(snap["turns"]) == 1
turn = snap["turns"][0]
assert turn["heard"] == "안녕" # what it heard
assert turn["reply"] # what it answered
assert turn["status"] == "ok" # it worked
assert turn["total_ms"] >= 0
# step-by-step: every stage is named and timed
names = [s["name"] for s in turn["steps"]]
assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"]
assert all(s["ok"] is True for s in turn["steps"])
assert all(s["ms"] >= 0 for s in turn["steps"])
def test_monitor_marks_errors():
class BoomBrain(MockBrain):
async def respond(self, user_text, screen, history):
raise RuntimeError("boom")
async def go():
mon = Monitor()
pipe = Pipeline(
brain=BoomBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
try:
await asyncio.wait_for(pipe.run(), timeout=5)
except BaseException:
pass # TaskGroup re-raises; we only care about recorded telemetry
return mon
mon = asyncio.run(go())
snap = mon.snapshot()
turn = snap["turns"][0]
assert turn["status"] == "error"
brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)")
assert brain_step["ok"] is False
assert "boom" in brain_step["error"]
assert snap["status"]["errors_total"] >= 1
def test_error_turn_increments_errors_total_once():
"""The dashboard voice turn (dashboard.voice_turn) records a turn and calls
turn.finish(error=...) directly — it never goes through the pipeline's
log("error") path. That failure must still land in errors_total, and exactly
once no matter how many times the turn is re-published."""
mon = Monitor()
turn = mon.turn(source="discord")
turn.heard("`코드` 얘기") # touches/publishes again
turn.replied("답변") # and again
assert mon.status_snapshot()["errors_total"] == 0
turn.finish(error="melo synth failed: KeyError '`'")
assert mon.status_snapshot()["errors_total"] == 1
# A stray re-finish/re-publish must not double count.
turn.finish(error="melo synth failed: KeyError '`'")
turn._touch()
assert mon.status_snapshot()["errors_total"] == 1
# The failure is also visible as an error-level event for the live feed.
events = mon.snapshot()["events"]
assert any(e["type"] == "log" and e["level"] == "error" for e in events)
def test_subscriber_receives_live_turn_events():
async def go():
mon = Monitor()
q = mon.subscribe()
pipe = Pipeline(
brain=MockBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
await asyncio.wait_for(pipe.run(), timeout=5)
return q
q = asyncio.run(go())
events = []
while not q.empty():
events.append(json.loads(q.get_nowait()))
types = {e["type"] for e in events}
assert "turn" in types # live turn updates were pushed
assert "status" in types # listening/running status changes were pushed