Files
roboco/tests/unit/agent_sdk/test_usage_sync.py
T
Renn F cc4ccb7ea3 fix(usage): capture agent token usage from the Claude Code transcript
The token-usage pipeline was fully built — per-session SDK counters,
/usage/status, the orchestrator finalize-fetch that writes token columns and
estimated cost to the spawn-session row, the daily rollup, and the dashboard
— but nothing ever populated the counters. /usage/report had zero callers, so
every session reported zero tokens and the cost dashboard rendered all-zeros.
A redeploy could not fix code that was never written.

Close the loop with the producer that was missing. Claude Code does not pass
token counts to hooks, but it does pass the session transcript path, and each
assistant entry records its API call's usage. Add:

- POST /usage/sync, which parses the transcript and *sets* the cumulative
  totals absolutely (idempotent — re-syncing the same or a grown transcript
  overwrites, never double-counts), with a (size, mtime) short-circuit so an
  unchanged transcript skips the re-parse.
- usage-report-hook.sh, which hands the SDK the transcript path. Registered on
  PostToolUse (keeps mid-run snapshots and reaped-agent sessions accurate) and
  Stop (guarantees a final sync at turn end before finalize reads the totals).

Field mapping verified against a real Claude Code transcript:
message.usage.{input_tokens, output_tokens, cache_read_input_tokens,
cache_creation_input_tokens}. Unit tests cover summation, idempotency, growth,
a missing transcript, and malformed lines.
2026-06-11 07:46:19 +02:00

171 lines
5.8 KiB
Python

"""Token-usage capture — /usage/sync parses the transcript and sets totals.
The agent SDK exposes /usage/report (additive) and /usage/status (read),
but nothing ever fed token counts in, so every session reported zero and the
cost dashboard rendered all-zeros. The fix: the usage-report hook hands the
SDK the Claude Code transcript path; /usage/sync parses the per-message
``usage`` blocks and *sets* the cumulative totals absolutely. These tests pin
that contract — correct summation, idempotency (no double-count on re-sync),
graceful handling of a missing/partial transcript, and growth on re-sync.
Expected totals are derived from the input rows (no magic literals), so the
assertions track whatever the fixtures declare.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import roboco.agent_sdk.server as srv
from fastapi.testclient import TestClient
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from pathlib import Path
_OK = 200
# Each row is (input, output, cache_read, cache_write).
_UsageRow = tuple[int, int, int, int]
@pytest.fixture(autouse=True)
def _reset_state() -> Iterator[None]:
srv._state.reset()
yield
srv._state.reset()
@pytest.fixture
def client() -> TestClient:
return TestClient(srv.app)
def _assistant_line(row: _UsageRow) -> str:
inp, out, cread, cwrite = row
return json.dumps(
{
"type": "assistant",
"message": {
"role": "assistant",
"usage": {
"input_tokens": inp,
"output_tokens": out,
"cache_read_input_tokens": cread,
"cache_creation_input_tokens": cwrite,
},
},
}
)
def _write(path: Path, *lines: str) -> None:
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _expected(rows: Sequence[_UsageRow]) -> dict[str, int]:
return {
"tokens_input": sum(r[0] for r in rows),
"tokens_output": sum(r[1] for r in rows),
"tokens_cache_read": sum(r[2] for r in rows),
"tokens_cache_write": sum(r[3] for r in rows),
}
def test_sums_usage_across_assistant_messages(
client: TestClient, tmp_path: Path
) -> None:
rows: list[_UsageRow] = [(100, 20, 5, 3), (50, 10, 2, 1)]
transcript = tmp_path / "session.jsonl"
_write(transcript, *(_assistant_line(r) for r in rows))
resp = client.post("/usage/sync", json={"transcript_path": str(transcript)})
assert resp.status_code == _OK
body = resp.json()
for key, value in _expected(rows).items():
assert body[key] == value
def test_status_reflects_synced_totals(client: TestClient, tmp_path: Path) -> None:
rows: list[_UsageRow] = [(200, 40, 0, 0)]
transcript = tmp_path / "session.jsonl"
_write(transcript, *(_assistant_line(r) for r in rows))
client.post("/usage/sync", json={"transcript_path": str(transcript)})
status = client.get("/usage/status").json()
for key, value in _expected(rows).items():
assert status[key] == value
def test_resync_is_idempotent_not_additive(client: TestClient, tmp_path: Path) -> None:
"""The set is absolute — syncing the same transcript twice must not double."""
rows: list[_UsageRow] = [(100, 20, 0, 0)]
transcript = tmp_path / "session.jsonl"
_write(transcript, *(_assistant_line(r) for r in rows))
client.post("/usage/sync", json={"transcript_path": str(transcript)})
client.post("/usage/sync", json={"transcript_path": str(transcript)})
status = client.get("/usage/status").json()
for key, value in _expected(rows).items():
assert status[key] == value
def test_resync_after_growth_overwrites_with_new_total(
client: TestClient, tmp_path: Path
) -> None:
first: list[_UsageRow] = [(100, 20, 0, 0)]
grown: list[_UsageRow] = [(100, 20, 0, 0), (80, 15, 0, 0)]
transcript = tmp_path / "session.jsonl"
_write(transcript, *(_assistant_line(r) for r in first))
client.post("/usage/sync", json={"transcript_path": str(transcript)})
# The transcript grows as the turn continues.
_write(transcript, *(_assistant_line(r) for r in grown))
client.post("/usage/sync", json={"transcript_path": str(transcript)})
status = client.get("/usage/status").json()
for key, value in _expected(grown).items():
assert status[key] == value
def test_missing_transcript_returns_zero_without_error(
client: TestClient, tmp_path: Path
) -> None:
resp = client.post(
"/usage/sync", json={"transcript_path": str(tmp_path / "nope.jsonl")}
)
assert resp.status_code == _OK
assert resp.json() == _expected([])
def test_malformed_lines_are_skipped(client: TestClient, tmp_path: Path) -> None:
rows: list[_UsageRow] = [(100, 20, 0, 0), (50, 10, 0, 0)]
transcript = tmp_path / "session.jsonl"
_write(
transcript,
"not json at all",
_assistant_line(rows[0]),
json.dumps({"type": "user", "message": {"role": "user"}}), # no usage
"{ broken",
_assistant_line(rows[1]),
)
body = client.post("/usage/sync", json={"transcript_path": str(transcript)}).json()
exp = _expected(rows)
assert body["tokens_input"] == exp["tokens_input"]
assert body["tokens_output"] == exp["tokens_output"]
def test_parser_handles_entries_without_message(tmp_path: Path) -> None:
rows: list[_UsageRow] = [(10, 5, 0, 0)]
transcript = tmp_path / "session.jsonl"
_write(
transcript,
json.dumps({"type": "system", "subtype": "init"}),
_assistant_line(rows[0]),
)
tin, tout, cread, cwrite = srv._sum_transcript_usage(transcript)
exp = _expected(rows)
assert (tin, tout, cread, cwrite) == (
exp["tokens_input"],
exp["tokens_output"],
exp["tokens_cache_read"],
exp["tokens_cache_write"],
)