test(backend): add Hermes runtime and task router tests

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-11 08:50:47 +08:00
parent 7f5b133fad
commit 847d9f96db
2 changed files with 296 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.agent_runtime.base import RuntimePreparedContext
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
class FakeAgent:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.model = kwargs.get("model", "fake-hermes-model")
def run_conversation(self, user_message, system_message=None, stream_callback=None):
if stream_callback is not None:
stream_callback("hello ")
stream_callback("world")
return {"final_response": "hello world"}
@pytest.fixture(autouse=True)
def clear_hermes_sessions():
hermes_session_manager._sessions.clear()
yield
hermes_session_manager._sessions.clear()
@pytest.fixture
def prepared_context():
return RuntimePreparedContext(
user=SimpleNamespace(id="user-1"),
conversation=SimpleNamespace(id="conv-1"),
user_message=SimpleNamespace(id="msg-user"),
assistant_message=SimpleNamespace(id="msg-assistant"),
raw_message="hi",
full_message="hi",
file_ids=[],
model_name="hermes-test-model",
memory_context="memory block",
)
@pytest.mark.asyncio
async def test_chat_once_calls_ai_agent(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
content, model = await adapter.chat_once(prepared_context)
assert content == "hello world"
assert model == "hermes-test-model"
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None
@pytest.mark.asyncio
async def test_chat_stream_emits_progress_and_chunks(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
events = []
async for event in adapter.chat_stream(prepared_context):
events.append(event)
assert events[0]["type"] == "progress"
chunks = [event["content"] for event in events if event["type"] == "chunk"]
assert "hello " in chunks
assert "world" in chunks
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None

View File

@@ -0,0 +1,221 @@
import asyncio
import sys
from datetime import UTC, date, datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
sys.modules.setdefault('psutil', Mock())
import app.models # noqa: F401
from app.database import Base, get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.routers.schedule_center import router as schedule_center_router
from app.routers.task import router as task_router
from app.services.auth_service import get_password_hash
@pytest.fixture
async def task_env(tmp_path):
db_path = tmp_path / 'test_task_router.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
user = User(
username='task_user',
email='task@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Task Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
test_app = FastAPI()
test_app.include_router(task_router)
test_app.include_router(schedule_center_router)
test_app.dependency_overrides[get_db] = override_get_db
test_app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield test_app
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_create_task_and_get_detail_with_business_fields(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Prepare daily status',
'description': 'Assemble the updated today status payload',
'priority': 'high',
'due_date': '2026-04-10T09:00:00Z',
'tags': ['today-status', 'chat'],
'source': 'chat',
'conversation_id': 'conv-123',
'quadrant': 'urgent-important',
'assignee_type': 'commander',
'assignee_id': 'code_commander',
},
)
assert create_response.status_code == 201
created = create_response.json()
detail_response = await client.get(f"/api/tasks/{created['id']}")
assert detail_response.status_code == 200
payload = detail_response.json()
assert payload['title'] == 'Prepare daily status'
assert payload['tags'] == ['today-status', 'chat']
assert payload['source'] == 'chat'
assert payload['conversation_id'] == 'conv-123'
assert payload['quadrant'] == 'urgent-important'
assert payload['assignee_type'] == 'commander'
assert payload['assignee_id'] == 'code_commander'
assert payload['dispatch']['status'] == 'idle'
assert [item['action'] for item in payload['history'][:2]] == ['created_from_chat', 'created']
@pytest.mark.asyncio
async def test_subtask_crud_and_reorder(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Implement kanban detail',
'due_date': '2026-04-10T11:00:00Z',
'quadrant': 'not-urgent-important',
},
)
task_id = task_response.json()['id']
first_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Load task detail', 'assignee_type': 'agent', 'assignee_id': 'planner'},
)
second_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Persist task edits', 'assignee_type': 'agent', 'assignee_id': 'executor'},
)
first_id = first_subtask.json()['subtasks'][0]['id']
second_id = second_subtask.json()['subtasks'][1]['id']
update_response = await client.patch(
f'/api/tasks/{task_id}/subtasks/{first_id}',
json={'status': 'done'},
)
reorder_response = await client.post(
f'/api/tasks/{task_id}/subtasks/reorder',
json={'items': [{'id': first_id, 'order_index': 1}, {'id': second_id, 'order_index': 0}]},
)
assert update_response.status_code == 200
assert reorder_response.status_code == 200
reordered = reorder_response.json()
assert [item['id'] for item in reordered['subtasks']] == [second_id, first_id]
assert reordered['subtasks'][1]['status'] == 'done'
assert any(item['action'] == 'subtask_reordered' for item in reordered['history'])
@pytest.mark.asyncio
async def test_create_today_status_task_persists_status_and_subtasks(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': '今日看板任务',
'description': '来自今日状态看板',
'status': 'in_progress',
'priority': 'high',
'source': 'today_status',
'quadrant': 'urgent-important',
'subtasks': [
{'title': '子任务一', 'status': 'todo'},
{'title': '子任务二', 'status': 'done'},
],
},
)
assert create_response.status_code == 201
payload = create_response.json()
detail_response = await client.get(f"/api/tasks/{payload['id']}")
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload['status'] == 'in_progress'
assert detail_payload['source'] == 'today_status'
assert detail_payload['quadrant'] == 'urgent-important'
assert [item['title'] for item in detail_payload['subtasks']] == ['子任务一', '子任务二']
assert detail_payload['subtasks'][0]['order_index'] == 0
assert detail_payload['subtasks'][1]['order_index'] == 1
assert detail_payload['subtasks'][1]['status'] == 'done'
assert detail_payload['subtasks'][1]['completed_at'] is not None
@pytest.mark.asyncio
async def test_dispatch_updates_task_and_schedule_center_summary(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Dispatch to commander',
'priority': 'urgent',
'due_date': '2026-04-10T15:00:00Z',
'quadrant': 'urgent-important',
},
)
task_id = task_response.json()['id']
dispatch_response = await client.post(
f'/api/tasks/{task_id}/dispatch',
json={'target': 'commander'},
)
assert dispatch_response.status_code == 200
assert dispatch_response.json()['task']['dispatch']['status'] == 'queued'
await asyncio.sleep(0.18)
detail_response = await client.get(f'/api/tasks/{task_id}')
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert detail_response.status_code == 200
detail = detail_response.json()
assert detail['dispatch']['status'] == 'completed'
assert detail['status'] == 'done'
assert detail['dispatch']['run_id']
assert detail['dispatch']['result_summary']
assert date_response.status_code == 200
payload = date_response.json()
assert payload['commander_summary'] == {
'total': 1,
'queued': 0,
'running': 0,
'completed': 1,
'failed': 0,
}
assert payload['focus_tasks'][0]['id'] == task_id
quadrants = {item['id']: item for item in payload['quadrants']}
assert quadrants['urgent-important']['tasks'][0]['id'] == task_id