427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""Unit tests for cursor-native token-usage capture.
|
|
|
|
Covers the pure pieces a live cursor-agent isn't needed for: normalizing the
|
|
``stop``-hook payload, the append-only usage log + recorder CLI, the cumulative
|
|
accumulator (per-turn sum + generation-id dedup), state round-trip, the
|
|
``external_session_usage`` POST shape, and the poll loop (POST-on-change,
|
|
no-repost-when-unchanged, persist-only-after-success). The live tmux +
|
|
cursor-agent hook path is exercised by the e2e gate, not here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent import cursor_native_usage as usage
|
|
|
|
# A representative cursor ``stop``-hook payload (live-captured field set).
|
|
_TURN1 = {
|
|
"generation_id": "g1",
|
|
"model": "claude-4-sonnet",
|
|
"status": "completed",
|
|
"input_tokens": 23666,
|
|
"output_tokens": 5,
|
|
"cache_read_tokens": 23617,
|
|
"cache_write_tokens": 47,
|
|
}
|
|
_TURN2 = {
|
|
"generation_id": "g2",
|
|
"model": "claude-4-sonnet",
|
|
"status": "completed",
|
|
"input_tokens": 24010,
|
|
"output_tokens": 120,
|
|
"cache_read_tokens": 23700,
|
|
"cache_write_tokens": 60,
|
|
}
|
|
|
|
|
|
class TestNormalizeHookPayload:
|
|
def test_extracts_usage_fields(self) -> None:
|
|
line = usage.normalize_hook_payload(_TURN1)
|
|
assert line == {
|
|
"generation_id": "g1",
|
|
"model": "claude-4-sonnet",
|
|
"input_tokens": 23666,
|
|
"output_tokens": 5,
|
|
"cache_read_tokens": 23617,
|
|
"cache_write_tokens": 47,
|
|
}
|
|
|
|
def test_falls_back_to_conversation_id(self) -> None:
|
|
line = usage.normalize_hook_payload({"conversation_id": "c1", "output_tokens": 3})
|
|
assert line is not None and line["generation_id"] == "c1"
|
|
|
|
def test_skips_when_no_generation_id(self) -> None:
|
|
assert usage.normalize_hook_payload({"output_tokens": 3}) is None
|
|
|
|
def test_skips_when_all_tokens_zero(self) -> None:
|
|
payload = {"generation_id": "g", "input_tokens": 0, "output_tokens": 0}
|
|
assert usage.normalize_hook_payload(payload) is None
|
|
|
|
def test_omits_model_when_absent(self) -> None:
|
|
line = usage.normalize_hook_payload({"generation_id": "g", "output_tokens": 4})
|
|
assert line is not None and "model" not in line
|
|
|
|
def test_coerces_and_floors_negative_tokens(self) -> None:
|
|
line = usage.normalize_hook_payload(
|
|
{"generation_id": "g", "input_tokens": "7", "output_tokens": -3}
|
|
)
|
|
assert line is not None
|
|
assert line["input_tokens"] == 7 # coerced from str
|
|
assert line["output_tokens"] == 0 # negative floored
|
|
|
|
def test_non_dict_is_skipped(self) -> None:
|
|
assert usage.normalize_hook_payload("nope") is None
|
|
|
|
|
|
class TestRecordUsagePayload:
|
|
def test_appends_one_line_per_turn(self, tmp_path: Path) -> None:
|
|
assert usage.record_usage_payload(tmp_path, _TURN1) is True
|
|
assert usage.record_usage_payload(tmp_path, _TURN2) is True
|
|
lines = (tmp_path / usage.USAGE_FILE).read_text().splitlines()
|
|
assert len(lines) == 2
|
|
assert json.loads(lines[0])["generation_id"] == "g1"
|
|
|
|
def test_non_billable_payload_is_skipped(self, tmp_path: Path) -> None:
|
|
assert usage.record_usage_payload(tmp_path, {"text": "hi"}) is False
|
|
assert not (tmp_path / usage.USAGE_FILE).exists()
|
|
|
|
|
|
class TestRecordUsageCli:
|
|
def test_cli_reads_stdin_appends_and_emits_continue(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
|
) -> None:
|
|
import io
|
|
|
|
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(_TURN1)))
|
|
rc = usage._cli_record_usage(tmp_path)
|
|
assert rc == 0
|
|
# cursor reads stdout as the hook response; "{}" means "continue".
|
|
assert capsys.readouterr().out == "{}"
|
|
assert (tmp_path / usage.USAGE_FILE).read_text().strip()
|
|
|
|
def test_cli_never_fails_on_garbage_stdin(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
|
) -> None:
|
|
import io
|
|
|
|
monkeypatch.setattr("sys.stdin", io.StringIO("not json{{{"))
|
|
assert usage._cli_record_usage(tmp_path) == 0
|
|
assert capsys.readouterr().out == "{}"
|
|
assert not (tmp_path / usage.USAGE_FILE).exists()
|
|
|
|
def test_module_main_entrypoint(self, tmp_path: Path) -> None:
|
|
# End-to-end through the real CLI the hooks.json command invokes. ``-I``
|
|
# is dropped here (the worktree isn't pip-installed); PYTHONPATH makes it
|
|
# importable, mirroring the installed-package resolution in production.
|
|
env = {"PYTHONPATH": str(Path(__file__).resolve().parents[1])}
|
|
proc = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"omnigent.cursor_native_usage",
|
|
"record-usage",
|
|
"--bridge-dir",
|
|
str(tmp_path),
|
|
],
|
|
input=json.dumps(_TURN1),
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert proc.stdout == "{}"
|
|
assert (tmp_path / usage.USAGE_FILE).read_text().strip()
|
|
|
|
|
|
class TestUsageAccumulator:
|
|
def test_sums_per_turn_counts(self) -> None:
|
|
acc = usage._UsageAccumulator()
|
|
assert acc.add_line(usage.normalize_hook_payload(_TURN1)) is True # type: ignore[arg-type]
|
|
assert acc.add_line(usage.normalize_hook_payload(_TURN2)) is True # type: ignore[arg-type]
|
|
assert acc.input_tokens == 23666 + 24010
|
|
assert acc.output_tokens == 5 + 120
|
|
assert acc.cache_read_tokens == 23617 + 23700
|
|
assert acc.model == "claude-4-sonnet"
|
|
|
|
def test_dedups_by_generation_id(self) -> None:
|
|
acc = usage._UsageAccumulator()
|
|
line = usage.normalize_hook_payload(_TURN1)
|
|
assert acc.add_line(line) is True # type: ignore[arg-type]
|
|
assert acc.add_line(line) is False # type: ignore[arg-type] — same gen id, ignored
|
|
assert acc.output_tokens == 5 # not doubled
|
|
|
|
def test_latest_model_wins(self) -> None:
|
|
acc = usage._UsageAccumulator()
|
|
acc.add_line({"generation_id": "a", "model": "claude-4-sonnet", "output_tokens": 1})
|
|
acc.add_line({"generation_id": "b", "model": "gpt-5", "output_tokens": 1})
|
|
assert acc.model == "gpt-5"
|
|
|
|
def test_line_without_gen_id_is_ignored(self) -> None:
|
|
acc = usage._UsageAccumulator()
|
|
assert acc.add_line({"output_tokens": 9}) is False
|
|
assert acc.output_tokens == 0
|
|
|
|
|
|
class TestStateRoundTrip:
|
|
def test_write_then_read_preserves_totals_and_seen(self, tmp_path: Path) -> None:
|
|
acc = usage._UsageAccumulator(
|
|
input_tokens=10, output_tokens=2, cache_read_tokens=4, model="gpt-5", seen={"g1"}
|
|
)
|
|
usage._write_usage_state(tmp_path, acc)
|
|
loaded = usage._read_usage_state(tmp_path)
|
|
assert loaded.input_tokens == 10
|
|
assert loaded.output_tokens == 2
|
|
assert loaded.cache_read_tokens == 4
|
|
assert loaded.model == "gpt-5"
|
|
assert loaded.seen == {"g1"}
|
|
|
|
def test_missing_state_is_cold_default(self, tmp_path: Path) -> None:
|
|
loaded = usage._read_usage_state(tmp_path)
|
|
assert (loaded.input_tokens, loaded.output_tokens, loaded.cache_read_tokens) == (0, 0, 0)
|
|
assert loaded.model is None and loaded.seen == set()
|
|
|
|
|
|
class TestReadUsageLines:
|
|
def test_reads_valid_lines_and_skips_garbage(self, tmp_path: Path) -> None:
|
|
(tmp_path / usage.USAGE_FILE).write_text(
|
|
json.dumps({"generation_id": "g1", "output_tokens": 1})
|
|
+ "\n\n" # blank line tolerated
|
|
+ "not json\n" # garbage tolerated
|
|
+ json.dumps({"generation_id": "g2", "output_tokens": 2})
|
|
+ "\n"
|
|
)
|
|
lines = usage._read_usage_lines(tmp_path)
|
|
assert [line["generation_id"] for line in lines] == ["g1", "g2"]
|
|
|
|
def test_missing_file_is_empty(self, tmp_path: Path) -> None:
|
|
assert usage._read_usage_lines(tmp_path) == []
|
|
|
|
|
|
class TestUsagePostBody:
|
|
def test_shape_with_model(self) -> None:
|
|
acc = usage._UsageAccumulator(
|
|
input_tokens=100, output_tokens=20, cache_read_tokens=80, model="gpt-5"
|
|
)
|
|
assert usage._usage_post_body(acc) == {
|
|
"cumulative_input_tokens": 100,
|
|
"cumulative_output_tokens": 20,
|
|
"cumulative_cache_read_input_tokens": 80,
|
|
"model": "gpt-5",
|
|
}
|
|
|
|
def test_omits_model_when_none(self) -> None:
|
|
body = usage._usage_post_body(usage._UsageAccumulator(output_tokens=5))
|
|
assert "model" not in body
|
|
assert body["cumulative_output_tokens"] == 5
|
|
|
|
|
|
class TestClearUsageState:
|
|
def test_removes_log_and_state(self, tmp_path: Path) -> None:
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
usage._write_usage_state(tmp_path, usage._UsageAccumulator(output_tokens=1))
|
|
usage.clear_cursor_usage_state(tmp_path)
|
|
assert not (tmp_path / usage.USAGE_FILE).exists()
|
|
assert not (tmp_path / usage._USAGE_STATE_FILE).exists()
|
|
|
|
def test_noop_when_absent(self, tmp_path: Path) -> None:
|
|
usage.clear_cursor_usage_state(tmp_path) # must not raise
|
|
|
|
|
|
class _CtxRecordingClient:
|
|
"""Async httpx-client stub (records POSTs, 200) usable as ``async with``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
|
|
async def __aenter__(self) -> _CtxRecordingClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
return None
|
|
|
|
async def post(self, url: str, *, json: dict) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
|
|
async def _run_loop_until(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
bridge_dir: Path,
|
|
until,
|
|
*,
|
|
client: _CtxRecordingClient | None = None,
|
|
max_wait_s: float = 3.0,
|
|
) -> _CtxRecordingClient:
|
|
"""Run the real poll loop with a recording client until *until(client)* holds."""
|
|
client = client or _CtxRecordingClient()
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: client)
|
|
task = asyncio.create_task(
|
|
usage.forward_cursor_usage_to_session(
|
|
base_url="http://test",
|
|
headers={},
|
|
session_id="conv_1",
|
|
bridge_dir=bridge_dir,
|
|
poll_interval_s=0.01,
|
|
)
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + max_wait_s
|
|
try:
|
|
while loop.time() < deadline:
|
|
if until(client):
|
|
return client
|
|
await asyncio.sleep(0.01)
|
|
raise AssertionError("loop condition was not met before timeout")
|
|
finally:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def _usage_posts(client: _CtxRecordingClient) -> list[tuple[str, dict]]:
|
|
"""Only the ``external_session_usage`` POSTs (excludes the idle wake edges)."""
|
|
return [(u, b) for (u, b) in client.posts if b.get("type") == "external_session_usage"]
|
|
|
|
|
|
def _idle_posts(client: _CtxRecordingClient) -> list[tuple[str, dict]]:
|
|
"""Only the ``external_session_status: idle`` turn-end wake POSTs."""
|
|
return [
|
|
(u, b)
|
|
for (u, b) in client.posts
|
|
if b.get("type") == "external_session_status" and b.get("data", {}).get("status") == "idle"
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestForwardLoop:
|
|
async def test_posts_cumulative_usage_and_persists(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
usage.record_usage_payload(tmp_path, _TURN2)
|
|
client = await _run_loop_until(
|
|
monkeypatch,
|
|
tmp_path,
|
|
lambda c: _usage_posts(c) and usage._read_usage_state(tmp_path).seen == {"g1", "g2"},
|
|
)
|
|
url, body = _usage_posts(client)[0]
|
|
assert url == "/v1/sessions/conv_1/events"
|
|
assert body["type"] == "external_session_usage"
|
|
assert body["data"] == {
|
|
"cumulative_input_tokens": 23666 + 24010,
|
|
"cumulative_output_tokens": 5 + 120,
|
|
"cumulative_cache_read_input_tokens": 23617 + 23700,
|
|
"model": "claude-4-sonnet",
|
|
}
|
|
# State persisted after the successful POST so a restart resumes.
|
|
persisted = usage._read_usage_state(tmp_path)
|
|
assert persisted.seen == {"g1", "g2"}
|
|
|
|
async def test_no_repost_when_unchanged(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
client = await _run_loop_until(monkeypatch, tmp_path, _usage_posts)
|
|
# Let several more polls run; with no new turns there must be no 2nd
|
|
# usage POST and no further idle edge.
|
|
await asyncio.sleep(0.1)
|
|
assert len(_usage_posts(client)) == 1
|
|
assert len(_idle_posts(client)) == 1
|
|
|
|
async def test_new_turn_triggers_followup_post(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
client = _CtxRecordingClient()
|
|
# First usage POST covers turn 1.
|
|
await _run_loop_until(monkeypatch, tmp_path, _usage_posts, client=client)
|
|
# Append a second turn and run again: a fresh cumulative POST must land.
|
|
usage.record_usage_payload(tmp_path, _TURN2)
|
|
await _run_loop_until(
|
|
monkeypatch, tmp_path, lambda c: len(_usage_posts(c)) >= 2, client=client
|
|
)
|
|
_, body = _usage_posts(client)[-1]
|
|
assert body["data"]["cumulative_output_tokens"] == 5 + 120
|
|
|
|
async def test_completed_turn_posts_idle_wake_edge(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A completed cursor turn posts external_session_status: idle.
|
|
|
|
This is the edge the runner turns into a parent-inbox wake for a cursor
|
|
sub-agent. Without it, a cursor child finishes with its review only in
|
|
its own transcript and the parent orchestrator is never woken.
|
|
"""
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
client = await _run_loop_until(monkeypatch, tmp_path, _idle_posts)
|
|
url, body = _idle_posts(client)[0]
|
|
assert url == "/v1/sessions/conv_1/events"
|
|
assert body == {"type": "external_session_status", "data": {"status": "idle"}}
|
|
|
|
async def test_idle_posted_once_per_turn(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Each completed turn wakes the parent exactly once within a run."""
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
client = _CtxRecordingClient()
|
|
await _run_loop_until(monkeypatch, tmp_path, _idle_posts, client=client)
|
|
await asyncio.sleep(0.1) # extra polls: turn 1 must not re-wake
|
|
assert len(_idle_posts(client)) == 1
|
|
# A second turn yields exactly one more idle edge.
|
|
usage.record_usage_payload(tmp_path, _TURN2)
|
|
await _run_loop_until(
|
|
monkeypatch, tmp_path, lambda c: len(_idle_posts(c)) >= 2, client=client
|
|
)
|
|
await asyncio.sleep(0.1)
|
|
assert len(_idle_posts(client)) == 2
|
|
|
|
async def test_restart_reposts_idle_for_dedup_not_skip(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A restart re-posts idle (server dedupes) rather than risk skipping a wake.
|
|
|
|
The idle counter is NOT seeded from persisted usage: if it were, a wake
|
|
whose idle POST crashed after the usage flush persisted would be skipped
|
|
forever. Seeding at 0 makes a restart re-post at most one idle, which the
|
|
server treats as an already-delivered no-op.
|
|
"""
|
|
# First run: turn 1 usage persisted.
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
first = _CtxRecordingClient()
|
|
await _run_loop_until(monkeypatch, tmp_path, _idle_posts, client=first)
|
|
assert usage._read_usage_state(tmp_path).seen == {"g1"}
|
|
|
|
# Fresh loop (simulated restart) over the SAME bridge dir, no new turns:
|
|
# idle is re-posted so a lost pre-crash wake still lands.
|
|
second = _CtxRecordingClient()
|
|
await _run_loop_until(monkeypatch, tmp_path, _idle_posts, client=second)
|
|
await asyncio.sleep(0.1)
|
|
# Exactly one re-post — the single already-seen turn, not a loop.
|
|
assert len(_idle_posts(second)) == 1
|
|
|
|
async def test_failed_post_is_not_persisted(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
class _FailingClient(_CtxRecordingClient):
|
|
async def post(self, url: str, *, json: dict) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
raise httpx.ConnectError("boom")
|
|
|
|
usage.record_usage_payload(tmp_path, _TURN1)
|
|
client = await _run_loop_until(
|
|
monkeypatch, tmp_path, lambda c: len(c.posts) >= 1, client=_FailingClient()
|
|
)
|
|
assert len(client.posts) >= 1 # it tried
|
|
# A failed flush must NOT advance persisted state — the turn stays unseen
|
|
# so the next poll retries it (no silent loss).
|
|
assert usage._read_usage_state(tmp_path).seen == set()
|