Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata. Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în lock, apoi pornea ca tur separat. Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin deschis, și al doilea mesaj intră în ACELAȘI tur. - `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume, drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry (max_live, reaper pe inactivitate, stop_all la shutdown) - `src/stream_json.py` — parser stream-json partajat cu `_run_claude`; pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe error_max_turns și depinde de asta) - `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc de 4 verificări copiate; repară și bug-ul preexistent prin care WhatsApp posta literal `__AUDIO__:/cale` - dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din decizie și cursa TOCTOU odată cu el - `/stop` oprește turul, nu sesiunea — active.json rămâne valid - rate limit prin proces persistent vine ca result.is_error, nu ca exit code; convertit înapoi în același RuntimeError, altfel fallback-ul local nu s-ar mai declanșa niciodată, în tăcere Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră într-un tur voice: împart același channel_id). Mesajele steered dintr-un tur care pică sunt re-livrate, nu pierdute. Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s, un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în [EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate fi refuzată ca prompt injection — pentru oprire folosește /stop. Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
102 lines
2.9 KiB
Python
102 lines
2.9 KiB
Python
"""src.sentinels is the single place that recognises router sentinels
|
|
(__AUDIO__:<path> and __STEERED__) so adapters don't each hand-roll the
|
|
prefix check. Offline, no subprocess/network involved.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from src.sentinels import AUDIO_PREFIX, STEERED, audio_path, is_steered
|
|
|
|
|
|
def test_audio_path_extracts_payload():
|
|
assert audio_path(f"{AUDIO_PREFIX}/tmp/echo-audio.wav") == "/tmp/echo-audio.wav"
|
|
|
|
|
|
def test_audio_path_none_for_plain_text():
|
|
assert audio_path("salut, ce mai faci?") is None
|
|
|
|
|
|
def test_audio_path_none_for_empty_and_none():
|
|
assert audio_path("") is None
|
|
assert audio_path(None) is None
|
|
|
|
|
|
def test_audio_path_none_for_steered():
|
|
assert audio_path(STEERED) is None
|
|
|
|
|
|
def test_is_steered_true_only_for_exact_sentinel():
|
|
assert is_steered(STEERED) is True
|
|
assert is_steered("__STEERED__ ") is False
|
|
assert is_steered("some text") is False
|
|
|
|
|
|
def test_is_steered_false_for_empty_and_none():
|
|
assert is_steered("") is False
|
|
assert is_steered(None) is False
|
|
|
|
|
|
def test_is_steered_false_for_audio_sentinel():
|
|
assert is_steered(f"{AUDIO_PREFIX}/tmp/x.wav") is False
|
|
|
|
|
|
# --- Voice pipeline: a steered response must not be mirrored into the
|
|
# text channel (blocker C2 in tasks/steering-plan.md). Reuses the fakes
|
|
# from tests/test_pipeline_mirror.py.
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from src.voice.pipeline import VoiceSession
|
|
|
|
|
|
def _make_text_channel(send_mock: AsyncMock) -> MagicMock:
|
|
tc = MagicMock(name="text_channel")
|
|
tc.send = send_mock
|
|
return tc
|
|
|
|
|
|
def _make_session(*, reply_text: str, text_channel) -> VoiceSession:
|
|
bot = MagicMock(name="bot")
|
|
bot.get_channel = MagicMock(return_value=text_channel)
|
|
bot.get_user = MagicMock(return_value=None)
|
|
ttsq = MagicMock(name="ttsq")
|
|
ttsq.push_text = MagicMock()
|
|
ttsq.clear = MagicMock()
|
|
route_mock = MagicMock(name="route_message", return_value=(reply_text, False))
|
|
return VoiceSession(
|
|
text_channel_id=1001,
|
|
voice_channel_id=2002,
|
|
guild_id=42,
|
|
voice_client=MagicMock(name="voice_client"),
|
|
bot=bot,
|
|
ttsq=ttsq,
|
|
whitelist=set(),
|
|
record_enabled=False,
|
|
mirror_enabled=True,
|
|
transcripts_jsonl_path=None,
|
|
loop=asyncio.get_event_loop_policy().new_event_loop(),
|
|
router_route_message=route_mock,
|
|
)
|
|
|
|
|
|
def _reply_chunks(send_mock: AsyncMock) -> list[str]:
|
|
return [
|
|
call.args[0]
|
|
for call in send_mock.call_args_list
|
|
if not call.args[0].startswith("\U0001f3a4")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_path_does_not_mirror_steered_sentinel():
|
|
send_mock = AsyncMock(name="text_send")
|
|
text_channel = _make_text_channel(send_mock)
|
|
session = _make_session(reply_text=STEERED, text_channel=text_channel)
|
|
|
|
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
|
|
|
assert _reply_chunks(send_mock) == []
|