Files
2026-07-13 13:12:33 +08:00

504 lines
16 KiB
Python

from __future__ import annotations
import time
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock
import pytest
from opensquilla.gateway.boot import _make_task_session_lifecycle_listener
from opensquilla.gateway.routing import RouteEnvelope, SourceKind
from opensquilla.gateway.session_events import build_sessions_changed_payload
from opensquilla.gateway.session_lifecycle import (
TaskLifecycleEvent,
apply_task_lifecycle_to_session,
session_status_for_task_status,
)
from opensquilla.gateway.task_runtime import TaskRuntime
from opensquilla.session.models import (
AgentTaskRecord,
AgentTaskStatus,
SessionNode,
SessionStatus,
)
def _make_envelope(session_key: str = "agent-1::sess-1") -> RouteEnvelope:
return RouteEnvelope(
source_kind=SourceKind.WEB,
source_name="test",
agent_id="agent-1",
session_key=session_key,
input_provenance={"kind": "test"},
metadata={},
)
def _make_task_storage() -> Any:
storage = MagicMock()
task_db: dict[str, AgentTaskRecord] = {}
async def create(record: AgentTaskRecord) -> None:
task_db[record.task_id] = record
async def update(task_id: str, **kwargs: Any) -> None:
rec = task_db.get(task_id)
if rec is None:
return
for key, value in kwargs.items():
if hasattr(rec, key):
object.__setattr__(rec, key, value)
async def get(task_id: str) -> AgentTaskRecord | None:
return task_db.get(task_id)
storage.create_agent_task = create
storage.update_agent_task = update
storage.get_agent_task = get
return storage
class _SessionManager:
def __init__(self, node: SessionNode) -> None:
self.node = node
self.finish_calls: list[tuple[str, str]] = []
self.update_calls: list[tuple[str, dict[str, Any]]] = []
async def get_session(self, session_key: str) -> SessionNode | None:
if session_key == self.node.session_key:
return self.node
return None
async def update(self, session_key: str, **fields: Any) -> SessionNode:
if session_key != self.node.session_key:
raise KeyError(session_key)
self.update_calls.append((session_key, dict(fields)))
for key, value in fields.items():
if hasattr(self.node, key):
setattr(self.node, key, value)
return self.node
async def finish(self, session_key: str, status: str = SessionStatus.DONE) -> SessionNode:
if session_key != self.node.session_key:
raise KeyError(session_key)
self.finish_calls.append((session_key, status))
self.node.status = status
self.node.ended_at = 2000
self.node.runtime_ms = 1000
return self.node
def _make_session(
session_key: str = "agent-1::sess-1",
*,
status: str = SessionStatus.RUNNING,
) -> SessionNode:
return SessionNode(
session_key=session_key,
session_id="session-id",
agent_id="agent-1",
created_at=1000,
updated_at=1000,
started_at=1000,
status=status,
)
def test_sessions_changed_payload_has_shared_schema_fields() -> None:
assert build_sessions_changed_payload("agent:main:test", "turn_complete") == {
"schema_version": 1,
"key": "agent:main:test",
"reason": "turn_complete",
"run_status": "idle",
}
def _make_runtime(
turn_handler: Callable[..., Awaitable[Any]],
*,
session_manager: _SessionManager,
events: list[tuple[str, str, dict[str, Any]]],
) -> TaskRuntime:
async def _emit(session_key: str, event_name: str, payload: dict[str, Any]) -> None:
events.append((session_key, event_name, payload))
return TaskRuntime(
storage=_make_task_storage(),
turn_handler=turn_handler,
event_emitter=_emit,
lifecycle_listener=_make_task_session_lifecycle_listener(
session_manager=session_manager,
event_emitter=_emit,
),
)
@pytest.mark.asyncio
async def test_task_timeout_terminalizes_running_session_and_broadcasts_change() -> None:
session = _make_session()
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
async def _timeout_handler(_run: Any) -> None:
raise TimeoutError("Gateway task timeout: Stream idle for more than 180.0s")
runtime = _make_runtime(_timeout_handler, session_manager=manager, events=events)
handle = await runtime.enqueue(_make_envelope(), "hello")
record = await runtime.wait(handle.task_id, timeout=2.0)
assert record.status == "timeout"
assert session.status == SessionStatus.TIMEOUT
assert [event_name for _, event_name, _ in events] == [
"task.queued",
"sessions.changed",
"task.running",
"sessions.changed",
"task.timeout",
"sessions.changed",
]
assert events[0] == (
session.session_key,
"task.queued",
{
"task_id": handle.task_id,
"session_key": session.session_key,
"queue_depth": 1,
"queue_position": 1,
},
)
assert events[1] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_queued",
"run_status": "queued",
"active_task": {"task_id": handle.task_id, "status": "queued"},
},
)
assert events[2] == (
session.session_key,
"task.running",
{"task_id": handle.task_id, "session_key": session.session_key},
)
assert events[3] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_running",
"run_status": "running",
"active_task": {"task_id": handle.task_id, "status": "running"},
},
)
assert events[4] == (
session.session_key,
"task.timeout",
{
"task_id": handle.task_id,
"session_key": session.session_key,
"terminal_reason": "timeout",
"terminal_message": "The task timed out before it could finish.",
},
)
assert events[-1] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_terminal",
"status": "timeout",
"run_status": "timeout",
"last_task": {
"task_id": handle.task_id,
"status": "timeout",
"terminal_reason": "timeout",
"terminal_message": "The task timed out before it could finish.",
},
},
)
assert manager.finish_calls == []
assert manager.update_calls[0] == (session.session_key, {})
assert manager.update_calls[1] == (session.session_key, {})
assert manager.update_calls[2][1]["status"] == SessionStatus.TIMEOUT
assert manager.update_calls[2][1]["ended_at"] > 0
assert manager.update_calls[2][1]["runtime_ms"] >= 0
def test_task_terminal_status_mapping_matches_session_lifecycle() -> None:
assert session_status_for_task_status(AgentTaskStatus.SUCCEEDED) == SessionStatus.DONE
assert session_status_for_task_status(AgentTaskStatus.FAILED) == SessionStatus.FAILED
assert session_status_for_task_status(AgentTaskStatus.CANCELLED) == SessionStatus.KILLED
assert session_status_for_task_status(AgentTaskStatus.TIMEOUT) == SessionStatus.TIMEOUT
assert session_status_for_task_status(AgentTaskStatus.ABANDONED) == SessionStatus.FAILED
assert session_status_for_task_status(AgentTaskStatus.RUNNING) is None
@pytest.mark.asyncio
async def test_terminal_lifecycle_is_idempotent_for_already_terminal_session() -> None:
session = _make_session(status=SessionStatus.TIMEOUT)
manager = _SessionManager(session)
changed = await apply_task_lifecycle_to_session(
TaskLifecycleEvent(
phase="terminal",
session_key=session.session_key,
task_id="task-1",
task_status=AgentTaskStatus.TIMEOUT,
run_kind="default",
terminal_reason="timeout",
),
session_manager=manager,
)
assert changed is False
assert manager.finish_calls == []
assert session.status == SessionStatus.TIMEOUT
@pytest.mark.asyncio
async def test_boot_lifecycle_listener_skips_subagent_tasks() -> None:
session = _make_session()
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
async def _emit(session_key: str, event_name: str, payload: dict[str, Any]) -> None:
events.append((session_key, event_name, payload))
listener = _make_task_session_lifecycle_listener(
session_manager=manager,
event_emitter=_emit,
)
await listener(
TaskLifecycleEvent(
phase="terminal",
session_key=session.session_key,
task_id="task-1",
task_status=AgentTaskStatus.TIMEOUT,
run_kind="subagent",
terminal_reason="timeout",
)
)
assert manager.finish_calls == []
assert events == []
assert session.status == SessionStatus.RUNNING
@pytest.mark.asyncio
async def test_task_running_broadcasts_change_for_already_running_session() -> None:
session = _make_session(status=SessionStatus.RUNNING)
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
async def _emit(session_key: str, event_name: str, payload: dict[str, Any]) -> None:
events.append((session_key, event_name, payload))
listener = _make_task_session_lifecycle_listener(
session_manager=manager,
event_emitter=_emit,
)
await listener(
TaskLifecycleEvent(
phase="running",
session_key=session.session_key,
task_id="task-active",
task_status=AgentTaskStatus.RUNNING,
run_kind="default",
)
)
assert manager.update_calls == [(session.session_key, {})]
assert events == [
(
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_running",
"run_status": "running",
"active_task": {"task_id": "task-active", "status": "running"},
},
)
]
@pytest.mark.asyncio
async def test_task_queued_broadcasts_change_for_waiting_session() -> None:
session = _make_session(status=SessionStatus.RUNNING)
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
async def _emit(session_key: str, event_name: str, payload: dict[str, Any]) -> None:
events.append((session_key, event_name, payload))
listener = _make_task_session_lifecycle_listener(
session_manager=manager,
event_emitter=_emit,
)
await listener(
TaskLifecycleEvent(
phase="queued",
session_key=session.session_key,
task_id="task-waiting",
task_status=AgentTaskStatus.QUEUED,
run_kind="default",
)
)
assert manager.update_calls == [(session.session_key, {})]
assert events == [
(
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_queued",
"run_status": "queued",
"active_task": {"task_id": "task-waiting", "status": "queued"},
},
)
]
@pytest.mark.asyncio
async def test_task_running_reactivates_terminal_session_before_next_turn() -> None:
session = _make_session(status=SessionStatus.TIMEOUT)
session.ended_at = 2000
session.runtime_ms = 1000
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
async def _success_handler(_run: Any) -> None:
return None
runtime = _make_runtime(_success_handler, session_manager=manager, events=events)
handle = await runtime.enqueue(_make_envelope(), "hello again")
await runtime.wait(handle.task_id, timeout=2.0)
assert session.status == SessionStatus.DONE
assert [event_name for _, event_name, _ in events] == [
"task.queued",
"sessions.changed",
"task.running",
"sessions.changed",
"task.succeeded",
"sessions.changed",
]
assert events[0] == (
session.session_key,
"task.queued",
{
"task_id": handle.task_id,
"session_key": session.session_key,
"queue_depth": 1,
"queue_position": 1,
},
)
assert events[1] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_queued",
"run_status": "queued",
"active_task": {"task_id": handle.task_id, "status": "queued"},
},
)
assert events[2] == (
session.session_key,
"task.running",
{"task_id": handle.task_id, "session_key": session.session_key},
)
assert events[3] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_running",
"run_status": "running",
"active_task": {"task_id": handle.task_id, "status": "running"},
},
)
assert events[-1] == (
session.session_key,
"sessions.changed",
{
"schema_version": 1,
"key": session.session_key,
"reason": "task_terminal",
"status": "done",
"run_status": "idle",
"last_task": {
"task_id": handle.task_id,
"status": "succeeded",
"terminal_reason": "completed",
},
},
)
assert events[4] == (
session.session_key,
"task.succeeded",
{
"task_id": handle.task_id,
"session_key": session.session_key,
"terminal_reason": "completed",
},
)
assert manager.finish_calls == []
assert manager.update_calls[0] == (session.session_key, {})
assert manager.update_calls[1][1]["status"] == SessionStatus.RUNNING
assert manager.update_calls[1][1]["started_at"] > 0
assert manager.update_calls[-1][1]["status"] == SessionStatus.DONE
@pytest.mark.asyncio
async def test_task_runtime_persists_agent_task_timestamps_as_epoch_ms() -> None:
session = _make_session()
manager = _SessionManager(session)
events: list[tuple[str, str, dict[str, Any]]] = []
storage = _make_task_storage()
async def _success_handler(_run: Any) -> None:
return None
async def _emit(session_key: str, event_name: str, payload: dict[str, Any]) -> None:
events.append((session_key, event_name, payload))
runtime = TaskRuntime(
storage=storage,
turn_handler=_success_handler,
event_emitter=_emit,
lifecycle_listener=_make_task_session_lifecycle_listener(
session_manager=manager,
event_emitter=_emit,
),
)
before_ms = int(time.time() * 1000) - 1000
handle = await runtime.enqueue(_make_envelope(), "hello")
await runtime.wait(handle.task_id, timeout=2.0)
after_ms = int(time.time() * 1000) + 1000
record = await storage.get_agent_task(handle.task_id)
assert record is not None
assert record.started_at is not None
assert record.finished_at is not None
assert before_ms <= record.started_at <= after_ms
assert before_ms <= record.finished_at <= after_ms
assert record.finished_at >= record.started_at