diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs new file mode 100644 index 00000000..b5eb6137 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs @@ -0,0 +1,66 @@ +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// One row of tokentracker sessions --format json. Field names mirror the +/// export's snake_case keys: cached_input_tokens is the cache *read* count, +/// cache_creation_input_tokens the write count. +/// ⚠️ is not unique across rows — TokenTracker splits one +/// Claude session into several rows (measured: 886 rows over 547 hashes), e.g. on a model switch. +/// The segments are disjoint, so summing rows that share a hash is correct; counting them is not +/// the same as counting sessions. +public sealed record TokenTrackerSession( + string SessionHash, + string Source, + string Model, + DateTimeOffset StartedAt, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + double CostUsd, + int Turns, + int RetryTurns, + bool Productive, + bool OneShot); + +/// A parsed export plus when we fetched it. is the +/// version field of the session rows — the only compatibility signal the export gives us. +public sealed record TokenTrackerExport( + int FormatVersion, + IReadOnlyList Sessions, + DateTime FetchedAtUtc); + +public sealed record TokenTrackerProbe( + bool Installed, + string? Version, + bool NodeOk, + string? NodeVersion, + string? Error) +{ + public static TokenTrackerProbe Unknown { get; } = new(false, null, false, null, null); +} + +/// Outcome of one CLI invocation. false always carries an +/// — callers turn that into state, never into an exception. +public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error); + +/// Aggregated per date/model/scope. Scope is "claudedo" or "other", +/// decided by whether the session hash belongs to one of our runs. Sessions replaces the +/// old assistant-message count — it counts export rows (session segments), because the +/// export has neither message granularity nor one row per session. +public sealed record TokenTrackerModelRow( + DateOnly Date, + string Model, + string Scope, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + int Sessions, + double CostUsd); + +/// What the export adds on top of what task_runs already knows about a task. +public sealed record TokenTrackerTaskExtras( + double CostUsd, + int Retries, + bool Productive, + bool OneShot); diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs new file mode 100644 index 00000000..fea5adac --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs @@ -0,0 +1,138 @@ +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public static class TokenTrackerFixtures +{ + /// Session id whose hash is the fixture's first ClaudeDo session. + public const string ClaudeDoSessionIdA = "77470328-94f7-49d3-b378-a562d1501b5f"; + + /// Session id whose hash is the fixture's second ClaudeDo session. + public const string ClaudeDoSessionIdB = "20ebc1bc-3e6a-4c09-a4b3-19472dffa841"; + + /// Shape copied from a real `tokentracker sessions --format json` run + /// (v0.88.4, 2026-08-24), trimmed to four rows and stripped of everything we don't read. + public const string SessionsJson = """ + { + "available": true, + "sessions": [ + { + "version": 11, + "session_hash": "b24babcbf2b615730459773f", + "source": "claude", + "project_key": "ClaudeDo", + "model": "claude-opus-5", + "started_at": "2026-08-20T10:00:00.000Z", + "ended_at": "2026-08-20T10:10:00.000Z", + "turns": 4, + "edit_turns": 2, + "retry_turns": 1, + "tokens": { + "input_tokens": 100, + "cached_input_tokens": 900, + "cache_creation_input_tokens": 50, + "output_tokens": 400, + "reasoning_output_tokens": 0, + "total_tokens": 1450 + }, + "total_tokens": 1450, + "cost_usd": 2.5, + "productive": true, + "first_pass": true, + "one_shot": true + }, + { + "version": 11, + "session_hash": "3e2a08fe8f2b941817fb554f", + "source": "claude", + "project_key": "02f6746f-d67b-4217-a581-f2f911f6ff0d", + "model": "claude-opus-5", + "started_at": "2026-08-20T12:00:00.000Z", + "ended_at": "2026-08-20T12:30:00.000Z", + "turns": 6, + "edit_turns": 0, + "retry_turns": 2, + "tokens": { + "input_tokens": 10, + "cached_input_tokens": 90, + "cache_creation_input_tokens": 5, + "output_tokens": 40, + "reasoning_output_tokens": 0, + "total_tokens": 145 + }, + "total_tokens": 145, + "cost_usd": 1.0, + "productive": false, + "first_pass": false, + "one_shot": false + }, + { + "version": 11, + "session_hash": "ffffffffffffffffffffffff", + "source": "claude", + "project_key": "SomeOtherRepo", + "model": "claude-sonnet-5", + "started_at": "2026-08-20T14:00:00.000Z", + "ended_at": "2026-08-20T14:05:00.000Z", + "turns": 2, + "edit_turns": 1, + "retry_turns": 0, + "tokens": { + "input_tokens": 7, + "cached_input_tokens": 3, + "cache_creation_input_tokens": 1, + "output_tokens": 9, + "reasoning_output_tokens": 0, + "total_tokens": 20 + }, + "total_tokens": 20, + "cost_usd": 0.25, + "productive": true, + "first_pass": true, + "one_shot": true + }, + { + "version": 11, + "session_hash": "aaaaaaaaaaaaaaaaaaaaaaaa", + "source": "claude", + "project_key": "ClaudeDo", + "model": "claude-opus-5", + "started_at": "2026-08-25T10:00:00.000Z", + "ended_at": "2026-08-25T10:20:00.000Z", + "turns": 3, + "edit_turns": 1, + "retry_turns": 0, + "tokens": { + "input_tokens": 1000, + "cached_input_tokens": 2000, + "cache_creation_input_tokens": 300, + "output_tokens": 4000, + "reasoning_output_tokens": 0, + "total_tokens": 7300 + }, + "total_tokens": 7300, + "cost_usd": 99.0, + "productive": true, + "first_pass": true, + "one_shot": true + } + ] + } + """; + + /// Same as but with a future format version. + public const string UnsupportedVersionJson = """ + { + "available": true, + "sessions": [ + { + "version": 99, + "session_hash": "b24babcbf2b615730459773f", + "source": "claude", + "model": "claude-opus-5", + "started_at": "2026-08-20T10:00:00.000Z", + "tokens": { "input_tokens": 1, "output_tokens": 2 }, + "cost_usd": 0.1 + } + ] + } + """; +}