Files
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

241 lines
12 KiB
Python

from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.auth.project_dependencies import ProjectContext
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import AgentMessageCreate, AgentMessageDelivery, AgentMessageReply
from app.domain.schemas.operational_task import AgentRunCommand
from app.services.agent_messages import AgentMessageService
from app.services.operational_tasks import OperationalTaskService
@pytest.fixture
def anyio_backend():
return "asyncio"
NOW = datetime(2026, 10, 13, 0, tzinfo=timezone.utc)
class Repository:
def __init__(self):
self.project_id, self.task_id, self.run_id = uuid4(), uuid4(), uuid4()
self.run = {"run_id": str(self.run_id), "task_id": str(self.task_id),
"status": "running", "agent_session_id": "task-session",
"lease_expires_at": NOW + timedelta(minutes=1), "revision": 2,
"generation": 1, "execution_request_id": str(uuid4()),
"next_dispatch_at": NOW, "created_at": NOW, "updated_at": NOW}
self.messages = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock(self, key):
pass
async def get_run(self, run_id):
return self.run if run_id == str(self.run_id) else None
async def get_task(self, task_id):
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
async def get_message(self, message_id):
return self.messages.get(message_id)
async def get_message_request(self, owner_id, session_id, request_id):
return next((row for row in self.messages.values()
if row["source_owner_id"] == owner_id and row["source_session_id"] == session_id
and row["request_id"] == request_id), None)
async def insert_message(self, message):
self.messages[message["message_id"]] = message
async def update_message_delivery(self, message_id, update):
self.messages[message_id].update(update)
def setup():
repo = Repository()
actor = ProjectContext(repo.project_id, "test", uuid4(), "member")
worker = ProjectContext(repo.project_id, "test", uuid4(), "member", system_role="service")
service = AgentMessageService(repo, actor, clock=lambda: NOW)
trusted = AgentMessageService(repo, worker, clock=lambda: NOW)
command = AgentMessageCreate(request_id=uuid4(), task_id=repo.task_id, run_id=repo.run_id,
source_session_id="main-session", text="核对当前压力")
return repo, actor, service, trusted, command
@pytest.mark.anyio
async def test_messages_are_bound_to_exact_run_and_idempotent_even_after_finish():
repo, actor, service, trusted, command = setup()
sent = await service.create(command)
assert sent.source_owner_id == actor.user_id == sent.conversation_owner_id
assert sent.native_message_id == f"msg_{sent.message_id}"
repo.run["status"] = "succeeded"
assert (await service.create(command)).message_id == sent.message_id
with pytest.raises(DomainError, match="request_id") as error:
await service.create(command.model_copy(update={"text": "different"}))
assert error.value.code == "IDEMPOTENCY_CONFLICT"
with pytest.raises(DomainError) as error:
await service.create(command.model_copy(update={"request_id": uuid4()}))
assert error.value.code == "RUN_TERMINAL"
assert len(repo.messages) == 1
@pytest.mark.anyio
async def test_wrong_task_or_expired_lease_cannot_accept_message():
repo, actor, service, trusted, command = setup()
with pytest.raises(DomainError) as error:
await service.create(command.model_copy(update={"task_id": uuid4()}))
assert error.value.code == "RUN_NOT_FOUND"
repo.run["lease_expires_at"] = NOW
with pytest.raises(DomainError) as error:
await service.create(command)
assert error.value.code == "LEASE_EXPIRED"
repo.run.update(status="queued", agent_session_id=None)
assert (await service.create(command)).target_session_id is None
@pytest.mark.anyio
async def test_reply_has_original_conversation_owner_and_immutable_channel():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
reply_command = AgentMessageReply(request_id=uuid4(), source_session_id="task-session", text="压力正常")
with pytest.raises(DomainError) as error:
await service.reply(incoming.message_id, reply_command)
assert error.value.code == "SERVICE_REQUIRED"
with pytest.raises(DomainError) as error:
await trusted.reply(incoming.message_id, reply_command.model_copy(update={"source_session_id": "other"}))
assert error.value.code == "SESSION_MISMATCH"
reply = await trusted.reply(incoming.message_id, reply_command)
assert reply.source_owner_id == trusted.context.user_id
assert reply.conversation_owner_id == actor.user_id
assert reply.target_session_id == command.source_session_id
followup = await service.reply(reply.message_id, AgentMessageReply(
request_id=uuid4(), source_session_id="main-session", text="请继续核对"))
assert followup.direction == "to_task" and followup.in_reply_to == reply.message_id
assert followup.target_session_id == "task-session"
with pytest.raises(DomainError) as error:
await service.reply(reply.message_id, AgentMessageReply(
request_id=uuid4(), source_session_id="wrong-main", text="伪造来源"))
assert error.value.code == "SESSION_MISMATCH"
repo.run["status"] = "succeeded"
assert (await trusted.reply(incoming.message_id, reply_command)).message_id == reply.message_id
with pytest.raises(DomainError) as error:
await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered", target_session_id="other"))
assert error.value.code == "SESSION_MISMATCH"
received = await service.delivery(reply.message_id, AgentMessageDelivery(status="enqueued"))
assert received.status == "enqueued" and received.delivered_at is None
assert (await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered"))).delivered_at == NOW
@pytest.mark.anyio
async def test_inbound_delivery_requires_service_run_session_and_fixed_native_identity():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
with pytest.raises(DomainError) as error:
await service.delivery(incoming.message_id, AgentMessageDelivery(status="delivered"))
assert error.value.code == "SERVICE_REQUIRED"
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="wrong"))
assert error.value.code == "SESSION_MISMATCH"
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session", native_message_id="changed"))
assert error.value.code == "MESSAGE_ID_MISMATCH"
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="task-session"))
assert (await service.get(incoming.message_id)).status == "enqueued"
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session"))
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="failed"))
assert error.value.code == "MESSAGE_TERMINAL"
@pytest.mark.anyio
async def test_other_project_member_cannot_read_private_message_or_pending_dispatch():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
outsider = AgentMessageService(repo, ProjectContext(repo.project_id, "test", uuid4(), "member"))
with pytest.raises(DomainError) as error:
await outsider.get(incoming.message_id)
assert error.value.code == "MESSAGE_NOT_FOUND"
with pytest.raises(DomainError) as error:
await outsider.pending(20)
assert error.value.code == "SERVICE_REQUIRED"
viewer_service = AgentMessageService(repo, ProjectContext(
repo.project_id, "test", uuid4(), "viewer", system_role="service"))
with pytest.raises(DomainError) as error:
await viewer_service.get(incoming.message_id)
assert error.value.code == "SERVICE_REQUIRED"
@pytest.mark.anyio
async def test_own_session_listing_uses_owner_and_keyset_cursor():
repo, actor, service, trusted, command = setup()
sent = await service.create(command)
other = await service.create(command.model_copy(update={"request_id": uuid4(), "text": "后续问题"}))
repo.list_session_messages = AsyncMock(return_value=[sent.model_dump(), other.model_dump()])
page = await service.list_session("main-session", 1, None, pending_only=True)
assert len(page.items) == 1 and page.next_cursor
repo.list_session_messages.assert_awaited_once_with(
str(actor.user_id), "main-session", 2,
cursor_at=None, cursor_id=None, pending_only=True)
repo.list_session_messages.reset_mock()
repo.list_session_messages.return_value = []
await service.list_session("main-session", 1, page.next_cursor)
assert repo.list_session_messages.await_args.kwargs["cursor_id"] == sent.message_id
with pytest.raises(DomainError) as error:
await service.list_session("main-session", 20, "invalid")
assert error.value.code == "INVALID_CURSOR"
@pytest.mark.anyio
async def test_pending_dispatch_page_returns_forward_keyset_cursor():
repo, actor, service, trusted, command = setup()
first = await service.create(command)
second = await service.create(command.model_copy(update={"request_id": uuid4()}))
repo.list_pending_messages = AsyncMock(return_value=[first.model_dump(), second.model_dump()])
page = await trusted.pending(1)
assert page.items[0].message_id == first.message_id and page.next_cursor
await trusted.pending(1, page.next_cursor)
assert repo.list_pending_messages.await_args.kwargs == {
"cursor_at": first.created_at, "cursor_id": first.message_id}
@pytest.mark.anyio
async def test_requeue_locks_and_rechecks_run_before_cancelling_it():
repo, actor, service, trusted, command = setup()
run = repo.run.copy()
replacement = {**run, "run_id": str(uuid4())}
repo.get_active_run = AsyncMock(side_effect=[run, replacement, replacement])
repo.lock = AsyncMock()
repo.update_run, repo.insert_run, repo.fail_run_messages = AsyncMock(), AsyncMock(), AsyncMock()
task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW)
task_service._run_event = AsyncMock()
task = {"task_id": str(repo.task_id), "execution_generation": 1, "revision": 2}
await task_service._requeue_task(task, NOW)
assert [call.args[0] for call in repo.lock.await_args_list] == [
f"run:{run['run_id']}", f"run:{replacement['run_id']}"]
assert repo.update_run.await_args.args[0]["run_id"] == replacement["run_id"]
repo.fail_run_messages.assert_awaited_once_with(replacement["run_id"], "Run superseded by requeue", NOW)
@pytest.mark.anyio
async def test_finish_refuses_messages_enqueued_but_not_entered_in_model_context():
repo, actor, service, trusted, command = setup()
repo.get_request = AsyncMock(return_value=None)
repo.pending_run_messages = AsyncMock(return_value=["pending-message"])
repo.get_latest_report = AsyncMock()
task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW)
task_service._task = AsyncMock(return_value={"task_type": "event_investigation"})
with pytest.raises(DomainError) as error:
await task_service.complete_run(repo.task_id, repo.run_id, AgentRunCommand(
request_id=uuid4(), expected_revision=2, source_session_id="task-session"))
assert error.value.code == "PENDING_AGENT_MESSAGES" and error.value.status == 409
assert error.value.refs == ["pending-message"]
repo.get_latest_report.assert_not_awaited()