forked from omnigent-ai/omnigent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_cursor_native_usage.py
More file actions
429 lines (365 loc) · 17.2 KB
/
Copy pathtest_cursor_native_usage.py
File metadata and controls
429 lines (365 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
"""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)
# Gate on the idle POST: it is the LAST side effect of processing turn 1
# (usage POST, then the state write, then idle), so once it lands both
# assertions below read fully-settled state instead of racing the write.
client = await _run_loop_until(monkeypatch, tmp_path, _idle_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()