fischer-agentkit/tests/unit/server/test_event_queue_integratio...

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