Stage 4: honest health, explicit error policy, event-bus integrity

Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:01:45 -04:00
parent 67a00defc3
commit 4048ce1db6
21 changed files with 492 additions and 357 deletions

View File

@@ -102,14 +102,9 @@ class TestEngineEventFunnel:
async def test_dtmf_and_trunk_events(self):
engine = _engine_on_loop()
_post_from_thread(engine, "incoming_invite", {
"leg_id": "leg_test4", "from_uri": "a", "to_uri": "b", "sdp": None,
})
await asyncio.sleep(0.05)
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
_post_from_thread(engine, "trunk_registered", {"registered": True})
await asyncio.sleep(0.05)
assert engine._legs["leg_test4"].dtmf_buffer == ["5"]
assert engine._trunk_registered is True

View File

@@ -0,0 +1,89 @@
"""
Engine-mode and event-bus-integrity tests.
The mock engine must be requested explicitly; a full subscriber queue
drops its oldest event but never loses the subscription; history is
replayable to late joiners.
"""
import asyncio
import pytest
from config import Settings
from core.event_bus import EventBus
from core.gateway import build_sip_engine
from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine
from models.events import EventType, GatewayEvent
def _noop(*args, **kwargs):
pass
class TestEngineMode:
def _build(self, settings: Settings):
return build_sip_engine(
settings,
MediaPipeline(sample_rate=16000),
on_leg_state_change=_noop,
on_device_registered=_noop,
on_incoming_call=_noop,
)
def test_mock_engine_only_when_asked(self):
settings = Settings(use_mock_sip=True)
assert isinstance(self._build(settings), MockSIPEngine)
def test_unconfigured_trunk_refuses_to_build(self):
settings = Settings(use_mock_sip=False)
settings.sip_trunk.host = "sip.yourprovider.com"
with pytest.raises(RuntimeError, match="not configured"):
self._build(settings)
def _event(i: int) -> GatewayEvent:
return GatewayEvent(
type=EventType.CALL_INITIATED,
call_id=f"call_{i}",
data={},
message=f"event {i}",
)
class TestEventBusIntegrity:
async def test_overflow_drops_oldest_keeps_subscription(self):
bus = EventBus()
sub = bus.subscribe(max_size=3)
for i in range(5):
await bus.publish(_event(i))
assert bus.subscriber_count == 1 # never evicted
assert sub.dropped == 2
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
assert [e.call_id for e in received] == ["call_2", "call_3", "call_4"]
async def test_replay_last_seeds_history(self):
bus = EventBus()
for i in range(10):
await bus.publish(_event(i))
sub = bus.subscribe(replay_last=3)
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
assert [e.call_id for e in received] == ["call_7", "call_8", "call_9"]
async def test_replay_respects_type_filter(self):
bus = EventBus()
await bus.publish(_event(1))
await bus.publish(GatewayEvent(
type=EventType.HUMAN_DETECTED, call_id="call_h", data={}, message="x"
))
sub = bus.subscribe(
event_types={EventType.HUMAN_DETECTED}, replay_last=5
)
event = await asyncio.wait_for(sub.get(), 1.0)
assert event.call_id == "call_h"
assert sub._queue.empty()

View File

@@ -24,10 +24,27 @@ class TestReceptionistDecide:
gw = _make_gateway()
svc = ReceptionistService(gw)
rule_action = RoutingAction(type=RoutingActionType.REJECT, message="nope")
decision = RoutingDecision(action=rule_action, reason="rule said so")
decision = RoutingDecision(
action=rule_action,
matched_rule_id="rule_1",
matched_rule_name="block",
reason="rule said so",
)
chosen = svc._decide(decision, {"recommended_action": "ring"})
assert chosen.type == RoutingActionType.REJECT
def test_matched_take_message_rule_beats_llm(self):
gw = _make_gateway()
svc = ReceptionistService(gw)
decision = RoutingDecision(
action=RoutingAction(type=RoutingActionType.TAKE_MESSAGE),
matched_rule_id="rule_2",
matched_rule_name="voicemail-hours",
reason="matched rule 'voicemail-hours'",
)
chosen = svc._decide(decision, {"recommended_action": "ring"})
assert chosen.type == RoutingActionType.TAKE_MESSAGE
def test_falls_back_to_llm_when_rule_is_default_take_message(self):
gw = _make_gateway()
svc = ReceptionistService(gw)

View File

@@ -123,14 +123,14 @@ class TestLLMClient:
assert result["key"] == "value"
@pytest.mark.asyncio
async def test_chat_http_error_returns_empty(self):
"""Verify HTTP errors return empty string gracefully."""
async def test_chat_error_raises(self):
"""Failures propagate to the caller (which owns the fallback)."""
client = self._make_client()
with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.side_effect = Exception("Connection refused")
result = await client.chat("test", system="test")
assert result == ""
with pytest.raises(Exception, match="Connection refused"):
await client.chat("test", system="test")
assert client._total_errors == 1
@pytest.mark.asyncio