Files
AgentHub/server/lib/__tests__/transcript-cache.test.js
T
nntrivi2001 8d48a038c7 fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus
the notification and brand work asked for alongside them.

Tokens: Claude Code writes ONE JSONL line per content block of an assistant
message, and every one of those lines repeats the same message-level `usage`.
Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran
1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId`
counts a message once, carried across the transcript cache's incremental chunk
boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser
cannot correct what is already stored, so `setTokenUsage` (exact, baselines
cleared) is added for the deliberate re-parse path and `reconcileTokens` now
covers every session with a transcript on disk rather than imported ones only.

Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on
the transcript's timestamp, so each hook re-inserted the session's whole turn
history — 587k rows, 75% of a real events table, one session holding 68,747
copies of the same turns. The row now carries the transcript timestamp, which
fixes the guard and the feed's ordering together. Same class of bug in the
session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms`
are assigned from the full parse instead of `+=`, since `extract()` returns
whole-transcript totals on every call.

Events: leftover checkout hook entries in `~/.claude/settings.json` double every
event, every subagent row and every token figure. `stripLegacyHooks` has always
removed them — but only inside `bootstrap()`, which `fastPathOk` skips on
exactly the healthy installs where nothing else forces a full pass, so they
survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path.

A one-time repair at the first start after upgrading (`user_version` 3) deletes
the duplicated `TurnDuration` rows and re-parses every transcript with exact
writes. Marker 3 and not 2: an install that ran the earlier pass had its
corrected rows re-inflated by whatever old build was still ingesting hooks.

Router: switching a lane's AI Router while a pane is live only half-applies —
Claude Code re-applies `env` to a running session but keeps the model it
resolved at startup, so the old router's model name reaches the new endpoint and
the next prompt fails with "model not found". The apply route reports
`restart_required` / `run_id`, and the picker offers a Restart that respawns the
pane from the lane id alone (replaying the old `--model` would re-create the
mismatch).

Notifications (#29): the two moments Claude is BLOCKED on the user now have
their own switches and default to on — `onAwaitingInput` (a permission prompt or
a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable
and the second hung off a preference that defaults to off, so the one thing a
user walks away from went unannounced. Prefs shape/defaults/storage move to
`client/src/lib/notifPrefs.ts`; Settings and the hook had private copies.

Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under
it, the collapsed rail shows the mark, and the favicon is vendored to match.
2026-09-18 16:52:05 +07:00

724 lines
24 KiB
JavaScript

/**
* @file Unit tests for the TranscriptCache class, which extracts token usage from Claude transcript JSONL files and caches results for performance. Tests cover cache hits/misses, compaction detection, multiple models, and edge cases like malformed files and eviction behavior.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
let tmpDir;
let TranscriptCache;
function writeJsonl(filePath, entries) {
fs.writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
}
describe("TranscriptCache", () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-"));
delete require.cache[require.resolve("../../lib/transcript-cache")];
TranscriptCache = require("../../lib/transcript-cache");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("should extract tokens on first read (cache miss)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.deepStrictEqual(result.tokensByModel, {
"claude-sonnet-4-20250514": { input: 300, output: 125, cacheRead: 0, cacheWrite: 0 },
});
assert.strictEqual(result.compaction, null);
});
it("should return cached result when file is unchanged", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
const r2 = cache.extract(file);
assert.deepStrictEqual(r1, r2);
// Same object reference proves cache hit (no re-parse)
assert.strictEqual(r1, r2);
});
it("should detect new data when file grows", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.tokensByModel["claude-sonnet-4-20250514"].input, 100);
// Append more data (simulates Claude writing to transcript)
fs.appendFileSync(
file,
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
}) + "\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 300);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].output, 125);
});
it("should do full re-read when file shrinks (compaction rewrite)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 300, output_tokens: 100 },
},
},
]);
const cache = new TranscriptCache();
cache.extract(file);
// Simulate compaction — file is rewritten with fewer entries + summary
writeJsonl(file, [
{ isCompactSummary: true, uuid: "abc-123", timestamp: "2026-03-20T10:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
]);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 50);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "abc-123");
});
it("should return null for non-existent file", () => {
const cache = new TranscriptCache();
assert.strictEqual(cache.extract("/nonexistent/file.jsonl"), null);
assert.strictEqual(cache.extract(null), null);
assert.strictEqual(cache.extract(""), null);
});
it("should expose compaction entries via extractCompactions()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
{ isCompactSummary: true, uuid: "c2", timestamp: "2026-03-20T10:00:00Z" },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 2);
assert.strictEqual(compactions[0].uuid, "c1");
assert.strictEqual(compactions[1].uuid, "c2");
});
it("should handle multiple models in same file", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-opus-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
assert.strictEqual(result.tokensByModel["claude-opus-4-20250514"].input, 500);
});
it("should skip <synthetic> model entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "<synthetic>", usage: { input_tokens: 999, output_tokens: 999 } } },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(Object.keys(result.tokensByModel).length, 1);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
});
it("should handle cache_read and cache_write tokens", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 15,
},
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheRead, 30);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheWrite, 15);
});
it("should remove entry on invalidate()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
cache.extract(file);
assert.strictEqual(cache.size, 1);
cache.invalidate(file);
assert.strictEqual(cache.size, 0);
});
it("should clear all entries", () => {
const file1 = path.join(tmpDir, "s1.jsonl");
const file2 = path.join(tmpDir, "s2.jsonl");
writeJsonl(file1, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
writeJsonl(file2, [
{ message: { model: "m1", usage: { input_tokens: 20, output_tokens: 10 } } },
]);
const cache = new TranscriptCache();
cache.extract(file1);
cache.extract(file2);
assert.strictEqual(cache.size, 2);
cache.clear();
assert.strictEqual(cache.size, 0);
});
it("should return correct stats()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
cache.extract(file);
const stats = cache.stats();
assert.strictEqual(stats.entries, 1);
assert.strictEqual(stats.paths.length, 1);
assert.strictEqual(stats.paths[0], file);
});
it("should only read new bytes on incremental update", () => {
const file = path.join(tmpDir, "session.jsonl");
const line1 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}) + "\n";
fs.writeFileSync(file, line1);
const cache = new TranscriptCache();
cache.extract(file);
// Append a second line
const line2 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 200, output_tokens: 75 } },
}) + "\n";
fs.appendFileSync(file, line2);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["m1"].input, 300);
// Verify bytesRead advanced to full file size
const entry = cache._cache.get(file);
assert.strictEqual(entry.bytesRead, Buffer.byteLength(line1 + line2, "utf8"));
});
it("should handle incremental read adding new compaction entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.compaction, null);
// Append a compaction entry
fs.appendFileSync(
file,
JSON.stringify({ isCompactSummary: true, uuid: "new-c", timestamp: "2026-03-20T12:00:00Z" }) +
"\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "new-c");
});
it("should return null for empty file", () => {
const file = path.join(tmpDir, "empty.jsonl");
fs.writeFileSync(file, "");
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file), null);
});
it("should skip malformed JSON lines gracefully", () => {
const file = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(
file,
[
"not valid json",
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}),
"{broken",
].join("\n") + "\n"
);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["m1"].input, 100);
});
it("should evict oldest entries when exceeding maxEntries", () => {
const cache = new TranscriptCache(3); // max 3 entries
const files = [];
for (let i = 0; i < 5; i++) {
const file = path.join(tmpDir, `s${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: i * 10, output_tokens: i * 5 } } },
]);
files.push(file);
}
// Fill cache with 5 entries, but max is 3
for (const f of files) cache.extract(f);
assert.strictEqual(cache.size, 3);
// Oldest two (s0, s1) should be evicted; newest three (s2, s3, s4) remain
const stats = cache.stats();
assert.ok(!stats.paths.includes(files[0]), "oldest entry s0 should be evicted");
assert.ok(!stats.paths.includes(files[1]), "second-oldest entry s1 should be evicted");
assert.ok(stats.paths.includes(files[2]), "s2 should remain");
assert.ok(stats.paths.includes(files[3]), "s3 should remain");
assert.ok(stats.paths.includes(files[4]), "s4 should remain");
});
it("should refresh LRU order on access", () => {
const cache = new TranscriptCache(3);
const files = [];
for (let i = 0; i < 3; i++) {
const file = path.join(tmpDir, `lru${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
files.push(file);
cache.extract(file);
}
// Access file[0] again (moves it to most-recently-used)
fs.appendFileSync(
files[0],
JSON.stringify({ message: { model: "m1", usage: { input_tokens: 5, output_tokens: 2 } } }) +
"\n"
);
cache.extract(files[0]);
// Add a new file — should evict file[1] (now the oldest), not file[0]
const newFile = path.join(tmpDir, "lru_new.jsonl");
writeJsonl(newFile, [
{ message: { model: "m1", usage: { input_tokens: 1, output_tokens: 1 } } },
]);
cache.extract(newFile);
assert.strictEqual(cache.size, 3);
const stats = cache.stats();
assert.ok(stats.paths.includes(files[0]), "recently accessed file should remain");
assert.ok(!stats.paths.includes(files[1]), "oldest untouched file should be evicted");
assert.ok(stats.paths.includes(files[2]), "file[2] should remain");
assert.ok(stats.paths.includes(newFile), "new file should be present");
});
it("should return defensive copy from extractCompactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 1);
// Mutate returned array — should NOT affect cache
compactions.push({ uuid: "fake", timestamp: null });
compactions[0].uuid = "mutated";
const compactions2 = cache.extractCompactions(file);
assert.strictEqual(compactions2.length, 1);
assert.strictEqual(compactions2[0].uuid, "c1");
});
it("should return empty array from extractCompactions for file with no compactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.deepStrictEqual(compactions, []);
});
it("should return empty array from extractCompactions for non-existent file", () => {
const cache = new TranscriptCache();
const compactions = cache.extractCompactions("/nonexistent.jsonl");
assert.deepStrictEqual(compactions, []);
});
it("should capture lastInterruptTs from an Esc-interrupt entry (text marker)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
{
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T12:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T12:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should capture lastInterruptTs via the interruptedMessageId field", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "msg_123",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
timestamp: "2026-06-28T13:30:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T13:30:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should flag pendingInterrupt for an Esc pressed BEFORE any output (prompt then interrupt)", () => {
// The hard case: user submits, then cancels before the model emits anything.
// Transcript order is [user prompt, interrupt] — no assistant entry between.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "do a big refactor" }] },
timestamp: "2026-06-28T15:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T15:00:00.001Z", // 1ms later — the real-world skew case
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true, "pre-output Esc must still be detected");
});
it("should flag pendingInterrupt when Esc follows assistant output", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "assistant",
message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } },
timestamp: "2026-06-28T16:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T16:00:05.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true);
});
it("should NOT flag pendingInterrupt when the user resumed after the interrupt", () => {
// [interrupt, new prompt] — the user came back and submitted again.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T17:00:00.000Z",
},
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "actually do this instead" }] },
timestamp: "2026-06-28T17:00:30.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T17:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, false, "resuming with a new prompt clears it");
});
it("should keep the latest interrupt timestamp (append-only, last wins)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "a",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T10:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "b",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T11:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T11:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should carry a newer interrupt across an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const first = cache.extract(file);
assert.strictEqual(first.lastInterruptTs, null);
assert.strictEqual(first.pendingInterrupt, false);
// Append an interrupt entry → incremental read path must surface it.
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T14:00:00.000Z",
}) + "\n"
);
const second = cache.extract(file);
assert.strictEqual(second.lastInterruptTs, "2026-06-28T14:00:00.000Z");
assert.strictEqual(second.pendingInterrupt, true);
});
it("should clear pendingInterrupt across an incremental read when the user resumes", () => {
// First read sees a tail interrupt; a later prompt appended in the next
// chunk must flip pendingInterrupt back to false via the merge path.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T18:00:00.000Z",
},
]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).pendingInterrupt, true);
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "resume" }] },
timestamp: "2026-06-28T18:01:00.000Z",
}) + "\n"
);
assert.strictEqual(cache.extract(file).pendingInterrupt, false, "incremental resume clears it");
});
it("should leave lastInterruptTs null and pendingInterrupt false when there is no interrupt", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, null);
assert.strictEqual(result.pendingInterrupt, false);
});
// Claude Code writes ONE line per content block of an assistant message, each
// repeating the whole message's usage. Counting them all inflated cost.
const M1 = ["m1", "standard", "global", "standard"].join(String.fromCharCode(1));
const blockLine = (id, block) => ({
type: "assistant",
message: {
id,
model: "m1",
content: [block],
usage: { input_tokens: 10, output_tokens: 40, cache_read_input_tokens: 1000 },
},
});
it("counts one assistant message once, however many block lines repeat its usage", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
blockLine("msg_a", { type: "thinking" }),
blockLine("msg_a", { type: "text", text: "hi" }),
blockLine("msg_a", { type: "tool_use", name: "Bash" }),
blockLine("msg_b", { type: "text", text: "next turn" }),
]);
const result = new TranscriptCache().extract(file);
assert.deepStrictEqual(result.tokensByModel[M1], {
model: "m1",
speed: "standard",
geo: "global",
tier: "standard",
input: 20,
output: 80,
cacheRead: 2000,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
});
assert.strictEqual(result.thinkingBlockCount, 1, "thinking blocks still counted per line");
});
it("does not re-count a message whose block lines straddle an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [blockLine("msg_a", { type: "thinking" })]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40);
fs.appendFileSync(file, JSON.stringify(blockLine("msg_a", { type: "text", text: "x" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40, "same message, one count");
fs.appendFileSync(file, JSON.stringify(blockLine("msg_b", { type: "text", text: "y" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 80, "new message counts");
});
});