feat: integrate OCI agent streaming and web voice client
This commit is contained in:
@@ -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"
|
||||
|
||||
132
tests/test_oci_connection.py
Normal file
132
tests/test_oci_connection.py
Normal 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,
|
||||
)
|
||||
|
||||
@@ -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á",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
58
tests/test_text_chunker.py
Normal file
58
tests/test_text_chunker.py
Normal 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."
|
||||
)
|
||||
|
||||
@@ -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
32
tests/test_web_client.py
Normal 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
|
||||
Reference in New Issue
Block a user