ORCH-042: new ORCH_TRACKER_MODE (Settings.tracker_mode, default edit) selects the live-tracker card behaviour. bump mode re-creates the card at the bottom of the chat on every update (delete_telegram + send silently + repoint message_id), keeping the "one card per task" invariant: <=1 new message per call, repoint only on successful send, delete result never gates the send. New never-raising delete_telegram helper. Anything != "bump" resolves to edit (zero regression). Also russify/cosmetic-fix the card text (both modes): "Подтверждение BRD" label, ✅ after approve-gate, Russian stage labels, "📦 Внедрено". Docs updated in the same PR (CHANGELOG, internals.md, .env.example). Refs: ORCH-042 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
628 lines
27 KiB
Python
628 lines
27 KiB
Python
"""feat/telegram-live-tracker: tests for the live Telegram task tracker.
|
|
|
|
Covers (per DEV_TASK_TELEGRAM_TRACKER.md):
|
|
* short_model_name: provider/claude- prefix trimming.
|
|
* render_task_tracker: per-stage line format (in↓/out↑, model, cost, minutes),
|
|
the "✅/⏸️ Подтверждение BRD · твоё время" line, the 💰 totals, and the finish block
|
|
(⏱️ three times + 🔗/📦).
|
|
* first message -> sendMessage stores message_id; transition -> editMessageText.
|
|
* fallback: editMessageText fails -> a NEW message is sent and the id updated.
|
|
* which alerts go out SEPARATELY (approve-gate / deploy-fail / agent-fail /
|
|
error) vs which do NOT (QG-pending / agent-start / stage-transition).
|
|
|
|
Isolated temp DB; no network (httpx is patched).
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_tracker.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
|
|
from unittest.mock import MagicMock, patch # noqa: E402
|
|
|
|
import pytest # noqa: E402
|
|
|
|
import src.db as db_module # noqa: E402
|
|
from src.db import init_db, get_db # noqa: E402
|
|
from src import notifications as N # noqa: E402
|
|
from src import usage as U # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_db(monkeypatch):
|
|
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
init_db()
|
|
# Re-enable send_telegram (conftest stubs it to a no-op); these tests patch
|
|
# httpx / the lower-level helpers explicitly per case.
|
|
yield
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# helpers to build a task + runs in the DB
|
|
# --------------------------------------------------------------------------- #
|
|
def _mk_task(stage="development", title="\u0422\u0440\u0435\u043a\u0438 \u0441 \u0437\u0443\u043c\u0430 z5",
|
|
wid="ET-012", brd_start=None, brd_end=None):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, "
|
|
"brd_review_started_at, brd_review_ended_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
("p1", wid, "enduro-trails", "feature/ET-012-x", stage, title,
|
|
brd_start, brd_end),
|
|
)
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return tid
|
|
|
|
|
|
def _mk_run(task_id, agent, started, finished, in_tok, out_tok,
|
|
cache_read=0, cache_creation=0, cost=0.0, model=None, exit_code=0):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at, "
|
|
"exit_code, input_tokens, output_tokens, cache_read_tokens, "
|
|
"cache_creation_tokens, cost_usd, model) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(task_id, agent, started, finished, exit_code, in_tok, out_tok,
|
|
cache_read, cache_creation, cost, model),
|
|
)
|
|
rid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return rid
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# short_model_name
|
|
# --------------------------------------------------------------------------- #
|
|
def test_short_model_name():
|
|
assert U.short_model_name("tokenator/claude-opus-4-8") == "opus-4-8"
|
|
assert U.short_model_name("vibecode/claude-sonnet-4.6") == "sonnet-4.6"
|
|
assert U.short_model_name("claude-opus-4-8") == "opus-4-8"
|
|
assert U.short_model_name("opus-4-8") == "opus-4-8"
|
|
assert U.short_model_name(None) == ""
|
|
assert U.short_model_name("") == ""
|
|
|
|
|
|
def test_parse_usage_extracts_model_from_modelusage():
|
|
blob = (
|
|
'{"total_cost_usd":0.01,'
|
|
'"usage":{"input_tokens":10,"output_tokens":5},'
|
|
'"modelUsage":{"claude-opus-4-8":{"inputTokens":10,"outputTokens":5}}}'
|
|
)
|
|
u = U.parse_usage_from_text(blob)
|
|
assert u["model"] == "claude-opus-4-8"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# render_task_tracker
|
|
# --------------------------------------------------------------------------- #
|
|
def test_render_in_progress_stage_lines_and_totals():
|
|
tid = _mk_task(stage="deploy", brd_start="2026-06-04 10:00:00",
|
|
brd_end="2026-06-04 10:08:00")
|
|
# Analysis: 10м, 1.1M in (mostly cache) / 39.6k out, $2.38, opus-4-8
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38,
|
|
model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "architect", "2026-06-04 10:08:00", "2026-06-04 10:17:00",
|
|
in_tok=500, out_tok=34400, cache_read=1_500_000, cost=2.24,
|
|
model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "developer", "2026-06-04 10:17:00", "2026-06-04 10:28:00",
|
|
in_tok=400, out_tok=45800, cache_read=8_400_000, cost=7.29,
|
|
model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "reviewer", "2026-06-04 10:28:00", "2026-06-04 10:31:00",
|
|
in_tok=300, out_tok=12900, cache_read=1_200_000, cost=1.53,
|
|
model="vibecode/claude-sonnet-4.6")
|
|
_mk_run(tid, "tester", "2026-06-04 10:31:00", "2026-06-04 10:36:00",
|
|
in_tok=200, out_tok=19500, cache_read=1_200_000, cost=1.51,
|
|
model="vibecode/claude-sonnet-4.6")
|
|
# deployer started but not finished -> active "идёт" line.
|
|
_mk_run(tid, "deployer", "2026-06-04 10:36:00", None,
|
|
in_tok=0, out_tok=0, model=None, exit_code=None)
|
|
|
|
text = N.render_task_tracker(tid)
|
|
|
|
# Header in-progress
|
|
assert text.startswith("\U0001f6e0\ufe0f ET-012 \u00b7 \u0422\u0440\u0435\u043a\u0438")
|
|
# Per-stage format: in↓/out↑ · cost · model
|
|
assert "\u2705 Анализ" in text
|
|
assert "10\u043c" in text # analysis duration
|
|
assert "39.6k\u2191" in text # analysis out
|
|
assert "$2.38" in text
|
|
assert "opus-4-8" in text
|
|
assert "sonnet-4.6" in text # reviewer/tester model
|
|
# BRD review line (human time, ended)
|
|
assert "Подтверждение BRD" in text
|
|
assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text
|
|
# Active stage
|
|
assert "\U0001f504 Внедрение" in text
|
|
assert "\u0438\u0434\u0451\u0442" in text
|
|
# Totals line present with 💰
|
|
assert "\U0001f4b0" in text
|
|
# In-progress: no final ⏱️ line
|
|
assert "\u0412\u0441\u0435\u0433\u043e" not in text
|
|
|
|
|
|
def test_render_brd_review_waiting_shows_hourglass():
|
|
tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00",
|
|
brd_end=None)
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38,
|
|
model="tokenator/claude-opus-4-8")
|
|
text = N.render_task_tracker(tid)
|
|
assert "Подтверждение BRD" in text
|
|
assert "\u23f3" in text # hourglass while waiting
|
|
|
|
|
|
def test_render_done_has_times_and_links():
|
|
tid = _mk_task(stage="done", brd_start="2026-06-04 10:00:00",
|
|
brd_end="2026-06-04 10:08:00")
|
|
# set created/updated to compute wall clock
|
|
conn = get_db()
|
|
conn.execute(
|
|
"UPDATE tasks SET created_at='2026-06-04 09:00:00', "
|
|
"updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,))
|
|
conn.commit()
|
|
conn.close()
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38,
|
|
model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00",
|
|
in_tok=400, out_tok=22400, cache_read=1_600_000, cost=1.73,
|
|
model="tokenator/claude-opus-4-8")
|
|
|
|
with patch("src.notifications.httpx") as _hx:
|
|
# No PR found -> just "📦 deployed"
|
|
_resp = MagicMock(status_code=200)
|
|
_resp.json.return_value = []
|
|
_hx.get.return_value = _resp
|
|
text = N.render_task_tracker(tid)
|
|
|
|
assert text.startswith("\U0001f389 ET-012")
|
|
assert "\u0413\u041e\u0422\u041e\u0412\u041e" in text
|
|
# ⏱️ with three times
|
|
assert "\u23f1\ufe0f" in text
|
|
assert "\u0412\u0441\u0435\u0433\u043e" in text
|
|
assert "\u0430\u0433\u0435\u043d\u0442\u044b" in text
|
|
assert "\u0442\u0432\u043e\u0451" in text
|
|
# 📦 deployed line
|
|
assert "\U0001f4e6" in text
|
|
|
|
|
|
def test_render_escapes_html_in_title():
|
|
tid = _mk_task(stage="analysis", title="A <b>& B</b>")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.0)
|
|
text = N.render_task_tracker(tid)
|
|
assert "<b>" in text
|
|
assert "&" in text
|
|
|
|
|
|
def test_render_omits_model_when_unknown():
|
|
tid = _mk_task(stage="analysis")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.0, model=None)
|
|
text = N.render_task_tracker(tid)
|
|
# No trailing " · <model>" — line ends at cost.
|
|
line = [l for l in text.splitlines() if l.startswith("\u2705 Анализ")][0]
|
|
assert line.rstrip().endswith("$0.00")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# tracker send / edit / fallback
|
|
# --------------------------------------------------------------------------- #
|
|
def test_first_call_sends_message_and_stores_id(monkeypatch):
|
|
tid = _mk_task(stage="analysis")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", None, in_tok=0, out_tok=0,
|
|
exit_code=None)
|
|
|
|
sent = {}
|
|
def _fake_send(text, disable_notification=False):
|
|
sent["text"] = text
|
|
sent["silent"] = disable_notification
|
|
return 555
|
|
monkeypatch.setattr(N, "send_telegram", _fake_send)
|
|
monkeypatch.setattr(N, "edit_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not edit on first call")))
|
|
|
|
N.update_task_tracker(tid)
|
|
|
|
from src.db import get_tracker_message_id
|
|
assert get_tracker_message_id(tid) == 555
|
|
assert sent["silent"] is True # tracker is silent
|
|
|
|
|
|
def test_second_call_edits_existing_message(monkeypatch):
|
|
tid = _mk_task(stage="development")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
from src.db import set_tracker_message_id
|
|
set_tracker_message_id(tid, 777)
|
|
|
|
edited = {}
|
|
monkeypatch.setattr(N, "edit_telegram",
|
|
lambda mid, text: edited.update(mid=mid) or N.EDIT_OK)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send when edit succeeds")))
|
|
|
|
N.update_task_tracker(tid)
|
|
assert edited["mid"] == 777
|
|
|
|
|
|
def test_fallback_to_new_message_when_edit_gone(monkeypatch):
|
|
"""edit returns 'gone' (message deleted/too old) -> send NEW + update id."""
|
|
tid = _mk_task(stage="development")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
from src.db import set_tracker_message_id, get_tracker_message_id
|
|
set_tracker_message_id(tid, 100)
|
|
|
|
monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_GONE)
|
|
monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 200)
|
|
|
|
N.update_task_tracker(tid)
|
|
assert get_tracker_message_id(tid) == 200 # id updated to the new message
|
|
|
|
|
|
def test_not_modified_does_not_send_new_message(monkeypatch):
|
|
"""edit returns 'not_modified' -> NO new message, id unchanged (no dupe)."""
|
|
tid = _mk_task(stage="development")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
from src.db import set_tracker_message_id, get_tracker_message_id
|
|
set_tracker_message_id(tid, 100)
|
|
|
|
monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_NOT_MODIFIED)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not send on not_modified")))
|
|
|
|
N.update_task_tracker(tid)
|
|
assert get_tracker_message_id(tid) == 100 # unchanged, no duplicate
|
|
|
|
|
|
def test_transient_edit_failure_does_not_send_new_message(monkeypatch):
|
|
"""edit returns 'failed' (network/timeout/5xx) -> NO new message (no dupe)."""
|
|
tid = _mk_task(stage="development")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
from src.db import set_tracker_message_id, get_tracker_message_id
|
|
set_tracker_message_id(tid, 100)
|
|
|
|
monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_FAILED)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not send on transient failure")))
|
|
|
|
N.update_task_tracker(tid)
|
|
assert get_tracker_message_id(tid) == 100 # unchanged, no duplicate
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# edit_telegram outcome classification (httpx mocked)
|
|
# --------------------------------------------------------------------------- #
|
|
def _edit_resp(ok, description=None):
|
|
resp = MagicMock()
|
|
body = {"ok": ok}
|
|
if description is not None:
|
|
body["description"] = description
|
|
resp.json.return_value = body
|
|
return resp
|
|
|
|
|
|
def _patch_tg_creds(monkeypatch):
|
|
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False)
|
|
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False)
|
|
|
|
|
|
def test_edit_telegram_ok(monkeypatch):
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(True)
|
|
assert N.edit_telegram(1, "x") == N.EDIT_OK
|
|
|
|
|
|
def test_edit_telegram_not_modified_is_success(monkeypatch):
|
|
# 400 "message is not modified" -> success, not gone, no duplicate
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(
|
|
False, "Bad Request: message is not modified: ...")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_NOT_MODIFIED
|
|
|
|
|
|
def test_edit_telegram_exactly_the_same_is_not_modified(monkeypatch):
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(
|
|
False, "Bad Request: specified new message content and reply markup "
|
|
"are exactly the same")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_NOT_MODIFIED
|
|
|
|
|
|
def test_edit_telegram_message_not_found_is_gone(monkeypatch):
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(
|
|
False, "Bad Request: message to edit not found")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_GONE
|
|
|
|
|
|
def test_edit_telegram_cant_be_edited_is_gone(monkeypatch):
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(
|
|
False, "Bad Request: message can't be edited")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_GONE
|
|
|
|
|
|
def test_edit_telegram_unknown_400_is_failed(monkeypatch):
|
|
# unknown 400 -> failed (NOT gone) -> caller won't duplicate
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(
|
|
False, "Bad Request: some other unexpected error")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_FAILED
|
|
|
|
|
|
def test_edit_telegram_timeout_is_failed(monkeypatch):
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.side_effect = Exception("read timeout")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_FAILED
|
|
|
|
|
|
def test_edit_telegram_5xx_is_failed(monkeypatch):
|
|
# Telegram 5xx still returns ok:false w/o gone/not_modified markers
|
|
_patch_tg_creds(monkeypatch)
|
|
with patch("src.notifications.httpx") as hx:
|
|
hx.post.return_value = _edit_resp(False, "Internal Server Error")
|
|
assert N.edit_telegram(1, "x") == N.EDIT_FAILED
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# render: repeated stage attempt shows "попытка N"
|
|
# --------------------------------------------------------------------------- #
|
|
_POPYTKA = "\u043f\u043e\u043f\u044b\u0442\u043a\u0430" # popytka
|
|
|
|
|
|
def test_render_active_stage_shows_attempt_on_second_run():
|
|
# Two reviewer runs while in review -> active line shows attempt 2.
|
|
tid = _mk_task(stage="review")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:20:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
# First review run finished (sent back to dev), second review run active.
|
|
_mk_run(tid, "reviewer", "2026-06-04 09:20:00", "2026-06-04 09:25:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6",
|
|
exit_code=0)
|
|
_mk_run(tid, "reviewer", "2026-06-04 09:30:00", None,
|
|
in_tok=0, out_tok=0, exit_code=None)
|
|
|
|
text = N.render_task_tracker(tid)
|
|
active = [l for l in text.splitlines()
|
|
if l.startswith("\U0001f504") and "Код ревью" in l][0]
|
|
assert _POPYTKA in active
|
|
assert "2" in active
|
|
assert "\u0438\u0434\u0451\u0442" in active
|
|
|
|
|
|
def test_render_active_stage_no_attempt_on_first_run():
|
|
# Single reviewer run -> active line has NO attempt marker.
|
|
tid = _mk_task(stage="review")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:20:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "reviewer", "2026-06-04 09:20:00", None,
|
|
in_tok=0, out_tok=0, exit_code=None)
|
|
|
|
text = N.render_task_tracker(tid)
|
|
active = [l for l in text.splitlines()
|
|
if l.startswith("\U0001f504") and "Код ревью" in l][0]
|
|
assert _POPYTKA not in active
|
|
assert "\u0438\u0434\u0451\u0442" in active
|
|
|
|
|
|
def test_render_finished_lines_unaffected_by_attempt_logic():
|
|
# Completed (checkmark) lines never carry an attempt marker.
|
|
tid = _mk_task(stage="review")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
# developer ran twice (retry) but is a FINISHED stage now.
|
|
_mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:15:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "developer", "2026-06-04 09:16:00", "2026-06-04 09:20:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
text = N.render_task_tracker(tid)
|
|
for l in text.splitlines():
|
|
if l.startswith("\u2705"):
|
|
assert _POPYTKA not in l
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# which alerts are SEPARATE vs tracker-only
|
|
# --------------------------------------------------------------------------- #
|
|
def test_approve_gate_sends_separate_message_and_starts_brd_clock(monkeypatch):
|
|
tid = _mk_task(stage="analysis")
|
|
calls = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1)
|
|
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
|
|
|
|
N.notify_approve_requested(tid)
|
|
|
|
# exactly one SEPARATE (notifying) send for the approve gate
|
|
assert len(calls) == 1
|
|
assert calls[0][1] is False # notifying
|
|
assert "Approved" in calls[0][0]
|
|
# BRD clock started
|
|
conn = get_db()
|
|
row = conn.execute("SELECT brd_review_started_at FROM tasks WHERE id=?", (tid,)).fetchone()
|
|
conn.close()
|
|
assert row[0] is not None
|
|
|
|
|
|
def test_error_sends_separate_message(monkeypatch):
|
|
tid = _mk_task(stage="development")
|
|
calls = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1)
|
|
N.notify_error(tid, "boom")
|
|
assert len(calls) == 1
|
|
assert calls[0][1] is False # notifying
|
|
assert "ERROR" in calls[0][0]
|
|
|
|
|
|
def test_stage_change_does_not_send_separate_message(monkeypatch):
|
|
tid = _mk_task(stage="development")
|
|
sent = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: sent.append(text) or 1)
|
|
# tracker refresh is allowed (edit/send silent) but must NOT use send_telegram
|
|
# for a separate notification; stub update to isolate.
|
|
refreshed = []
|
|
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id))
|
|
|
|
N.notify_stage_change(tid, "development", "review")
|
|
assert sent == [] # no separate message
|
|
assert refreshed == [tid] # tracker refreshed instead
|
|
|
|
|
|
def test_agent_started_does_not_send_separate_message(monkeypatch):
|
|
tid = _mk_task(stage="analysis")
|
|
sent = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: sent.append(text) or 1)
|
|
refreshed = []
|
|
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id))
|
|
|
|
N.notify_agent_started(1, "analyst", tid)
|
|
assert sent == []
|
|
assert refreshed == [tid]
|
|
|
|
|
|
def test_qg_failure_does_not_send_separate_message(monkeypatch):
|
|
tid = _mk_task(stage="development")
|
|
sent = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: sent.append(text) or 1)
|
|
N.notify_qg_failure(tid, "development", "check_ci_green", "CI state: pending")
|
|
assert sent == [] # QG-pending is log-only, never a separate ping
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ORCH-042: mode resolution + text changes (edit-mode default)
|
|
# --------------------------------------------------------------------------- #
|
|
def _brd_line(text):
|
|
return [ln for ln in text.splitlines() if "Подтверждение BRD" in ln][0]
|
|
|
|
|
|
def test_unknown_mode_falls_back_to_edit_branch(monkeypatch):
|
|
"""TC-02/AC-2: garbage mode -> edit branch, no exception, no extra send."""
|
|
monkeypatch.setattr(N._get_settings(), "tracker_mode", "garbage", raising=False)
|
|
tid = _mk_task(stage="development")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
from src.db import set_tracker_message_id, get_tracker_message_id
|
|
set_tracker_message_id(tid, 777)
|
|
|
|
edited = {}
|
|
monkeypatch.setattr(N, "edit_telegram",
|
|
lambda mid, text: edited.update(mid=mid) or N.EDIT_OK)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda *a, **k: (_ for _ in ()).throw(
|
|
AssertionError("garbage mode must take edit branch")))
|
|
monkeypatch.setattr(N, "delete_telegram",
|
|
lambda *a, **k: (_ for _ in ()).throw(
|
|
AssertionError("garbage mode must NOT bump-delete")))
|
|
|
|
N.update_task_tracker(tid) # must not raise
|
|
assert edited["mid"] == 777
|
|
assert get_tracker_message_id(tid) == 777 # unchanged
|
|
|
|
|
|
def test_render_brd_label_is_confirmation_not_review():
|
|
"""TC-18/AC-15: 'Подтверждение BRD' present, 'Ревью БРД' absent."""
|
|
tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00",
|
|
brd_end="2026-06-04 10:08:00")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
text = N.render_task_tracker(tid)
|
|
assert "Подтверждение BRD" in text
|
|
assert "Ревью БРД" not in text
|
|
|
|
|
|
def test_render_brd_passed_uses_check_not_pause():
|
|
"""TC-19/AC-16: approve-gate passed (ended set) -> BRD line starts with ✅."""
|
|
tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00",
|
|
brd_end="2026-06-04 10:08:00")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
line = _brd_line(N.render_task_tracker(tid))
|
|
assert line.startswith("✅") # ✅
|
|
assert not line.startswith("⏸") # not ⏸️
|
|
assert "⏳" not in line # no hourglass once passed
|
|
|
|
|
|
def test_render_brd_waiting_keeps_pause_and_hourglass():
|
|
"""TC-20/AC-16: still waiting (ended empty) -> ⏳ indicator, not ✅."""
|
|
tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00",
|
|
brd_end=None)
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1)
|
|
line = _brd_line(N.render_task_tracker(tid))
|
|
assert "⏳" in line # ⏳ still waiting
|
|
assert not line.startswith("✅") # NOT ✅ yet
|
|
|
|
|
|
def test_render_stage_labels_are_russian():
|
|
"""TC-21/AC-17: russian stage labels in both ✅- and 🔄-lines; no english."""
|
|
tid = _mk_task(stage="deploy")
|
|
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "architect", "2026-06-04 09:10:00", "2026-06-04 09:20:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "developer", "2026-06-04 09:20:00", "2026-06-04 09:30:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
|
_mk_run(tid, "reviewer", "2026-06-04 09:30:00", "2026-06-04 09:35:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6")
|
|
_mk_run(tid, "tester", "2026-06-04 09:35:00", "2026-06-04 09:40:00",
|
|
in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6")
|
|
_mk_run(tid, "deployer", "2026-06-04 09:40:00", None,
|
|
in_tok=0, out_tok=0, exit_code=None)
|
|
text = N.render_task_tracker(tid)
|
|
for ru in ("Анализ", "Архитектура", "Разработка", "Код ревью",
|
|
"Тестирование", "Внедрение"):
|
|
assert ru in text, f"missing russian label {ru!r}"
|
|
for en in ("Analysis", "Architecture", "Development", "Review",
|
|
"Testing", "Deploy"):
|
|
assert en not in text, f"english label leaked: {en!r}"
|
|
|
|
|
|
def test_render_done_says_vnedreno_not_deployed():
|
|
"""TC-22/AC-18: final line says '📦 Внедрено', not 'deployed'."""
|
|
tid = _mk_task(stage="done")
|
|
conn = get_db()
|
|
conn.execute(
|
|
"UPDATE tasks SET created_at='2026-06-04 09:00:00', "
|
|
"updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,))
|
|
conn.commit()
|
|
conn.close()
|
|
_mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00",
|
|
in_tok=400, out_tok=22400, cost=1.73, model="tokenator/claude-opus-4-8")
|
|
with patch("src.notifications.httpx") as _hx:
|
|
_resp = MagicMock(status_code=200)
|
|
_resp.json.return_value = [] # no PR
|
|
_hx.get.return_value = _resp
|
|
text = N.render_task_tracker(tid)
|
|
assert "\U0001f4e6 Внедрено" in text # 📦 Внедрено
|
|
assert "deployed" not in text
|