# -*- 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 ` → чтение; несуществующий → 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 : показывает задачу", "целевая задача для чтения" 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 : клонов НЕТ (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)