# -*- 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:, 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)