Files
roboco/tests/unit/services/test_telegram_inbound_commands.py
T
a036c97985 fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking (#666)
* fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking

Three root causes behind the Mini App/bot showing wrong numbers:

Pricing: glm-5.2 gets a grounded per-token rate (z.ai published pricing,
$1.40/$4.40/$0.26 per 1M, source+date in the table comment) so a GLM
fleet day stops reporting $0.00 for half a million tokens; ungrounded
Ollama-Cloud models render "subscription (untracked)" instead of a bare
zero (is_ollama_cloud_model, consumed directly by the cockpit). Side
effect, intended and documented: honestly-priced GLM now trips the
downgrade-only comparator for new qa/documenter complexity pins.

Display timezone: the cockpit bucketed days in UTC for a GMT+2 operator.
New pure foundation module display_time (resolve_zone/local_date/
trailing_dates/day_bounds_utc, DST-correct with tests for the 23h/25h
days) + ROBOCO_DISPLAY_TIMEZONE (IANA-validated, default UTC); the
cockpit's spend/velocity series bucket raw session/completion rows by
the display zone. The UTC-keyed rollup table and the main dashboard are
deliberately untouched.

Agent activity: AgentTable.status was never set to ACTIVE and
current_task_id was never written anywhere — "active: 0, working: []"
was structurally permanent. Every claim path now marks the claimant
ACTIVE with rollback symmetry (_finalize_claim for dev/PM claims,
_qa_or_doc_claim for QA/doc/PR-gate claims, pr_review_claim for external
review) and every release path clears it (pass/fail QA, pr_pass/pr_fail,
complete_review, advance-to-PM-review, reaper unclaim, voluntary
unclaim, reassign retarget, pool divert, admin transitions, unblock
restore-to-in-progress). The bot's /status shares the cockpit's fleet
derivation so the two surfaces can't disagree. Known ceiling, commented:
one current_task_id column shows a multi-root coordinator PM's most
recent claim only.

Drill: sonnet develop -> sonnet adversarial (refuted the original
chokepoint coverage claim; QA/doc/reviewer paths were unwired) ->
correction round (wired them all + restored a dropped assertion, deleted
a dead helper and the dead subscription_billed field) -> review.

* fix(db): post_update on AgentTable.current_task breaks the flush cycle

agents.current_task_id and tasks.assigned_to reference each other, so a
flush touching both rows — every claim now marks its agent ACTIVE — is
an instance-level circular dependency SQLAlchemy cannot topologically
sort. The e2e smoke's full verb paths (12 tests) hit it; the unit and
integration suites never flush both dirty rows with relationships
loaded. post_update emits the FK as a second UPDATE, the canonical fix
for mutually-referencing rows.

* fix(budgets): enforce only explicitly-set budgets — no per-TaskType defaults

The per-TaskType default cap table blocked an unbudgeted coordination
root one opus planning turn in ($1.50 PLANNING default vs. real
coordination spend) — a false positive by design the moment the fleet
runs a priced model. Budgets are now explicit-input only:
effective_task_budget_usd returns None for an unset budget_usd, the
budget sweep skips enforcement (and never prices spend) on None, and
the unblock re-check passes on None so clearing the budget field is
itself a valid resolution. The project monthly cap stays as the
explicit-input fleet-wide backstop. Panel copy tells the truth
("No cap" placeholder; empty = uncapped), and the TaskType default
table plus its resolver are deleted.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:09:50 +02:00

231 lines
7.2 KiB
Python

"""V4 bot-command tier: the BOT_COMMANDS registry drives /help and the
once-per-process setMyCommands sync; /agents, /usage, and /blocked render
from the same services the panel reads.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.services import telegram_inbound as ti
COMMAND_COUNT = len(ti.BOT_COMMANDS)
def _fake_session() -> MagicMock:
session = MagicMock()
session.commit = AsyncMock()
session.execute = AsyncMock()
return session
def _engine() -> ti.TelegramInboundEngine:
return ti.TelegramInboundEngine(_fake_session())
def _uuid_with_prefix(prefix: str) -> UUID:
return UUID(hex=prefix + uuid4().hex[len(prefix) :])
def _task(id8: str, title: str) -> SimpleNamespace:
return SimpleNamespace(id=_uuid_with_prefix(id8), title=title)
# ---------------------------------------------------------------------------
# registry ↔ help ↔ dispatch coherence
# ---------------------------------------------------------------------------
def test_help_text_derives_from_the_registry() -> None:
for entry in ti.BOT_COMMANDS:
assert f"/{entry['command']}{entry['description']}" in ti._HELP_TEXT
@pytest.mark.asyncio
@pytest.mark.parametrize("cmd", ["agents", "usage", "blocked"])
async def test_registry_commands_dispatch_to_a_renderer(
cmd: str, monkeypatch: pytest.MonkeyPatch
) -> None:
engine = _engine()
renderer = AsyncMock(return_value="rendered")
monkeypatch.setattr(engine, f"_render_{cmd}", renderer)
client = AsyncMock()
await engine._dispatch_command(cmd, "", client)
renderer.assert_awaited_once()
assert client.send_message.await_args.args[0] == "rendered"
# ---------------------------------------------------------------------------
# setMyCommands sync — once per process
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_cycle_syncs_commands_exactly_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(ti.TelegramInboundEngine, "_commands_synced", False)
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
creds = SimpleNamespace(bot_token="123:ABC", chat_id="777")
creds_svc = MagicMock(get_decrypted=AsyncMock(return_value=creds))
monkeypatch.setattr(ti, "get_telegram_credentials_service", lambda _s: creds_svc)
settings_svc = MagicMock(get_int=AsyncMock(return_value=0), set=AsyncMock())
monkeypatch.setattr(ti, "get_settings_service", lambda _s: settings_svc)
client = AsyncMock()
client.configured = True
client.get_updates = AsyncMock(return_value=[])
engine = ti.TelegramInboundEngine(_fake_session(), client=client)
await engine.run_cycle()
await engine.run_cycle()
client.set_my_commands.assert_awaited_once_with(list(ti.BOT_COMMANDS))
# ---------------------------------------------------------------------------
# renderers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_render_status_shares_fleet_derivation_with_render_agents(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The /status fleet line must come from the SAME by_status breakdown
/agents and the Today brief use (TgCockpitService.fleet) — a second,
independent agent-status query previously could (and did) disagree."""
fleet: dict[str, Any] = {
"total": 27,
"by_status": {"active": 3, "idle": 20, "offline": 4},
"working": [],
}
cockpit = MagicMock(fleet=AsyncMock(return_value=fleet))
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
tasks = MagicMock(
count_by_status=AsyncMock(return_value={"in_progress": 5, "pending": 2})
)
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
text = await _engine()._render_status()
assert "3</b> active" in text
assert "20</b> idle" in text
assert "4</b> offline" in text
assert "in_progress" in text
@pytest.mark.asyncio
async def test_render_agents_lists_working_agents(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fleet: dict[str, Any] = {
"total": 26,
"by_status": {"active": 4, "idle": 22},
"working": [
{"name": "be-dev-1", "task_title": "GitProvider seam"},
{"name": "fe-qa", "task_title": None},
],
}
cockpit = MagicMock(fleet=AsyncMock(return_value=fleet))
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
text = await _engine()._render_agents()
assert "26 total" in text
assert "4 active" in text
assert "be-dev-1" in text
assert "GitProvider seam" in text
assert "fe-qa" in text
@pytest.mark.asyncio
async def test_render_usage_formats_today_summary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cockpit = MagicMock(
today_spend=AsyncMock(
return_value={
"tokens_today": 2_400_000,
"cost_today_usd": 18.7,
"subscription_billed": False,
}
)
)
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
text = await _engine()._render_usage()
assert "$18.70" in text
assert "2,400,000 tokens" in text
@pytest.mark.asyncio
async def test_render_usage_labels_subscription_billed_spend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An untracked-subscription spend day (Ollama Cloud, no grounded rate)
must never render as a bare, misleading '$0.00'."""
cockpit = MagicMock(
today_spend=AsyncMock(
return_value={
"tokens_today": 456_221,
"cost_today_usd": 0.0,
"subscription_billed": True,
}
)
)
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
text = await _engine()._render_usage()
assert "subscription (untracked)" in text
assert "456,221 tokens" in text
assert "$0.00" not in text
@pytest.mark.asyncio
async def test_render_blocked_sections_and_links(
monkeypatch: pytest.MonkeyPatch,
) -> None:
tasks = MagicMock(
list_awaiting_ceo_approval=AsyncMock(
return_value=[_task("aaaa1111", "Root PR ready")]
),
list_blocked=AsyncMock(return_value=[_task("bbbb2222", "Infra <wedge>")]),
)
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
monkeypatch.setattr(ti.settings, "panel_base_url", "https://nas.example")
text = await _engine()._render_blocked()
assert "Awaiting you" in text
assert "Blocked" in text
assert '<a href="https://nas.example/tasks/aaaa1111">Root PR ready</a>' in text
assert "aaaa1111" in text
# HTML-escaped title, never raw.
assert "Infra &lt;wedge&gt;" in text
@pytest.mark.asyncio
async def test_render_blocked_all_clear(
monkeypatch: pytest.MonkeyPatch,
) -> None:
tasks = MagicMock(
list_awaiting_ceo_approval=AsyncMock(return_value=[]),
list_blocked=AsyncMock(return_value=[]),
)
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
text = await _engine()._render_blocked()
assert "Nothing is blocked" in text