feat: integrate OCI agent streaming and web voice client

This commit is contained in:
2026-10-13 19:49:27 +00:00
parent 088473a0c8
commit 19a415daae
19 changed files with 2034 additions and 23 deletions

View File

@@ -1698,3 +1698,105 @@ def test_websocket_registration_failure_awaits_task_before_abort():
assert len(tasks) == 1
assert tasks[0].done()
assert abort_checked
def test_websocket_streaming_agent_and_tts():
"""
Verify streaming Agent text and TTS audio through WebSocket.
The first audio segment must be delivered before the
Agent's complete response.
"""
created_stt.clear()
class StreamingTestAgent:
async def stream(self, session_id, turn_id, text):
yield "Olá, como posso ajudar?"
yield " Estou à disposição."
class StreamingTestTTS:
async def synthesize(self, text):
yield text.encode("utf-8")
with patch.object(
main,
"create_agent_adapter",
return_value=StreamingTestAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=StreamingTestTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
stt = created_stt[0]
stt.emit_final_now("Olá")
received_text = []
received_audio = []
received_complete_response = False
# The streaming path emits two deltas and
# two TTS audio segments.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
received_audio.append(message["bytes"])
else:
import json
event = json.loads(message["text"])
if event["type"] == "agent.delta":
received_text.append(event["text"])
elif event["type"] == "agent.response":
received_complete_response = True
elif event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
if received_complete_response and len(received_audio) >= 2:
break
assert "".join(received_text) == (
"Olá, como posso ajudar?"
" Estou à disposição."
)
assert received_audio
assert received_complete_response
websocket.send_json(
{
"type": "audio.stop",
"turn_id": turn_id,
}
)
turn_ended = websocket.receive_json()
assert turn_ended["type"] == "turn.ended"

View File

@@ -0,0 +1,132 @@
import asyncio
import threading
from types import SimpleNamespace
import pytest
from voice_assistant.agent.oci_connection import open_oci_stream
class FakeStream:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
@pytest.mark.anyio
async def test_open_oci_stream_success():
stream = FakeStream()
response = SimpleNamespace(data=stream)
result = await open_oci_stream(lambda: response)
assert result is response
assert stream.closed is False
stream.close()
@pytest.mark.anyio
async def test_open_oci_stream_closes_late_response():
started = threading.Event()
release = threading.Event()
finished = threading.Event()
stream = FakeStream()
response = SimpleNamespace(data=stream)
def open_connection():
started.set()
release.wait(timeout=5.0)
try:
return response
finally:
finished.set()
task = asyncio.create_task(
open_oci_stream(open_connection)
)
try:
assert await asyncio.to_thread(
started.wait,
1.0,
)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
assert await asyncio.to_thread(
finished.wait,
1.0,
)
# The worker may still be completing its cleanup.
for _ in range(100):
if stream.closed:
break
await asyncio.sleep(0.001)
assert stream.closed is True
@pytest.mark.anyio
async def test_open_oci_stream_cancellation_during_handoff():
"""
Verify that a response is not abandoned when cancellation
happens around completion of the opening operation.
"""
stream = FakeStream()
response = SimpleNamespace(data=stream)
opened = threading.Event()
release = threading.Event()
def open_connection():
opened.set()
release.wait(timeout=5.0)
return response
task = asyncio.create_task(
open_oci_stream(open_connection)
)
try:
assert await asyncio.to_thread(
opened.wait,
1.0,
)
release.set()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
for _ in range(100):
if stream.closed:
break
await asyncio.sleep(0.001)
assert stream.closed is True
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(
task,
return_exceptions=True,
)

View File

@@ -20,6 +20,7 @@ import pytest
from pydantic import ValidationError
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
from voice_assistant.audio_gateway.app.protocol import (
AgentDelta,
AgentResponse,
AudioChunk,
AudioStart,
@@ -38,6 +39,7 @@ from voice_assistant.audio_gateway.app.protocol import (
)
@pytest.mark.parametrize(
("model", "expected_type"),
[
@@ -101,6 +103,7 @@ def test_session_ready_output():
(TurnStarted, "turn.started"),
(STTPartial, "stt.partial"),
(STTFinal, "stt.final"),
(AgentDelta, "agent.delta"),
(AgentResponse, "agent.response"),
(TurnEnded, "turn.ended"),
(Interrupted, "interrupted"),
@@ -115,7 +118,7 @@ def test_turn_output_event_types(
"turn_id": 1,
}
if model in (STTPartial, STTFinal, AgentResponse):
if model in (STTPartial, STTFinal, AgentDelta, AgentResponse):
data["text"] = "test"
event = model(**data)
@@ -168,3 +171,30 @@ def test_error_event_defaults():
assert event.retryable is False
assert event.details == {}
def test_agent_delta_serialization():
event = AgentDelta(
session_id="session-1",
turn_id=7,
text=" mundo",
)
assert event.model_dump() == {
"type": "agent.delta",
"session_id": "session-1",
"turn_id": 7,
"text": " mundo",
}
def test_agent_delta_rejects_wrong_type():
with pytest.raises(ValidationError):
AgentDelta.model_validate(
{
"type": "agent.response",
"session_id": "session-1",
"turn_id": 7,
"text": "Olá",
}
)

View File

@@ -0,0 +1,58 @@
from voice_assistant.agent.text_chunker import TextChunker
def test_chunker_emits_sentence():
chunker = TextChunker(min_chars=5)
assert chunker.feed("Olá, tudo bem?") == [
"Olá, tudo bem?"
]
def test_chunker_handles_incremental_deltas():
chunker = TextChunker(min_chars=5)
assert chunker.feed("Olá,") == []
assert chunker.feed(" tudo") == []
assert chunker.feed(" bem?") == [
"Olá, tudo bem?"
]
def test_chunker_preserves_remaining_text():
chunker = TextChunker(min_chars=5)
assert chunker.feed("Olá, tudo bem? Posso ajudar") == [
"Olá, tudo bem?"
]
assert chunker.flush() == "Posso ajudar"
def test_chunker_breaks_long_text():
chunker = TextChunker(
min_chars=5,
max_chars=20,
)
segments = chunker.feed(
"Esta é uma frase bastante longa sem pontuação"
)
assert segments
assert all(
len(segment) <= 20
for segment in segments
)
def test_chunker_rejects_invalid_limits():
try:
TextChunker(min_chars=20, max_chars=10)
except ValueError:
pass
else:
raise AssertionError(
"Expected ValueError for invalid limits."
)

View File

@@ -18,10 +18,17 @@ Non-responsibilities:
import pytest
import asyncio
from contextlib import aclosing
from voice_assistant.voice_pipeline import (
PipelineAudioChunk,
PipelineAgentResponse,
PipelineTextDelta,
VoicePipeline,
)
from voice_assistant.audio_gateway.app.session import SessionState
from voice_assistant.turn_manager import TurnManager, TurnState
from voice_assistant.voice_pipeline import VoicePipeline
from voice_assistant.agent.mock import MockAgentAdapter
from voice_assistant.tts.mock import MockTTSAdapter
@@ -818,3 +825,268 @@ async def test_pipeline_rejects_new_turn_while_previous_task_runs():
assert second_turn.turn_id == 2
assert session.active_turn_id == 2
@pytest.mark.anyio
async def test_stream_turn_response_produces_audio_before_agent_finishes():
"""
Verify that TTS audio can be delivered while the Agent
is still generating its response.
"""
class StreamingAgent:
def __init__(self):
self.first_delta_sent = asyncio.Event()
self.release_generation = asyncio.Event()
self.finished = False
async def stream(self, session_id, turn_id, text):
yield "Olá, como posso ajudar?"
self.first_delta_sent.set()
await self.release_generation.wait()
yield " Estou à disposição."
self.finished = True
class StreamingTTS:
async def synthesize(self, text):
yield text.encode("utf-8")
agent = StreamingAgent()
pipeline = VoicePipeline(
session=SessionState(session_id="stream-session"),
turn_manager=TurnManager(),
agent=agent,
tts=StreamingTTS(),
)
turn = pipeline.start_turn()
pipeline.on_final(turn.turn_id, "Olá")
first_audio_received = asyncio.Event()
received_events = []
async def consume():
async with aclosing(
pipeline.stream_turn_response(
session_id="stream-session",
turn_id=turn.turn_id,
text="Olá",
)
) as stream:
async for event in stream:
received_events.append(event)
if isinstance(event, PipelineAudioChunk):
first_audio_received.set()
task = asyncio.create_task(consume())
try:
await asyncio.wait_for(
first_audio_received.wait(),
timeout=2.0,
)
assert agent.finished is False
assert any(
isinstance(event, PipelineTextDelta)
for event in received_events
)
assert any(
isinstance(event, PipelineAudioChunk)
for event in received_events
)
agent.release_generation.set()
await asyncio.wait_for(task, timeout=2.0)
assert agent.finished is True
assert any(
isinstance(event, PipelineAgentResponse)
for event in received_events
)
assert (
pipeline.turn_manager.active_turn.state
== TurnState.COMPLETED
)
finally:
agent.release_generation.set()
if not task.done():
task.cancel()
await asyncio.gather(
task,
return_exceptions=True,
)
@pytest.mark.anyio
async def test_stream_turn_response_propagates_agent_failure():
class FailingStreamingAgent:
async def stream(self, session_id, turn_id, text):
yield "Olá, como posso ajudar?"
raise RuntimeError("Simulated OCI streaming failure.")
class StreamingTTS:
async def synthesize(self, text):
yield text.encode("utf-8")
pipeline = VoicePipeline(
session=SessionState(session_id="agent-failure-session"),
turn_manager=TurnManager(),
agent=FailingStreamingAgent(),
tts=StreamingTTS(),
)
turn = pipeline.start_turn()
pipeline.on_final(turn.turn_id, "Olá")
async def consume():
async with aclosing(
pipeline.stream_turn_response(
session_id="agent-failure-session",
turn_id=turn.turn_id,
text="Olá",
)
) as stream:
return [event async for event in stream]
with pytest.raises(ExceptionGroup) as exc_info:
await asyncio.wait_for(consume(), timeout=2.0)
assert any(
isinstance(error, RuntimeError)
and "Simulated OCI streaming failure" in str(error)
for error in exc_info.value.exceptions
)
@pytest.mark.anyio
async def test_stream_turn_response_cancels_agent_on_tts_failure():
class BlockingStreamingAgent:
def __init__(self):
self.cancelled = asyncio.Event()
async def stream(self, session_id, turn_id, text):
try:
yield "Olá, como posso ajudar?"
await asyncio.Event().wait()
finally:
self.cancelled.set()
class FailingTTS:
async def synthesize(self, text):
raise RuntimeError("Simulated TTS failure.")
yield b"" # Keep this method an async generator.
agent = BlockingStreamingAgent()
pipeline = VoicePipeline(
session=SessionState(session_id="tts-failure-session"),
turn_manager=TurnManager(),
agent=agent,
tts=FailingTTS(),
)
turn = pipeline.start_turn()
pipeline.on_final(turn.turn_id, "Olá")
async def consume():
async with aclosing(
pipeline.stream_turn_response(
session_id="tts-failure-session",
turn_id=turn.turn_id,
text="Olá",
)
) as stream:
async for _ in stream:
pass
with pytest.raises(ExceptionGroup) as exc_info:
await asyncio.wait_for(consume(), timeout=2.0)
assert any(
isinstance(error, RuntimeError)
and "Simulated TTS failure" in str(error)
for error in exc_info.value.exceptions
)
assert agent.cancelled.is_set()
@pytest.mark.anyio
async def test_stream_turn_response_external_cancellation():
class BlockingAgent:
def __init__(self):
self.closed = asyncio.Event()
async def stream(self, session_id, turn_id, text):
try:
yield "Olá, como posso ajudar?"
await asyncio.Event().wait()
finally:
self.closed.set()
class BlockingTTS:
def __init__(self):
self.started = asyncio.Event()
self.closed = asyncio.Event()
async def synthesize(self, text):
try:
self.started.set()
await asyncio.Event().wait()
yield b"audio"
finally:
self.closed.set()
agent = BlockingAgent()
tts = BlockingTTS()
pipeline = VoicePipeline(
session=SessionState(session_id="cancel-session"),
turn_manager=TurnManager(),
agent=agent,
tts=tts,
)
turn = pipeline.start_turn()
pipeline.on_final(turn.turn_id, "Olá")
async def consume():
async with aclosing(
pipeline.stream_turn_response(
session_id="cancel-session",
turn_id=turn.turn_id,
text="Olá",
)
) as stream:
async for _ in stream:
pass
task = asyncio.create_task(consume())
try:
await asyncio.wait_for(tts.started.wait(), timeout=2.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=2.0)
assert agent.closed.is_set()
assert tts.closed.is_set()
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)

32
tests/test_web_client.py Normal file
View File

@@ -0,0 +1,32 @@
from fastapi.testclient import TestClient
from voice_assistant.audio_gateway.app.main import app
client = TestClient(app)
def test_web_client_page():
response = client.get("/ui")
assert response.status_code == 200
assert "AgentVoice" in response.text
assert "/web/app.js" in response.text
def test_web_client_javascript():
response = client.get("/web/app.js")
assert response.status_code == 200
assert "audio.start" in response.text
assert "audio.stop" in response.text
assert "agent.delta" in response.text
def test_web_client_audio_worklet():
response = client.get("/web/pcm-worklet.js")
assert response.status_code == 200
assert "registerProcessor" in response.text
assert "pcm-input" in response.text