mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -22,6 +22,7 @@ from roboco.billing.pricing import (
|
||||
calculate_cost,
|
||||
calculate_cost_result,
|
||||
input_price_per_million,
|
||||
is_ollama_cloud_model,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,6 +69,14 @@ _CODEX_OUTPUT = 14.00
|
||||
_CODEX_CACHE_READ = 0.175
|
||||
_CODEX_CACHE_WRITE = 1.75
|
||||
|
||||
# Z.ai GLM-5.2 — priced non-Anthropic (Ollama Cloud's `glm-5.2:cloud` tag,
|
||||
# subscription-billed but attributed at the API-equivalent rate). Source:
|
||||
# https://docs.z.ai/guides/overview/pricing (fetched 2026-07-23).
|
||||
_GLM_INPUT = 1.40
|
||||
_GLM_OUTPUT = 4.40
|
||||
_GLM_CACHE_READ = 0.26
|
||||
_GLM_CACHE_WRITE = 1.40
|
||||
|
||||
# Tolerance for floating-point comparisons
|
||||
_TOL = 1e-4
|
||||
|
||||
@@ -386,6 +395,57 @@ class TestCodexTier:
|
||||
assert _CODEX_OUTPUT > _CODEX_INPUT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GLM-5.2 tier (Ollama Cloud — priced non-Anthropic, grounded in a citable
|
||||
# published rate; see the module's pricing-table comment for the source).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGlmTier:
|
||||
"""glm-5.2:cloud pricing — Ollama Cloud, priced like grok-build/codex."""
|
||||
|
||||
def test_input_only(self) -> None:
|
||||
cost = calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _GLM_INPUT) < _TOL
|
||||
|
||||
def test_output_only(self) -> None:
|
||||
cost = calculate_cost("glm-5.2:cloud", tokens_input=0, tokens_output=_M)
|
||||
assert abs(cost - _GLM_OUTPUT) < _TOL
|
||||
|
||||
def test_cached_input(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"glm-5.2:cloud", tokens_input=0, tokens_output=0, tokens_cache_read=_M
|
||||
)
|
||||
assert abs(cost - _GLM_CACHE_READ) < _TOL
|
||||
|
||||
def test_cache_write(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"glm-5.2:cloud", tokens_input=0, tokens_output=0, tokens_cache_write=_M
|
||||
)
|
||||
assert abs(cost - _GLM_CACHE_WRITE) < _TOL
|
||||
|
||||
def test_all_token_types(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"glm-5.2:cloud",
|
||||
tokens_input=_M,
|
||||
tokens_output=_M,
|
||||
tokens_cache_read=_M,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
expected = _GLM_INPUT + _GLM_OUTPUT + _GLM_CACHE_READ + _GLM_CACHE_WRITE
|
||||
assert abs(cost - expected) < _TOL
|
||||
|
||||
def test_glm_is_not_treated_as_anthropic(self) -> None:
|
||||
assert _is_anthropic_model("glm-5.2:cloud") is False
|
||||
assert calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=0) > 0.0
|
||||
|
||||
def test_bare_glm_tag_without_cloud_suffix_still_prices(self) -> None:
|
||||
"""The fragment match is on 'glm-5.2', independent of the ':cloud'
|
||||
tag suffix — a differently-tagged variant still resolves."""
|
||||
cost = calculate_cost("glm-5.2", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _GLM_INPUT) < _TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown / edge cases — must return 0.0 without raising
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -477,16 +537,23 @@ class TestSubstringMatchPriority:
|
||||
|
||||
|
||||
class TestProviderAwareness:
|
||||
"""Non-Anthropic models (local Ollama / Ollama Cloud) cost 0.0 per token."""
|
||||
"""Genuinely-free local Ollama costs 0.0 per token; an ungrounded Ollama
|
||||
Cloud model also costs 0.0 (we have no rate for it — see
|
||||
``is_ollama_cloud_model`` for the caller-side distinction from "free"). A
|
||||
GROUNDED Ollama Cloud model (glm-5.2) is priced for real — see
|
||||
``TestGlmTier``."""
|
||||
|
||||
def test_ollama_prefixed_model_returns_zero(self) -> None:
|
||||
"""Self-hosted Ollama models (``ollama/`` prefix) have no API cost."""
|
||||
cost = calculate_cost("ollama/llama3", tokens_input=_M, tokens_output=_M)
|
||||
assert cost == _ZERO_COST
|
||||
|
||||
def test_ollama_cloud_model_returns_zero(self) -> None:
|
||||
"""Ollama Cloud (``:cloud`` tag) is subscription-billed, not per token."""
|
||||
cost = calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=_M)
|
||||
def test_ungrounded_ollama_cloud_model_returns_zero(self) -> None:
|
||||
"""An Ollama Cloud (``:cloud`` tag) model with no table entry has no
|
||||
rate to price from — still 0.0 (not "unpriced"; see TestCostResult)."""
|
||||
cost = calculate_cost(
|
||||
"some-future-model:cloud", tokens_input=_M, tokens_output=_M
|
||||
)
|
||||
assert cost == _ZERO_COST
|
||||
|
||||
def test_bare_local_model_returns_zero(self) -> None:
|
||||
@@ -589,6 +656,25 @@ def test_sonnet5_promo_active_on_or_before_2026_08_31(
|
||||
)
|
||||
|
||||
|
||||
class TestIsOllamaCloudModel:
|
||||
"""The ':cloud' tag convention, shared by pricing.py's own attribution
|
||||
logic and external callers (the TG cockpit's spend label)."""
|
||||
|
||||
def test_cloud_tagged_model_is_cloud(self) -> None:
|
||||
assert is_ollama_cloud_model("glm-5.2:cloud") is True
|
||||
assert is_ollama_cloud_model("SOME-MODEL:CLOUD") is True
|
||||
|
||||
def test_local_model_is_not_cloud(self) -> None:
|
||||
assert is_ollama_cloud_model("ollama/llama3") is False
|
||||
assert is_ollama_cloud_model("qwen3-embedding:0.6b") is False
|
||||
|
||||
def test_anthropic_model_is_not_cloud(self) -> None:
|
||||
assert is_ollama_cloud_model("claude-sonnet-5") is False
|
||||
|
||||
def test_empty_string_is_not_cloud(self) -> None:
|
||||
assert is_ollama_cloud_model("") is False
|
||||
|
||||
|
||||
def test_sonnet5_reverts_to_list_rate_after_2026_08_31(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -635,12 +721,17 @@ class TestInputPricePerMillion:
|
||||
)
|
||||
|
||||
def test_unpriced_non_anthropic_model_is_free_tier(self) -> None:
|
||||
"""A self-hosted / Ollama Cloud model has no per-token rate — treated
|
||||
as the cheapest possible tier, so it can never be rejected as
|
||||
"costlier" by the downgrade-only policy."""
|
||||
assert input_price_per_million("glm-5.2:cloud") == 0.0
|
||||
"""A self-hosted / ungrounded-Ollama-Cloud model has no per-token
|
||||
rate — treated as the cheapest possible tier, so it can never be
|
||||
rejected as "costlier" by the downgrade-only policy."""
|
||||
assert input_price_per_million("some-future-model:cloud") == 0.0
|
||||
assert input_price_per_million("my-custom-self-hosted-model:7b") == 0.0
|
||||
|
||||
def test_grounded_ollama_cloud_model_has_real_rate(self) -> None:
|
||||
"""GLM-5.2 is now grounded in a real published rate, unlike an
|
||||
unpriced Ollama Cloud model."""
|
||||
assert input_price_per_million("glm-5.2:cloud") == _GLM_INPUT
|
||||
|
||||
def test_empty_model_returns_zero(self) -> None:
|
||||
assert input_price_per_million("") == 0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""display_time — pure display-timezone day bucketing, incl. DST boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from roboco.foundation.policy.display_time import (
|
||||
day_bounds_utc,
|
||||
local_date,
|
||||
resolve_zone,
|
||||
trailing_dates,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveZone:
|
||||
def test_known_zone(self) -> None:
|
||||
assert resolve_zone("Europe/Berlin").key == "Europe/Berlin"
|
||||
|
||||
def test_utc_default(self) -> None:
|
||||
assert resolve_zone("UTC").key == "UTC"
|
||||
|
||||
def test_unknown_zone_falls_back_to_utc(self) -> None:
|
||||
assert resolve_zone("Not/AZone").key == "UTC"
|
||||
|
||||
|
||||
class TestLocalDate:
|
||||
def test_utc_noop(self) -> None:
|
||||
instant = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
|
||||
assert local_date(instant, "UTC") == date(2026, 7, 23)
|
||||
|
||||
def test_gmt_plus_2_evening_utc_is_next_day_local(self) -> None:
|
||||
"""22:30 UTC on the 22nd is 00:30 the NEXT day in GMT+2 — the exact
|
||||
'CEO's evening activity lands on the wrong display day' bug."""
|
||||
instant = datetime(2026, 7, 22, 22, 30, tzinfo=UTC)
|
||||
assert local_date(instant, "Europe/Berlin") == date(2026, 7, 23)
|
||||
|
||||
def test_gmt_plus_2_early_morning_utc_is_prior_day_local(self) -> None:
|
||||
# No — GMT+2 is AHEAD of UTC, so early UTC morning is still the same
|
||||
# local day; use a clearly-behind zone for the "prior day" case.
|
||||
instant = datetime(2026, 7, 23, 2, 0, tzinfo=UTC)
|
||||
assert local_date(instant, "America/Los_Angeles") == date(2026, 7, 22)
|
||||
|
||||
|
||||
class TestTrailingDates:
|
||||
def test_seven_days_oldest_to_today(self) -> None:
|
||||
now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
||||
dates = trailing_dates("UTC", 7, now=now)
|
||||
assert len(dates) == 7 # noqa: PLR2004
|
||||
assert dates[-1] == date(2026, 7, 23)
|
||||
assert dates[0] == date(2026, 7, 17)
|
||||
assert dates == sorted(dates)
|
||||
|
||||
def test_timezone_shifts_which_day_is_today(self) -> None:
|
||||
"""23:00 UTC on the 22nd is already the 23rd in Europe/Berlin."""
|
||||
now = datetime(2026, 7, 22, 23, 0, tzinfo=UTC)
|
||||
assert trailing_dates("UTC", 1, now=now) == [date(2026, 7, 22)]
|
||||
assert trailing_dates("Europe/Berlin", 1, now=now) == [date(2026, 7, 23)]
|
||||
|
||||
|
||||
class TestDayBoundsUtc:
|
||||
def test_utc_day_is_exactly_24h(self) -> None:
|
||||
start, end = day_bounds_utc("UTC", date(2026, 7, 23))
|
||||
assert start == datetime(2026, 7, 23, 0, 0, tzinfo=UTC)
|
||||
assert end == datetime(2026, 7, 24, 0, 0, tzinfo=UTC)
|
||||
assert (end - start).total_seconds() == 24 * 3600
|
||||
|
||||
def test_gmt_plus_2_day_bounds(self) -> None:
|
||||
start, end = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
|
||||
# Summer time (CEST, UTC+2): local midnight is 22:00 UTC the day before.
|
||||
assert start == datetime(2026, 7, 22, 22, 0, tzinfo=UTC)
|
||||
assert end == datetime(2026, 7, 23, 22, 0, tzinfo=UTC)
|
||||
|
||||
def test_dst_spring_forward_day_is_23_hours(self) -> None:
|
||||
"""Europe/Berlin springs forward on the last Sunday of March —
|
||||
2026-03-29 02:00 CET -> 03:00 CEST — so that local day is only 23h
|
||||
of real UTC time, not 24."""
|
||||
start, end = day_bounds_utc("Europe/Berlin", date(2026, 3, 29))
|
||||
assert (end - start).total_seconds() == 23 * 3600
|
||||
|
||||
def test_dst_fall_back_day_is_25_hours(self) -> None:
|
||||
"""Europe/Berlin falls back on the last Sunday of October —
|
||||
2026-10-25 03:00 CEST -> 02:00 CET — so that local day is 25h."""
|
||||
start, end = day_bounds_utc("Europe/Berlin", date(2026, 10, 25))
|
||||
assert (end - start).total_seconds() == 25 * 3600
|
||||
|
||||
def test_an_instant_at_start_falls_in_this_day(self) -> None:
|
||||
start, _ = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
|
||||
assert local_date(start, "Europe/Berlin") == date(2026, 7, 23)
|
||||
|
||||
def test_an_instant_just_before_end_falls_in_this_day(self) -> None:
|
||||
_, end = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
|
||||
assert local_date(end - timedelta(seconds=1), "Europe/Berlin") == date(
|
||||
2026, 7, 23
|
||||
)
|
||||
@@ -8,11 +8,12 @@ agents_config data) and the reaper-safe service write
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.models.base import AgentStatus, TaskStatus
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.gateway.choreographer._impl import Choreographer
|
||||
from roboco.services.task import TaskService
|
||||
@@ -98,6 +99,12 @@ def _build_task(**over: object) -> MagicMock:
|
||||
def _service() -> TaskService:
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# reassign_active_claim now retargets the agent-side claim marker
|
||||
# (_retarget_agent_claim), which reads old/new agent rows via
|
||||
# session.get — default to "no matching row" so tests that don't care
|
||||
# about the agent side effect stay a no-op there, same as before this
|
||||
# write existed.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
return TaskService(session)
|
||||
|
||||
|
||||
@@ -123,3 +130,33 @@ async def test_reassign_active_claim_refuses_non_active_status() -> None:
|
||||
svc = _service()
|
||||
object.__setattr__(svc, "get", AsyncMock(return_value=task))
|
||||
assert await svc.reassign_active_claim(task.id, uuid4()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_active_claim_retargets_agent_active_marker() -> None:
|
||||
"""The old claimant's ACTIVE/current_task_id marker must move to the new
|
||||
claimant — otherwise the fleet keeps showing the SUPERSEDED agent as
|
||||
working on this task, and never shows the real new claimant as active."""
|
||||
old_id, new_id = uuid4(), uuid4()
|
||||
task = _build_task(status=TaskStatus.IN_PROGRESS, claimed_by=old_id)
|
||||
old_agent = MagicMock(status=AgentStatus.ACTIVE, current_task_id=task.id)
|
||||
new_agent = MagicMock(status=AgentStatus.IDLE, current_task_id=None)
|
||||
svc = _service()
|
||||
object.__setattr__(svc, "get", AsyncMock(return_value=task))
|
||||
|
||||
async def _fake_get(_model: object, agent_id: object) -> object:
|
||||
if agent_id == old_id:
|
||||
return old_agent
|
||||
if agent_id == new_id:
|
||||
return new_agent
|
||||
return None
|
||||
|
||||
cast("MagicMock", svc.session).get = AsyncMock(side_effect=_fake_get)
|
||||
|
||||
result = await svc.reassign_active_claim(task.id, new_id)
|
||||
|
||||
assert result is task
|
||||
assert old_agent.status == AgentStatus.IDLE
|
||||
assert old_agent.current_task_id is None
|
||||
assert new_agent.status == AgentStatus.ACTIVE
|
||||
assert new_agent.current_task_id == task.id
|
||||
|
||||
@@ -236,16 +236,16 @@ async def test_handle_breach_skips_a_task_that_already_moved_on() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _task_budget_breach: cap resolution (null -> TaskType default) + spend sum
|
||||
# _task_budget_breach: explicit-input-only cap (null budget = never a breach)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breach_falls_back_to_tasktype_default_when_budget_null() -> None:
|
||||
"""Cap resolution (task.budget_usd null -> TaskType default) and spend
|
||||
both delegate to TaskService now (task_spend_usd's own open-session
|
||||
pricing is covered directly by its shared implementation — see
|
||||
test_project_month_spend_usd_db.py's real-DB open-session case)."""
|
||||
async def test_null_budget_is_never_a_breach() -> None:
|
||||
"""Budgets enforce only when explicitly set: a task with no budget_usd is
|
||||
uncapped, regardless of spend — the spend query is never even issued (the
|
||||
old per-TaskType default table blocked a default-budget coordination root
|
||||
one opus planning turn in)."""
|
||||
orch = _make_orchestrator()
|
||||
task_id = "44444444-4444-4444-4444-444444444444"
|
||||
task = MagicMock(
|
||||
@@ -262,10 +262,8 @@ async def test_breach_falls_back_to_tasktype_default_when_budget_null() -> None:
|
||||
):
|
||||
breach = await orch._task_budget_breach(task_id)
|
||||
|
||||
assert breach is not None
|
||||
cap_usd, spend_usd = breach
|
||||
assert cap_usd == 1.0 # TASK_TYPE_DEFAULT_BUDGET_USD[DOCUMENTATION]
|
||||
assert spend_usd == _MOCK_TASK_SPEND_USD
|
||||
assert breach is None
|
||||
task_svc.task_spend_usd.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -201,6 +201,9 @@ async def test_fail_qa_emits_auditor_alert(
|
||||
"""``fail_qa`` calls the auditor rework producer with QA attribution."""
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# fail_qa releases the QA agent's fleet marker via session.get — default
|
||||
# to "no matching row" for a test that doesn't care about that side effect.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
task = _mock_task(status=TaskStatus.AWAITING_QA)
|
||||
task.orchestration_markers = {"original_developer": str(uuid4())}
|
||||
|
||||
@@ -235,6 +238,9 @@ async def test_pr_fail_emits_auditor_alert(
|
||||
"""``pr_fail`` calls the auditor rework producer with reviewer attribution."""
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# pr_fail releases the reviewer's fleet marker via session.get — default
|
||||
# to "no matching row" for a test that doesn't care about that side effect.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
reviewer_id = uuid4()
|
||||
pm_id = uuid4()
|
||||
task = _mock_task(status=TaskStatus.AWAITING_PR_REVIEW)
|
||||
|
||||
@@ -34,6 +34,11 @@ def _bind(svc: TaskService, name: str, value: object) -> None:
|
||||
def _service() -> TaskService:
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# reassign_active_claim now retargets the agent-side claim marker
|
||||
# (_retarget_agent_claim), which reads agent rows via session.get —
|
||||
# default to "no matching row" so tests that don't care about the
|
||||
# agent side effect stay a no-op there.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
return TaskService(session)
|
||||
|
||||
|
||||
@@ -611,6 +616,9 @@ async def test_unblock_with_restore_emits_audit_event() -> None:
|
||||
``AuditLogTable`` added to the session."""
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# An IN_PROGRESS restore now looks up the restored owner via session.get
|
||||
# to flip its ACTIVE marker — default to "no matching row".
|
||||
session.get = AsyncMock(return_value=None)
|
||||
added: list[object] = []
|
||||
session.add.side_effect = added.append
|
||||
svc = TaskService(session)
|
||||
|
||||
@@ -253,6 +253,12 @@ async def test_pr_review_claim_and_complete(db_session: AsyncSession) -> None:
|
||||
assert claimed.active_claimant_id is not None
|
||||
assert UUID(str(claimed.active_claimant_id)) == reviewer_id
|
||||
assert claimed.claimed_at is not None
|
||||
# The external-PR-review claim chokepoint flips the reviewer's fleet
|
||||
# marker too — otherwise pr_reviewer agents never show as active.
|
||||
reviewer_row = await db_session.get(AgentTable, reviewer_id)
|
||||
assert reviewer_row is not None
|
||||
assert reviewer_row.status == AgentStatus.ACTIVE
|
||||
assert reviewer_row.current_task_id == task_id
|
||||
|
||||
# Re-claiming a non-pending task is a no-op.
|
||||
assert await svc.pr_review_claim(reviewer_id, task_id) is None
|
||||
@@ -269,6 +275,10 @@ async def test_pr_review_claim_and_complete(db_session: AsyncSession) -> None:
|
||||
assert done.claimed_by is None
|
||||
# Single-claimant lock cleared on completion (the review hand-off is done).
|
||||
assert done.active_claimant_id is None
|
||||
# complete_review releases the reviewer's fleet marker too.
|
||||
reviewer_row = await db_session.get(AgentTable, reviewer_id)
|
||||
assert reviewer_row is not None
|
||||
assert reviewer_row.current_task_id is None
|
||||
|
||||
# Re-completing a completed task is a no-op.
|
||||
assert await svc.complete_review(reviewer_id, task_id) is None
|
||||
|
||||
@@ -567,12 +567,15 @@ async def test_wire_sibling_collision_dag_notifies_only_for_new_edges() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_agent_idle_sets_status_idle() -> None:
|
||||
agent = MagicMock(id=uuid4(), status=AgentStatus.ACTIVE)
|
||||
agent = MagicMock(id=uuid4(), status=AgentStatus.ACTIVE, current_task_id=uuid4())
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = agent
|
||||
svc = _service_with(result)
|
||||
await svc.mark_agent_idle(agent.id)
|
||||
assert agent.status == AgentStatus.IDLE
|
||||
# Otherwise the agent keeps reporting its last task as "currently
|
||||
# working" forever — nothing else ever clears this column.
|
||||
assert agent.current_task_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -587,6 +590,10 @@ async def test_qa_claim_sets_assignment_on_awaiting_qa() -> None:
|
||||
result.scalar_one_or_none.return_value = task
|
||||
session = MagicMock(flush=AsyncMock())
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
# _qa_or_doc_claim now looks up the claiming agent via session.get to
|
||||
# flip its ACTIVE marker — default to "no matching row" for a test that
|
||||
# doesn't care about that side effect.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
svc = TaskService(session)
|
||||
qa_id = uuid4()
|
||||
out = await svc.qa_claim(qa_id, task.id)
|
||||
@@ -617,6 +624,7 @@ async def test_doc_claim_sets_assignment_on_awaiting_documentation() -> None:
|
||||
result.scalar_one_or_none.return_value = task
|
||||
session = MagicMock(flush=AsyncMock())
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
session.get = AsyncMock(return_value=None)
|
||||
svc = TaskService(session)
|
||||
doc_id = uuid4()
|
||||
out = await svc.doc_claim(doc_id, task.id)
|
||||
@@ -677,7 +685,10 @@ async def test_unblock_with_restore_returns_to_pre_block_state() -> None:
|
||||
blocker_resolver_type=BlockerResolverType.AGENT,
|
||||
blocker_raised_by=pre_assignee,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# An IN_PROGRESS restore now looks up the restored owner via session.get
|
||||
# to flip its ACTIVE marker — default to "no matching row".
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.unblock_with_restore(uuid4(), task.id, restore=True)
|
||||
assert out is task
|
||||
@@ -721,13 +732,17 @@ async def test_unblock_no_branch_returns_to_pending() -> None:
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED, branch_name=None, blocker_raised_by=raiser
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
session = MagicMock(flush=AsyncMock())
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
|
||||
out = await svc.unblock(task.id)
|
||||
assert out is task
|
||||
assert task.status == TaskStatus.PENDING
|
||||
assert task.assigned_to == raiser
|
||||
# A PENDING restore is NOT a resume — the owner isn't marked active here;
|
||||
# a fresh claim() is what actually resumes it, so no agent lookup runs.
|
||||
session.get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -747,7 +762,10 @@ async def test_admin_set_status_out_of_blocked_restores_pre_block_owner() -> Non
|
||||
pre_block_state="in_progress",
|
||||
pre_block_assignee=dev,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# An IN_PROGRESS restore now looks up the restored owner (dev) via
|
||||
# session.get to flip its ACTIVE marker.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
|
||||
assert out is task
|
||||
@@ -791,7 +809,10 @@ async def test_admin_set_status_into_review_queue_clears_active_claimant() -> No
|
||||
claimed_by=dev,
|
||||
active_claimant_id=dev,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# Clearing the stale claimant now looks it up via session.get to release
|
||||
# its ACTIVE marker too.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_QA)
|
||||
assert out is task
|
||||
@@ -943,7 +964,10 @@ async def test_admin_set_status_blocked_to_review_state_clears_claim() -> None:
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# Clearing the stale claim now looks it up via session.get to release
|
||||
# its ACTIVE marker too.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_PM_REVIEW)
|
||||
assert out is task
|
||||
@@ -970,7 +994,10 @@ async def test_admin_set_status_blocked_to_needs_revision_clears_claim() -> None
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# Clearing the stale claim now looks it up via session.get to release
|
||||
# its ACTIVE marker too.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.NEEDS_REVISION)
|
||||
assert out is task
|
||||
@@ -1085,7 +1112,10 @@ async def test_admin_set_status_pre_block_restore_syncs_active_claimant() -> Non
|
||||
pre_block_state="in_progress",
|
||||
pre_block_assignee=dev,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# An IN_PROGRESS restore now looks up the restored owner (dev) via
|
||||
# session.get to flip its ACTIVE marker.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
|
||||
assert out is task
|
||||
@@ -1566,7 +1596,10 @@ async def test_unblock_with_branch_resumes_in_progress() -> None:
|
||||
branch_name="feature/backend/abc12345",
|
||||
blocker_raised_by=uuid4(),
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
# Resuming IN_PROGRESS now looks up the restored owner via session.get
|
||||
# to flip its ACTIVE marker.
|
||||
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
|
||||
svc = TaskService(session)
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
|
||||
out = await svc.unblock(task.id)
|
||||
@@ -1809,6 +1842,56 @@ async def test_finalize_claim_rollback_emits_reversal_audit() -> None:
|
||||
assert {"from": "claimed", "to": "pending"} in audit_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_claim_sets_agent_active_then_rolls_back_on_failure() -> None:
|
||||
"""_finalize_claim must flip agent.status/current_task_id to ACTIVE/this
|
||||
task BEFORE the branch step runs (previously nothing ever wrote these
|
||||
fields at all — the fleet-status bug), and roll them back to their
|
||||
pre-claim values on a branch-creation failure, same as the task fields.
|
||||
"""
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
svc = TaskService(session)
|
||||
|
||||
task = _build_task(
|
||||
status=TaskStatus.PENDING,
|
||||
branch_name=None,
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
cell_projects=[],
|
||||
pr_created=False,
|
||||
pr_number=None,
|
||||
)
|
||||
agent = MagicMock(
|
||||
id=uuid4(),
|
||||
role=AgentRole.DEVELOPER,
|
||||
status=AgentStatus.IDLE,
|
||||
current_task_id=None,
|
||||
)
|
||||
_bind(svc, "_emit_status_transition_audit", MagicMock())
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _boom(_task: object, _agent_id: object) -> str:
|
||||
captured["status"] = agent.status
|
||||
captured["current_task_id"] = agent.current_task_id
|
||||
raise RuntimeError("branch boom")
|
||||
|
||||
_bind(svc, "_ensure_branch_for_task", _boom)
|
||||
|
||||
with pytest.raises(RuntimeError, match="branch boom"):
|
||||
await svc._finalize_claim(task, agent, agent.id)
|
||||
|
||||
# Set to ACTIVE/this-task before the branch step ran...
|
||||
assert captured["status"] == AgentStatus.ACTIVE
|
||||
assert captured["current_task_id"] == task.id
|
||||
# ...and rolled back to the pre-claim values once branch creation failed.
|
||||
assert agent.status == AgentStatus.IDLE
|
||||
assert agent.current_task_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_status_transition_audit_writes_in_session_atomically() -> None:
|
||||
"""The status-transition audit row is written into the CALLER's session (same
|
||||
|
||||
@@ -30,6 +30,11 @@ def _bind(svc: TaskService, name: str, value: object) -> None:
|
||||
def _service() -> TaskService:
|
||||
session = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
# reassign_active_claim now retargets the agent-side claim marker
|
||||
# (_retarget_agent_claim), which reads agent rows via session.get —
|
||||
# default to "no matching row" so tests that don't care about the
|
||||
# agent side effect stay a no-op there.
|
||||
session.get = AsyncMock(return_value=None)
|
||||
return TaskService(session)
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,33 @@ async def test_run_cycle_syncs_commands_exactly_once(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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,
|
||||
@@ -124,12 +151,16 @@ async def test_render_agents_lists_working_agents(
|
||||
async def test_render_usage_formats_today_summary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
usage = MagicMock(
|
||||
get_today_summary=AsyncMock(
|
||||
return_value={"tokens_today": 2_400_000, "cost_today_usd": 18.7}
|
||||
cockpit = MagicMock(
|
||||
today_spend=AsyncMock(
|
||||
return_value={
|
||||
"tokens_today": 2_400_000,
|
||||
"cost_today_usd": 18.7,
|
||||
"subscription_billed": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_usage_service", lambda _s: usage)
|
||||
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
|
||||
|
||||
text = await _engine()._render_usage()
|
||||
|
||||
@@ -137,6 +168,30 @@ async def test_render_usage_formats_today_summary(
|
||||
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,
|
||||
|
||||
@@ -9,12 +9,13 @@ assumed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.db.tables import AgentSpawnSessionTable, AgentTable, TaskTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import (
|
||||
@@ -43,6 +44,28 @@ SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
|
||||
CI_WATCH_SOURCE = "ci_watch"
|
||||
|
||||
_COST_TOL = 0.005
|
||||
_TOKENS_FLOOR = 1
|
||||
|
||||
|
||||
def _spawn_session(
|
||||
*, started_at: datetime, model: str, cost: float, tokens_input: int = 1000
|
||||
) -> AgentSpawnSessionTable:
|
||||
return AgentSpawnSessionTable(
|
||||
id=uuid4(),
|
||||
agent_slug=f"be-dev-{uuid4().hex[:6]}",
|
||||
team="backend",
|
||||
role="developer",
|
||||
model=model,
|
||||
task_id=None,
|
||||
started_at=started_at,
|
||||
ended_at=started_at + timedelta(minutes=5),
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=0,
|
||||
estimated_cost_usd=cost,
|
||||
)
|
||||
|
||||
|
||||
# 1 awaiting + 1 blocked + 4 held drafts (release/x/video/roadmap-item).
|
||||
EXPECTED_NEEDS_YOU_TOTAL = 6
|
||||
|
||||
@@ -195,3 +218,110 @@ async def test_today_composes_needs_you_fleet_and_ship(
|
||||
|
||||
assert brief["ship"]["open_release_proposal"] is True
|
||||
assert brief["ship"]["ci_fix_tasks"] == baseline["ship"]["ci_fix_tasks"] + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display-timezone bucketing (Issue 2) — "today" is display_timezone-aware,
|
||||
# not always the server's UTC day. `_session_metrics_by_day` is called
|
||||
# directly with an explicit historical `days` window so the test is fully
|
||||
# deterministic (disconnected from the real "now").
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_metrics_by_day_buckets_by_display_timezone(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""23:30 UTC on the 15th is already 00:30 on the 16th in Europe/Berlin
|
||||
(winter, CET = UTC+1) — the exact 'evening activity lands on the wrong
|
||||
display day' bug this fix targets."""
|
||||
started = datetime(2026, 1, 15, 23, 30, tzinfo=UTC)
|
||||
session_row = _spawn_session(started_at=started, model="claude-sonnet-5", cost=1.23)
|
||||
db_session.add(session_row)
|
||||
await db_session.flush()
|
||||
|
||||
svc = get_tg_cockpit_service(db_session)
|
||||
|
||||
monkeypatch.setattr(settings, "display_timezone", "UTC")
|
||||
cost_utc, tokens_utc, _ = await svc._session_metrics_by_day([date(2026, 1, 15)])
|
||||
assert cost_utc.get(date(2026, 1, 15), 0.0) >= _COST_TOL
|
||||
assert tokens_utc.get(date(2026, 1, 15), 0) >= _TOKENS_FLOOR
|
||||
|
||||
monkeypatch.setattr(settings, "display_timezone", "Europe/Berlin")
|
||||
cost_berlin, tokens_berlin, _ = await svc._session_metrics_by_day(
|
||||
[date(2026, 1, 16)]
|
||||
)
|
||||
assert cost_berlin.get(date(2026, 1, 16), 0.0) >= _COST_TOL
|
||||
assert tokens_berlin.get(date(2026, 1, 16), 0) >= _TOKENS_FLOOR
|
||||
# And the SAME row must NOT double-count into the UTC calendar day under
|
||||
# the Berlin bucketing — the 15th should now come up empty for this row.
|
||||
cost_berlin_15, _, _ = await svc._session_metrics_by_day([date(2026, 1, 15)])
|
||||
assert cost_berlin_15.get(date(2026, 1, 15), 0.0) < _COST_TOL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_window_dates_shifts_with_display_timezone(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`_window_dates` (real 'now') must reflect the configured display
|
||||
timezone, not always UTC."""
|
||||
svc = get_tg_cockpit_service(cast("AsyncSession", None))
|
||||
monkeypatch.setattr(settings, "display_timezone", "UTC")
|
||||
utc_dates = svc._window_dates()
|
||||
monkeypatch.setattr(settings, "display_timezone", "Pacific/Kiritimati")
|
||||
# UTC+14 — the furthest-ahead real timezone; "today" there is never
|
||||
# earlier, and is later whenever UTC hasn't crossed its own midnight yet.
|
||||
kiritimati_dates = svc._window_dates()
|
||||
assert kiritimati_dates[-1] >= utc_dates[-1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama Cloud honesty-labeling (Issue 1) — an ungrounded ':cloud' model's $0
|
||||
# is flagged subscription_billed, never rendered as a bare misleading "$0".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_spend_flags_ungrounded_ollama_cloud_as_subscription_billed(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
_spawn_session(started_at=now, model="some-future-model:cloud", cost=0.0)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
summary = await get_tg_cockpit_service(db_session).today_spend()
|
||||
|
||||
assert summary["cost_today_usd"] == pytest.approx(0.0)
|
||||
assert summary["subscription_billed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_spend_not_subscription_billed_when_priced(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A real per-token cost (even from a priced Ollama Cloud model like
|
||||
GLM-5.2) is never mislabeled as an untracked subscription figure."""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(_spawn_session(started_at=now, model="glm-5.2:cloud", cost=2.5))
|
||||
await db_session.flush()
|
||||
|
||||
summary = await get_tg_cockpit_service(db_session).today_spend()
|
||||
|
||||
assert summary["subscription_billed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_spend_not_subscription_billed_for_local_ollama(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A genuinely-free self-hosted model (no ':cloud' tag) at $0 is just
|
||||
free, not an untracked subscription."""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(_spawn_session(started_at=now, model="ollama/llama3", cost=0.0))
|
||||
await db_session.flush()
|
||||
|
||||
summary = await get_tg_cockpit_service(db_session).today_spend()
|
||||
|
||||
assert summary["subscription_billed"] is False
|
||||
|
||||
@@ -200,3 +200,24 @@ def test_resolve_uvicorn_loop_factory_uvloop_returns_new_event_loop() -> None:
|
||||
assert isinstance(loop, uvloop.Loop)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# display_timezone — the TG cockpit's day-bucketing timezone (Issue 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_display_timezone_defaults_to_utc() -> None:
|
||||
"""Default is a no-op for every deployment that doesn't set it."""
|
||||
assert Settings().display_timezone == "UTC"
|
||||
|
||||
|
||||
def test_display_timezone_accepts_valid_iana_name() -> None:
|
||||
assert Settings(display_timezone="Europe/Berlin").display_timezone == (
|
||||
"Europe/Berlin"
|
||||
)
|
||||
|
||||
|
||||
def test_display_timezone_rejects_unknown_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(display_timezone="Not/AZone")
|
||||
|
||||
Reference in New Issue
Block a user