Files
roboco/tests/unit/runtime/test_orchestrator_write_hooks.py
T
547fe444f2 [4865ff8b] Add WebSocket support to the usage dashboard (#115)
* [e7349d84] feat(dashboard): WS usage store, hook extension, status badge, and smooth animations (#111) (#113)

- Add src/store/usage-store.ts with typed UsageData interface, useUsageStore
  Zustand store, setUsageData, clearUsageData, and setWsState actions
- Export useUsageStore and UsageData from store/index.ts
- Extend use-rate-limit-websocket.ts: rename msg type to SystemWsMessage,
  add key_metrics field; add useEffect syncing wsState into useUsageStore;
  add USAGE_UPDATE/USAGE_SNAPSHOT handler dispatching to useUsageStore
  (RATE_LIMIT_HIT/LIFTED handling and onReconnect unchanged)
- Update CommandCenter to read key_metrics from useUsageStore when
  wsState === 'connected' and usageData non-null; falls back to
  useCeoOverview() (refetchInterval: 60000) when WS disconnected
- Update KeyMetricsPanel: add wsState prop, render connection status Badge
  matching AgentStreamViewer pattern (bg-green-500+Wifi / bg-yellow-500+
  Loader2 spin / bg-gray-500+WifiOff); add transition-all duration-300
  ease-in-out to metric value spans for smooth animated updates

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [c9745ee8] feat(events): add USAGE_UPDATE/SNAPSHOT event types, throttled publisher, /ws/system usage bridge (#112) (#114)

- Add EventType.USAGE_UPDATE='usage.update' and EventType.USAGE_SNAPSHOT='usage.snapshot'
  to the EventType StrEnum in roboco/models/events.py

- Create roboco/services/usage_events.py with _UsageThrottle class (5-second per-agent
  window using time.monotonic()) and publish_usage_update() / publish_usage_snapshot()
  helpers; lazy imports prevent circular dependency with roboco.events

- Extend orchestrator._sweep_token_snapshots() to publish USAGE_UPDATE per active agent
  (throttled) and a USAGE_SNAPSHOT aggregate after each sweep cycle; wrapped in
  contextlib.suppress so event errors never abort DB snapshot operations

- Add _handle_usage_event() to websocket_bridge.py following _handle_rate_limit_event
  pattern; register USAGE_UPDATE and USAGE_SNAPSHOT subscriptions in
  register_websocket_bridge_handlers() forwarding both to /ws/system via broadcast_system()

- Add unit tests: test_usage_events.py (throttle suppression, publish helpers) and
  test_websocket_bridge.py extended with _handle_usage_event coverage and updated
  registration assertion to include USAGE_UPDATE/USAGE_SNAPSHOT

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* fix(usage-ws): reconcile the realtime token/cost contract end-to-end

The backend and frontend halves shipped mismatched contracts, so the usage
dashboard never received live data:

- The bridge forwarded the dotted event value ("usage.update") while the panel
  switched on "USAGE_UPDATE"; map both to the UPPER_SNAKE type string the same
  way the rate-limit handler does.
- The backend emitted token/cost telemetry but the frontend read a key_metrics
  field and fed the org-metrics panel. Rewire the frontend to consume the
  USAGE_SNAPSHOT token/cost payload into the "Token Usage & Cost" panel —
  WS-first with polling fallback and a connection-status badge — and revert the
  unrelated KeyMetricsPanel / CommandCenter wiring.

Backend cleanups in the same path:

- Replace the multi-argument publish helpers with typed UsageUpdate /
  UsageSnapshot payloads, removing the too-many-arguments lint suppressions.
- Extract _fetch_agent_tokens and _persist_token_snapshot from the token sweep,
  removing the too-many-statements suppression; label the live snapshot "live".

Hardening uncovered while fixing the above:

- _finalize_spawn_session pulled the full RAG stack into the
  session-finalization path through a transcript-parse import; move the pure
  parser into a dependency-light roboco.agent_sdk.transcript_usage module so
  finalization never imports the agent SDK server.
- Reduce _finalize_spawn_session complexity by extracting
  _resolve_final_token_usage, and widen the transcript-fallback guard so a read
  error can never abort finalization.

Also align KeyMetricsPanel with the metrics /dashboard/ceo actually returns: it
read velocity_24h / avg_time_to_done / active_agents, none of which
get_key_metrics() emits, so four of five rows rendered "—". Render
velocity_weekly, completion_rate, documentation_coverage and active_blockers.

* docs: note live usage push over /ws/system on the usage dashboard

* fix(usage): finalize on self-exit and de-duplicate transcript token counts

Two bugs left token capture broken even after the transcript-read fallback
landed — surfaced by a live agent run:

- Agents that self-exit (the normal i_am_idle -> container shutdown, exit 0)
  were never finalized. _finalize_spawn_session is only called from
  stop_agent(), but a graceful self-exit goes through _handle_stopped_container,
  which set the instance OFFLINE and returned without finalizing — leaving the
  spawn-session row open with zero tokens. Finalize there for both graceful
  (exit_reason="completed") and crash (exit_reason="crashed") exits.

- sum_transcript_usage double-counted. Claude Code logs one assistant message
  as several JSONL lines (one per content block — thinking / text / tool_use),
  each repeating the same message.usage, so summing every line roughly doubled
  the totals. De-duplicate by message.id.

Verified against a live agent transcript: the raw sum (12, 1068, 62502, 115828)
vs the de-duped (6, 516, 62502, 63336), which matches the session's
authoritative result.usage exactly.

* feat(usage): fall back to the transcript in the live token sweep

The 60s token sweep read only the agent SDK's /usage/status, which races
container teardown and reports zero mid-run — so live usage (and the
USAGE_SNAPSHOT pushed to /ws/system) stayed at zero for active agents.
Extract _resolve_active_tokens: try the SDK, then fall back to the durable
transcript (the same source finalize uses) so running agents report live.

* feat(usage): add GET /usage/sessions for the dashboard's Recent Sessions

The panel's Recent Sessions table was mock-only — the backend had no sessions
endpoint, so production always showed 'No sessions recorded yet'. Add
UsageService.get_recent_sessions + a /usage/sessions route returning the most
recent spawn-session rows (token totals + cost), and point the panel client
at it.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 23:19:50 +02:00

651 lines
22 KiB
Python

"""
Unit tests for orchestrator write-hooks:
_finalize_spawn_session — closes the agent_spawn_sessions DB row on stop
_sweep_token_snapshots — polls active agents and upserts token snapshots
These tests mock the httpx transport and the SQLAlchemy session factory so no
real network or database is required.
Coverage:
1. _finalize_spawn_session success — SDK returns token data → DB update
carries those exact values to calculate_cost and the UPDATE statement.
2. _finalize_spawn_session HTTP error — SDK unreachable → DB update proceeds
with all-zero token counts (finalization must not raise).
3. _sweep_token_snapshots active agent — non-zero tokens → snapshot row
inserted and session row updated.
4. _sweep_token_snapshots per-agent HTTP error — ConnectError on one agent
is caught; the sweep continues and the next agent is still processed.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4
import httpx
from roboco.models.runtime import (
AgentInstance,
OrchestratorAgentConfig,
OrchestratorAgentState,
)
from roboco.runtime.orchestrator import AgentOrchestrator
# ---------------------------------------------------------------------------
# Module-level constants (ruff PLR2004: no magic values in comparisons)
# ---------------------------------------------------------------------------
_AGENT_ID = "be-dev-1"
_AGENT_ID_2 = "be-dev-2"
# Token counts used in success-path assertions
_TI = 111 # tokens_input
_TO = 222 # tokens_output
_TCR = 33 # tokens_cache_read
_TCW = 44 # tokens_cache_write
# Token counts for the snapshot test
_SNAP_TI = 50
_SNAP_TO = 100
_SNAP_TCR = 10
_SNAP_TCW = 5
# Token counts for the loop-continues test (agent-2)
_LOOP_TI = 25
_LOOP_TO = 75
# Expected number of DB execute() calls for a normal finalize (SELECT + UPDATE)
_FINALIZE_EXEC_CALLS = 2
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _make_orchestrator() -> AgentOrchestrator:
"""Minimal AgentOrchestrator — no background tasks, no real DB."""
return AgentOrchestrator(mcp_config_dir=Path("/tmp"), project_root=Path("/tmp"))
def _make_instance(
agent_id: str = _AGENT_ID,
usage_session_id: UUID | None = None,
) -> AgentInstance:
"""Return an ACTIVE AgentInstance with a running container."""
return AgentInstance(
agent_id=agent_id,
state=OrchestratorAgentState.ACTIVE,
container_id="abc123def456",
config=OrchestratorAgentConfig(
agent_id=agent_id,
blueprint_path=Path("/tmp/blueprint.md"),
model="sonnet",
),
usage_session_id=usage_session_id,
)
def _mock_response(
status: int = 200,
json_data: dict[str, Any] | None = None,
) -> MagicMock:
"""Build a mock httpx.Response."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status
resp.json = MagicMock(return_value=json_data or {})
return resp
def _make_db_factory(
session_row: Any = None,
add_list: list[Any] | None = None,
execute_list: list[Any] | None = None,
) -> Any:
"""Return a callable that acts like get_session_factory().
The returned callable, when called with no arguments, returns an async
context manager yielding a mock AsyncSession whose execute() returns a
result whose scalar_one_or_none() returns *session_row*.
"""
@asynccontextmanager
async def _db_context() -> Any:
db = MagicMock()
result = MagicMock()
result.scalar_one_or_none = MagicMock(return_value=session_row)
async def _exec(stmt: Any) -> MagicMock:
if execute_list is not None:
execute_list.append(stmt)
return result
db.execute = AsyncMock(side_effect=_exec)
db.commit = AsyncMock()
def _add(obj: Any) -> None:
if add_list is not None:
add_list.append(obj)
db.add = _add if add_list is not None else MagicMock()
yield db
return _db_context
class _FakeHTTPClient:
"""Drop-in replacement for ``httpx.AsyncClient`` in tests.
Accepts a *handler* callable ``(url: str) -> httpx.Response | raises``
that is invoked by ``get()``. Supports the ``async with`` protocol.
"""
def __init__(self, handler: Any, **_: Any) -> None:
self._handler = handler
async def __aenter__(self) -> _FakeHTTPClient:
return self
async def __aexit__(self, *_: Any) -> None:
pass
async def get(self, url: str, **_: Any) -> Any:
return self._handler(url)
# ---------------------------------------------------------------------------
# _finalize_spawn_session — success path
# ---------------------------------------------------------------------------
async def test_finalize_spawn_session_success_calls_calculate_cost() -> None:
"""Token values returned by the SDK /usage/status are passed to calculate_cost.
This verifies the full data-flow: SDK response → token vars → cost calc.
"""
orch = _make_orchestrator()
session_uuid = uuid4()
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
token_data = {
"tokens_input": _TI,
"tokens_output": _TO,
"tokens_cache_read": _TCR,
"tokens_cache_write": _TCW,
}
def _handler(_url: str) -> Any:
return _mock_response(200, token_data)
session_row = MagicMock()
session_row.id = session_uuid
db_factory = _make_db_factory(session_row=session_row)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
patch("roboco.billing.pricing.calculate_cost", return_value=0.001) as mock_cost,
):
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
mock_cost.assert_called_once_with(
model="sonnet",
tokens_input=_TI,
tokens_output=_TO,
tokens_cache_read=_TCR,
tokens_cache_write=_TCW,
)
async def test_finalize_spawn_session_success_executes_select_and_update() -> None:
"""When a session row exists the function calls execute() twice: SELECT + UPDATE."""
orch = _make_orchestrator()
session_uuid = uuid4()
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
def _handler(_url: str) -> Any:
return _mock_response(
200,
{
"tokens_input": 10,
"tokens_output": 20,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
},
)
session_row = MagicMock()
session_row.id = session_uuid
execute_calls: list[Any] = []
db_factory = _make_db_factory(session_row=session_row, execute_list=execute_calls)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
patch("roboco.billing.pricing.calculate_cost", return_value=0.0),
):
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="completed")
# SELECT (find the row) + UPDATE (write the values) = 2 execute() calls
assert len(execute_calls) == _FINALIZE_EXEC_CALLS
# ---------------------------------------------------------------------------
# _finalize_spawn_session — HTTP-error path
# ---------------------------------------------------------------------------
async def test_finalize_spawn_session_http_error_uses_zero_tokens() -> None:
"""When the SDK endpoint is unreachable, finalization uses zero tokens.
The function must not raise; cost must be calculated with all-zero counts.
"""
orch = _make_orchestrator()
session_uuid = uuid4()
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
def _boom(_url: str) -> Any:
raise httpx.ConnectError("container not reachable")
session_row = MagicMock()
session_row.id = session_uuid
db_factory = _make_db_factory(session_row=session_row)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_boom)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch.object(orch, "_usage_from_transcript", return_value=(0, 0, 0, 0)),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
):
# Must not raise even though the SDK is unreachable
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
mock_cost.assert_called_once_with(
model="sonnet",
tokens_input=0,
tokens_output=0,
tokens_cache_read=0,
tokens_cache_write=0,
)
async def test_finalize_spawn_session_non_200_uses_zero_tokens() -> None:
"""A non-200 SDK response results in zero-token finalization, no exception."""
orch = _make_orchestrator()
session_uuid = uuid4()
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
def _handler(_url: str) -> Any:
return _mock_response(503)
session_row = MagicMock()
session_row.id = session_uuid
db_factory = _make_db_factory(session_row=session_row)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch.object(orch, "_usage_from_transcript", return_value=(0, 0, 0, 0)),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
):
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
mock_cost.assert_called_once_with(
model="sonnet",
tokens_input=0,
tokens_output=0,
tokens_cache_read=0,
tokens_cache_write=0,
)
# ---------------------------------------------------------------------------
# _sweep_token_snapshots — active agent
# ---------------------------------------------------------------------------
async def test_sweep_token_snapshots_inserts_snapshot_for_active_agent() -> None:
"""An active agent with non-zero tokens gets a snapshot row added to the DB."""
orch = _make_orchestrator()
instance = _make_instance(_AGENT_ID)
instance.state = OrchestratorAgentState.ACTIVE
orch._instances[_AGENT_ID] = instance
token_data = {
"tokens_input": _SNAP_TI,
"tokens_output": _SNAP_TO,
"tokens_cache_read": _SNAP_TCR,
"tokens_cache_write": _SNAP_TCW,
}
def _handler(_url: str) -> Any:
return _mock_response(200, token_data)
session_row = MagicMock()
session_row.id = uuid4()
added: list[Any] = []
db_factory = _make_db_factory(session_row=session_row, add_list=added)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
):
await orch._sweep_token_snapshots()
# Exactly one snapshot row must have been passed to db.add()
assert len(added) == 1
snap = added[0]
assert snap.tokens_input == _SNAP_TI
assert snap.tokens_output == _SNAP_TO
assert snap.tokens_cache_read == _SNAP_TCR
assert snap.tokens_cache_write == _SNAP_TCW
async def test_sweep_token_snapshots_skips_zero_token_agents() -> None:
"""An agent whose SDK reports all-zero tokens is skipped (no DB writes)."""
orch = _make_orchestrator()
instance = _make_instance(_AGENT_ID)
instance.state = OrchestratorAgentState.ACTIVE
orch._instances[_AGENT_ID] = instance
def _handler(_url: str) -> Any:
return _mock_response(
200,
{
"tokens_input": 0,
"tokens_output": 0,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
},
)
added: list[Any] = []
db_factory = _make_db_factory(add_list=added)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch.object(orch, "_usage_from_transcript", return_value=(0, 0, 0, 0)),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
):
await orch._sweep_token_snapshots()
assert added == []
async def test_sweep_token_snapshots_per_agent_error_does_not_abort_loop() -> None:
"""A ConnectError for one agent is caught; the next agent is still processed."""
orch = _make_orchestrator()
# Agent 1: HTTP error
inst1 = _make_instance(_AGENT_ID)
inst1.state = OrchestratorAgentState.ACTIVE
orch._instances[_AGENT_ID] = inst1
# Agent 2: success with non-zero tokens
inst2 = _make_instance(_AGENT_ID_2)
inst2.state = OrchestratorAgentState.ACTIVE
orch._instances[_AGENT_ID_2] = inst2
def _handler(url: str) -> Any:
if _AGENT_ID in url and _AGENT_ID_2 not in url:
raise httpx.ConnectError("agent-1 unreachable")
return _mock_response(
200,
{
"tokens_input": _LOOP_TI,
"tokens_output": _LOOP_TO,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
},
)
session_row = MagicMock()
session_row.id = uuid4()
added: list[Any] = []
db_factory = _make_db_factory(session_row=session_row, add_list=added)
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
return _FakeHTTPClient(_handler)
with (
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
patch("roboco.db.base.get_session_factory", return_value=db_factory),
):
await orch._sweep_token_snapshots()
# Only agent-2's snapshot should be present; agent-1's error was caught.
assert len(added) == 1
assert added[0].tokens_input == _LOOP_TI
assert added[0].tokens_output == _LOOP_TO
# ---------------------------------------------------------------------------
# _sweep_daily_rollup — inserts new row when none exists
# ---------------------------------------------------------------------------
# Token counts for the rollup test
_ROLLUP_TI = 200
_ROLLUP_TO = 300
_ROLLUP_TCR = 20
_ROLLUP_TCW = 10
async def test_sweep_daily_rollup_inserts_new_row() -> None:
"""When no existing DailyUsageRollupTable row exists, db.add() is called
with the correct aggregated token values."""
orch = _make_orchestrator()
# Build a fake aggregate result row
agg_row = MagicMock()
agg_row.date = "2026-06-10"
agg_row.agent_slug = _AGENT_ID
agg_row.team = "backend"
agg_row.model = "sonnet"
agg_row.tokens_input = _ROLLUP_TI
agg_row.tokens_output = _ROLLUP_TO
agg_row.tokens_cache_read = _ROLLUP_TCR
agg_row.tokens_cache_write = _ROLLUP_TCW
agg_row.total_cost_usd = 0.0
agg_row.session_count = 1
added: list[Any] = []
call_count = 0
@asynccontextmanager
async def _db_context() -> Any:
nonlocal call_count
db = MagicMock()
db.commit = AsyncMock()
def _add(obj: Any) -> None:
added.append(obj)
db.add = _add
async def _exec(_stmt: Any) -> MagicMock:
nonlocal call_count
call_count += 1
result = MagicMock()
if call_count == 1:
# First call: aggregate SELECT — return one agg_row via fetchall()
result.fetchall = MagicMock(return_value=[agg_row])
result.scalar_one_or_none = MagicMock(return_value=None)
else:
# Second call: lookup SELECT for existing row — return None
result.fetchall = MagicMock(return_value=[])
result.scalar_one_or_none = MagicMock(return_value=None)
return result
db.execute = AsyncMock(side_effect=_exec)
yield db
with patch("roboco.db.base.get_session_factory", return_value=_db_context):
await orch._sweep_daily_rollup()
# Exactly one new DailyUsageRollupTable row must have been added
assert len(added) == 1
row = added[0]
assert row.tokens_input == _ROLLUP_TI
assert row.tokens_output == _ROLLUP_TO
assert row.tokens_cache_read == _ROLLUP_TCR
assert row.tokens_cache_write == _ROLLUP_TCW
# ---------------------------------------------------------------------------
# stop_agent — _finalize_spawn_session is awaited before acquiring the lock
# ---------------------------------------------------------------------------
async def test_stop_agent_finalizes_before_lock() -> None:
"""stop_agent awaits _finalize_spawn_session when the instance has a
running container_id (the finalization must happen before the lock)."""
orch = _make_orchestrator()
instance = _make_instance(_AGENT_ID)
instance.container_id = "abc123def456" # non-None → finalize must be called
orch._instances[_AGENT_ID] = instance
finalized: list[str] = []
async def _fake_finalize(agent_id: str, **_kwargs: object) -> None:
finalized.append(agent_id)
# Stub out the Docker subprocess so stop_agent doesn't actually run Docker
mock_proc = MagicMock()
mock_proc.wait = AsyncMock()
with (
patch.object(orch, "_finalize_spawn_session", side_effect=_fake_finalize),
patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)),
patch.object(orch, "_remove_container", AsyncMock()),
):
await orch.stop_agent(_AGENT_ID, graceful=True)
# _finalize_spawn_session must have been called exactly once with our agent id
assert finalized == [_AGENT_ID]
# ---------------------------------------------------------------------------
# _handle_stopped_container — self-exits finalize (stop_agent was not called)
# ---------------------------------------------------------------------------
async def test_handle_stopped_container_graceful_finalizes() -> None:
"""A graceful self-exit (exit 0) finalizes the spawn session.
The agent calls i_am_idle and its container exits 0 without stop_agent
being invoked, so _handle_stopped_container must finalize to capture the
token usage; otherwise the session row is left open with zero tokens.
"""
orch = _make_orchestrator()
instance = _make_instance(_AGENT_ID)
instance.container_id = "abc123def456"
orch._instances[_AGENT_ID] = instance
calls: list[tuple[str, str]] = []
async def _fake_finalize(agent_id: str, exit_reason: str = "stopped") -> None:
calls.append((agent_id, exit_reason))
with patch.object(orch, "_finalize_spawn_session", side_effect=_fake_finalize):
await orch._handle_stopped_container(_AGENT_ID, instance, 0)
assert calls == [(_AGENT_ID, "completed")]
assert instance.state is OrchestratorAgentState.OFFLINE
async def test_handle_stopped_container_crash_finalizes_then_restarts() -> None:
"""A non-zero exit finalizes (exit_reason='crashed') before auto-restart."""
orch = _make_orchestrator()
instance = _make_instance(_AGENT_ID)
instance.container_id = "abc123def456"
instance.error_count = 0
orch._instances[_AGENT_ID] = instance
calls: list[tuple[str, str]] = []
async def _fake_finalize(agent_id: str, exit_reason: str = "stopped") -> None:
calls.append((agent_id, exit_reason))
with (
patch.object(orch, "_finalize_spawn_session", side_effect=_fake_finalize),
patch.object(orch, "spawn_agent", AsyncMock()) as mock_spawn,
):
await orch._handle_stopped_container(_AGENT_ID, instance, 1)
assert calls == [(_AGENT_ID, "crashed")]
mock_spawn.assert_awaited_once()
# ---------------------------------------------------------------------------
# _resolve_active_tokens — SDK first, transcript fallback for live agents
# ---------------------------------------------------------------------------
async def test_resolve_active_tokens_falls_back_to_transcript() -> None:
"""When the SDK reports all-zero, live resolution uses the transcript."""
orch = _make_orchestrator()
def _handler(_url: str) -> Any:
return _mock_response(
200,
{
"tokens_input": 0,
"tokens_output": 0,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
},
)
client = _FakeHTTPClient(_handler)
with patch.object(orch, "_usage_from_transcript", return_value=(6, 514, 100, 50)):
tokens = await orch._resolve_active_tokens(client, _AGENT_ID)
assert tokens == (6, 514, 100, 50)
async def test_resolve_active_tokens_prefers_sdk() -> None:
"""A non-zero SDK response is used directly — no transcript fallback."""
orch = _make_orchestrator()
def _handler(_url: str) -> Any:
return _mock_response(
200,
{
"tokens_input": 10,
"tokens_output": 20,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
},
)
client = _FakeHTTPClient(_handler)
with patch.object(
orch, "_usage_from_transcript", return_value=(999, 999, 999, 999)
) as mock_tx:
tokens = await orch._resolve_active_tokens(client, _AGENT_ID)
assert tokens == (10, 20, 0, 0)
mock_tx.assert_not_called()