feat(04-01): finalize AgentApi migration
This commit is contained in:
parent
cd59d89617
commit
430c82dba1
9 changed files with 225 additions and 350 deletions
|
|
@ -58,9 +58,9 @@ Plans:
|
||||||
**Plans:** 3 plans
|
**Plans:** 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests
|
- [x] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests
|
||||||
- [ ] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception
|
- [x] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception
|
||||||
- [ ] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update
|
- [x] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: — Production-ready surfaces
|
milestone_name: — Production-ready surfaces
|
||||||
status: Ready to execute
|
status: Ready to execute
|
||||||
last_updated: "2026-04-17T12:34:33.578Z"
|
last_updated: "2026-04-17T16:10:00.000Z"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 1
|
completed_phases: 2
|
||||||
total_plans: 12
|
total_plans: 12
|
||||||
completed_plans: 6
|
completed_plans: 9
|
||||||
percent: 50
|
percent: 75
|
||||||
---
|
---
|
||||||
|
|
||||||
# State
|
# State
|
||||||
|
|
@ -19,13 +19,13 @@ progress:
|
||||||
See: .planning/PROJECT.md (updated 2026-04-02)
|
See: .planning/PROJECT.md (updated 2026-04-02)
|
||||||
|
|
||||||
**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра
|
**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра
|
||||||
**Current focus:** Phase 02 — SDK Integration (blocked on Lambda platform SDK readiness)
|
**Current focus:** Phase 04 complete — Matrix MVP implementation ready for testing
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
|
|
||||||
**Phase 2** of 3: SDK Integration
|
**Phase 4** implementation complete: Matrix MVP
|
||||||
|
|
||||||
Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is available.
|
Phase 4 is implemented. Next step is manual and automated testing of the Matrix MVP flow before deciding on follow-up work.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
|
|
@ -43,6 +43,9 @@ Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is av
|
||||||
- [Phase 01]: Removed Matrix reaction conversion entirely and kept command callbacks limited to !yes/!no.
|
- [Phase 01]: Removed Matrix reaction conversion entirely and kept command callbacks limited to !yes/!no.
|
||||||
- [Phase 01]: Kept !settings as a pure snapshot surface while preserving mutable subcommands outside the dashboard.
|
- [Phase 01]: Kept !settings as a pure snapshot surface while preserving mutable subcommands outside the dashboard.
|
||||||
- [Phase 01]: Seeded invite and dispatcher tests with explicit next_chat_index and room ids instead of treating C1 as Matrix transport identity.
|
- [Phase 01]: Seeded invite and dispatcher tests with explicit next_chat_index and room ids instead of treating C1 as Matrix transport identity.
|
||||||
|
- [Phase 04]: Replaced AgentSessionClient with AgentApiWrapper and persistent agent connection lifecycle in Matrix runtime.
|
||||||
|
- [Phase 04]: Added !save, !load, !reset, and !context commands with pending-state interception and local prototype session metadata.
|
||||||
|
- [Phase 04]: Added Matrix-only Docker packaging for MVP deployment; platform services remain external to this compose setup.
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
|
||||||
|
|
@ -54,6 +57,7 @@ Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is av
|
||||||
|
|
||||||
- Phase 01.1 inserted after Phase 01: Matrix restart reconciliation and dev reset workflow (URGENT)
|
- Phase 01.1 inserted after Phase 01: Matrix restart reconciliation and dev reset workflow (URGENT)
|
||||||
- Phase 4 added: Matrix MVP: shared agent context and context management command
|
- Phase 4 added: Matrix MVP: shared agent context and context management command
|
||||||
|
- New platform signal: upcoming proper `chat_id` support should enable file-level context separation and stronger context management in a future follow-up phase.
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
|
|
@ -65,8 +69,11 @@ Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is av
|
||||||
| 01 | 04 | 3 min | 2 | 7 | 2026-04-02T20:03:38Z |
|
| 01 | 04 | 3 min | 2 | 7 | 2026-04-02T20:03:38Z |
|
||||||
| 01 | 05 | 2 min | 2 | 7 | 2026-04-03T09:28:47Z |
|
| 01 | 05 | 2 min | 2 | 7 | 2026-04-03T09:28:47Z |
|
||||||
| 01 | 06 | 4 min | 2 | 7 | 2026-04-03T09:35:39Z |
|
| 01 | 06 | 4 min | 2 | 7 | 2026-04-03T09:35:39Z |
|
||||||
|
| 04 | 01 | 1 session | 1 wave | 8 | 2026-04-17 |
|
||||||
|
| 04 | 02 | 1 session | 2 commits + summary | 8 | 2026-04-17 |
|
||||||
|
| 04 | 03 | 1 session | 1 commit + summary | 4 | 2026-04-17 |
|
||||||
|
|
||||||
## Session
|
## Session
|
||||||
|
|
||||||
- Last session: 2026-04-03T09:35:39Z
|
- Last session: 2026-04-17T16:10:00Z
|
||||||
- Stopped at: Completed 01-06-PLAN.md
|
- Stopped at: Phase 4 implementation complete, ready for testing
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 04-01 Summary
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Replaced the Matrix real backend's custom `AgentSessionClient` path with a shared
|
||||||
|
`AgentApiWrapper` over upstream `lambda_agent_api.AgentApi`.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added `sdk/agent_api_wrapper.py` to capture `MsgEventEnd.tokens_used` without
|
||||||
|
modifying `external/`.
|
||||||
|
- Rewrote `sdk/real.py` to use a shared `agent_api`, stream text chunks from
|
||||||
|
`AgentApi.send_message()`, and emit a final `MessageChunk` with
|
||||||
|
`last_tokens_used`.
|
||||||
|
- Updated `adapter/matrix/bot.py` to construct `RealPlatformClient` with
|
||||||
|
`AgentApiWrapper`, keep `AGENT_WS_URL` unchanged, and manage
|
||||||
|
`agent_api.connect()` / `agent_api.close()` around `sync_forever()`.
|
||||||
|
- Stubbed `sdk/agent_session.py` as a compatibility placeholder.
|
||||||
|
- Updated Matrix/runtime tests away from `thread_key` and per-request websocket
|
||||||
|
assumptions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pytest tests/platform/test_real.py -q`
|
||||||
|
- `pytest tests/adapter/matrix/test_dispatcher.py -q`
|
||||||
|
- `pytest tests/core/test_integration.py -q`
|
||||||
|
- `pytest tests/platform/test_agent_session.py -q`
|
||||||
|
|
||||||
|
All listed commands passed locally.
|
||||||
88
sdk/agent_api_wrapper.py
Normal file
88
sdk/agent_api_wrapper.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
|
||||||
|
if str(_api_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_api_root))
|
||||||
|
|
||||||
|
from lambda_agent_api.agent_api import AgentApi, AgentException
|
||||||
|
from lambda_agent_api.server import (
|
||||||
|
MsgError,
|
||||||
|
MsgEventEnd,
|
||||||
|
MsgEventTextChunk,
|
||||||
|
MsgGracefulDisconnect,
|
||||||
|
ServerMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentApiWrapper(AgentApi):
|
||||||
|
"""Capture tokens_used from MsgEventEnd without patching upstream code."""
|
||||||
|
|
||||||
|
def __init__(self, agent_id: str, url: str, **kwargs) -> None:
|
||||||
|
super().__init__(agent_id=agent_id, url=url, **kwargs)
|
||||||
|
self.last_tokens_used = 0
|
||||||
|
|
||||||
|
async def _listen(self):
|
||||||
|
try:
|
||||||
|
async for msg in self._ws:
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
outgoing_msg = ServerMessage.validate_json(msg.data)
|
||||||
|
|
||||||
|
if isinstance(outgoing_msg, MsgEventTextChunk):
|
||||||
|
if self._current_queue:
|
||||||
|
await self._current_queue.put(outgoing_msg)
|
||||||
|
elif self.callback:
|
||||||
|
self.callback(outgoing_msg)
|
||||||
|
else:
|
||||||
|
logger.warning("[%s] AgentEvent without active request", self.id)
|
||||||
|
|
||||||
|
elif isinstance(outgoing_msg, MsgEventEnd):
|
||||||
|
self.last_tokens_used = outgoing_msg.tokens_used
|
||||||
|
if self._current_queue:
|
||||||
|
await self._current_queue.put(outgoing_msg)
|
||||||
|
|
||||||
|
elif isinstance(outgoing_msg, MsgError):
|
||||||
|
if self.callback:
|
||||||
|
self.callback(outgoing_msg)
|
||||||
|
error = AgentException(outgoing_msg.code, outgoing_msg.details)
|
||||||
|
logger.error("[%s] Agent error: %s", self.id, error)
|
||||||
|
if self._current_queue:
|
||||||
|
await self._current_queue.put(error)
|
||||||
|
|
||||||
|
elif isinstance(outgoing_msg, MsgGracefulDisconnect):
|
||||||
|
if self.callback:
|
||||||
|
self.callback(outgoing_msg)
|
||||||
|
logger.info("[%s] Gracefully disconnecting", self.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning("[%s] Unknown message type: %s", self.id, outgoing_msg.type)
|
||||||
|
if self.callback:
|
||||||
|
self.callback(outgoing_msg)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[%s] Failed to deserialize message: %s", self.id, exc)
|
||||||
|
if self._current_queue:
|
||||||
|
await self._current_queue.put(
|
||||||
|
AgentException("PARSE_ERROR", f"Validation failed: {exc}")
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
|
||||||
|
logger.error("[%s] WebSocket closed/error: %s", self.id, msg.type)
|
||||||
|
break
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[%s] Error in listen loop: %s", self.id, exc)
|
||||||
|
finally:
|
||||||
|
await self._cleanup()
|
||||||
|
|
@ -1,93 +1 @@
|
||||||
from __future__ import annotations
|
"""Compatibility stub: AgentSessionClient was replaced by AgentApiWrapper in Phase 4."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import AsyncIterator
|
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
||||||
|
|
||||||
from sdk.interface import MessageChunk, MessageResponse, PlatformError
|
|
||||||
|
|
||||||
|
|
||||||
def build_thread_key(platform: str, user_id: str, chat_id: str) -> str:
|
|
||||||
return f"{len(platform)}:{platform}{len(user_id)}:{user_id}{len(chat_id)}:{chat_id}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AgentSessionConfig:
|
|
||||||
base_ws_url: str
|
|
||||||
timeout_seconds: float = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
class AgentSessionClient:
|
|
||||||
def __init__(self, config: AgentSessionConfig) -> None:
|
|
||||||
self._config = config
|
|
||||||
|
|
||||||
async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
|
|
||||||
response_parts: list[str] = []
|
|
||||||
tokens_used = 0
|
|
||||||
|
|
||||||
async for chunk in self.stream_message(thread_key=thread_key, text=text):
|
|
||||||
if chunk.delta:
|
|
||||||
response_parts.append(chunk.delta)
|
|
||||||
if chunk.finished:
|
|
||||||
tokens_used = chunk.tokens_used
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
message_id=thread_key,
|
|
||||||
response="".join(response_parts),
|
|
||||||
tokens_used=tokens_used,
|
|
||||||
finished=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stream_message(self, *, thread_key: str, text: str) -> AsyncIterator[MessageChunk]:
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.ws_connect(
|
|
||||||
self._ws_url(thread_key),
|
|
||||||
heartbeat=30,
|
|
||||||
) as ws:
|
|
||||||
status = await ws.receive_json(timeout=self._config.timeout_seconds)
|
|
||||||
if status.get("type") != "STATUS":
|
|
||||||
raise PlatformError("Agent did not send STATUS", code="AGENT_PROTOCOL_ERROR")
|
|
||||||
|
|
||||||
await ws.send_json({"type": "USER_MESSAGE", "text": text})
|
|
||||||
|
|
||||||
while True:
|
|
||||||
payload = await ws.receive_json(timeout=self._config.timeout_seconds)
|
|
||||||
msg_type = payload.get("type")
|
|
||||||
|
|
||||||
if msg_type == "AGENT_EVENT_TEXT_CHUNK":
|
|
||||||
yield MessageChunk(
|
|
||||||
message_id=thread_key,
|
|
||||||
delta=payload["text"],
|
|
||||||
finished=False,
|
|
||||||
)
|
|
||||||
elif msg_type == "AGENT_EVENT_END":
|
|
||||||
yield MessageChunk(
|
|
||||||
message_id=thread_key,
|
|
||||||
delta="",
|
|
||||||
finished=True,
|
|
||||||
tokens_used=payload.get("tokens_used", 0),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif msg_type == "ERROR":
|
|
||||||
raise PlatformError(
|
|
||||||
payload.get("details", "Agent error"),
|
|
||||||
code=payload.get("code", "AGENT_ERROR"),
|
|
||||||
)
|
|
||||||
elif msg_type == "GRACEFUL_DISCONNECT":
|
|
||||||
raise PlatformError(
|
|
||||||
"Agent disconnected gracefully",
|
|
||||||
code="GRACEFUL_DISCONNECT",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise PlatformError(
|
|
||||||
f"Unexpected agent message: {payload}",
|
|
||||||
code="AGENT_PROTOCOL_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _ws_url(self, thread_key: str) -> str:
|
|
||||||
parts = urlsplit(self._config.base_ws_url)
|
|
||||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
||||||
query["thread_id"] = thread_key
|
|
||||||
return urlunsplit(parts._replace(query=urlencode(query)))
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
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.handlers.auth import handle_invite
|
||||||
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
|
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
|
||||||
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
|
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
|
||||||
|
from sdk.interface import PlatformError
|
||||||
from sdk.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
from sdk.real import RealPlatformClient
|
from sdk.real import RealPlatformClient
|
||||||
|
|
||||||
|
|
@ -199,6 +201,31 @@ async def test_bot_ignores_its_own_messages():
|
||||||
bot._send_all.assert_not_awaited()
|
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():
|
async def test_mat11_settings_returns_dashboard():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
current_chat_id = "C9"
|
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):
|
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("MATRIX_PLATFORM_BACKEND", "real")
|
||||||
monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
|
monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
|
||||||
|
|
||||||
runtime = build_runtime()
|
runtime = build_runtime()
|
||||||
|
|
||||||
assert isinstance(runtime.platform, RealPlatformClient)
|
assert isinstance(runtime.platform, RealPlatformClient)
|
||||||
|
assert runtime.platform.agent_api.url == "ws://agent.example/agent_ws/"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ Smoke test: полный цикл через dispatcher + реальные manag
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from sdk.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
from sdk.agent_session import build_thread_key
|
|
||||||
from sdk.interface import MessageChunk, MessageResponse
|
from sdk.interface import MessageChunk, MessageResponse
|
||||||
from sdk.prototype_state import PrototypeStateStore
|
from sdk.prototype_state import PrototypeStateStore
|
||||||
from sdk.real import RealPlatformClient
|
from sdk.real import RealPlatformClient
|
||||||
|
|
@ -22,28 +21,15 @@ from core.protocol import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeAgentSessionClient:
|
class FakeAgentApi:
|
||||||
def __init__(self) -> None:
|
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:
|
async def send_message(self, text: str):
|
||||||
self.send_calls.append((thread_key, text))
|
self.calls.append(text)
|
||||||
return MessageResponse(
|
yield type("Chunk", (), {"text": f"[REAL] {text}"})()
|
||||||
message_id=thread_key,
|
self.last_tokens_used = 5
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -62,9 +48,9 @@ def dispatcher():
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def real_dispatcher():
|
def real_dispatcher():
|
||||||
agent_sessions = FakeAgentSessionClient()
|
agent_api = FakeAgentApi()
|
||||||
platform = RealPlatformClient(
|
platform = RealPlatformClient(
|
||||||
agent_sessions=agent_sessions,
|
agent_api=agent_api,
|
||||||
prototype_state=PrototypeStateStore(),
|
prototype_state=PrototypeStateStore(),
|
||||||
platform="matrix",
|
platform="matrix",
|
||||||
)
|
)
|
||||||
|
|
@ -76,7 +62,7 @@ def real_dispatcher():
|
||||||
settings_mgr=SettingsManager(platform, store),
|
settings_mgr=SettingsManager(platform, store),
|
||||||
)
|
)
|
||||||
register_all(d)
|
register_all(d)
|
||||||
return d, agent_sessions
|
return d, agent_api
|
||||||
|
|
||||||
|
|
||||||
async def test_full_flow_start_then_message(dispatcher):
|
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))
|
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):
|
async def test_full_flow_with_real_platform_uses_shared_agent_api(real_dispatcher):
|
||||||
dispatcher, agent_sessions = real_dispatcher
|
dispatcher, agent_api = real_dispatcher
|
||||||
|
|
||||||
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
|
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
|
||||||
result = await dispatcher.dispatch(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)]
|
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
|
||||||
|
|
||||||
assert texts == ["[REAL] Привет!"]
|
assert texts == ["[REAL] Привет!"]
|
||||||
assert agent_sessions.send_calls == [
|
assert agent_api.calls == ["Привет!"]
|
||||||
(build_thread_key("matrix", "u1", "C1"), "Привет!")
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,193 +1,21 @@
|
||||||
|
"""Compatibility tests after the Phase 4 migration."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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():
|
_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
|
||||||
assert build_thread_key("matrix", "@alice:example.org", "C1") == "6:matrix18:@alice:example.org2:C1"
|
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():
|
def test_lambda_agent_api_module_is_importable():
|
||||||
left = build_thread_key("matrix", "@alice:example.org", "C1")
|
from lambda_agent_api.agent_api import AgentApi
|
||||||
right = build_thread_key("matrix", "@alice", "example.org:C1")
|
|
||||||
|
|
||||||
assert left != right
|
assert AgentApi is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_agent_session_module_is_intentionally_stubbed():
|
||||||
async def test_stream_message_yields_text_chunks_and_end(aiohttp_server):
|
contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py"
|
||||||
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
|
|
||||||
|
|
||||||
async def handler(request):
|
assert "replaced by AgentApiWrapper" in contents.read_text()
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,27 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.protocol import SettingsAction
|
from core.protocol import SettingsAction
|
||||||
from sdk.agent_session import build_thread_key
|
|
||||||
from sdk.interface import MessageChunk, MessageResponse, UserSettings
|
from sdk.interface import MessageChunk, MessageResponse, UserSettings
|
||||||
from sdk.prototype_state import PrototypeStateStore
|
from sdk.prototype_state import PrototypeStateStore
|
||||||
from sdk.real import RealPlatformClient
|
from sdk.real import RealPlatformClient
|
||||||
|
|
||||||
|
|
||||||
class FakeAgentSessionClient:
|
class FakeAgentApi:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.send_calls: list[tuple[str, str]] = []
|
self.calls: list[str] = []
|
||||||
self.stream_calls: list[tuple[str, str]] = []
|
self.last_tokens_used = 0
|
||||||
|
|
||||||
async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
|
async def send_message(self, text: str):
|
||||||
self.send_calls.append((thread_key, text))
|
self.calls.append(text)
|
||||||
return MessageResponse(
|
yield type("Chunk", (), {"text": text[:2]})()
|
||||||
message_id=thread_key,
|
yield type("Chunk", (), {"text": text[2:]})()
|
||||||
response=f"echo:{text}",
|
self.last_tokens_used = 3
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_client_get_or_create_user_uses_local_state():
|
async def test_real_platform_client_get_or_create_user_uses_local_state():
|
||||||
client = RealPlatformClient(
|
client = RealPlatformClient(
|
||||||
agent_sessions=FakeAgentSessionClient(),
|
agent_api=FakeAgentApi(),
|
||||||
prototype_state=PrototypeStateStore(),
|
prototype_state=PrototypeStateStore(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,61 +36,65 @@ async def test_real_platform_client_get_or_create_user_uses_local_state():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_client_send_message_uses_surface_user_thread_identity():
|
async def test_real_platform_client_send_message_collects_stream_output():
|
||||||
agent_sessions = FakeAgentSessionClient()
|
agent_api = FakeAgentApi()
|
||||||
client = RealPlatformClient(
|
client = RealPlatformClient(
|
||||||
agent_sessions=agent_sessions,
|
agent_api=agent_api,
|
||||||
prototype_state=PrototypeStateStore(),
|
prototype_state=PrototypeStateStore(),
|
||||||
platform="matrix",
|
platform="matrix",
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
|
|
||||||
result = await client.send_message("@alice:example.org", "C1", "hello")
|
result = await client.send_message("@alice:example.org", "C1", "hello")
|
||||||
|
|
||||||
assert result == MessageResponse(
|
assert result == MessageResponse(
|
||||||
message_id=thread_key,
|
message_id="@alice:example.org",
|
||||||
response="echo:hello",
|
response="hello",
|
||||||
tokens_used=3,
|
tokens_used=3,
|
||||||
finished=True,
|
finished=True,
|
||||||
)
|
)
|
||||||
assert agent_sessions.send_calls == [(thread_key, "hello")]
|
assert agent_api.calls == ["hello"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_client_stream_message_uses_surface_user_thread_identity():
|
async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
|
||||||
agent_sessions = FakeAgentSessionClient()
|
agent_api = FakeAgentApi()
|
||||||
client = RealPlatformClient(
|
client = RealPlatformClient(
|
||||||
agent_sessions=agent_sessions,
|
agent_api=agent_api,
|
||||||
prototype_state=PrototypeStateStore(),
|
prototype_state=PrototypeStateStore(),
|
||||||
platform="matrix",
|
platform="matrix",
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_key = build_thread_key("matrix", "@alice:example.org", "C1")
|
|
||||||
chunks = []
|
chunks = []
|
||||||
async for chunk in client.stream_message("@alice:example.org", "C1", "hello"):
|
async for chunk in client.stream_message("@alice:example.org", "C1", "hello"):
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
|
|
||||||
assert chunks == [
|
assert chunks == [
|
||||||
MessageChunk(
|
MessageChunk(
|
||||||
message_id=thread_key,
|
message_id="@alice:example.org",
|
||||||
delta="he",
|
delta="he",
|
||||||
finished=False,
|
finished=False,
|
||||||
tokens_used=0,
|
tokens_used=0,
|
||||||
),
|
),
|
||||||
MessageChunk(
|
MessageChunk(
|
||||||
message_id=thread_key,
|
message_id="@alice:example.org",
|
||||||
delta="llo",
|
delta="llo",
|
||||||
|
finished=False,
|
||||||
|
tokens_used=0,
|
||||||
|
),
|
||||||
|
MessageChunk(
|
||||||
|
message_id="@alice:example.org",
|
||||||
|
delta="",
|
||||||
finished=True,
|
finished=True,
|
||||||
tokens_used=3,
|
tokens_used=3,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
assert agent_sessions.stream_calls == [(thread_key, "hello")]
|
assert agent_api.calls == ["hello"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_client_settings_are_local():
|
async def test_real_platform_client_settings_are_local():
|
||||||
client = RealPlatformClient(
|
client = RealPlatformClient(
|
||||||
agent_sessions=FakeAgentSessionClient(),
|
agent_api=FakeAgentApi(),
|
||||||
prototype_state=PrototypeStateStore(),
|
prototype_state=PrototypeStateStore(),
|
||||||
platform="matrix",
|
platform="matrix",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue