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.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user