feat: integrate SQ/EQ into portal WebSocket and CLI (Phase 4)
- app.py: initialize EventQueue + SubmissionQueue in app.state, close on shutdown - portal.py: emit unified events (task.created/started/completed/failed, turn.thinking/tool_call/tool_result/final_answer) to EQ alongside WebSocket messages - cli/chat.py: optional --event-queue flag for event emission - EQ is bypass-only: emit failures never affect WebSocket or CLI main flow - WebSocket message format unchanged (backward compatible) Tests: 650 passed, 0 failed, 4 skipped
This commit is contained in:
parent
773a62ead2
commit
ecf87391a5
|
|
@ -32,9 +32,14 @@ def chat(
|
||||||
None, "--system-prompt", "-s", help="Custom system prompt"
|
None, "--system-prompt", "-s", help="Custom system prompt"
|
||||||
),
|
),
|
||||||
no_stream: bool = typer.Option(False, "--no-stream", help="Disable token streaming"),
|
no_stream: bool = typer.Option(False, "--no-stream", help="Disable token streaming"),
|
||||||
|
event_queue: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--event-queue",
|
||||||
|
help="Enable EventQueue for structured event emission (for future extensions)",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Start an interactive chat session with an Agent."""
|
"""Start an interactive chat session with an Agent."""
|
||||||
asyncio.run(_chat_async(model, agent_name, config, system_prompt, no_stream))
|
asyncio.run(_chat_async(model, agent_name, config, system_prompt, no_stream, event_queue))
|
||||||
|
|
||||||
|
|
||||||
async def _chat_async(
|
async def _chat_async(
|
||||||
|
|
@ -43,6 +48,7 @@ async def _chat_async(
|
||||||
config_arg: str | None,
|
config_arg: str | None,
|
||||||
system_prompt: str | None,
|
system_prompt: str | None,
|
||||||
no_stream: bool,
|
no_stream: bool,
|
||||||
|
enable_event_queue: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Async implementation of the chat command."""
|
"""Async implementation of the chat command."""
|
||||||
from agentkit.cli.onboarding import run_onboarding
|
from agentkit.cli.onboarding import run_onboarding
|
||||||
|
|
@ -87,6 +93,58 @@ async def _chat_async(
|
||||||
session_manager = SessionManager(store=InMemorySessionStore())
|
session_manager = SessionManager(store=InMemorySessionStore())
|
||||||
session = await session_manager.create_session(agent_name=agent_name)
|
session = await session_manager.create_session(agent_name=agent_name)
|
||||||
|
|
||||||
|
# Initialize EventQueue if --event-queue flag is set (lightweight, optional)
|
||||||
|
# The EQ is a side-channel for structured event emission; CLI output format is unchanged.
|
||||||
|
eq = None
|
||||||
|
_emit = None # type: ignore[assignment]
|
||||||
|
if enable_event_queue:
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
from agentkit.core.event_queue import EventQueue
|
||||||
|
from agentkit.core.protocol import (
|
||||||
|
Event,
|
||||||
|
SessionEventType,
|
||||||
|
TaskEventType,
|
||||||
|
TurnEventType,
|
||||||
|
)
|
||||||
|
|
||||||
|
eq = EventQueue()
|
||||||
|
|
||||||
|
# Map ReAct engine event_type strings to TurnEventType constants for EQ emission
|
||||||
|
_CLI_REACT_EVENT_MAP: dict[str, str] = {
|
||||||
|
"thinking": TurnEventType.THINKING,
|
||||||
|
"tool_call": TurnEventType.TOOL_CALL,
|
||||||
|
"tool_result": TurnEventType.TOOL_RESULT,
|
||||||
|
"final_answer": TurnEventType.FINAL_ANSWER,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _emit( # type: ignore[no-redef]
|
||||||
|
event_type: str,
|
||||||
|
task_id: str,
|
||||||
|
session_id: str,
|
||||||
|
data: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Emit an event to the EQ. Best-effort: never raises."""
|
||||||
|
try:
|
||||||
|
await eq.emit( # type: ignore[union-attr]
|
||||||
|
Event.create(
|
||||||
|
event_type=event_type,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=session_id,
|
||||||
|
data=data or {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # EQ is best-effort; never break CLI flow
|
||||||
|
|
||||||
|
# Emit session.started event
|
||||||
|
await _emit(
|
||||||
|
SessionEventType.SESSION_STARTED,
|
||||||
|
task_id="",
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"agent_name": agent_name, "model": model},
|
||||||
|
)
|
||||||
|
|
||||||
# Build tools list — all available tools for chat mode
|
# Build tools list — all available tools for chat mode
|
||||||
search_api_keys = _extract_search_keys(server_config)
|
search_api_keys = _extract_search_keys(server_config)
|
||||||
tools: list[Tool] = [
|
tools: list[Tool] = [
|
||||||
|
|
@ -199,6 +257,16 @@ async def _chat_async(
|
||||||
|
|
||||||
conversation_had_messages = True
|
conversation_had_messages = True
|
||||||
|
|
||||||
|
# Generate task_id for this user message and emit task.created to EQ (if enabled)
|
||||||
|
cli_task_id = str(_uuid.uuid4()) if enable_event_queue else ""
|
||||||
|
if _emit is not None:
|
||||||
|
await _emit(
|
||||||
|
TaskEventType.TASK_CREATED,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"message": user_input},
|
||||||
|
)
|
||||||
|
|
||||||
# Append user message to session
|
# Append user message to session
|
||||||
await session_manager.append_message(
|
await session_manager.append_message(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
|
|
@ -227,6 +295,15 @@ async def _chat_async(
|
||||||
f"[dim]Skill: {routing.skill_name} ({routing.match_method}, {int(routing.match_confidence * 100)}%)[/dim]"
|
f"[dim]Skill: {routing.skill_name} ({routing.match_method}, {int(routing.match_confidence * 100)}%)[/dim]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Emit task.started to EQ (if enabled)
|
||||||
|
if _emit is not None:
|
||||||
|
await _emit(
|
||||||
|
TaskEventType.TASK_STARTED,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"agent_name": routing.skill_name or agent_name},
|
||||||
|
)
|
||||||
|
|
||||||
exec_system_prompt = routing.system_prompt
|
exec_system_prompt = routing.system_prompt
|
||||||
exec_tools = routing.tools
|
exec_tools = routing.tools
|
||||||
exec_model = routing.model
|
exec_model = routing.model
|
||||||
|
|
@ -254,6 +331,21 @@ async def _chat_async(
|
||||||
content=output,
|
content=output,
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Emit turn.final_answer and task.completed to EQ (if enabled)
|
||||||
|
if _emit is not None:
|
||||||
|
await _emit(
|
||||||
|
TurnEventType.FINAL_ANSWER,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"output": output},
|
||||||
|
)
|
||||||
|
await _emit(
|
||||||
|
TaskEventType.TASK_COMPLETED,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"output": output},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Streaming mode — Live displays under the "Agent:" label
|
# Streaming mode — Live displays under the "Agent:" label
|
||||||
full_content = ""
|
full_content = ""
|
||||||
|
|
@ -286,6 +378,18 @@ async def _chat_async(
|
||||||
if full_content:
|
if full_content:
|
||||||
live.update(Text(full_content))
|
live.update(Text(full_content))
|
||||||
|
|
||||||
|
# Emit turn events to EQ (if enabled)
|
||||||
|
# Maps ReAct event types to TurnEventType constants
|
||||||
|
if _emit is not None:
|
||||||
|
_turn_type = _CLI_REACT_EVENT_MAP.get(event.event_type)
|
||||||
|
if _turn_type is not None:
|
||||||
|
await _emit(
|
||||||
|
_turn_type,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data=event.data,
|
||||||
|
)
|
||||||
|
|
||||||
# Live already displayed the final content, no need to rprint again
|
# Live already displayed the final content, no need to rprint again
|
||||||
|
|
||||||
await session_manager.append_message(
|
await session_manager.append_message(
|
||||||
|
|
@ -295,8 +399,25 @@ async def _chat_async(
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Emit task.completed to EQ (if enabled)
|
||||||
|
if _emit is not None:
|
||||||
|
await _emit(
|
||||||
|
TaskEventType.TASK_COMPLETED,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"output": full_content},
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
rprint(f"\n[red]Error: {e}[/red]")
|
rprint(f"\n[red]Error: {e}[/red]")
|
||||||
|
# Emit task.failed to EQ (if enabled)
|
||||||
|
if _emit is not None:
|
||||||
|
await _emit(
|
||||||
|
TaskEventType.TASK_FAILED,
|
||||||
|
task_id=cli_task_id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={"error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
# ── Session end: generate daily log ────────────────────────────
|
# ── Session end: generate daily log ────────────────────────────
|
||||||
if conversation_had_messages:
|
if conversation_had_messages:
|
||||||
|
|
@ -323,6 +444,19 @@ async def _chat_async(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Daily log generation is best-effort
|
pass # Daily log generation is best-effort
|
||||||
|
|
||||||
|
# Close EventQueue if it was enabled (emit session.ended and close)
|
||||||
|
if eq is not None and _emit is not None:
|
||||||
|
try:
|
||||||
|
await _emit(
|
||||||
|
SessionEventType.SESSION_ENDED,
|
||||||
|
task_id="",
|
||||||
|
session_id=session.session_id,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
eq.close()
|
||||||
|
|
||||||
|
|
||||||
# ruff: noqa: F821 — string annotations resolved at runtime via from __future__ import annotations
|
# ruff: noqa: F821 — string annotations resolved at runtime via from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from agentkit.core.agent_pool import AgentPool
|
from agentkit.core.agent_pool import AgentPool
|
||||||
|
from agentkit.core.event_queue import EventQueue, SubmissionQueue
|
||||||
from agentkit.llm.gateway import LLMGateway
|
from agentkit.llm.gateway import LLMGateway
|
||||||
from agentkit.llm.providers.anthropic import AnthropicProvider
|
from agentkit.llm.providers.anthropic import AnthropicProvider
|
||||||
from agentkit.llm.providers.gemini import GeminiProvider
|
from agentkit.llm.providers.gemini import GeminiProvider
|
||||||
|
|
@ -340,6 +341,16 @@ async def lifespan(app: FastAPI):
|
||||||
if server_config is not None:
|
if server_config is not None:
|
||||||
server_config.stop_watching()
|
server_config.stop_watching()
|
||||||
|
|
||||||
|
# Close SQ/EQ dual-queue system (Phase 4 integration)
|
||||||
|
# SubmissionQueue.close() marks the queue as closed (no new submissions).
|
||||||
|
# EventQueue.close() sends sentinel to all subscribers so they can exit gracefully.
|
||||||
|
event_queue = getattr(app.state, "event_queue", None)
|
||||||
|
if event_queue is not None:
|
||||||
|
event_queue.close()
|
||||||
|
submission_queue = getattr(app.state, "submission_queue", None)
|
||||||
|
if submission_queue is not None:
|
||||||
|
submission_queue.close()
|
||||||
|
|
||||||
await task_store.stop_cleanup()
|
await task_store.stop_cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -626,6 +637,13 @@ def create_app(
|
||||||
app.state.api_key = effective_api_key
|
app.state.api_key = effective_api_key
|
||||||
app.state._config_reload_lock = asyncio.Lock()
|
app.state._config_reload_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Initialize SQ/EQ dual-queue system (Phase 4 integration)
|
||||||
|
# - SubmissionQueue (SQ): receives user input, returns task_id
|
||||||
|
# - EventQueue (EQ): pushes Agent events, supports multi-subscriber broadcast
|
||||||
|
# Both are closed on app shutdown (see lifespan()).
|
||||||
|
app.state.submission_queue = SubmissionQueue()
|
||||||
|
app.state.event_queue = EventQueue()
|
||||||
|
|
||||||
# Initialize session manager for Chat mode
|
# Initialize session manager for Chat mode
|
||||||
from agentkit.session.manager import SessionManager
|
from agentkit.session.manager import SessionManager
|
||||||
from agentkit.session.store import create_session_store
|
from agentkit.session.store import create_session_store
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from agentkit.core.config_driven import ConfigDrivenAgent
|
from agentkit.core.config_driven import ConfigDrivenAgent
|
||||||
|
from agentkit.core.event_queue import EventQueue
|
||||||
|
from agentkit.core.protocol import Event, TaskEventType, TurnEventType
|
||||||
from agentkit.core.react import ReActEngine
|
from agentkit.core.react import ReActEngine
|
||||||
from agentkit.chat.skill_routing import ExecutionMode, SkillRoutingResult
|
from agentkit.chat.skill_routing import ExecutionMode, SkillRoutingResult
|
||||||
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
||||||
|
|
@ -35,6 +37,16 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["portal"])
|
router = APIRouter(tags=["portal"])
|
||||||
|
|
||||||
|
# Map ReAct engine event_type strings to TurnEventType constants for EQ emission.
|
||||||
|
# Only events with a corresponding TurnEventType are forwarded to the EQ;
|
||||||
|
# other events (e.g. "token") are still sent over WebSocket but not duplicated to EQ.
|
||||||
|
_REACT_EVENT_TYPE_MAP: dict[str, str] = {
|
||||||
|
"thinking": TurnEventType.THINKING,
|
||||||
|
"tool_call": TurnEventType.TOOL_CALL,
|
||||||
|
"tool_result": TurnEventType.TOOL_RESULT,
|
||||||
|
"final_answer": TurnEventType.FINAL_ANSWER,
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API Key Authentication
|
# API Key Authentication
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -50,6 +62,39 @@ def _ensure_non_empty(text: str | None) -> str:
|
||||||
return EMPTY_LLM_RESPONSE
|
return EMPTY_LLM_RESPONSE
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit_event_safe(
|
||||||
|
event_queue: EventQueue | None,
|
||||||
|
event_type: str,
|
||||||
|
task_id: str,
|
||||||
|
session_id: str,
|
||||||
|
data: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Emit an event to the EventQueue without blocking or raising.
|
||||||
|
|
||||||
|
The EQ is a side-channel: emit failures must never break the WebSocket flow.
|
||||||
|
All exceptions are swallowed and logged at warning level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_queue: The EventQueue to emit to (no-op if None)
|
||||||
|
event_type: Event type (see TaskEventType / TurnEventType)
|
||||||
|
task_id: Associated task ID
|
||||||
|
session_id: Associated session ID (conversation_id)
|
||||||
|
data: Optional event payload
|
||||||
|
"""
|
||||||
|
if event_queue is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
event = Event.create(
|
||||||
|
event_type=event_type,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=session_id,
|
||||||
|
data=data or {},
|
||||||
|
)
|
||||||
|
await event_queue.emit(event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"EventQueue emit failed (type={event_type}): {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _verify_api_key(
|
async def _verify_api_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
api_key_header: str | None = Security(_api_key_header),
|
api_key_header: str | None = Security(_api_key_header),
|
||||||
|
|
@ -645,6 +690,8 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
|
|
||||||
# Wait for first chat message before creating conversation
|
# Wait for first chat message before creating conversation
|
||||||
conv: Conversation | None = None
|
conv: Conversation | None = None
|
||||||
|
# task_id is per-user-message; tracked here so the outer except can emit task.failed
|
||||||
|
task_id: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -693,6 +740,18 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
conv = await _conversation_store.get_or_create(conv_id)
|
conv = await _conversation_store.get_or_create(conv_id)
|
||||||
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
|
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
|
||||||
|
|
||||||
|
# Generate task_id for this user message and emit task.created to EQ
|
||||||
|
# (EQ is a side-channel: emit failures never break the WebSocket flow)
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
event_queue: EventQueue | None = getattr(websocket.app.state, "event_queue", None)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_CREATED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"message": message_text},
|
||||||
|
)
|
||||||
|
|
||||||
# Add user message to conversation
|
# Add user message to conversation
|
||||||
await _conversation_store.add_message(conv.id, "user", message_text)
|
await _conversation_store.add_message(conv.id, "user", message_text)
|
||||||
start_time = datetime.now(timezone.utc)
|
start_time = datetime.now(timezone.utc)
|
||||||
|
|
@ -771,6 +830,20 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Emit task.started to EQ (execution begins after routing)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_STARTED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={
|
||||||
|
"agent_name": routing_result.agent_name or "default",
|
||||||
|
"execution_mode": routing_result.execution_mode.value
|
||||||
|
if hasattr(routing_result.execution_mode, "value")
|
||||||
|
else str(routing_result.execution_mode),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Execute based on routing result's execution_mode
|
# Execute based on routing result's execution_mode
|
||||||
# This is the single source of truth for path selection,
|
# This is the single source of truth for path selection,
|
||||||
# replacing fragile string-matching on match_method.
|
# replacing fragile string-matching on match_method.
|
||||||
|
|
@ -796,6 +869,23 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
# Store assistant reply for multi-turn context continuity
|
# Store assistant reply for multi-turn context continuity
|
||||||
response_content = _ensure_non_empty(response.content)
|
response_content = _ensure_non_empty(response.content)
|
||||||
await _conversation_store.add_message(conv.id, "assistant", response_content)
|
await _conversation_store.add_message(conv.id, "assistant", response_content)
|
||||||
|
|
||||||
|
# Emit turn.final_answer and task.completed to EQ
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TurnEventType.FINAL_ANSWER,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"output": response_content},
|
||||||
|
)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_COMPLETED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"output": response_content},
|
||||||
|
)
|
||||||
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
|
|
@ -860,6 +950,23 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
# Store assistant reply for multi-turn context continuity
|
# Store assistant reply for multi-turn context continuity
|
||||||
response_content = _ensure_non_empty(response.content)
|
response_content = _ensure_non_empty(response.content)
|
||||||
await _conversation_store.add_message(conv.id, "assistant", response_content)
|
await _conversation_store.add_message(conv.id, "assistant", response_content)
|
||||||
|
|
||||||
|
# Emit turn.final_answer and task.completed to EQ (fallback path)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TurnEventType.FINAL_ANSWER,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"output": response_content},
|
||||||
|
)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_COMPLETED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"output": response_content},
|
||||||
|
)
|
||||||
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
|
|
@ -915,6 +1022,19 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
):
|
):
|
||||||
if event.event_type == "final_answer":
|
if event.event_type == "final_answer":
|
||||||
collected_output.append(event.data.get("output", ""))
|
collected_output.append(event.data.get("output", ""))
|
||||||
|
|
||||||
|
# Map ReAct event types to TurnEventType and emit to EQ
|
||||||
|
# (side-channel: failures are swallowed by _emit_event_safe)
|
||||||
|
_turn_event_type = _REACT_EVENT_TYPE_MAP.get(event.event_type)
|
||||||
|
if _turn_event_type is not None:
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
_turn_event_type,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data=event.data,
|
||||||
|
)
|
||||||
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "step",
|
"type": "step",
|
||||||
|
|
@ -927,6 +1047,14 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Emit task.failed to EQ before sending error to WebSocket
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_FAILED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"error": str(e)},
|
||||||
|
)
|
||||||
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -936,6 +1064,25 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
await _conversation_store.add_message(conv.id, "assistant", response_text)
|
await _conversation_store.add_message(conv.id, "assistant", response_text)
|
||||||
|
|
||||||
outcome = "success" if response_text != EMPTY_LLM_RESPONSE else "failure"
|
outcome = "success" if response_text != EMPTY_LLM_RESPONSE else "failure"
|
||||||
|
|
||||||
|
# Emit task.completed (success) or task.failed (empty response) to EQ
|
||||||
|
if outcome == "success":
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_COMPLETED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"output": response_text},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_FAILED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"error": "Empty LLM response"},
|
||||||
|
)
|
||||||
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
|
|
@ -956,6 +1103,17 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
logger.debug(f"Portal WebSocket disconnected for conversation {conv.id if conv else 'N/A'}")
|
logger.debug(f"Portal WebSocket disconnected for conversation {conv.id if conv else 'N/A'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Portal WebSocket error: {e}")
|
logger.error(f"Portal WebSocket error: {e}")
|
||||||
|
# Emit task.failed to EQ if a task was in progress
|
||||||
|
# (task_id is set when a user message is received; None before that)
|
||||||
|
if task_id is not None and conv is not None:
|
||||||
|
event_queue = getattr(websocket.app.state, "event_queue", None)
|
||||||
|
await _emit_event_safe(
|
||||||
|
event_queue,
|
||||||
|
TaskEventType.TASK_FAILED,
|
||||||
|
task_id=task_id,
|
||||||
|
session_id=conv.id,
|
||||||
|
data={"error": str(e)},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,516 @@
|
||||||
|
"""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")
|
||||||
Loading…
Reference in New Issue