simbiot-harness/tests/test_no_clone_inbox.py

159 lines
8 KiB
Python
Raw Permalink 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-261004160532: служебный текст inbox не порождает задачу-клон.
Паттерн само-клонирования чинился дважды:
e-261003125707 — 'list' из чата → мин-агент звал `cli.py task list`,
создавался клон «list»; фикс: 'list'/'tasks'/'task list' = доска;
e-261004134613 — агент звал `cli.py task t-...` для ЧТЕНИЯ задачи,
создавался клон с title=ID; фикс: title=ID → показать / exit 2.
Регресс проверяет весь путь: парсер команд ('list' — команда, а не речь),
CLI-гварды (list/ID не создают задач), и инбокс-уровень (служебные тексты
распознаются как команды → answered, LLM их не плодит).
Запуск: ./venv/bin/python tests/test_no_clone_inbox.py | pytest tests/test_no_clone_inbox.py
"""
import os
import subprocess
import sys
# ── изоляция: свежая БД в /tmp, прод не нужен ──
_DST = "/tmp/simbiot-test-no-clone.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
sys.path.insert(0, "/root/simbiot-harness")
os.environ["SIMBIOT_DB"] = _DST
from harness import es, tg_commands, tg_inbox
fails = []
_REPO = "/root/simbiot-harness"
_PY = os.path.join(_REPO, "venv", "bin", "python")
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "", flush=True)
if not cond:
fails.append(name)
raise AssertionError(f"{name} {extra}")
es.init_db()
def _cli(*args):
"""cli.py в изолированной БД (env наследуется)."""
r = subprocess.run([_PY, os.path.join(_REPO, "cli.py")] + list(args),
capture_output=True, text=True, timeout=60,
cwd=_REPO)
return r.returncode, (r.stdout or "") + (r.stderr or "")
def _task_count():
return len((es.run_projections().get("tasks") or {}))
# ── 1. парсер: служебные слова — команды, а не речь для LLM ──────────────────
def test_parse_service_words():
P = tg_commands.parse
check("parse 'list' → команда задачи (e-261001124837)",
P("list") == "задачи", f"{P('list')!r}")
check("parse 'tasks' → команда", P("tasks") == "задачи")
check("parse 'LIST' регистронезависимо", P("LIST") == "задачи")
check("parse '/задачи' → команда", P("/задачи") == "задачи")
check("parse 'list please' — НЕ команда (речь, но не служебный текст)",
P("list please") is None)
# ID задачи в тексте — тоже служебный шум, не команда и не «задача»
check("parse 't-261004160531-ed25ed' — не команда", P("t-261004160531-ed25ed") is None)
# ── 2. CLI-гварды: list / ID не создают задач ────────────────────────────────
def test_cli_list_is_board_not_clone():
"""`cli.py task list` → доска; число задач не растёт (e-261003125707)."""
from harness import commands
commands.submit_task("реальная работа", "", minutes=5)
before = _task_count()
check("задача-незабудка создана", before >= 1)
for variant in ("list", "tasks", "task list"):
rc, out = _cli("task", variant)
check(f"cli task '{variant}': rc=0", rc == 0, f"rc={rc} {out[:120]}")
check(f"cli task '{variant}': показывает доску",
"реальная работа" in out, out[:150])
after = _task_count()
check("cli task list/tasks/task list: клонов НЕТ (счёт задач тот же)",
after == before, f"было {before}, стало {after}")
def test_cli_task_id_reads_not_clones():
"""`cli.py task <id>` → чтение; несуществующий → exit 2 без клона."""
from harness import commands
real_id, _ = commands.submit_task("целевая задача для чтения", "", minutes=5)
before = _task_count()
rc, out = _cli("task", real_id)
check("cli task <существующий id>: rc=0", rc == 0, f"rc={rc}")
check("cli task <id>: показывает задачу", "целевая задача для чтения" in out, out[:200])
fake = "t-269999999999-deadbe"
rc2, out2 = _cli("task", fake)
check("cli task <несуществующий id>: exit 2", rc2 == 2, f"rc={rc2}")
check("cli task <несуществующий id>: отказ в создании клона",
"не найдена" in out2 and "запрещено" in out2, out2[:200])
after = _task_count()
check("cli task <id>: клонов НЕТ (e-261004134613)",
after == before, f"было {before}, стало {after}")
# ── 3. инбокс-уровень: служебный текст распознан и отвечен без клона ────────
def test_inbox_service_text_no_task():
"""'list' и ID задачи в inbox → ответ командой, задач не прибавилось."""
before = _task_count()
# в реальном стриме их кладёт поллер; кладём так же, как он
tg_inbox.receive_message({"update_id": 991001, "message": {
"message_id": 991001 % 100000, "date": 1780000100,
"chat": {"id": 42}, "from": {"id": 1, "username": "alex", "first_name": "Alex"},
"text": "list"}})
tg_inbox.receive_message({"update_id": 991002, "message": {
"message_id": 991002 % 100000, "date": 1780000101,
"chat": {"id": 42}, "from": {"id": 1, "username": "alex", "first_name": "Alex"},
"text": "t-261004160531-ed25ed что там по срокам?"}})
model = es.run_projections()
inbox = model.get("inbox") or {}
check("инбокс: оба служебных текста приняты",
"m-991001" in inbox and "m-991002" in inbox)
check("инбокс: 'list' распознан как команда ('задачи')",
tg_commands.parse(inbox["m-991001"]["text"]) == "задачи")
check("инбокс: ID в фразе — не команда (речь, уходит LLM)",
tg_commands.parse(inbox["m-991002"]["text"]) is None)
# minute-level фильтр: команды не показываются мин-LLM как «сырые» входящие
from harness.scheduler import build_context, LEVELS
fake = {"inbox": dict(inbox), "tasks": dict(model.get("tasks") or {}),
"escalations": {}, "policy": {}}
ctx = build_context(LEVELS["min"], fake)
check("build_context(min): 'list' не показан как открытое входящее",
"m-991001" not in ctx or "list" not in _line(ctx, "m-991001"))
check("после обработки служебных текстов задач-клонов НЕТ",
_task_count() == before, f"было {before}, стало {_task_count()}")
def _line(ctx, marker):
for ln in ctx.splitlines():
if marker in ln:
return ln
return ""
# ────────────────────────────── plain-run режим ──────────────────────────────
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for t in tests:
try:
t()
except AssertionError:
pass
print(f"\n{'='*60}")
print(f"{'ALL OK' if not fails else 'FAILED: ' + ', '.join(fails)} ({len(tests)} тестов)")
sys.exit(1 if fails else 0)