From 20e655e4ec308d2666870fbbaf0c5af43e9bb8be Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 4 Oct 2026 17:15:55 +0000 Subject: [PATCH] =?UTF-8?q?test:=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20inbox=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B6=D0=B4=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D1=83-=D0=BA=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=20(t-261004160532-0fb500)=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81=20=D0=BE=D0=B1=D0=BE=D0=B8?= =?UTF-8?q?=D1=85=20=D1=84=D0=B8=D0=BA=D1=81=D0=BE=D0=B2=20(e-261003125707?= =?UTF-8?q?=20'list'=3D=D0=B4=D0=BE=D1=81=D0=BA=D0=B0,=20e-261004134613=20?= =?UTF-8?q?task=3D=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5):=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80,=20CLI-=D0=B3=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D1=8B=20(=D1=81=D1=87=D1=91=D1=82=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=20=D0=BD=D0=B5=20=D1=80=D0=B0=D1=81=D1=82=D1=91?= =?UTF-8?q?=D1=82,=20=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20ID=20=E2=86=92=20exit=20?= =?UTF-8?q?2),=20=D0=B8=D0=BD=D0=B1=D0=BE=D0=BA=D1=81-=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D1=8C=20('list'/ID=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B5=20=E2=86=92=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0/=D1=80=D0=B5=D1=87=D1=8C,=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2);=204=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=20pytest+plain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_no_clone_inbox.py | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_no_clone_inbox.py diff --git a/tests/test_no_clone_inbox.py b/tests/test_no_clone_inbox.py new file mode 100644 index 0000000..fa49fe0 --- /dev/null +++ b/tests/test_no_clone_inbox.py @@ -0,0 +1,159 @@ +# -*- 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)