first commit

This commit is contained in:
2026-08-21 08:37:51 -03:00
commit 881a99b0a8
214 changed files with 61407 additions and 0 deletions

View File

@@ -0,0 +1 @@

View 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

View 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

View 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()

File diff suppressed because it is too large Load Diff

View 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())

View 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"

View 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())

View 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

View 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