feat(04-01): finalize AgentApi migration

This commit is contained in:
Mikhail Putilovskij 2026-04-17 16:31:48 +03:00
parent cd59d89617
commit 430c82dba1
9 changed files with 225 additions and 350 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
@ -10,6 +11,7 @@ from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
from sdk.interface import PlatformError
from sdk.mock import MockPlatformClient
from sdk.real import RealPlatformClient
@ -199,6 +201,31 @@ async def test_bot_ignores_its_own_messages():
bot._send_all.assert_not_awaited()
async def test_bot_degrades_platform_errors_to_user_reply():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(
user_id="@bot:example.org",
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(
side_effect=PlatformError("Missing Authentication header", code="401")
)
room = SimpleNamespace(room_id="!dm:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
client.room_send.assert_awaited_once_with(
"!dm:example.org",
"m.room.message",
{
"msgtype": "m.text",
"body": "Сервис временно недоступен. Попробуйте ещё раз позже.",
},
)
async def test_mat11_settings_returns_dashboard():
runtime = build_runtime(platform=MockPlatformClient())
current_chat_id = "C9"
@ -260,9 +287,18 @@ async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
bot_module = importlib.import_module("adapter.matrix.bot")
class FakeAgentApiWrapper:
def __init__(self, agent_id: str, url: str) -> None:
self.agent_id = agent_id
self.url = url
monkeypatch.setattr(bot_module, "AgentApiWrapper", FakeAgentApiWrapper)
monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
runtime = build_runtime()
assert isinstance(runtime.platform, RealPlatformClient)
assert runtime.platform.agent_api.url == "ws://agent.example/agent_ws/"

View file

@ -5,7 +5,6 @@ Smoke test: полный цикл через dispatcher + реальные manag
"""
import pytest
from sdk.mock import MockPlatformClient
from sdk.agent_session import build_thread_key
from sdk.interface import MessageChunk, MessageResponse
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
@ -22,28 +21,15 @@ from core.protocol import (
)
class FakeAgentSessionClient:
class FakeAgentApi:
def __init__(self) -> None:
self.send_calls: list[tuple[str, str]] = []
self.calls: list[str] = []
self.last_tokens_used = 0
async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
self.send_calls.append((thread_key, text))
return MessageResponse(
message_id=thread_key,
response=f"[REAL] {text}",
tokens_used=5,
finished=True,
)
async def stream_message(self, *, thread_key: str, text: str):
self.send_calls.append((thread_key, text))
if False:
yield MessageChunk(
message_id=thread_key,
delta=text,
tokens_used=0,
finished=True,
)
async def send_message(self, text: str):
self.calls.append(text)
yield type("Chunk", (), {"text": f"[REAL] {text}"})()
self.last_tokens_used = 5
@pytest.fixture
@ -62,9 +48,9 @@ def dispatcher():
@pytest.fixture
def real_dispatcher():
agent_sessions = FakeAgentSessionClient()
agent_api = FakeAgentApi()
platform = RealPlatformClient(
agent_sessions=agent_sessions,
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
@ -76,7 +62,7 @@ def real_dispatcher():
settings_mgr=SettingsManager(platform, store),
)
register_all(d)
return d, agent_sessions
return d, agent_api
async def test_full_flow_start_then_message(dispatcher):
@ -132,8 +118,8 @@ async def test_toggle_skill_callback(dispatcher):
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))
async def test_full_flow_with_real_platform_uses_thread_key(real_dispatcher):
dispatcher, agent_sessions = real_dispatcher
async def test_full_flow_with_real_platform_uses_shared_agent_api(real_dispatcher):
dispatcher, agent_api = real_dispatcher
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
@ -144,6 +130,4 @@ async def test_full_flow_with_real_platform_uses_thread_key(real_dispatcher):
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert texts == ["[REAL] Привет!"]
assert agent_sessions.send_calls == [
(build_thread_key("matrix", "u1", "C1"), "Привет!")
]
assert agent_api.calls == ["Привет!"]

View file

@ -1,193 +1,21 @@
"""Compatibility tests after the Phase 4 migration."""
import sys
from pathlib import Path
from types import ModuleType
import pytest
from aiohttp import web
from sdk.interface import MessageChunk, MessageResponse
from sdk.agent_session import AgentSessionClient, AgentSessionConfig, build_thread_key
AGENT_ROOT = Path(__file__).resolve().parents[2] / "external" / "platform-agent"
AGENT_API_ROOT = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
for path in (AGENT_ROOT, AGENT_API_ROOT):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
if "fastapi" not in sys.modules:
fastapi = ModuleType("fastapi")
class _Router:
def websocket(self, _path: str):
def decorator(fn):
return fn
return decorator
class _WebSocketDisconnect(Exception):
pass
def _depends(value):
return value
fastapi.APIRouter = _Router
fastapi.WebSocket = object
fastapi.WebSocketDisconnect = _WebSocketDisconnect
fastapi.Depends = _depends
sys.modules["fastapi"] = fastapi
if "src.agent" not in sys.modules:
agent_module = ModuleType("src.agent")
class _AgentService:
async def astream(self, text: str, thread_id: str):
yield text
def _get_agent_service():
return _AgentService()
agent_module.AgentService = _AgentService
agent_module.get_agent_service = _get_agent_service
sys.modules["src.agent"] = agent_module
from lambda_agent_api.client import MsgUserMessage # noqa: E402
from src.api.external import process_message # noqa: E402
def test_build_thread_key_uses_platform_user_and_chat_id():
assert build_thread_key("matrix", "@alice:example.org", "C1") == "6:matrix18:@alice:example.org2:C1"
_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
if str(_api_root) not in sys.path:
sys.path.insert(0, str(_api_root))
def test_build_thread_key_does_not_collide_when_user_id_contains_colons():
left = build_thread_key("matrix", "@alice:example.org", "C1")
right = build_thread_key("matrix", "@alice", "example.org:C1")
def test_lambda_agent_api_module_is_importable():
from lambda_agent_api.agent_api import AgentApi
assert left != right
assert AgentApi is not None
@pytest.mark.asyncio
async def test_stream_message_yields_text_chunks_and_end(aiohttp_server):
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
def test_agent_session_module_is_intentionally_stubbed():
contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py"
async def handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
assert request.query["thread_id"] == thread_key
await ws.send_json({"type": "STATUS"})
message = await ws.receive_json()
assert message == {"type": "USER_MESSAGE", "text": "hello"}
await ws.send_json({"type": "AGENT_EVENT_TEXT_CHUNK", "text": "hel"})
await ws.send_json({"type": "AGENT_EVENT_TEXT_CHUNK", "text": "lo"})
await ws.send_json({"type": "AGENT_EVENT_END", "tokens_used": 7})
await ws.close()
return ws
app = web.Application()
app.router.add_get("/agent_ws/", handler)
server = await aiohttp_server(app)
client = AgentSessionClient(AgentSessionConfig(base_ws_url=str(server.make_url("/agent_ws/"))))
chunks = []
async for chunk in client.stream_message(
thread_key=thread_key,
text="hello",
):
chunks.append(chunk)
assert chunks == [
MessageChunk(message_id=thread_key, delta="hel", finished=False, tokens_used=0),
MessageChunk(message_id=thread_key, delta="lo", finished=False, tokens_used=0),
MessageChunk(message_id=thread_key, delta="", finished=True, tokens_used=7),
]
@pytest.mark.asyncio
async def test_send_message_collects_streamed_chunks_and_tokens(aiohttp_server):
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
async def handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
assert request.query["thread_id"] == thread_key
await ws.send_json({"type": "STATUS"})
message = await ws.receive_json()
assert message == {"type": "USER_MESSAGE", "text": "hello world"}
await ws.send_json({"type": "AGENT_EVENT_TEXT_CHUNK", "text": "hello "})
await ws.send_json({"type": "AGENT_EVENT_TEXT_CHUNK", "text": "world"})
await ws.send_json({"type": "AGENT_EVENT_END", "tokens_used": 11})
await ws.close()
return ws
app = web.Application()
app.router.add_get("/agent_ws/", handler)
server = await aiohttp_server(app)
client = AgentSessionClient(AgentSessionConfig(base_ws_url=str(server.make_url("/agent_ws/"))))
result = await client.send_message(
thread_key=thread_key,
text="hello world",
)
assert result == MessageResponse(
message_id=thread_key,
response="hello world",
tokens_used=11,
finished=True,
)
@pytest.mark.asyncio
async def test_process_message_requires_thread_id_query_param():
class FakeWebSocket:
query_params = {}
async def send_text(self, text: str) -> None:
raise AssertionError(f"send_text should not be called: {text}")
class FakeAgentService:
async def astream(self, text: str, thread_id: str):
yield text
with pytest.raises(ValueError, match="thread_id query parameter is required"):
await process_message(
FakeWebSocket(),
MsgUserMessage(text="hello"),
FakeAgentService(),
)
@pytest.mark.asyncio
async def test_process_message_passes_thread_id_to_agent_service():
class FakeWebSocket:
def __init__(self) -> None:
self.query_params = {"thread_id": "6:matrix18:@alice:example.org2:C1"}
self.sent_messages: list[str] = []
async def send_text(self, text: str) -> None:
self.sent_messages.append(text)
class FakeAgentService:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def astream(self, text: str, thread_id: str):
self.calls.append((text, thread_id))
yield "hello"
ws = FakeWebSocket()
agent_service = FakeAgentService()
await process_message(ws, MsgUserMessage(text="hello"), agent_service)
assert agent_service.calls == [("hello", "6:matrix18:@alice:example.org2:C1")]
assert any("AGENT_EVENT_TEXT_CHUNK" in message for message in ws.sent_messages)
assert any("AGENT_EVENT_END" in message for message in ws.sent_messages)
assert "replaced by AgentApiWrapper" in contents.read_text()

View file

@ -1,36 +1,27 @@
import pytest
from core.protocol import SettingsAction
from sdk.agent_session import build_thread_key
from sdk.interface import MessageChunk, MessageResponse, UserSettings
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
class FakeAgentSessionClient:
class FakeAgentApi:
def __init__(self) -> None:
self.send_calls: list[tuple[str, str]] = []
self.stream_calls: list[tuple[str, str]] = []
self.calls: list[str] = []
self.last_tokens_used = 0
async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
self.send_calls.append((thread_key, text))
return MessageResponse(
message_id=thread_key,
response=f"echo:{text}",
tokens_used=3,
finished=True,
)
async def stream_message(self, *, thread_key: str, text: str):
self.stream_calls.append((thread_key, text))
yield MessageChunk(message_id=thread_key, delta=text[:2], finished=False)
yield MessageChunk(message_id=thread_key, delta=text[2:], finished=True, tokens_used=3)
async def send_message(self, text: str):
self.calls.append(text)
yield type("Chunk", (), {"text": text[:2]})()
yield type("Chunk", (), {"text": text[2:]})()
self.last_tokens_used = 3
@pytest.mark.asyncio
async def test_real_platform_client_get_or_create_user_uses_local_state():
client = RealPlatformClient(
agent_sessions=FakeAgentSessionClient(),
agent_api=FakeAgentApi(),
prototype_state=PrototypeStateStore(),
)
@ -45,61 +36,65 @@ async def test_real_platform_client_get_or_create_user_uses_local_state():
@pytest.mark.asyncio
async def test_real_platform_client_send_message_uses_surface_user_thread_identity():
agent_sessions = FakeAgentSessionClient()
async def test_real_platform_client_send_message_collects_stream_output():
agent_api = FakeAgentApi()
client = RealPlatformClient(
agent_sessions=agent_sessions,
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
result = await client.send_message("@alice:example.org", "C1", "hello")
assert result == MessageResponse(
message_id=thread_key,
response="echo:hello",
message_id="@alice:example.org",
response="hello",
tokens_used=3,
finished=True,
)
assert agent_sessions.send_calls == [(thread_key, "hello")]
assert agent_api.calls == ["hello"]
@pytest.mark.asyncio
async def test_real_platform_client_stream_message_uses_surface_user_thread_identity():
agent_sessions = FakeAgentSessionClient()
async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
agent_api = FakeAgentApi()
client = RealPlatformClient(
agent_sessions=agent_sessions,
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
chunks = []
async for chunk in client.stream_message("@alice:example.org", "C1", "hello"):
chunks.append(chunk)
assert chunks == [
MessageChunk(
message_id=thread_key,
message_id="@alice:example.org",
delta="he",
finished=False,
tokens_used=0,
),
MessageChunk(
message_id=thread_key,
message_id="@alice:example.org",
delta="llo",
finished=False,
tokens_used=0,
),
MessageChunk(
message_id="@alice:example.org",
delta="",
finished=True,
tokens_used=3,
),
]
assert agent_sessions.stream_calls == [(thread_key, "hello")]
assert agent_api.calls == ["hello"]
@pytest.mark.asyncio
async def test_real_platform_client_settings_are_local():
client = RealPlatformClient(
agent_sessions=FakeAgentSessionClient(),
agent_api=FakeAgentApi(),
prototype_state=PrototypeStateStore(),
platform="matrix",
)