517 lines
17 KiB
Python
517 lines
17 KiB
Python
"""Tests for SQ/EQ integration into app.py and portal.py (Phase 4)
|
|
|
|
Test scenarios:
|
|
- app.py initializes event_queue and submission_queue on app.state
|
|
- portal WebSocket emits correct event types to EQ
|
|
- EQ emit failure does not break WebSocket flow
|
|
- Events contain correct session_id and task_id
|
|
- _emit_event_safe helper handles None EQ gracefully
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from agentkit.core.event_queue import EventQueue, SubmissionQueue
|
|
from agentkit.core.protocol import (
|
|
Event,
|
|
TaskEventType,
|
|
TurnEventType,
|
|
)
|
|
from agentkit.llm.gateway import LLMGateway
|
|
from agentkit.llm.protocol import LLMResponse, TokenUsage
|
|
from agentkit.server.app import create_app
|
|
from agentkit.server.routes.portal import _emit_event_safe
|
|
from agentkit.skills.registry import SkillRegistry
|
|
from agentkit.tools.registry import ToolRegistry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_gateway():
|
|
gateway = LLMGateway()
|
|
mock_provider = AsyncMock()
|
|
mock_provider.chat.return_value = LLMResponse(
|
|
content="Hello from LLM",
|
|
model="test-model",
|
|
usage=TokenUsage(prompt_tokens=10, completion_tokens=20),
|
|
)
|
|
gateway.register_provider("test", mock_provider)
|
|
return gateway
|
|
|
|
|
|
@pytest.fixture
|
|
def skill_registry():
|
|
return SkillRegistry()
|
|
|
|
|
|
@pytest.fixture
|
|
def tool_registry():
|
|
return ToolRegistry()
|
|
|
|
|
|
@pytest.fixture
|
|
def app(mock_llm_gateway, skill_registry, tool_registry):
|
|
return create_app(
|
|
llm_gateway=mock_llm_gateway,
|
|
skill_registry=skill_registry,
|
|
tool_registry=tool_registry,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app.py initialization tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppInitialization:
|
|
"""Verify app.py initializes SQ/EQ on app.state."""
|
|
|
|
def test_app_has_event_queue(self, app):
|
|
"""app.state should have an EventQueue instance."""
|
|
assert hasattr(app.state, "event_queue")
|
|
assert isinstance(app.state.event_queue, EventQueue)
|
|
|
|
def test_app_has_submission_queue(self, app):
|
|
"""app.state should have a SubmissionQueue instance."""
|
|
assert hasattr(app.state, "submission_queue")
|
|
assert isinstance(app.state.submission_queue, SubmissionQueue)
|
|
|
|
def test_event_queue_initially_open(self, app):
|
|
"""EventQueue should be open (not closed) after app creation."""
|
|
assert app.state.event_queue.is_closed is False
|
|
|
|
def test_submission_queue_initially_open(self, app):
|
|
"""SubmissionQueue should be open (not closed) after app creation."""
|
|
assert app.state.submission_queue.is_closed is False
|
|
|
|
def test_event_queue_supports_subscribers(self, app):
|
|
"""EventQueue should start with zero subscribers."""
|
|
assert app.state.event_queue.subscriber_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _emit_event_safe helper tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmitEventSafe:
|
|
"""Verify the _emit_event_safe helper handles edge cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_with_none_queue_is_noop(self):
|
|
"""Emitting to None queue should be a no-op (no exception)."""
|
|
# No exception should be raised
|
|
await _emit_event_safe(
|
|
None,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="test-task",
|
|
session_id="test-session",
|
|
data={"message": "hello"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_with_valid_queue_emits_event(self):
|
|
"""Emitting to a valid EventQueue should emit the event."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="test-task",
|
|
session_id="test-session",
|
|
data={"message": "hello"},
|
|
)
|
|
# Buffer should contain the event
|
|
assert len(eq._buffer) == 1
|
|
event = eq._buffer[0]
|
|
assert event.event_type == TaskEventType.TASK_CREATED
|
|
assert event.task_id == "test-task"
|
|
assert event.session_id == "test-session"
|
|
assert event.data == {"message": "hello"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_with_failing_queue_does_not_raise(self):
|
|
"""Emit failures should be swallowed (never raise to caller)."""
|
|
|
|
# Create a broken EventQueue that raises on emit
|
|
class BrokenEventQueue(EventQueue):
|
|
async def emit(self, event: Event) -> None:
|
|
raise RuntimeError("Simulated EQ failure")
|
|
|
|
broken_eq = BrokenEventQueue()
|
|
# Should NOT raise — error is caught and logged
|
|
await _emit_event_safe(
|
|
broken_eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="test-task",
|
|
session_id="test-session",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_with_none_data_uses_empty_dict(self):
|
|
"""Emitting with data=None should default to empty dict."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_STARTED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
data=None,
|
|
)
|
|
assert eq._buffer[0].data == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Portal WebSocket EQ integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPortalWebSocketEQIntegration:
|
|
"""Verify portal WebSocket emits events to EQ alongside WebSocket messages.
|
|
|
|
NOTE: Starlette TestClient's sync WS client has known issues with disconnect
|
|
handling (see test_portal_routes.py::TestPortalWebSocket for skipped tests).
|
|
These tests verify EQ integration through the helper function and app state
|
|
rather than full end-to-end WS flows.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_app_state_event_queue_is_functional(self, app):
|
|
"""The EventQueue on app.state should be functional (accept emits)."""
|
|
eq: EventQueue = app.state.event_queue
|
|
|
|
# Emit a task.created event as the WebSocket handler would
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="test-task-id",
|
|
session_id="test-session-id",
|
|
data={"message": "hello"},
|
|
)
|
|
|
|
# Verify the event was buffered
|
|
assert len(eq._buffer) == 1
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "task.created"
|
|
assert event.task_id == "test-task-id"
|
|
assert event.session_id == "test-session-id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eq_emit_failure_does_not_break_app(self, app):
|
|
"""If EQ.emit() raises, the app should continue to function normally."""
|
|
|
|
# Replace EQ with a broken one that raises on emit
|
|
class BrokenEventQueue(EventQueue):
|
|
async def emit(self, event: Event) -> None:
|
|
raise RuntimeError("Simulated EQ failure")
|
|
|
|
app.state.event_queue = BrokenEventQueue()
|
|
|
|
# The _emit_event_safe helper should swallow the error
|
|
await _emit_event_safe(
|
|
app.state.event_queue,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
|
|
# App state should still be accessible
|
|
assert app.state.event_queue is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_queue_close_on_shutdown(self):
|
|
"""EventQueue should be closed when app shuts down."""
|
|
app = create_app(
|
|
llm_gateway=LLMGateway(),
|
|
skill_registry=SkillRegistry(),
|
|
tool_registry=ToolRegistry(),
|
|
)
|
|
eq = app.state.event_queue
|
|
sq = app.state.submission_queue
|
|
|
|
assert eq.is_closed is False
|
|
assert sq.is_closed is False
|
|
|
|
# Simulate lifespan shutdown by calling close directly
|
|
# (TestClient lifespan handling would also work but is heavier)
|
|
eq.close()
|
|
sq.close()
|
|
|
|
assert eq.is_closed is True
|
|
assert sq.is_closed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_task_lifecycle_events_simulated(self, app):
|
|
"""Simulate the full task lifecycle that the WebSocket handler would emit.
|
|
|
|
This verifies that the EventQueue correctly receives and buffers
|
|
the sequence of events emitted during a typical WebSocket chat flow.
|
|
"""
|
|
eq: EventQueue = app.state.event_queue
|
|
task_id = "simulated-task-123"
|
|
session_id = "simulated-session-456"
|
|
|
|
# Simulate the event sequence from portal_websocket handler
|
|
await _emit_event_safe(
|
|
eq, TaskEventType.TASK_CREATED, task_id, session_id, {"message": "hello"}
|
|
)
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_STARTED,
|
|
task_id,
|
|
session_id,
|
|
{"agent_name": "default", "execution_mode": "direct_chat"},
|
|
)
|
|
await _emit_event_safe(
|
|
eq,
|
|
TurnEventType.FINAL_ANSWER,
|
|
task_id,
|
|
session_id,
|
|
{"output": "Hello back!"},
|
|
)
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_COMPLETED,
|
|
task_id,
|
|
session_id,
|
|
{"output": "Hello back!"},
|
|
)
|
|
|
|
# Verify all 4 events were buffered in order
|
|
assert len(eq._buffer) == 4
|
|
event_types = [e.event_type for e in eq._buffer]
|
|
assert event_types == [
|
|
"task.created",
|
|
"task.started",
|
|
"turn.final_answer",
|
|
"task.completed",
|
|
]
|
|
|
|
# Verify all events carry the same task_id and session_id
|
|
for event in eq._buffer:
|
|
assert event.task_id == task_id
|
|
assert event.session_id == session_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event type correctness tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventTypes:
|
|
"""Verify event type constants are correctly used."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_created_event_type(self):
|
|
"""task.created should map to TaskEventType.TASK_CREATED."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
data={"message": "test"},
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "task.created"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_started_event_type(self):
|
|
"""task.started should map to TaskEventType.TASK_STARTED."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_STARTED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "task.started"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_completed_event_type(self):
|
|
"""task.completed should map to TaskEventType.TASK_COMPLETED."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_COMPLETED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
data={"output": "done"},
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "task.completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_failed_event_type(self):
|
|
"""task.failed should map to TaskEventType.TASK_FAILED."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_FAILED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
data={"error": "oops"},
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "task.failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_thinking_event_type(self):
|
|
"""turn.thinking should map to TurnEventType.THINKING."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TurnEventType.THINKING,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "turn.thinking"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_tool_call_event_type(self):
|
|
"""turn.tool_call should map to TurnEventType.TOOL_CALL."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TurnEventType.TOOL_CALL,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "turn.tool_call"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_tool_result_event_type(self):
|
|
"""turn.tool_result should map to TurnEventType.TOOL_RESULT."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TurnEventType.TOOL_RESULT,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "turn.tool_result"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_final_answer_event_type(self):
|
|
"""turn.final_answer should map to TurnEventType.FINAL_ANSWER."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TurnEventType.FINAL_ANSWER,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
event = eq._buffer[0]
|
|
assert event.event_type == "turn.final_answer"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event field correctness tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventFields:
|
|
"""Verify events contain correct session_id and task_id."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_carries_correct_task_id(self):
|
|
"""Emitted event should carry the provided task_id."""
|
|
eq = EventQueue()
|
|
expected_task_id = "unique-task-id-123"
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id=expected_task_id,
|
|
session_id="s1",
|
|
)
|
|
assert eq._buffer[0].task_id == expected_task_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_carries_correct_session_id(self):
|
|
"""Emitted event should carry the provided session_id."""
|
|
eq = EventQueue()
|
|
expected_session_id = "conv-id-456"
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="t1",
|
|
session_id=expected_session_id,
|
|
)
|
|
assert eq._buffer[0].session_id == expected_session_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_has_iso_timestamp(self):
|
|
"""Emitted event should have a non-empty ISO 8601 timestamp."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(
|
|
eq,
|
|
TaskEventType.TASK_CREATED,
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
timestamp = eq._buffer[0].timestamp
|
|
assert isinstance(timestamp, str)
|
|
assert len(timestamp) > 0
|
|
# ISO 8601 format should contain 'T' separator
|
|
assert "T" in timestamp
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_events_preserve_order(self):
|
|
"""Multiple events should be buffered in emission order."""
|
|
eq = EventQueue()
|
|
await _emit_event_safe(eq, TaskEventType.TASK_CREATED, "t1", "s1")
|
|
await _emit_event_safe(eq, TaskEventType.TASK_STARTED, "t1", "s1")
|
|
await _emit_event_safe(eq, TurnEventType.FINAL_ANSWER, "t1", "s1")
|
|
await _emit_event_safe(eq, TaskEventType.TASK_COMPLETED, "t1", "s1")
|
|
|
|
event_types = [e.event_type for e in eq._buffer]
|
|
assert event_types == [
|
|
"task.created",
|
|
"task.started",
|
|
"turn.final_answer",
|
|
"task.completed",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SubmissionQueue integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubmissionQueueIntegration:
|
|
"""Verify SubmissionQueue is properly initialized on app.state."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submission_queue_accepts_submissions(self, app):
|
|
"""SubmissionQueue on app.state should accept submissions."""
|
|
sq: SubmissionQueue = app.state.submission_queue
|
|
task_id = await sq.submit("hello", "session-1")
|
|
assert isinstance(task_id, str)
|
|
assert len(task_id) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submission_queue_close_marks_closed(self, app):
|
|
"""Closing SubmissionQueue should mark it as closed."""
|
|
sq: SubmissionQueue = app.state.submission_queue
|
|
assert sq.is_closed is False
|
|
sq.close()
|
|
assert sq.is_closed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submission_queue_rejects_after_close(self, app):
|
|
"""Closed SubmissionQueue should reject new submissions."""
|
|
sq: SubmissionQueue = app.state.submission_queue
|
|
sq.close()
|
|
with pytest.raises(RuntimeError, match="SubmissionQueue is closed"):
|
|
await sq.submit("hello", "session-1")
|