aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tests/test_usage.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_usage.py')
-rw-r--r--tests/test_usage.py337
1 files changed, 337 insertions, 0 deletions
diff --git a/tests/test_usage.py b/tests/test_usage.py
new file mode 100644
index 0000000..f2d55e3
--- /dev/null
+++ b/tests/test_usage.py
@@ -0,0 +1,337 @@
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent.parent
+WF = HERE / "wf.py"
+sys.path.insert(0, str(HERE))
+
+from wflib import usage # noqa: E402
+
+
+def entry(mid, req, model, ts, inp=0, cw5=0, cw1h=0, cr=0, out=0, type="assistant", stop=None, content=None,
+ uuid=None):
+ u = {"input_tokens": inp, "cache_creation_input_tokens": cw5 + cw1h, "cache_read_input_tokens": cr,
+ "output_tokens": out}
+ if cw1h:
+ u["cache_creation"] = {"ephemeral_5m_input_tokens": cw5, "ephemeral_1h_input_tokens": cw1h}
+ d = {"type": type, "requestId": req, "timestamp": ts,
+ "message": {"id": mid, "model": model, "role": "assistant", "usage": u}}
+ if stop:
+ d["message"]["stop_reason"] = stop
+ if content is not None:
+ d["message"]["content"] = content
+ if uuid:
+ d["uuid"] = uuid
+ return json.dumps(d)
+
+
+# Subagent transcripts: stop_reason always null, output_tokens = message_start placeholder.
+# e1: thinking (signature 1800 chars) + text 1000 chars + tool_use (input json 100 chars), placeholder 8.
+# by hand: 0.3*(1800-800) + 0.3*1000 + (30 + 0.44*100) = 300 + 300 + 74 = 674
+# e2: same tool_use again but stop_reason set (final): reported 50 is trusted.
+# e3: no content, placeholder 9 kept (nothing to estimate).
+TOOL = {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"a": "y" * 91}} # json.dumps → 100 chars
+SUB = "\n".join([
+ entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:00Z", out=8, uuid="u1",
+ content=[{"type": "thinking", "thinking": "", "signature": "z" * 1800}]),
+ entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:01Z", out=8, uuid="u2",
+ content=[{"type": "text", "text": "x" * 1000}]),
+ entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:01Z", out=8, uuid="u2",
+ content=[{"type": "text", "text": "x" * 1000}]), # duplicated line: counted once
+ entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:02Z", out=8, uuid="u3", content=[TOOL]),
+ entry("e2", "q2", "claude-sonnet-5-5", "2026-10-04T10:01:00Z", out=50, stop="tool_use", content=[TOOL]),
+ entry("e3", "q3", "claude-sonnet-5-5", "2026-10-04T10:02:00Z", out=9),
+]) + "\n"
+
+
+# main session, opus 5.5: m1 streamed as 2 entries (same usage), m2 as 2 entries (out 4000 then 10000).
+# by hand: m1 = 1000*4 + 100000*5 + 2000*20 = 544000 µ$; m2 = 50000*8 + 1e6*0.2 + 10000*20 = 800000 µ$
+MAIN = "\n".join([
+ json.dumps({"type": "user", "timestamp": "2026-10-04T09:00:00Z", "message": {"role": "user", "content": "hi"}}),
+ entry("m1", "r1", "claude-opus-5-5", "2026-10-04T09:00:01Z", inp=1000, cw5=100000, out=2000),
+ entry("m1", "r1", "claude-opus-5-5", "2026-10-04T09:00:02Z", inp=1000, cw5=100000, out=2000),
+ "not json",
+ json.dumps({"type": "attachment", "timestamp": "2026-10-04T09:00:03Z"}),
+ entry("m2", "r2", "claude-opus-5-5", "2026-10-04T09:01:00Z", cw1h=50000, cr=1000000, out=4000),
+ entry("m2", "r2", "claude-opus-5-5", "2026-10-04T09:01:05Z", cw1h=50000, cr=1000000, out=10000),
+ entry("m9", "r9", "<synthetic>", "2026-10-04T09:02:00Z", out=5),
+]) + "\n"
+
+# subagent, sonnet 5.5: s1 = 10*2 + 20000*2.5 + 30000*0.2 + 500*10 = 61020 µ$; s2 = 50000*0.2 + 1500*10 = 25000 µ$
+AGENT = "\n".join([
+ entry("s1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:00Z", inp=10, cw5=20000, cr=30000, out=500),
+ entry("s2", "q2", "claude-sonnet-5-5", "2026-10-04T12:00:00Z", cr=50000, out=1500),
+ entry("s3", "q3", "claude-mystery-1", "2026-10-04T12:30:00Z", inp=7, out=3),
+]) + "\n"
+
+SID = "11111111-2222-3333-4444-555555555555"
+
+
+class Parse(unittest.TestCase):
+ def test_dedupes_streamed_entries_and_takes_last_output(self):
+ got = usage.parse(MAIN)
+ self.assertEqual(list(got), ["claude-opus-5-5"])
+ u = got["claude-opus-5-5"]
+ self.assertEqual((u.turns, u.inp, u.cw5, u.cw1h, u.cr, u.out), (2, 1000, 100000, 50000, 1000000, 12000))
+ self.assertEqual(u.cw, 150000)
+ self.assertAlmostEqual(usage.cost("claude-opus-5-5", u), 1.344)
+
+ def test_models_kept_apart_unknown_price_none(self):
+ got = usage.parse(AGENT)
+ s = got["claude-sonnet-5-5"]
+ self.assertEqual((s.turns, s.cr, s.out), (2, 80000, 2000))
+ self.assertAlmostEqual(usage.cost("claude-sonnet-5-5", s), 0.08602)
+ self.assertIsNone(usage.cost("claude-mystery-1", got["claude-mystery-1"]))
+
+ def test_since_until_filter_on_utc_timestamp(self):
+ s = usage.parse(AGENT, since="2026-10-04T11:00", until="2026-10-04T12:10")["claude-sonnet-5-5"]
+ self.assertEqual((s.turns, s.cr), (1, 50000))
+ self.assertAlmostEqual(usage.cost("claude-sonnet-5-5", s), 0.025)
+
+ def test_dated_model_id_priced_by_prefix(self):
+ u = usage.Usage(turns=1, inp=1000000, out=1000000)
+ self.assertAlmostEqual(usage.cost("claude-haiku-4-5-20251001", u), 6.0)
+ self.assertAlmostEqual(usage.cost("claude-opus-5", u), 30.0)
+
+ def test_subagent_output_estimated_from_content(self):
+ u = usage.parse(SUB)["claude-sonnet-5-5"]
+ self.assertEqual((u.turns, u.out, u.est), (3, 674 + 50 + 9, 1))
+
+ def test_log_line_marks_estimate(self):
+ line = usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB))
+ self.assertIn(" out=733 est=1 usd=", line)
+ self.assertNotIn(" est=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(AGENT)))
+
+ def test_log_line_lane_and_model(self):
+ by = usage.parse(SUB)
+ self.assertIn(" lane=fast model=sonnet effort=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", by, lane="fast"))
+ self.assertIn(" lane=sonnet model=sonnet effort=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", by))
+
+ def test_report_lane_model_key(self):
+ es = [{"lane": "fast", "model": "sonnet", "outcome": "done", "usd": 1.0, "turns": 10, "effort": "1h"},
+ {"lane": "fast", "model": "opus", "outcome": "done", "usd": 2.0, "turns": 20, "effort": "1h"},
+ {"lane": "opus", "outcome": "done", "usd": 3.0, "turns": 30, "effort": "1h"}]
+ self.assertEqual(usage.report(es, ("1h",)), [
+ ("fast/opus", "all", 1, 1, 0, 2.0, 2.0, 2.0, 20, None), ("fast/opus", "1h", 1, 1, 0, 2.0, 2.0, 2.0, 20, None),
+ ("fast/sonnet", "all", 1, 1, 0, 1.0, 1.0, 1.0, 10, None), ("fast/sonnet", "1h", 1, 1, 0, 1.0, 1.0, 1.0, 10, None),
+ ("opus", "all", 1, 1, 0, 3.0, 3.0, 3.0, 30, None), ("opus", "1h", 1, 1, 0, 3.0, 3.0, 3.0, 30, None)])
+
+ def test_fmt(self):
+ self.assertEqual([usage.fmt(n) for n in (999, 1500, 8948413)], ["999", "1.5k", "8.95M"])
+
+
+def tcall(mid, ctx, name, inp, tid):
+ return json.dumps({"type": "assistant", "timestamp": "2026-10-04T10:00:00Z", "message": {
+ "id": mid, "model": "claude-sonnet-5-5", "usage": {"input_tokens": 10, "cache_read_input_tokens": ctx - 10},
+ "content": [{"type": "tool_use", "id": tid, "name": name, "input": inp}]}})
+
+
+def tres(tid, text):
+ return json.dumps({"type": "user", "message": {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": tid, "content": text}]}})
+
+
+# agent A: ctx 1000 Read(400 chars=100 tok), 2000 grep(800=200), 3000 Edit, 4000 git commit.
+# exp 3000 / mut 3000 / book 4000 of 10000; first edit after 2 calls, pre 3000 = 30%, context +2000
+EXA = "\n".join([
+ tcall("a1", 1000, "Read", {"file_path": "/w/proj/.worktrees/x/src/a.py"}, "t1"), tres("t1", "x" * 400),
+ tcall("a2", 2000, "Bash", {"command": "grep -n foo src/a.py 2>/dev/null"}, "t2"), tres("t2", "y" * 800),
+ tcall("a3", 3000, "Edit", {"file_path": "/w/proj/src/a.py"}, "t3"), tres("t3", "ok"),
+ tcall("a4", 4000, "Bash", {"command": "git commit -m x"}, "t4"), tres("t4", "done"),
+]) + "\n"
+# agent B: 4 exploring calls of 1000 each, reads src/a.py (100 tok) + sed -n of b.py (400 chars)
+EXB = "\n".join([
+ tcall("b1", 1000, "Read", {"file_path": "/w/proj/src/a.py"}, "u1"), tres("u1", "x" * 400),
+ tcall("b2", 1000, "Bash", {"command": "sed -n 1,9p src/b.py"}, "u2"), tres("u2", "z" * 400),
+ tcall("b3", 1000, "Bash", {"command": "ls"}, "u3"), tres("u3", "q"),
+ tcall("b4", 1000, "Glob", {}, "u4"), tres("u4", "q"),
+]) + "\n"
+
+
+class Explore(unittest.TestCase):
+ def test_one_agent(self):
+ e = usage.explore(EXA, root="/w/proj")
+ self.assertEqual((e.calls, e.first_edit, e.pre, e.ctx_growth), (4, 2, 3000, 2000))
+ self.assertEqual(e.cost, {"exp": 3000, "mut": 3000, "book": 4000})
+ self.assertEqual(e.results, {"Read": 100, "grep": 200, "Edit": 0, "other": 1})
+ self.assertEqual(e.files, {"src/a.py": 300})
+
+ def test_call_kind_book_is_writes_only(self):
+ kind = lambda c: usage.call_kind({"name": "Bash", "input": {"command": c}})
+ for c in ("git worktree list", "git branch --list 'fast/*'", "git branch --show-current", "git status --short"):
+ self.assertEqual(kind(c), "exp", c)
+ for c in ("git worktree add .w/x -b x master", "git worktree remove .w/x", "git branch -d x",
+ "python3 /projects/public/workflow/wf.py finish t-x -m ok", "git switch -c x"):
+ self.assertEqual(kind(c), "book", c)
+
+ def test_since_drops_early_calls(self):
+ e = usage.explore(EXA.replace("10:00:00Z", "09:00:00Z", 2), since="2026-10-04T10")
+ self.assertEqual(e.calls, 2)
+
+ def test_report(self):
+ a, b = usage.explore(EXA, root="/w/proj"), usage.explore(EXB, root="/w/proj")
+ r = usage.explore_report([("A", a), ("B", b)], min_pre=4)
+ self.assertEqual(r["rows"], [("A", 4, 2, 0.3, 0.3), ("B", 4, 4, 1.0, 0.0)])
+ self.assertEqual((r["total"], r["cost"]["exp"]), (14000, 7000))
+ self.assertEqual((r["pre_n"], r["pre_share"], r["pre_calls"], r["pre_ctx"]), (1, 0.3, 2, 2000))
+ self.assertEqual(r["results"]["sed-cat"], 100)
+ self.assertEqual(r["files"], [("src/a.py", 2, 400)])
+ self.assertEqual(usage.explore_report([("A", a)], min_calls=5)["rows"], [])
+
+
+class Cli(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.home = Path(self.tmp.name)
+ proj = self.home / "projects" / "-work-demo"
+ sub = proj / SID / "subagents"
+ sub.mkdir(parents=True)
+ (proj / f"{SID}.jsonl").write_text(MAIN)
+ (sub / "agent-abc123.jsonl").write_text(AGENT)
+ (sub / "agent-abc123.meta.json").write_text(json.dumps({"description": "wf-worker sonnet t-x"}))
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def wf(self, *args, sid="", cwd=None):
+ env = {**os.environ, "CLAUDE_CONFIG_DIR": str(self.home), "CLAUDE_CODE_SESSION_ID": sid}
+ r = subprocess.run([sys.executable, str(WF), "usage", *args], capture_output=True, text=True,
+ cwd=cwd or self.tmp.name, env=env, timeout=30)
+ return r.returncode, r.stdout, r.stderr
+
+ def test_explore_skips_main_and_short_agents(self):
+ code, out, err = self.wf("--session", SID[:8], "--explore")
+ self.assertEqual((code, err), (0, ""))
+ self.assertEqual(out.strip(), "no subagent with >= 4 calls")
+
+ def test_session_rows_and_total(self):
+ code, out, err = self.wf("--session", SID[:8])
+ self.assertEqual((code, err), (0, ""))
+ rows = [l.split() for l in out.strip().split("\n")[1:]]
+ self.assertEqual(rows[0], ["main", "opus-5-5", "2", "1.0k", "150.0k", "1.00M", "12.0k", "1.34"])
+ self.assertEqual(rows[1], ["abc123", "wf-worker", "sonnet", "t-x",
+ "sonnet-5-5", "2", "10", "20.0k", "80.0k", "2.0k", "0.09"])
+ self.assertEqual(rows[2][-7:], ["mystery-1", "1", "7", "0", "0", "3", "?"])
+ self.assertEqual(rows[3], ["total", "5", "1.0k", "170.0k", "1.08M", "14.0k", "1.43+"])
+
+ def test_session_from_env(self):
+ code, out, _ = self.wf(sid=SID)
+ self.assertEqual(code, 0)
+ self.assertIn("abc123", out)
+
+ def test_agent_only(self):
+ code, out, err = self.wf("--agent", "agent-abc123", "--since", "2026-10-04T11:00")
+ self.assertEqual((code, err), (0, ""))
+ lines = out.strip().split("\n")
+ self.assertNotIn("main", out)
+ self.assertEqual(lines[1].split()[-7:], ["sonnet-5-5", "1", "0", "0", "50.0k", "1.5k", "0.03"])
+
+ def test_log_appends_one_key_value_line(self):
+ root = self.home / "demo"
+ root.mkdir()
+ (root / "workflow.toml").write_text("format = 1\n")
+ code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "done", "--effort", "1h", cwd=root)
+ self.assertEqual((code, err), (0, ""))
+ code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "handback", cwd=root)
+ log = (root / "out" / "wf-cost.log").read_text().split("\n")
+ self.assertEqual(len(log), 3) # two lines + trailing newline
+ # lane = model with most turns (sonnet 2 vs mystery 1); usd = known models only
+ self.assertRegex(log[0], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ project=demo task=t-x lane=sonnet model=sonnet effort=1h "
+ r"outcome=done turns=3 in=17 cw=20000 cr=80000 out=2003 usd=0\.0860 agent=abc123$")
+ self.assertIn(" effort=- outcome=handback ", log[1])
+ self.assertEqual(out, log[1] + "\n")
+
+ def test_estimated_out_marked_with_tilde(self):
+ other = self.home / "projects" / "-work-demo" / "other-session" / "subagents"
+ other.mkdir(parents=True)
+ (other / "agent-est1.jsonl").write_text(SUB)
+ code, out, err = self.wf("--agent", "est1")
+ self.assertEqual((code, err), (0, ""))
+ self.assertEqual(out.strip().split("\n")[1].split()[-3:], ["0", "~733", "0.01"])
+
+ def test_log_needs_agent(self):
+ code, out, err = self.wf("--log", "t-x", "done")
+ self.assertEqual((code, err), (2, "wf: --log needs --agent\n"))
+
+ def test_log_effort_must_be_an_estimate(self):
+ code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "done", "--effort", "medium")
+ self.assertEqual(code, 2)
+ self.assertIn("invalid choice: 'medium'", err)
+
+ def test_missing_agent_one_line_error(self):
+ code, out, err = self.wf("--agent", "nope")
+ self.assertEqual((code, out), (1, ""))
+ self.assertEqual(err, "wf: no transcript for agent nope\n")
+
+
+LOG_A = """\
+2026-10-01T10:00:00Z project=a task=t-1 lane=sonnet effort=1h outcome=done turns=10 usd=0.50
+2026-10-01T11:00:00Z project=a task=t-2 lane=sonnet effort=1h outcome=handback turns=30 usd=1.50
+garbage line
+"""
+LOG_B = """\
+2026-10-02T10:00:00Z project=b task=t-3 lane=sonnet effort=<1h outcome=done turns=20 usd=0.40
+2026-10-02T11:00:00Z project=b task=t-4 lane=opus effort=5h outcome=done turns=80 usd=3.00
+2026-10-03T11:00:00Z project=b task=t-5 lane=opus effort=1h outcome=done turns=40 usd=2.00
+"""
+
+
+class Report(unittest.TestCase):
+ def test_rows_by_lane_then_effort(self):
+ entries = usage.parse_log(LOG_A + LOG_B)
+ self.assertEqual(len(entries), 5)
+ rows = usage.report(entries, ("<1h", "1h", "5h"))
+ # lane, effort, n, done, other, median $, total $, $/done, median turns — all by hand
+ self.assertEqual(rows, [
+ ("opus", "all", 2, 2, 0, 2.5, 5.0, 2.5, 60, None),
+ ("opus", "1h", 1, 1, 0, 2.0, 2.0, 2.0, 40, None),
+ ("opus", "5h", 1, 1, 0, 3.0, 3.0, 3.0, 80, None),
+ ("sonnet", "all", 3, 2, 1, 0.5, 2.4, 1.2, 20, None),
+ ("sonnet", "<1h", 1, 1, 0, 0.4, 0.4, 0.4, 20, None),
+ ("sonnet", "1h", 2, 1, 1, 1.0, 2.0, 2.0, 20, None),
+ ])
+
+ def test_duration(self):
+ line = usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB), dur=125)
+ self.assertIn(" dur=125 agent=a1", line)
+ self.assertNotIn("dur=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB)))
+ log = ("2026-10-04T10:00:00Z project=a task=t-6 lane=fast effort=<1h outcome=done turns=1 usd=1 dur=100\n"
+ "2026-10-04T10:00:00Z project=a task=t-7 lane=fast effort=<1h outcome=done turns=1 usd=1 dur=300\n"
+ "2026-10-04T10:00:00Z project=a task=t-8 lane=fast effort=<1h outcome=done turns=1 usd=1\n")
+ self.assertEqual(usage.report(usage.parse_log(log), ("<1h",))[0][9], 200)
+
+ def test_done_gate_red_counts_as_done(self):
+ log = ("2026-10-04T10:00:00Z project=a task=t-6 lane=fast effort=<1h outcome=done+gate-red turns=12 usd=1.00\n"
+ "2026-10-04T11:00:00Z project=a task=t-7 lane=fast effort=<1h outcome=donex turns=8 usd=0.50\n")
+ rows = usage.report(usage.parse_log(log), ("<1h",))
+ self.assertEqual(rows[0], ("fast", "all", 2, 1, 1, 0.75, 1.5, 1.5, 10, None))
+
+ def test_since_and_no_done(self):
+ entries = usage.parse_log(LOG_A + LOG_B, since="2026-10-01T10:30")
+ rows = usage.report(entries, ("1h",))
+ self.assertIn(("sonnet", "all", 2, 1, 1, 0.95, 1.9, 1.9, 25, None), rows)
+ rows = usage.report(usage.parse_log(LOG_A, since="2026-10-01T10:30"), ("1h",))
+ self.assertEqual(rows[0], ("sonnet", "all", 1, 0, 1, 1.5, 1.5, None, 30, None))
+
+ def test_cli_reads_every_project(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ for name, text in (("a", LOG_A), ("b", LOG_B)):
+ (Path(tmp) / name / "out").mkdir(parents=True)
+ (Path(tmp) / name / "workflow.toml").write_text("format = 1\n")
+ (Path(tmp) / name / "out" / "wf-cost.log").write_text(text)
+ r = subprocess.run([sys.executable, str(WF), "usage", "--report"], capture_output=True, text=True,
+ cwd=tmp, env={**os.environ, "WF_ROOT": tmp}, timeout=30)
+ self.assertEqual((r.returncode, r.stderr), (0, ""))
+ lines = [l.split() for l in r.stdout.strip().split("\n")]
+ self.assertEqual(lines[0], ["lane", "effort", "n", "done", "other", "med$", "total$", "$/done", "med_turns", "med_dur"])
+ self.assertEqual(lines[1], ["opus", "all", "2", "2", "0", "2.50", "5.00", "2.50", "60", "-"])
+ self.assertEqual(lines[6], ["sonnet", "1h", "2", "1", "1", "1.00", "2.00", "2.00", "20", "-"])
+
+
+if __name__ == "__main__":
+ unittest.main()