refactor(usage): drop the transcript aggregation path in favour of TokenTracker

This commit is contained in:
mika kuns
2026-08-24 13:49:49 +02:00
parent 44337d4b94
commit 40879705ac
6 changed files with 23 additions and 281 deletions
@@ -9,10 +9,6 @@ public sealed class FakeTranscriptUsageReader : ITranscriptUsageReader
public void SetTotals(string sessionId, SessionUsageTotals totals) => _totalsBySession[sessionId] = totals;
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<UsageAggregateRow>>(Array.Empty<UsageAggregateRow>());
public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) =>
Task.FromResult(_totalsBySession.TryGetValue(sessionId, out var totals) ? totals : null);
}
@@ -155,10 +155,6 @@ public sealed class RunUsagePersistenceTests : IDisposable
private sealed class ThrowingUsageReader : ITranscriptUsageReader
{
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
throw new IOException("boom");
public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) =>
throw new IOException("boom");
}
@@ -4,6 +4,10 @@ using ClaudeDo.Worker.Usage;
namespace ClaudeDo.Worker.Tests.Usage;
/// <summary>
/// Covers what is left of the reader after the model/scope aggregation moved to TokenTracker:
/// the per-session totals that back <c>task_runs</c>' per-run token delta.
/// </summary>
public class TranscriptUsageReaderTests : IDisposable
{
private readonly string _root;
@@ -57,178 +61,6 @@ public class TranscriptUsageReaderTests : IDisposable
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 Files_Last_Written_Before_The_Window_Are_Not_Read()
{
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
// record inside it, so it is skipped unread. Here the content would match the window —
// proving the file was never opened, which is what keeps a 7-day range off the full history.
var path = WriteSession("proj", "old.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact]
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
{
var path = WriteSession("proj", "edge.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Single(result);
}
[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()
{
@@ -276,6 +108,20 @@ public class TranscriptUsageReaderTests : IDisposable
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()
{