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", "", "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()