using System.Text.Json; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Usage; namespace ClaudeDo.Worker.Tests.Usage; /// /// Covers what is left of the reader after the model/scope aggregation moved to TokenTracker: /// the per-session totals that back task_runs' per-run token delta. /// public class TranscriptUsageReaderTests : IDisposable { private readonly string _root; private readonly WorkerConfig _cfg; public TranscriptUsageReaderTests() { _root = Path.Combine(Path.GetTempPath(), $"tur_{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); _cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_root, "central-worktrees"), SandboxRoot = Path.Combine(_root, "sandbox"), }; } public void Dispose() { try { Directory.Delete(_root, true); } catch { } } private string WriteSession(string projectDir, string file, params string[] lines) { var dir = Path.Combine(_root, "projects", projectDir); Directory.CreateDirectory(dir); var path = Path.Combine(dir, file); File.WriteAllLines(path, lines); return path; } private static string AssistantLine( string cwd, string ts, string model, long input, long output, long cacheRead, long cacheCreation, string? requestId = null, string? messageId = null) => JsonSerializer.Serialize(new Dictionary { ["type"] = "assistant", ["cwd"] = cwd, ["timestamp"] = ts, ["requestId"] = requestId, ["message"] = new Dictionary { ["id"] = messageId, ["model"] = model, ["usage"] = new Dictionary { ["input_tokens"] = input, ["output_tokens"] = output, ["cache_read_input_tokens"] = cacheRead, ["cache_creation_input_tokens"] = cacheCreation, }, }, }); private TranscriptUsageReader MakeReader() => new(_cfg, Path.Combine(_root, "projects")); [Fact] public async Task ReadSessionTotalsAsync_Sums_All_Assistant_Messages_In_The_Session_File() { WriteSession("proj", "sess-1.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 100, 5, requestId: "r1"), AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 7, 200, 0, requestId: "r2")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-1"); Assert.NotNull(totals); Assert.Equal(13, totals!.InputTokens); Assert.Equal(27, totals.OutputTokens); Assert.Equal(300, totals.CacheReadTokens); Assert.Equal(5, totals.CacheCreationTokens); } [Fact] public async Task ReadSessionTotalsAsync_Skips_Synthetic_Model_Messages() { WriteSession("proj", "sess-2.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "r1"), AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "", 999, 999, 999, 999, requestId: "r2")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-2"); Assert.NotNull(totals); Assert.Equal(10, totals!.InputTokens); Assert.Equal(20, totals.OutputTokens); } [Fact] public async Task ReadSessionTotalsAsync_Dedupes_By_RequestId_Within_The_Session() { WriteSession("proj", "sess-3.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup"), AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:01Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-3"); Assert.NotNull(totals); Assert.Equal(10, totals!.InputTokens); Assert.Equal(20, totals.OutputTokens); } [Fact] public async Task ReadSessionTotalsAsync_Malformed_Line_Does_Not_Abort_The_Read() { WriteSession("proj", "sess-4.jsonl", "this is not json", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "r1")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-4"); Assert.NotNull(totals); Assert.Equal(5, totals!.InputTokens); } [Fact] public async Task ReadSessionTotalsAsync_Returns_Null_When_No_Matching_Transcript_File() { var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("does-not-exist"); Assert.Null(totals); } [Fact] public async Task ReadSessionTotalsAsync_Returns_Null_When_ProjectsRoot_Missing() { var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist")); var totals = await reader.ReadSessionTotalsAsync("sess-1"); Assert.Null(totals); } }