task_runs.tokens_in only ever held the API's uncached "input_tokens" field (off by a factor of ~400,000 on a resumed session), and tokens_out summed only the last result event instead of the whole session. TaskRunner now reads each run's cache-read/cache-write/input/output totals from the session transcript via a new ITranscriptUsageReader.ReadSessionTotalsAsync, storing the delta against prior runs on the same session so a --resume doesn't double-count. New task_runs.cache_read_tokens/cache_write_tokens columns; the Session tab now shows the raw total (what actually counts against the 5h/7d usage limit) with a breakdown tooltip.
268 lines
11 KiB
C#
268 lines
11 KiB
C#
using System.Text.Json;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Usage;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Usage;
|
|
|
|
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<string, object?>
|
|
{
|
|
["type"] = "assistant",
|
|
["cwd"] = cwd,
|
|
["timestamp"] = ts,
|
|
["requestId"] = requestId,
|
|
["message"] = new Dictionary<string, object?>
|
|
{
|
|
["id"] = messageId,
|
|
["model"] = model,
|
|
["usage"] = new Dictionary<string, object?>
|
|
{
|
|
["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 Aggregates_By_Date_And_Model_Including_Cache_Tokens()
|
|
{
|
|
WriteSession("proj", "s.jsonl",
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 3, 1),
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 5, 6, 1, 0),
|
|
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-opus-5", 100, 200, 30, 10));
|
|
|
|
var reader = MakeReader();
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
Assert.Equal(2, result.Count);
|
|
var day1 = Assert.Single(result, r => r.Model == "claude-sonnet-5");
|
|
Assert.Equal(new DateOnly(2026, 6, 1), day1.Date);
|
|
Assert.Equal(15, day1.InputTokens);
|
|
Assert.Equal(26, day1.OutputTokens);
|
|
Assert.Equal(4, day1.CacheReadTokens);
|
|
Assert.Equal(1, day1.CacheCreationTokens);
|
|
Assert.Equal(2, day1.Messages);
|
|
|
|
var day2 = Assert.Single(result, r => r.Model == "claude-opus-5");
|
|
Assert.Equal(new DateOnly(2026, 6, 2), day2.Date);
|
|
Assert.Equal(100, day2.InputTokens);
|
|
Assert.Equal(1, day2.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Same_RequestId_Across_Files_Counts_Once()
|
|
{
|
|
WriteSession("proj-a", "s1.jsonl",
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1"));
|
|
WriteSession("proj-b", "s2.jsonl",
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1"));
|
|
|
|
var reader = MakeReader();
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
var row = Assert.Single(result);
|
|
Assert.Equal(1, row.Messages);
|
|
Assert.Equal(10, row.InputTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scope_Split_Covers_Sibling_Central_And_Sandbox_Roots()
|
|
{
|
|
var siblingCwd = Path.Combine(_root, "some-repo", ".claudedo-worktrees", "list-slug", "task-id");
|
|
var centralCwd = Path.Combine(_cfg.CentralWorktreeRoot, "list-slug", "task-id");
|
|
var sandboxCwd = Path.Combine(_cfg.SandboxRoot, "task-id");
|
|
var otherCwd = Path.Combine(_root, "some-repo");
|
|
|
|
WriteSession("proj", "s.jsonl",
|
|
AssistantLine(siblingCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sibling"),
|
|
AssistantLine(centralCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "central"),
|
|
AssistantLine(sandboxCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sandbox"),
|
|
AssistantLine(otherCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "other"));
|
|
|
|
var reader = MakeReader();
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
Assert.Equal(2, result.Count);
|
|
var claudeDo = Assert.Single(result, r => r.Scope == UsageScope.ClaudeDo);
|
|
Assert.Equal(3, claudeDo.Messages);
|
|
var other = Assert.Single(result, r => r.Scope == UsageScope.Other);
|
|
Assert.Equal(1, other.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Date_Filter_Excludes_Lines_Outside_Window()
|
|
{
|
|
WriteSession("proj", "s.jsonl",
|
|
AssistantLine(@"C:\Dev\App", "2026-05-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0),
|
|
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0),
|
|
AssistantLine(@"C:\Dev\App", "2026-07-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0));
|
|
|
|
var reader = MakeReader();
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
var row = Assert.Single(result);
|
|
Assert.Equal(5, row.InputTokens);
|
|
Assert.Equal(1, row.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Malformed_Line_Does_Not_Abort_The_Run()
|
|
{
|
|
WriteSession("proj", "s.jsonl",
|
|
"this is not json",
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
|
|
|
|
var reader = MakeReader();
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
var row = Assert.Single(result);
|
|
Assert.Equal(1, row.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cache_Skips_Unchanged_File_And_Picks_Up_Appended_Lines()
|
|
{
|
|
var path = WriteSession("proj", "s.jsonl",
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "req-a"));
|
|
|
|
var reader = MakeReader();
|
|
var first = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
|
|
Assert.Equal(1, first.Messages);
|
|
|
|
// Overwrite with different content but the SAME length and mtime: if the reader honored
|
|
// the cache it must still return the ORIGINAL aggregate, proving it did not re-read the file.
|
|
var originalBytes = File.ReadAllBytes(path);
|
|
var originalWriteUtc = File.GetLastWriteTimeUtc(path);
|
|
var tamperedLine = AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 9, 9, 0, 0, requestId: "req-9");
|
|
// Pad/truncate to the EXACT same byte length as the original file so the (length, mtime)
|
|
// cache key still matches — the reader must then serve the cached (stale) aggregate.
|
|
var tamperedPadded = tamperedLine.Length + 1 <= originalBytes.Length
|
|
? tamperedLine.PadRight(originalBytes.Length - 1) + "\n"
|
|
: tamperedLine[..(originalBytes.Length - 1)] + "\n";
|
|
var tamperedBytes = System.Text.Encoding.UTF8.GetBytes(tamperedPadded);
|
|
Assert.Equal(originalBytes.Length, tamperedBytes.Length);
|
|
File.WriteAllBytes(path, tamperedBytes);
|
|
File.SetLastWriteTimeUtc(path, originalWriteUtc);
|
|
|
|
var stale = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
|
|
Assert.Equal(5, stale.InputTokens);
|
|
|
|
// Now really append a new line: length/mtime change, so the file must be re-read.
|
|
File.AppendAllLines(path, new[]
|
|
{
|
|
AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 3, 0, 0, requestId: "req-b"),
|
|
});
|
|
|
|
var updated = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
|
|
Assert.Equal(2, updated.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Missing_ProjectsRoot_Returns_Empty_Without_Throwing()
|
|
{
|
|
var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist"));
|
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[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", "<synthetic>", 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_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);
|
|
}
|
|
}
|