simbiot-harness/tests/test_agent_channel.py

143 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""t-261015090129-ef5029: канал агент↔агент через MCP (ask_agent/poll_messages).
Запуск: ./venv/bin/python tests/test_agent_channel.py
Сеть НЕ трогается: harness.agent_channel._rpc_tool подменяется фейком —
"портал" отвечает заготовками ask_agent/poll_messages. Проверяет:
- pull(): agent_message → MessageReceived в стор (kind=agent_message,
chat_id=portal:agent:<from>, update_id=id события портала);
- дедуп: повторный pull с теми же id не дублирует инбокс (курсор портала
не сдвинулся — стор второй линией отсекает);
- push_agent(): ask_agent(to, text, kind) — ok:true → True; error → False;
- send_reply() на agent-сообщение: ветка portal:agent:* уходит ask_agent'ом
(fake перехватывает), ReplySent пишется, сообщение закрывается;
- send_batch_reply(): сводный ответ агенту одним ask_agent;
- sec_reflex(): rate-limit (не чаще MIN_REFLEX_GAP), после сброса — pull.
"""
import json
import os
import sys
_DST = "/tmp/simbiot-test-agent-channel.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_agent_channel"
os.environ.pop("SIMBIOT_HOME", None)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import agent_channel, es, tg_inbox
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("" if cond else ("| " + str(extra))))
if not cond:
fails.append(name)
es.init_db()
# ── фейк MCP-шлюза ──────────────────────────────────────────────────────────
CALLS = []
def _fake_rpc(tool, arguments):
CALLS.append((tool, arguments))
if tool == "poll_messages":
return json.dumps({"messages": FAKE_QUEUE.get("messages", []),
"count": len(FAKE_QUEUE.get("messages", [])),
"note": "ok"})
if tool == "ask_agent":
return json.dumps({"ok": True, "to": arguments.get("to"),
"delivered": "agent_message"})
return json.dumps({"error": "unknown"})
agent_channel._rpc_tool = _fake_rpc
FAKE_QUEUE = {"messages": [
{"id": 9001, "from": "OpenCode", "kind": "help",
"text": "🆘 Запрос помощи от «OpenCode»:\n\nСимбиот, глянь deploy_app для groupsign",
"ts": "2026-10-15 09:00:00"},
{"id": 9002, "from": "Roy", "kind": "handoff",
"text": "🤝 Передача задачи от «Roy»:\n\nЗабери мою задачу по туннелям",
"ts": "2026-10-15 09:05:00"},
]}
# ── 1. pull → инбокс ────────────────────────────────────────────────────────
n = agent_channel.pull()
check("pull инжестит 2 сообщения", n == 2, n)
model = es.run_projections()
inbox = model.get("inbox") or {}
m1 = inbox.get("m-9001")
check("m-9001 в сторе", m1 is not None)
if m1:
check("chat_id агентный", m1.get("chat_id") == "portal:agent:OpenCode", m1.get("chat_id"))
check("kind=agent_message", m1.get("kind") == "agent_message")
check("username=агент", m1.get("username") == "OpenCode")
check("agent_kind=help", m1.get("agent_kind") == "help")
check("m-9002 в сторе", inbox.get("m-9002") is not None)
# ── 2. дедуп: тот же poll снова ─────────────────────────────────────────────
n2 = agent_channel.pull()
check("повторный pull — 0 новых (дедуп стора)", n2 == 0, n2)
# ── 3. push_agent ───────────────────────────────────────────────────────────
ok = agent_channel.push_agent("OpenCode", "deploy ок, лог чист", "help")
check("push_agent ok", ok is True and CALLS[-1][0] == "ask_agent", CALLS[-1:])
args = CALLS[-1][1]
check("ask_agent аргументы", args.get("to") == "OpenCode"
and args.get("text") == "deploy ок, лог чист"
and args.get("kind") == "help", args)
ok = agent_channel.push_agent("", "пустой адресат")
check("push_agent без адресата — False", ok is False)
ok = agent_channel.push_agent("Nobody", "")
check("push_agent без текста — False", ok is False)
# ── 4. send_reply на агентское сообщение ────────────────────────────────────
rid = tg_inbox.send_reply("m-9001", "Симбиот: deploy посмотрел, всё чисто. Детали в тике.")
check("send_reply вернул id", rid == "m-9001", rid)
check("ушёл ask_agent (не TG)", CALLS[-1][0] == "ask_agent", CALLS[-1:])
check("адресат из chat_id", CALLS[-1][1].get("to") == "OpenCode", CALLS[-1][1])
model = es.run_projections()
m1 = (model.get("inbox") or {}).get("m-9001")
check("m-9001 закрыт (replied)", bool(m1 and m1.get("replied")))
check("канал ответа agent_mcp", m1 and m1.get("reply_text") == "Симбиот: deploy посмотрел, всё чисто. Детали в тике.")
# ── 5. батч: сводный ответ агенту ───────────────────────────────────────────
FAKE_QUEUE["messages"] = [{"id": 9003, "from": "Roy", "kind": "review",
"text": "Проверь мой отчёт по туннелям", "ts": "2026-10-15 09:10:00"},
{"id": 9004, "from": "Roy", "kind": "review",
"text": "И заодно по слотам", "ts": "2026-10-15 09:11:00"}]
n = agent_channel.pull()
check("pull инжестит вторую партию 2 сообщения", n == 2, n)
CALLS.clear()
head = tg_inbox.send_batch_reply(["m-9003", "m-9004"], "Принял оба: отчёт и слоты проверю.")
check("батч закрыт головным", head == "m-9003", head)
ask_calls = [c for c in CALLS if c[0] == "ask_agent"]
check("один ask_agent на батч", len(ask_calls) == 1, CALLS)
model = es.run_projections()
m3 = (model.get("inbox") or {}).get("m-9003")
m4 = (model.get("inbox") or {}).get("m-9004")
check("оба закрыты", bool(m3 and m3.get("replied") and m4 and m4.get("replied")))
# ── 6. sec_reflex rate-limit ────────────────────────────────────────────────
agent_channel._last_reflex["ts"] = 0.0
r1 = agent_channel.sec_reflex()
r2 = agent_channel.sec_reflex()
check("первый рефлекс работает", "ingested" in r1, r1)
check("второй — rate-limit", r2.get("skipped") == "rate-limit", r2)
# ── 7. pull без токена — тихо ───────────────────────────────────────────────
_tok = os.environ.pop("SIMBIOT_PORTAL_MCP_TOKEN")
n = agent_channel.pull()
check("без токена — 0, без исключений", n == 0, n)
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = _tok
print()
print("FAILS:", len(fails), fails if fails else "")
sys.exit(1 if fails else 0)