diff --git a/harness/commands.py b/harness/commands.py index bbc48d0..a7ec8f5 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -260,6 +260,9 @@ def looks_like_budget_stub(answer: str) -> bool: return bool(_BUDGET_STUB_RE.search(s) and _BUDGET_STUB_MARKERS_RE.search(s)) +MIN_ANSWER = 10 # символов в итоге задачи: короче — не результат («x», «готово») + + def complete_task_with_delivery(task_id: str, answer: str, level: str, result_ref: str = None): """TaskCompleted + немедленная доставка результата в TG-чат. @@ -279,6 +282,12 @@ def complete_task_with_delivery(task_id: str, answer: str, level: str, raise ValueError(f"задача {task_id} в статусе {t['status']} — завершать можно только running") if (answer or "").strip() == "": raise ValueError("пустой результат — доставлять нечего, заверши как failed/requeue") + # мусорный итог (2026-10-18: задача закрыта с answer «x» — Alex получил в чат + # «✅ Готово … x»; раньше — «2», «готово»): результат должен говорить, что сделано + if len((answer or "").strip()) < MIN_ANSWER: + raise ValueError( + f"результат «{answer.strip()}» слишком короткий (< {MIN_ANSWER} симв.): опиши, что " + "сделано и где результат (файл/ссылка/вывод) — это уйдёт Alex в чат") if looks_like_budget_stub(answer): requeue_task(task_id, reason="guard: 'бюджет исчерпан' — не результат, задача в ready (инцидент e-261001074527)") raise ValueError( diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index 4302e17..bf71f27 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -541,9 +541,28 @@ def send_reply(msg_id: str, text: str, timeout: int = 35): "to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")), "channel": "tg", }) + if r.get("ok"): + mark_answered(m) return msg_id +def mark_answered(m: dict): + """✅ на входящее, на которое отвечено (t-261018103940): видно, какие + сообщения батча закрыты. Best effort: недоступность TG не мешает ответу + (ReplySent уже записан — повторно не ставим, т.к. replied-гейт выше). + Только TG-чаты; реакция ✅ заменяет маркер «взято» 👀.""" + chat_id = m.get("chat_id") + if not chat_id or str(chat_id).startswith(("portal", "tui:")): + return + # ✅ не входит в допустимый Telegram набор реакций → 400 REACTION_EMPTY + # (Alex не видел галочку; проверено 2026-10-18). Валидный маркер — 👍. + tg_mid = m.get("message_id") or (m.get("update_id") or 0) % 100000 + try: + set_reaction(chat_id, tg_mid, emoji="👍") + except Exception: + pass # реакция — только визуальный маркер, не блокирует ответ + + def send_followup(msg_id: str, text: str, timeout: int = 35) -> bool: """Досылка результата в ЧАТ входящего, на которое уже ответили (аудит 2026-10-16). @@ -688,6 +707,8 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35): "tg_ok": bool(r.get("ok")), "batch": {"id": batch_id, "size": len(pending), "head": head_mid}, }) + if r.get("ok"): + mark_answered(m) return head_mid diff --git a/tests/test_tg_group_ux.py b/tests/test_tg_group_ux.py index 947c0c5..69b9d39 100644 --- a/tests/test_tg_group_ux.py +++ b/tests/test_tg_group_ux.py @@ -72,5 +72,52 @@ check("правка принятого сообщения — не новое", check("новое сообщение — новое", not tg_inbox._already_received({"chat": {"id": G}, "message_id": 9999})) +# ── 👍 на отвеченное (правка Коди, t-261018103940: ✅ Telegram не принимает) ── +def reactions(): + return [p for m, p in API if m == "setMessageReaction"] + + +for uid, mid in ((10, 3001), (11, 3002), (12, 3003)): + es.append_event("inbox", f"m-{uid}", "MessageReceived", { + "update_id": uid, "chat_id": P, "chat_type": "private", "message_id": mid, + "text": "вопрос", "kind": "text", "first_name": "Alex"}) +API.clear() +tg_inbox.send_reply("m-10", "ответ") +r = reactions() +check("ответ → 👍 на это сообщение", r and r[-1]["message_id"] == 3001 + and json.loads(r[-1]["reaction"]) == [{"type": "emoji", "emoji": "👍"}], r) +API.clear() +tg_inbox.send_batch_reply(["m-11", "m-12"], "сводный") +check("батч → 👍 на каждое сообщение", sorted(p["message_id"] for p in reactions()) == [3002, 3003]) +es.append_event("inbox", "m-13", "MessageReceived", {"update_id": 13, "chat_id": "portal:host", + "text": "x", "kind": "text"}) +API.clear() +tg_inbox.mark_answered({"chat_id": "portal:host", "message_id": 1}) +check("портал — без реакции", not reactions()) +es.append_event("inbox", "m-14", "MessageReceived", {"update_id": 14, "chat_id": P, + "chat_type": "private", "message_id": 3004, "text": "вопрос", "kind": "text"}) +_orig = tg_inbox.set_reaction +tg_inbox.set_reaction = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("TG 400")) +try: + tg_inbox.send_reply("m-14", "ответ при упавшей реакции") + ok = es.run_projections()["inbox"]["m-14"].get("replied") is True +except Exception as e: + ok = False +check("сбой реакции не ломает ответ", ok) +tg_inbox.set_reaction = _orig + +# ── мусорный итог задачи не принимается (2026-10-18: «x» ушёл Alex в чат) ── +from harness import commands +es.append_event("task", "t-x", "TaskSubmitted", {"id": "t-x", "title": "задача", "size": "S"}) +es.append_event("task", "t-x", "TaskClaimed", {"level": "min"}) +for junk in ("x", "готово", "2"): + try: + commands.complete_task_with_delivery("t-x", junk, level="min") + check(f"итог «{junk}» отклонён", False) + except ValueError: + check(f"итог «{junk}» отклонён", True) +tid, _ = commands.complete_task_with_delivery("t-x", "Реакция 👍 ставится после ответа", level="min") +check("нормальный итог принят", es.run_projections()["tasks"]["t-x"]["status"] == "done") + print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") sys.exit(1 if fails else 0)