test: служебный текст inbox не порождает задачу-клон (t-261004160532-0fb500) — регресс обоих фиксов (e-261003125707 'list'=доска, e-261004134613 task<ID>=чтение): парсер, CLI-гварды (счёт задач не растёт, несуществующий ID → exit 2), инбокс-уровень ('list'/ID в тексте → команда/речь, без клонов); 4 теста pytest+plain

This commit is contained in:
Simbiot Harness 2026-10-04 17:15:55 +00:00
parent 5ca2bbe96c
commit 20e655e4ec

View file

@ -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 <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)