first commit
This commit is contained in:
1
tests/ws_gateway/__init__.py
Normal file
1
tests/ws_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
9
tests/ws_gateway/test_audio_flow_logging.py
Normal file
9
tests/ws_gateway/test_audio_flow_logging.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.ws_gateway import main as main_module
|
||||
|
||||
|
||||
def test_audio_flow_burst_gap_controls_repeated_logs(monkeypatch) -> None:
|
||||
monkeypatch.setattr(main_module, "FLOW_AUDIO_BURST_GAP_S", 0.8)
|
||||
|
||||
assert main_module._is_new_audio_burst(10.0, 0.0) is True
|
||||
assert main_module._is_new_audio_burst(10.5, 10.0) is False
|
||||
assert main_module._is_new_audio_burst(10.8, 10.0) is True
|
||||
305
tests/ws_gateway/test_audio_input_backlog.py
Normal file
305
tests/ws_gateway/test_audio_input_backlog.py
Normal file
@@ -0,0 +1,305 @@
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from app.ws_gateway import main as main_module
|
||||
|
||||
|
||||
def _frame(value: int) -> bytes:
|
||||
return bytes([value % 256]) * main_module.BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _silent_frame() -> bytes:
|
||||
return b"\x00" * main_module.BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _voiced_frame(amp: int = 8000) -> bytes:
|
||||
samples = main_module.BYTES_PER_FRAME // 2
|
||||
return struct.pack("<%dh" % samples, *([amp] * samples))
|
||||
|
||||
|
||||
def _config(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
energy_shed_enabled: bool = False,
|
||||
energy_shed_max_excess_ms: int = 1000,
|
||||
silence_dbfs: float = -50.0,
|
||||
) -> main_module.AudioInputBacklogConfig:
|
||||
return main_module.AudioInputBacklogConfig(
|
||||
shed_enabled=enabled,
|
||||
shed_threshold_ms=100,
|
||||
shed_keep_ms=40,
|
||||
latency_metrics_enabled=True,
|
||||
latency_alert_ms=1000,
|
||||
latency_log_interval_s=15.0,
|
||||
livekit_source_queue_size_ms=500,
|
||||
livekit_source_clear_on_shed=True,
|
||||
energy_shed_enabled=energy_shed_enabled,
|
||||
energy_shed_max_excess_ms=energy_shed_max_excess_ms,
|
||||
silence_dbfs=silence_dbfs,
|
||||
)
|
||||
|
||||
|
||||
def test_audio_input_latency_snapshot_separates_raw_and_effective_delay() -> None:
|
||||
tracker = main_module.AudioInputLatencyTracker(_config())
|
||||
tracker.mark_enabled(100.0)
|
||||
|
||||
fields = tracker.snapshot(frame_count=1064, now=110.872, queue_size_frames=0)
|
||||
|
||||
assert fields["received_audio_ms"] == 21280
|
||||
assert fields["elapsed_since_enable_ms"] == 10872
|
||||
assert fields["raw_excess_ms"] == 10408
|
||||
assert fields["raw_lag_ms"] == 10408
|
||||
assert fields["excess_ms"] == 10408
|
||||
assert fields["lag_ms"] == 10408
|
||||
|
||||
tracker.total_dropped_frames = 520
|
||||
fields = tracker.snapshot(frame_count=1064, now=110.872, queue_size_frames=0)
|
||||
|
||||
assert fields["total_dropped_ms"] == 10400
|
||||
assert fields["saved_latency_ms"] == 10400
|
||||
assert fields["raw_excess_ms"] == 10408
|
||||
assert fields["raw_lag_ms"] == 10408
|
||||
assert fields["excess_ms"] == 8
|
||||
assert fields["lag_ms"] == 8
|
||||
|
||||
|
||||
def test_shed_audio_queue_backlog_keeps_recent_frames_and_logs_savings(monkeypatch) -> None:
|
||||
events = []
|
||||
debug_events = []
|
||||
|
||||
def fake_log_flow_event(logger, step, **payload):
|
||||
events.append((step, payload))
|
||||
|
||||
monkeypatch.setattr(main_module, "log_flow_event", fake_log_flow_event)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
tracker = main_module.AudioInputLatencyTracker(
|
||||
_config(),
|
||||
debug_event_publisher=lambda event, payload: debug_events.append(
|
||||
(event, payload)
|
||||
),
|
||||
)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=_config(),
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 6
|
||||
assert audio_q.qsize() == 2
|
||||
assert [audio_q.get_nowait(), audio_q.get_nowait()] == [_frame(6), _frame(7)]
|
||||
assert events[-1][0] == "audio_in_latency_shed"
|
||||
assert events[-1][1]["dropped_ms"] == 120
|
||||
assert events[-1][1]["saved_ms"] == 120
|
||||
assert events[-1][1]["saved_latency_ms"] == 120
|
||||
assert events[-1][1]["queue_before_ms"] == 160
|
||||
assert events[-1][1]["queue_after_ms"] == 40
|
||||
assert events[-1][1]["total_dropped_ms"] == 120
|
||||
assert debug_events[-1][0] == "bridge.audio_in.shed"
|
||||
assert debug_events[-1][1]["dropped_ms"] == 120
|
||||
|
||||
|
||||
def test_shed_audio_queue_backlog_is_disabled_by_flag(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=_config(enabled=False),
|
||||
tracker=main_module.AudioInputLatencyTracker(_config(enabled=False)),
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 0
|
||||
assert audio_q.qsize() == 8
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_defaults_to_enabled(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_IN_BACKLOG_SHED_ENABLED",
|
||||
"AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS",
|
||||
"AUDIO_IN_BACKLOG_SHED_KEEP_MS",
|
||||
"AUDIO_IN_LATENCY_METRICS_ENABLED",
|
||||
"AUDIO_IN_LATENCY_ALERT_MS",
|
||||
"AUDIO_IN_LATENCY_LOG_INTERVAL_S",
|
||||
"LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS",
|
||||
"LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 500
|
||||
assert config.shed_keep_ms == 300
|
||||
assert config.latency_metrics_enabled is True
|
||||
assert config.livekit_source_queue_size_ms == 500
|
||||
assert config.livekit_source_clear_on_shed is True
|
||||
assert config.config_source == "env"
|
||||
assert config.call_config_overrides == ()
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_preserves_legacy_source_queue_when_disabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_ENABLED", "0")
|
||||
monkeypatch.delenv("LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS", raising=False)
|
||||
monkeypatch.delenv("LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED", raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.shed_enabled is False
|
||||
assert config.livekit_source_queue_size_ms == 5000
|
||||
assert config.livekit_source_clear_on_shed is False
|
||||
assert config.config_source == "env"
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_prefers_call_config_and_falls_back_to_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_ENABLED", "0")
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS", "900")
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_KEEP_MS", "600")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_METRICS_ENABLED", "1")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_ALERT_MS", "1200")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_LOG_INTERVAL_S", "20")
|
||||
monkeypatch.setenv("LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS", "2000")
|
||||
monkeypatch.setenv("LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED", "0")
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env(
|
||||
{
|
||||
"ws": {
|
||||
"audioInputBacklogShedEnabled": True,
|
||||
"audioInputBacklogShedThresholdMs": 500,
|
||||
"audioInputBacklogShedKeepMs": 300,
|
||||
"audioInputLatencyAlertMs": 800,
|
||||
"livekitAudioSourceQueueSizeMs": 400,
|
||||
"livekitAudioSourceClearOnShed": True,
|
||||
"audioInputBacklogEnergyShedEnabled": True,
|
||||
"audioInputBacklogEnergyShedMaxExcessMs": 600,
|
||||
"audioInputBacklogSilenceDbfs": -60,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 500
|
||||
assert config.shed_keep_ms == 300
|
||||
assert config.latency_metrics_enabled is True
|
||||
assert config.latency_alert_ms == 800
|
||||
assert config.latency_log_interval_s == 20.0
|
||||
assert config.livekit_source_queue_size_ms == 400
|
||||
assert config.livekit_source_clear_on_shed is True
|
||||
assert config.config_source == "call_config"
|
||||
assert config.call_config_overrides == (
|
||||
"audio_in_backlog_shed_enabled",
|
||||
"audio_in_backlog_shed_threshold_ms",
|
||||
"audio_in_backlog_shed_keep_ms",
|
||||
"audio_in_latency_alert_ms",
|
||||
"livekit_audio_source_queue_size_ms",
|
||||
"livekit_audio_source_clear_on_shed",
|
||||
"audio_in_backlog_energy_shed_enabled",
|
||||
"audio_in_backlog_energy_shed_max_excess_ms",
|
||||
"audio_in_backlog_silence_dbfs",
|
||||
)
|
||||
|
||||
|
||||
def test_shed_energy_guided_drops_only_silence_under_small_excess(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
config = _config(energy_shed_enabled=True, energy_shed_max_excess_ms=1000)
|
||||
# [V, S, V, S, V, S, V, S] -> 4 de voz, 4 de silencio
|
||||
frames = []
|
||||
for _ in range(4):
|
||||
frames.append(_voiced_frame())
|
||||
frames.append(_silent_frame())
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for frame in frames:
|
||||
audio_q.put_nowait(frame)
|
||||
tracker = main_module.AudioInputLatencyTracker(config)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=config,
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
# excesso pequeno (120 ms < 1000): descarta so os 4 silencios, preserva a voz.
|
||||
assert dropped.dropped == 4
|
||||
assert audio_q.qsize() == 4
|
||||
remaining = [audio_q.get_nowait() for _ in range(4)]
|
||||
assert all(f == _voiced_frame() for f in remaining)
|
||||
assert events[-1][0] == "audio_in_latency_shed"
|
||||
assert events[-1][1]["mode"] == "energy"
|
||||
assert events[-1][1]["dropped_silent_frames"] == 4
|
||||
assert events[-1][1]["dropped_voiced_frames"] == 0
|
||||
assert events[-1][1]["content_policy"] == "silence_only"
|
||||
assert events[-1][1]["sync_action"] == "compress_silence"
|
||||
|
||||
|
||||
def test_shed_falls_back_to_blind_under_large_excess(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
# max_excess minusculo: qualquer excesso cai no regime de rajada (cego)
|
||||
config = _config(energy_shed_enabled=True, energy_shed_max_excess_ms=40)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
tracker = main_module.AudioInputLatencyTracker(config)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=config,
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 6
|
||||
assert audio_q.qsize() == 2
|
||||
assert [audio_q.get_nowait(), audio_q.get_nowait()] == [_frame(6), _frame(7)]
|
||||
assert events[-1][1]["mode"] == "blind"
|
||||
assert events[-1][1]["content_policy"] == "oldest_frames"
|
||||
assert events[-1][1]["sync_action"] == "drop_audio_to_catch_up"
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_energy_defaults(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_IN_BACKLOG_ENERGY_SHED_ENABLED",
|
||||
"AUDIO_IN_BACKLOG_ENERGY_SHED_MAX_EXCESS_MS",
|
||||
"AUDIO_IN_BACKLOG_SILENCE_DBFS",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.energy_shed_enabled is True
|
||||
assert config.energy_shed_max_excess_ms == 1000
|
||||
assert config.silence_dbfs == -60.0
|
||||
assert config.silence_rms_threshold == 32
|
||||
79
tests/ws_gateway/test_fake_remote_agent.py
Normal file
79
tests/ws_gateway/test_fake_remote_agent.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.ws_gateway.fake_remote_agent import build_fake_remote_agent_response
|
||||
|
||||
|
||||
CONTA_OPENING = (
|
||||
"Olá! Eu sou a Especialista em Contas e vou ajudar você a entender a sua "
|
||||
"fatura. Posso explicar valores, detalhar serviços e itens eventuais, "
|
||||
"identificar cobranças que você não reconhece e, se for o caso, realizar "
|
||||
"ajustes necessários ou solicitações relacionadas à sua conta. Então vamos "
|
||||
"lá, me conte o que você gostaria de entender ou resolver na sua conta."
|
||||
)
|
||||
|
||||
|
||||
class FakeRemoteAgentTests(unittest.TestCase):
|
||||
def test_conta_opening_uses_the_configured_greeting(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {"agent": "conta", "stage": "PRESENTATION"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertEqual(CONTA_OPENING, response["result"]["content"])
|
||||
|
||||
def test_conta_contract_returns_result_content(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {
|
||||
"agent": "conta",
|
||||
"stage": "PRESENTATION",
|
||||
"message": "quero detalhes da fatura",
|
||||
"msisdn": "5511999990000",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("result", response["type"])
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertEqual("final", response["result"]["type"])
|
||||
self.assertIn("fatura", response["result"]["content"].lower())
|
||||
self.assertIn("quero detalhes da fatura", response["result"]["content"].lower())
|
||||
|
||||
def test_conta_done_when_user_asks_to_finish(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {
|
||||
"agent": "conta",
|
||||
"stage": "FORMALIZATION",
|
||||
"message": "obrigado, pode encerrar",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("DONE", response["stage"])
|
||||
self.assertEqual("result", response["type"])
|
||||
|
||||
def test_generic_contract_returns_text(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"agent": "oferta",
|
||||
"stage": "PRESENTATION",
|
||||
"text": "quero saber da oferta",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("final", response["type"])
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertIn("oferta", response["text"].lower())
|
||||
self.assertIn("quero saber da oferta", response["text"].lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1056
tests/ws_gateway/test_readiness.py
Normal file
1056
tests/ws_gateway/test_readiness.py
Normal file
File diff suppressed because it is too large
Load Diff
290
tests/ws_gateway/test_session_audio.py
Normal file
290
tests/ws_gateway/test_session_audio.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.ws_gateway.session_audio import (
|
||||
enable_client_audio,
|
||||
notify_client_audio_enabled,
|
||||
stream_agent_audio_when_ready,
|
||||
watch_mock_stop_after_first_audio,
|
||||
)
|
||||
from app.ws_gateway.session_lifecycle import RoomLifecycleState
|
||||
|
||||
|
||||
class _FakeTimeline:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def emit(self, event: str, **fields) -> None:
|
||||
self.events.append((event, fields))
|
||||
|
||||
|
||||
class _FakeLocalParticipant:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[dict] = []
|
||||
|
||||
async def publish_data(self, payload: str, **kwargs) -> None:
|
||||
self.published.append({"payload": json.loads(payload), "kwargs": kwargs})
|
||||
|
||||
|
||||
class _FakeRoom:
|
||||
def __init__(self) -> None:
|
||||
self.local_participant = _FakeLocalParticipant()
|
||||
|
||||
|
||||
class _FakeParticipant:
|
||||
def __init__(self, identity: str) -> None:
|
||||
self.identity = identity
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.text_messages: list[dict] = []
|
||||
self.closed = False
|
||||
|
||||
async def send_text(self, payload: str) -> None:
|
||||
self.text_messages.append(json.loads(payload))
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_enable_client_audio_sets_gate_and_emits_timeline() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.enable")
|
||||
client_audio_enabled = asyncio.Event()
|
||||
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
|
||||
assert client_audio_enabled.is_set()
|
||||
assert timeline.events == [
|
||||
("client_audio_enabled", {}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_enable_client_audio_is_idempotent() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.enable.idempotent")
|
||||
client_audio_enabled = asyncio.Event()
|
||||
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
|
||||
assert client_audio_enabled.is_set()
|
||||
assert timeline.events == [("client_audio_enabled", {})]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_notify_client_audio_enabled_publishes_bridge_control_message() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.notify")
|
||||
room = _FakeRoom()
|
||||
track_published = asyncio.Event()
|
||||
track_published.set()
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
lifecycle = RoomLifecycleState(agent_participant=_FakeParticipant("agent-001"))
|
||||
|
||||
await notify_client_audio_enabled(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
track_published=track_published,
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
)
|
||||
|
||||
assert room.local_participant.published == [
|
||||
{
|
||||
"payload": {
|
||||
"type": "client_audio_enabled",
|
||||
"room": "room-dev-123",
|
||||
"protocol": "PRT-1",
|
||||
},
|
||||
"kwargs": {
|
||||
"reliable": True,
|
||||
"destination_identities": ["agent-001"],
|
||||
"topic": "bridge.control",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert timeline.events == [
|
||||
(
|
||||
"bridge_control_sent",
|
||||
{"control_type": "client_audio_enabled", "agent_identity": "agent-001"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_agent_audio_when_ready_uses_lifecycle_participant() -> None:
|
||||
async def _run() -> None:
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
participant = _FakeParticipant("agent-001")
|
||||
lifecycle = RoomLifecycleState(agent_participant=participant)
|
||||
calls: list[dict] = []
|
||||
|
||||
async def _stream_agent_audio(agent_q, agent_participant, activity, timeline=None) -> None:
|
||||
calls.append(
|
||||
{
|
||||
"agent_q": agent_q,
|
||||
"agent_participant": agent_participant,
|
||||
"activity": activity,
|
||||
"timeline": timeline,
|
||||
}
|
||||
)
|
||||
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
activity = SimpleNamespace(last_client_in=0.0, last_agent_out=0.0)
|
||||
timeline = _FakeTimeline()
|
||||
|
||||
await stream_agent_audio_when_ready(
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
agent_q=queue,
|
||||
activity=activity,
|
||||
timeline=timeline,
|
||||
stream_agent_audio=_stream_agent_audio,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"agent_q": queue,
|
||||
"agent_participant": participant,
|
||||
"activity": activity,
|
||||
"timeline": timeline,
|
||||
}
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_agent_audio_when_ready_repeats_for_reconnected_agent() -> None:
|
||||
async def _run() -> None:
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
first = _FakeParticipant("agent-001")
|
||||
second = _FakeParticipant("agent-002")
|
||||
lifecycle = RoomLifecycleState(agent_participant=first)
|
||||
calls: list[str] = []
|
||||
second_call = asyncio.Event()
|
||||
|
||||
async def _stream_agent_audio(agent_q, agent_participant, activity, timeline=None) -> None:
|
||||
calls.append(agent_participant.identity)
|
||||
if agent_participant is first:
|
||||
lifecycle.agent_participant = None
|
||||
lifecycle.agent_generation += 1
|
||||
lifecycle.agent_connected.clear()
|
||||
|
||||
async def _reconnect() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
lifecycle.agent_participant = second
|
||||
lifecycle.agent_generation += 1
|
||||
lifecycle.agent_connected.set()
|
||||
|
||||
asyncio.create_task(_reconnect())
|
||||
return
|
||||
|
||||
second_call.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
activity = SimpleNamespace(last_client_in=0.0, last_agent_out=0.0)
|
||||
task = asyncio.create_task(
|
||||
stream_agent_audio_when_ready(
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
agent_q=queue,
|
||||
activity=activity,
|
||||
timeline=None,
|
||||
stream_agent_audio=_stream_agent_audio,
|
||||
repeat=True,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(second_call.wait(), timeout=1.0)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert calls == ["agent-001", "agent-002"]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_mock_stop_after_first_audio_sends_resolved_terminal_stop() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.mock_stop")
|
||||
ws = _FakeWebSocket()
|
||||
first_agent_audio_sent = asyncio.Event()
|
||||
activity = SimpleNamespace(last_agent_out=time.monotonic())
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
task = asyncio.create_task(
|
||||
watch_mock_stop_after_first_audio(
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
first_agent_audio_sent=first_agent_audio_sent,
|
||||
activity=activity,
|
||||
agent_q=agent_q,
|
||||
silence_s=0.01,
|
||||
reason="stage_done",
|
||||
)
|
||||
)
|
||||
|
||||
first_agent_audio_sent.set()
|
||||
await asyncio.sleep(0.03)
|
||||
await task
|
||||
|
||||
assert ws.text_messages == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "stage_done",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed is True
|
||||
assert timeline.events == [
|
||||
("mock_stop_after_first_audio_armed", {"silence_ms": 10, "reason": "stage_done"}),
|
||||
("mock_stop_after_first_audio_sent", {"reason": "stage_done"}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
201
tests/ws_gateway/test_session_bootstrap.py
Normal file
201
tests/ws_gateway/test_session_bootstrap.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.ws_gateway.session_bootstrap import build_bridge_session_bootstrap
|
||||
from app.ws_gateway.session_start import StartSessionContext
|
||||
|
||||
|
||||
def _build_start_ctx(**data_overrides: str) -> StartSessionContext:
|
||||
data = {
|
||||
"agent": "oferta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440100",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-1234567890",
|
||||
**data_overrides,
|
||||
}
|
||||
payload = {
|
||||
"type": "start",
|
||||
"data": data,
|
||||
"callConfig": {
|
||||
"tts": {"provider": "azure"},
|
||||
},
|
||||
}
|
||||
return StartSessionContext(
|
||||
payload=payload,
|
||||
data=data,
|
||||
agent_data=dict(data.get("agentData") or {}),
|
||||
audio_format={},
|
||||
call_config=payload["callConfig"],
|
||||
session_data={"gsm": data.get("gsm", ""), "msisdn": data.get("gsm", ""), **data},
|
||||
intro="intro",
|
||||
nudge="nudge",
|
||||
)
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_uses_protocol_from_data_and_builds_dispatch_metadata(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
data = {
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511888888888",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-321",
|
||||
"callIdGed": "GED-321",
|
||||
"protocolo": "PRT-321",
|
||||
"agentData": {
|
||||
"idFatura": "fat-999",
|
||||
},
|
||||
}
|
||||
start_ctx = StartSessionContext(
|
||||
payload={
|
||||
"type": "start",
|
||||
"data": data,
|
||||
"callConfig": {
|
||||
"tts": {"provider": "azure"},
|
||||
},
|
||||
},
|
||||
data=data,
|
||||
agent_data={"idFatura": "fat-999"},
|
||||
audio_format={},
|
||||
call_config={"tts": {"provider": "azure"}},
|
||||
session_data={"gsm": "5511888888888", "msisdn": "5511888888888", **data},
|
||||
intro="intro",
|
||||
nudge="nudge",
|
||||
)
|
||||
|
||||
uuids = iter(
|
||||
[
|
||||
type("U", (), {"hex": "room123456789"})(),
|
||||
type("U", (), {"hex": "ident12345678"})(),
|
||||
]
|
||||
)
|
||||
token_calls: list[tuple[str, str]] = []
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda identity, room_name: token_calls.append((identity, room_name)) or "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: next(uuids),
|
||||
)
|
||||
|
||||
assert bootstrap.call_id_ged == "GED-321"
|
||||
assert bootstrap.room_name == "custom-room-room1234"
|
||||
assert bootstrap.identity == "ws-bridge-dev-ident123"
|
||||
assert bootstrap.token == "jwt-token"
|
||||
assert bootstrap.protocol == "PRT-321"
|
||||
assert bootstrap.phone_number == "5511888888888"
|
||||
assert bootstrap.remote_agent_context["agent"] == "conta"
|
||||
assert bootstrap.remote_agent_context["ID_FATURA"] == "fat-999"
|
||||
assert bootstrap.remote_agent_context["callIdGed"] == "GED-321"
|
||||
assert bootstrap.call_config["tts"]["provider"] == "azure"
|
||||
assert bootstrap.dispatch_metadata["bridge_identity"] == "ws-bridge-dev-ident123"
|
||||
assert bootstrap.dispatch_metadata["protocol"] == "PRT-321"
|
||||
assert bootstrap.dispatch_metadata["call_id_ged"] == "GED-321"
|
||||
assert bootstrap.dispatch_metadata["agent_starts_conversation"] is True
|
||||
assert bootstrap.dispatch_metadata["remote_agent"]["ID_FATURA"] == "fat-999"
|
||||
assert bootstrap.dispatch_metadata["timeline_id"] == "custom-room-room1234"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "550e8400-e29b-41d4-a716-446655440101"
|
||||
assert token_calls == [("ws-bridge-dev-ident123", "custom-room-room1234")]
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_falls_back_to_router_call_key_when_protocol_is_missing(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx()
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: type("U", (), {"hex": "room123456789"})(),
|
||||
)
|
||||
|
||||
assert bootstrap.protocol == "RCK-001"
|
||||
assert bootstrap.dispatch_metadata["protocol"] == "RCK-001"
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_falls_back_to_generated_protocol_and_phone(monkeypatch) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx(routerCallKey="", gsm="")
|
||||
start_ctx = StartSessionContext(
|
||||
payload=start_ctx.payload,
|
||||
data=start_ctx.data,
|
||||
agent_data=start_ctx.agent_data,
|
||||
audio_format=start_ctx.audio_format,
|
||||
call_config=start_ctx.call_config,
|
||||
session_data={**start_ctx.session_data, "phone": "5511777777777"},
|
||||
intro=start_ctx.intro,
|
||||
nudge=start_ctx.nudge,
|
||||
)
|
||||
uuids = iter(
|
||||
[
|
||||
type("U", (), {"hex": "roomabcdef1234"})(),
|
||||
type("U", (), {"hex": "identfedcba98"})(),
|
||||
]
|
||||
)
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="qa",
|
||||
livekit_room="",
|
||||
default_protocol="",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1712345678,
|
||||
uuid_factory=lambda: next(uuids),
|
||||
)
|
||||
|
||||
assert bootstrap.room_name == "qa-room-roomabcd"
|
||||
assert bootstrap.identity == "ws-bridge-qa-identfed"
|
||||
assert bootstrap.protocol == "WS-GED-1234567890-1712345678"
|
||||
assert bootstrap.phone_number == "5511777777777"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "550e8400-e29b-41d4-a716-446655440100"
|
||||
assert bootstrap.dispatch_metadata["agent_starts_conversation"] is True
|
||||
assert bootstrap.dispatch_metadata["session_data"]["phone"] == "5511777777777"
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_uses_explicit_session_id_without_protocol_fallback(monkeypatch) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap.session_id")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx(protocolo="PRT-001", session_id="sess-001")
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: type("U", (), {"hex": "room123456789"})(),
|
||||
)
|
||||
|
||||
assert bootstrap.protocol == "PRT-001"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "sess-001"
|
||||
365
tests/ws_gateway/test_session_lifecycle.py
Normal file
365
tests/ws_gateway/test_session_lifecycle.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ws_gateway.session_lifecycle import (
|
||||
RoomLifecycleState,
|
||||
register_room_lifecycle_handlers,
|
||||
watch_call_done,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRoom:
|
||||
def __init__(self) -> None:
|
||||
self.handlers = {}
|
||||
|
||||
def on(self, event_name: str):
|
||||
def _decorator(fn):
|
||||
self.handlers[event_name] = fn
|
||||
return fn
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
class _FakeTimeline:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def emit(self, event: str, **fields) -> None:
|
||||
self.events.append((event, fields))
|
||||
|
||||
|
||||
class _FakeParticipant:
|
||||
def __init__(self, identity: str) -> None:
|
||||
self.identity = identity
|
||||
|
||||
|
||||
class _FakePacket:
|
||||
def __init__(self, topic: str, data) -> None:
|
||||
self.topic = topic
|
||||
self.data = data
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict] = []
|
||||
self.closed = 0
|
||||
self.close_calls: list[dict] = []
|
||||
|
||||
async def send_text(self, payload: str) -> None:
|
||||
self.sent.append(json.loads(payload))
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
self.closed += 1
|
||||
self.close_calls.append({"code": code, "reason": reason})
|
||||
|
||||
|
||||
def test_register_room_lifecycle_handlers_updates_state_and_emits_timeline() -> None:
|
||||
room = _FakeRoom()
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_lifecycle.register")
|
||||
call_done = asyncio.Event()
|
||||
agent_ready = asyncio.Event()
|
||||
state = RoomLifecycleState()
|
||||
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
dispatch_started_at=100.0,
|
||||
agent_ready=agent_ready,
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
is_agent=lambda participant: str(getattr(participant, "identity", "")).startswith("agent"),
|
||||
monotonic_fn=lambda: 100.123,
|
||||
)
|
||||
|
||||
participant = _FakeParticipant("agent-001")
|
||||
room.handlers["participant_connected"](participant)
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.stage", b'{"stage":"DONE","reason":"completed"}')
|
||||
)
|
||||
room.handlers["participant_disconnected"](participant)
|
||||
|
||||
assert agent_ready.is_set()
|
||||
assert call_done.is_set()
|
||||
assert state.agent_participant is None
|
||||
assert state.done_payload == {"stage": "DONE", "reason": "completed"}
|
||||
assert timeline.events == [
|
||||
("agent_join", {"agent_identity": "agent-001", "dispatch_dt_ms": 123}),
|
||||
(
|
||||
"done_packet_received",
|
||||
{"reason": "completed", "payload": {"stage": "DONE", "reason": "completed"}},
|
||||
),
|
||||
("agent_leave", {"agent_identity": "agent-001"}),
|
||||
]
|
||||
|
||||
|
||||
def test_agent_disconnect_without_done_notifies_recovery_handler() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_lifecycle.agent_disconnect")
|
||||
call_done = asyncio.Event()
|
||||
agent_ready = asyncio.Event()
|
||||
state = RoomLifecycleState()
|
||||
calls: list[dict] = []
|
||||
|
||||
async def _on_agent_disconnect(agent_identity: str, generation: int) -> None:
|
||||
calls.append({"agent_identity": agent_identity, "generation": generation})
|
||||
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
dispatch_started_at=100.0,
|
||||
agent_ready=agent_ready,
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
is_agent=lambda participant: str(getattr(participant, "identity", "")).startswith("agent"),
|
||||
on_agent_disconnect=_on_agent_disconnect,
|
||||
monotonic_fn=lambda: 100.123,
|
||||
)
|
||||
|
||||
participant = _FakeParticipant("agent-001")
|
||||
room.handlers["participant_connected"](participant)
|
||||
connected_generation = state.agent_generation
|
||||
room.handlers["participant_disconnected"](participant)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert state.agent_participant is None
|
||||
assert not state.agent_connected.is_set()
|
||||
assert state.agent_generation == connected_generation + 1
|
||||
assert calls == [
|
||||
{"agent_identity": "agent-001", "generation": connected_generation + 1}
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_sends_stop_and_closes_socket() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "finished"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "finished",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
assert ws.close_calls == [{"code": 1000, "reason": "call_done:finished"}]
|
||||
assert timeline.events == [
|
||||
("call_done", {"reason": "finished"}),
|
||||
("stop_sent", {"reason": "finished"}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_agent_debug_packet_is_forwarded_when_enabled() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
ws = _FakeWebSocket()
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logging.getLogger("test.session_lifecycle.debug"),
|
||||
timeline=_FakeTimeline(),
|
||||
room_name="room-load-1",
|
||||
protocol="LOAD-1",
|
||||
dispatch_started_at=1.0,
|
||||
agent_ready=asyncio.Event(),
|
||||
call_done=asyncio.Event(),
|
||||
state=RoomLifecycleState(),
|
||||
is_agent=lambda _participant: False,
|
||||
ws=ws,
|
||||
debug_events_enabled=True,
|
||||
)
|
||||
event = {
|
||||
"type": "debug_event",
|
||||
"version": 1,
|
||||
"source": "agent",
|
||||
"event": "agent.speech.finished",
|
||||
"data": {"text": "Resposta", "stage": "ARGUMENTATION"},
|
||||
}
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.debug", json.dumps(event).encode("utf-8"))
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert ws.sent == [{**event, "stress_test": True}]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_agent_debug_packet_is_not_forwarded_when_disabled() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
ws = _FakeWebSocket()
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logging.getLogger("test.session_lifecycle.debug.disabled"),
|
||||
timeline=_FakeTimeline(),
|
||||
room_name="room-regular-1",
|
||||
protocol="REGULAR-1",
|
||||
dispatch_started_at=1.0,
|
||||
agent_ready=asyncio.Event(),
|
||||
call_done=asyncio.Event(),
|
||||
state=RoomLifecycleState(),
|
||||
is_agent=lambda _participant: False,
|
||||
ws=ws,
|
||||
debug_events_enabled=False,
|
||||
)
|
||||
event = {
|
||||
"type": "debug_event",
|
||||
"version": 1,
|
||||
"source": "agent",
|
||||
"event": "stt.completed",
|
||||
"data": {"duration_ms": 100},
|
||||
}
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.debug", json.dumps(event).encode("utf-8"))
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert ws.sent == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_maps_no_user_response_to_long_silence_status() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.long_silence")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "no_user_response"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_silencio_longo",
|
||||
"reason": "no_user_response",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_uses_default_status_when_reason_is_not_exact_match() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.default")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "finished"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "finished",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_preserves_terminal_status_from_agent_payload() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.custom")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(
|
||||
done_payload={
|
||||
"stage": "DONE",
|
||||
"status": "stop_agent_backend_unavailable",
|
||||
"reason": "resource_unhealthy",
|
||||
"resource": "agent_backend",
|
||||
"failed_resources": ["agent_backend"],
|
||||
"phase": "in_session",
|
||||
}
|
||||
)
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_agent_backend_unavailable",
|
||||
"reason": "resource_unhealthy",
|
||||
"resource": "agent_backend",
|
||||
"failed_resources": ["agent_backend"],
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
385
tests/ws_gateway/test_session_start.py
Normal file
385
tests/ws_gateway/test_session_start.py
Normal file
@@ -0,0 +1,385 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ws_gateway.session_start import (
|
||||
build_remote_agent_context,
|
||||
parse_start_payload,
|
||||
parse_transferencia_session_id_payload,
|
||||
recv_start_message,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_builds_context_from_data() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
"audioFormat": {"encoding": "linear16"},
|
||||
"callConfig": {"agentBackend": "remote_ws"},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.data["agent"] == "oferta"
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert ctx.data["protocolo"] == "PRT-001"
|
||||
assert ctx.agent_data == {}
|
||||
assert ctx.audio_format == {"encoding": "linear16"}
|
||||
assert ctx.call_config == {"agentBackend": "remote_ws"}
|
||||
assert ctx.session_data["msisdn"] == "5511999999999"
|
||||
assert ctx.session_data["audioFormat"] == {"encoding": "linear16"}
|
||||
assert ctx.agent_starts_conversation is True
|
||||
assert ctx.intro == ""
|
||||
assert ctx.nudge == "Alô, você ainda está aí?"
|
||||
|
||||
|
||||
def test_parse_start_payload_lets_conta_agent_own_first_message() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"agentData": {
|
||||
"idFatura": "fat-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.agent_starts_conversation is True
|
||||
assert ctx.intro == ""
|
||||
assert ctx.nudge == "Alô, você ainda está aí?"
|
||||
|
||||
|
||||
def test_parse_start_payload_rejects_non_start_message() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
parse_start_payload({"type": "ping"})
|
||||
|
||||
|
||||
def test_parse_start_payload_rejects_invalid_fake_agent_responses() -> None:
|
||||
with pytest.raises(RuntimeError, match="callConfig invalido"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260819",
|
||||
"routerCallKey": "RCK-1",
|
||||
"callIdGed": "GED-1",
|
||||
"session_id": "session-1",
|
||||
"agentData": {"idFatura": "FAT-1"},
|
||||
},
|
||||
"callConfig": {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {"responses": "resposta curta;outra curta"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_id_fatura_for_conta() -> None:
|
||||
with pytest.raises(RuntimeError, match="idFatura"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_protocolo_for_oferta() -> None:
|
||||
with pytest.raises(RuntimeError, match="protocolo"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_session_id() -> None:
|
||||
with pytest.raises(RuntimeError, match="session_id"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_accepts_camel_session_id_and_normalizes() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440004"
|
||||
|
||||
|
||||
def test_parse_transferencia_session_id_payload_extracts_session_id() -> None:
|
||||
session_id = parse_transferencia_session_id_payload(
|
||||
{
|
||||
"type": "transferencia_session_id",
|
||||
"data": {"session_id": "550e8400-e29b-41d4-a716-446655440005"},
|
||||
}
|
||||
)
|
||||
|
||||
assert session_id == "550e8400-e29b-41d4-a716-446655440005"
|
||||
|
||||
|
||||
def test_recv_start_message_uses_transferencia_session_id_before_start() -> None:
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.messages = [
|
||||
{
|
||||
"type": "transferencia_session_id",
|
||||
"data": {"session_id": "550e8400-e29b-41d4-a716-446655440006"},
|
||||
},
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async def receive_text(self) -> str:
|
||||
return json.dumps(self.messages.pop(0))
|
||||
|
||||
ctx = asyncio.run(recv_start_message(FakeWebSocket()))
|
||||
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440006"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_uses_camel_case_payload() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "contas",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
},
|
||||
agent_data={"idFatura": "fat-123"},
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"agent": "conta",
|
||||
"RouterCallKeyDay": "20260406",
|
||||
"RouterCallKey": "RCK-123",
|
||||
"ANI": "5511888888888",
|
||||
"GSM": "5511777777777",
|
||||
"msisdn": "5511777777777",
|
||||
"callIdGed": "GED-777",
|
||||
"ID_FATURA": "fat-123",
|
||||
"current_invoice_number": "fat-123",
|
||||
}
|
||||
|
||||
|
||||
def test_build_remote_agent_context_for_conta_forwards_protocol_id() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocol_id": "PRT-777",
|
||||
},
|
||||
agent_data={"idFatura": "fat-123"},
|
||||
)
|
||||
|
||||
assert context["protocol_id"] == "PRT-777"
|
||||
assert context["protocolo"] == "PRT-777"
|
||||
assert context["protocolNumber"] == "PRT-777"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_for_oferta_uses_protocolo_and_never_fatura() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "ofertas",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
"assetId": "asset-777",
|
||||
"channelId": "ura",
|
||||
},
|
||||
agent_data={"idFatura": "fat-should-not-leak"},
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"agent": "oferta",
|
||||
"RouterCallKeyDay": "20260406",
|
||||
"RouterCallKey": "RCK-123",
|
||||
"ANI": "5511888888888",
|
||||
"GSM": "5511777777777",
|
||||
"msisdn": "5511777777777",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
"protocolNumber": "PRT-777",
|
||||
"channelId": "ura",
|
||||
"assetId": "asset-777",
|
||||
}
|
||||
|
||||
|
||||
def test_build_remote_agent_context_only_forwards_explicit_session_id() -> None:
|
||||
without_session = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "oferta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
with_session = build_remote_agent_context(
|
||||
data={
|
||||
**without_session,
|
||||
"agent": "oferta",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"sessionId": "sess-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
|
||||
assert "sessionId" not in without_session
|
||||
assert "session_id" not in without_session
|
||||
assert with_session["sessionId"] == "sess-777"
|
||||
assert with_session["session_id"] == "sess-777"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_forwards_explicit_message_id() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"messageId": "msg-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
|
||||
assert context["message_id"] == "msg-777"
|
||||
|
||||
|
||||
def test_parse_start_payload_does_not_enable_debug_events_from_client_flag_alone() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"debug": {"events": True},
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "load-debug-1",
|
||||
"routerCallKeyDay": "20260817",
|
||||
"routerCallKey": "LOAD-1",
|
||||
"callIdGed": "GED-LOAD-1",
|
||||
"agentData": {"idFatura": "fat-load-1"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.stress_test is False
|
||||
assert ctx.debug_events_enabled is False
|
||||
|
||||
|
||||
def test_parse_start_payload_enables_debug_events_for_scripted_fake_agent() -> None:
|
||||
response = "Esta resposta simulada possui tamanho suficiente para validar o contrato."
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "stress-debug-1",
|
||||
"routerCallKeyDay": "20260817",
|
||||
"routerCallKey": "STRESS-1",
|
||||
"callIdGed": "GED-STRESS-1",
|
||||
"agentData": {"idFatura": "fat-stress-1"},
|
||||
},
|
||||
"callConfig": {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {"responses": f"{response};{response}"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.stress_test is True
|
||||
assert ctx.debug_events_enabled is True
|
||||
56
tests/ws_gateway/test_voice_client.py
Normal file
56
tests/ws_gateway/test_voice_client.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VOICE_CLIENT_HTML = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "app"
|
||||
/ "ws_gateway"
|
||||
/ "voice_client.html"
|
||||
)
|
||||
|
||||
|
||||
def test_voice_client_exposes_required_protocolo_for_oferta() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "oferta: [" in html
|
||||
assert 'key: "protocolo"' in html
|
||||
assert 'inputId: "agentFieldProtocolo"' in html
|
||||
assert 'label: "PROTOCOLO"' in html
|
||||
assert 'required: true' in html
|
||||
assert 'target: "data"' in html
|
||||
|
||||
|
||||
def test_voice_client_keeps_id_fatura_inside_agent_data() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "const agentData = {};" in html
|
||||
assert 'key: "idFatura"' in html
|
||||
assert 'data.agentData = agentData;' in html
|
||||
|
||||
|
||||
def test_voice_client_exposes_required_protocol_id_for_conta() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "conta: [" in html
|
||||
assert 'key: "protocol_id"' in html
|
||||
assert 'inputId: "agentFieldProtocolId"' in html
|
||||
assert 'label: "PROTOCOL_ID"' in html
|
||||
assert 'target: "data"' in html
|
||||
|
||||
|
||||
def test_voice_client_sends_session_id_in_start_data() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="sessionId"' in html
|
||||
assert "function defaultSessionId()" in html
|
||||
assert "session_id: sessionId" in html
|
||||
|
||||
|
||||
def test_voice_client_guards_stale_microphone_streams() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "function stopMicStreaming()" in html
|
||||
assert "const wsAtStart = state.ws;" in html
|
||||
assert "state.ws !== wsAtStart" in html
|
||||
assert "mediaStream.getTracks().forEach((track) => track.stop());" in html
|
||||
Reference in New Issue
Block a user