From 12a0454bcc98ada9279e202c66e3cf66f59df5b4 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Mon, 3 Aug 2026 00:50:33 -0600 Subject: [PATCH 1/3] fix(cost): quarantine unreadable session logs instead of failing every poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readSessionUsage` treated its two failure modes asymmetrically. An oversized event marked the session `unreadable` before rethrowing, so the next poll skipped that file and the failure surfaced once. A session file that could not be opened at all — a non-ENOENT `open()` error such as EACCES — was rethrown without marking anything, so every subsequent poll reopened the same file and threw again, forever. The permanence compounds in `#readSessions`, which only defers a failure when the session's thread is already known. Because `open()` failed, the thread id was never read, so the error bypasses the `included` thread-tree filter and aborts the whole scan even when the file belongs to an unrelated prior session. A root-owned rollout left behind by a single `sudo codex` run is enough to trigger it: with a cost limit the poll's `onError` aborts the scan almost immediately, and without one `stop()` rejects and reports a scan that completed successfully as failed. Quarantine unopenable sessions through the same path as oversized ones so the failure is reported once and later polls keep tracking the sessions they can read. `isMissingFile` handling is unchanged: a file that vanished between the directory walk and the open is still skipped silently. Also guard the `refresh()` inside `stop()`. `stop()` runs after the turn is over, so it cannot abort anything and cannot under-enforce `--max-cost`; enforcement happens in the polling path, which still reports errors. Letting it reject only discarded the authoritative usage the completed turn handed to it, which is exactly the fallback the existing "falls back to the completed turn when session logs are unavailable" test documents but which was unreachable whenever the sessions directory existed and could not be scanned. --- sdk/typescript/src/cost.ts | 15 ++++-- sdk/typescript/tests-ts/cost.test.ts | 71 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 321b9751..bdb53c12 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -101,7 +101,9 @@ export class ScanCostTracker { clearInterval(this.#timer); this.#timer = null; } - await this.refresh(); + try { + await this.refresh(); + } catch {} if (this.#snapshot.usage !== null) return this.#snapshot; const cost = estimateScanCost(this.#options.model, fallbackUsage); this.#snapshot = { usage: fallbackUsage ?? null, cost }; @@ -207,6 +209,7 @@ async function readSessionUsage( file = await open(path, "r"); } catch (error) { if (isMissingFile(error)) return; + quarantineSession(session); throw error; } try { @@ -223,9 +226,7 @@ async function readSessionUsage( try { readSessionChunk(buffer.subarray(0, bytesRead), session); } catch (error) { - session.unreadable = true; - session.pendingLine = []; - session.pendingLineBytes = 0; + quarantineSession(session); throw error; } } @@ -234,6 +235,12 @@ async function readSessionUsage( } } +function quarantineSession(session: SessionUsage): void { + session.unreadable = true; + session.pendingLine = []; + session.pendingLineBytes = 0; +} + function readSessionChunk(contents: Buffer, session: SessionUsage): void { let lineStart = 0; while (lineStart < contents.length) { diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 16d6fc5b..dc1f663e 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -1,5 +1,6 @@ import { appendFile, + chmod, mkdir, mkdtemp, realpath, @@ -12,6 +13,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { estimateScanCost, ScanCostTracker } from "../src/cost.js"; const temporaryDirectories: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; afterEach(async () => { await Promise.all( @@ -334,6 +336,41 @@ describe("live scan cost tracking", () => { }); }); + testPosix( + "keeps tracking after an unreadable unrelated session is reported", + async () => { + const home = await codexHome(); + const unrelated = await writeSession(home, "unrelated-thread", { + input_tokens: 99, + output_tokens: 1, + }); + await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + await chmod(unrelated, 0o000); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + + try { + await expect(tracker.refresh()).rejects.toThrow(); + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 100, + outputTokens: 10, + }); + expect((await tracker.stop()).cost).toMatchObject({ + inputTokens: 100, + outputTokens: 10, + }); + } finally { + await chmod(unrelated, 0o600); + } + }, + ); + test("reports a changed running cost only once", async () => { const home = await codexHome(); await writeSession(home, "scan-thread", { @@ -375,4 +412,38 @@ describe("live scan cost tracking", () => { }, }); }); + + testPosix( + "falls back to the completed turn when session logs cannot be read", + async () => { + const home = await codexHome(); + const unrelated = await writeSession(home, "unrelated-thread", { + input_tokens: 99, + output_tokens: 1, + }); + await chmod(unrelated, 0o000); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-luna", + }); + const usage = { input_tokens: 1_000, output_tokens: 20 }; + tracker.start("scan-thread"); + + try { + expect(await tracker.stop(usage)).toEqual({ + usage, + cost: { + model: "gpt-5.6-luna", + inputTokens: 1_000, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 20, + estimatedUsd: 0.00112, + }, + }); + } finally { + await chmod(unrelated, 0o600); + } + }, + ); }); From 17510b7bdee24103fd250045f949c74eddfa4df4 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Mon, 3 Aug 2026 17:58:54 -0600 Subject: [PATCH 2/3] fix(cost): keep the completed turn reachable and retry transient opens Two follow-ups to the quarantine change, both about usage the tracker stops observing. `stop()` swallows a failed final refresh so a completed turn is not reported as a failure, but it then returned `#snapshot` purely because it was non-null. When the last poll succeeded and the final refresh did not - the scan's own rollout gaining an oversized event, or its permissions changing as the turn completes - that snapshot predates the completed turn, so the caller's authoritative `fallbackUsage` was discarded, no fresh `onCost` fired, and `api.ts` recorded the stale below-limit cost for a turn that had already passed `maxCostUsd`. `stop()` now tracks whether this refresh succeeded and, when it did not, takes whichever of the two charges more, so a stale poll can no longer hide spend and the snapshot still wins whenever it counts delegated worker threads the completed turn does not. The open failure exit quarantined unconditionally, which is wrong for a process-wide shortage: an EMFILE from momentary descriptor pressure retires the session permanently, and every later refresh short-circuits at the `session.unreadable` guard, so its usage is never observed again even after descriptors free up. Quarantine is now limited to persistent file-specific codes (EACCES, EPERM, EISDIR, ELOOP, ENAMETOOLONG, ENOTDIR); anything else stays retryable. The root-owned rollout from issue #223 is EACCES, so it is still reported once and then skipped. Four tests in tests-ts/cost.test.ts: the stale snapshot losing to a larger completed turn and winning against a smaller one, and an open failure that recovers on a later poll for EMFILE while EACCES is never reopened. --- sdk/typescript/src/cost.ts | 46 ++++++++- sdk/typescript/tests-ts/cost.test.ts | 134 ++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 1a63eceb..739319c2 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -56,6 +56,17 @@ const MODEL_PRICING_NANODOLLARS: Readonly> = { "gpt-5.6-luna": [1_000, 100, 1_250, 6_000], }; +// Open failures that keep failing until the file itself changes. Every other +// code (EMFILE, ENFILE, EIO and friends) is treated as transient and retried. +const PERMANENT_ACCESS_ERROR_CODES: ReadonlySet = new Set([ + "EACCES", + "EPERM", + "EISDIR", + "ELOOP", + "ENAMETOOLONG", + "ENOTDIR", +]); + const COST_POLL_INTERVAL_MS = 100; const SESSION_READ_SIZE = 64 * 1_024; const MAX_SESSION_EVENT_BYTES = 1 * 1_024 * 1_024; @@ -116,11 +127,23 @@ export class ScanCostTracker { clearInterval(this.#timer); this.#timer = null; } + let refreshed = true; try { await this.refresh(); - } catch {} - if (this.#snapshot.usage !== null) return this.#snapshot; + } catch { + refreshed = false; + } + if (refreshed && this.#snapshot.usage !== null) return this.#snapshot; + // This refresh failed, so the snapshot predates the completed turn. The + // caller's own usage is authoritative for the scan thread, so it has to be + // able to win; keeping the stale snapshot would hide spend from `onCost`. const cost = estimateScanCost(this.#options.model, fallbackUsage); + if ( + this.#snapshot.usage !== null && + !chargesMore(cost, this.#snapshot.cost) + ) { + return this.#snapshot; + } this.#snapshot = { usage: fallbackUsage ?? null, cost }; this.#reportCost(cost); return this.#snapshot; @@ -224,7 +247,10 @@ async function readSessionUsage( file = await open(path, "r"); } catch (error) { if (isMissingFile(error)) return; - quarantineSession(session); + // A process-wide shortage such as EMFILE clears on its own, so quarantining + // would stop observing this session's usage for the rest of the scan. Only + // a persistent, file-specific access failure earns a permanent quarantine. + if (isPermanentAccessError(error)) quarantineSession(session); throw error; } try { @@ -379,6 +405,20 @@ function isMissingFile(error: unknown): boolean { return isRecord(error) && error["code"] === "ENOENT"; } +function isPermanentAccessError(error: unknown): boolean { + if (!isRecord(error)) return false; + const code = error["code"]; + return typeof code === "string" && PERMANENT_ACCESS_ERROR_CODES.has(code); +} + +function chargesMore( + cost: ScanCost | null, + previous: ScanCost | null, +): boolean { + if (cost === null) return false; + return previous === null || cost.estimatedUsd > previous.estimatedUsd; +} + export function estimateScanCost( model: string | undefined, usage: unknown, diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 584985ff..5343ae84 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -7,9 +7,10 @@ import { rm, writeFile, } from "node:fs/promises"; +import * as fsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { estimateScanCost, ScanCostTracker } from "../src/cost.js"; const temporaryDirectories: string[] = []; @@ -79,6 +80,33 @@ async function writeSession( return path; } +function failingOpen( + path: string, + code: string, +): { attempts: () => number; restore: () => void } { + const originalOpen = fsPromises.open; + let attempts = 0; + mock.module("node:fs/promises", () => ({ + ...fsPromises, + open: async (...parameters: Parameters) => { + if (String(parameters[0]) !== path) return originalOpen(...parameters); + attempts += 1; + throw Object.assign(new Error(`${code}: simulated open failure`), { + code, + }); + }, + })); + return { + attempts: () => attempts, + restore: () => { + mock.module("node:fs/promises", () => ({ + ...fsPromises, + open: originalOpen, + })); + }, + }; +} + describe("scan cost", () => { test("uses published GPT-5.6 model rates", () => { const usage = { input_tokens: 1_000_000, output_tokens: 1_000_000 }; @@ -493,6 +521,110 @@ describe("live scan cost tracking", () => { }); }); + test("prefers the completed turn when the final refresh fails", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const updates: number[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + onCost: (cost) => updates.push(cost.estimatedUsd), + }); + tracker.start("scan-thread"); + expect((await tracker.refresh()).cost?.estimatedUsd).toBe(0.0004); + + await appendFile(path, "x".repeat(1 * 1_024 * 1_024 + 1)); + const usage = { input_tokens: 1_000, output_tokens: 100 }; + + expect(await tracker.stop(usage)).toEqual({ + usage, + cost: { + model: "gpt-5.6-terra", + inputTokens: 1_000, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 100, + estimatedUsd: 0.004, + }, + }); + expect(updates).toEqual([0.0004, 0.004]); + }); + + test("keeps the observed usage when the completed turn charges less", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const updates: number[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + onCost: (cost) => updates.push(cost.estimatedUsd), + }); + tracker.start("scan-thread"); + await tracker.refresh(); + + await appendFile(path, "x".repeat(1 * 1_024 * 1_024 + 1)); + + expect( + (await tracker.stop({ input_tokens: 10, output_tokens: 1 })).cost, + ).toMatchObject({ inputTokens: 100, outputTokens: 10 }); + expect(updates).toEqual([0.0004]); + }); + + test("retries a session log after a transient open failure", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + const open = failingOpen(path, "EMFILE"); + + try { + await expect(tracker.refresh()).rejects.toThrow("EMFILE"); + await expect(tracker.refresh()).rejects.toThrow("EMFILE"); + expect(open.attempts()).toBe(2); + } finally { + open.restore(); + } + + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 100, + outputTokens: 10, + }); + }); + + test("stops reopening a session log after a permanent open failure", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + const open = failingOpen(path, "EACCES"); + + try { + await expect(tracker.refresh()).rejects.toThrow("EACCES"); + expect((await tracker.refresh()).cost).toBeNull(); + expect(open.attempts()).toBe(1); + } finally { + open.restore(); + } + }); + testPosix( "falls back to the completed turn when session logs cannot be read", async () => { From 09f1e118cfb65317728ecc2643858e619a557921 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Tue, 4 Aug 2026 11:12:04 -0600 Subject: [PATCH 3/3] fix(cost): bound open retries and pin the budget bypass end to end The retryable half of the open-failure classification had no floor. Only the six listed codes were quarantined, so any other code that never clears - EBUSY from a Windows rollout held open without sharing, EIO from a failing disk - was reopened on every poll for the life of the scan, which is the exact defect this PR exists to remove. The allowlist is now the fast path rather than the only path: a session is retried up to MAX_SESSION_OPEN_ATTEMPTS consecutive failures and then retired, and the counter resets on any successful open so separate outages never add up. EMFILE still recovers within the budget; EACCES is still retired on its first failure without spending retries on a file that cannot change. The stop() fallback also gains the api.ts-level test the reasoning depends on: a scan whose recorded snapshot is below --max-cost, whose rollout gains an oversized event as the turn completes, and whose completed turn is over the limit. With the fallback in place the run rejects with ScanCostLimitExceededError for the completed turn's own $0.00625 and records fail-scan; with only the stop() guard reverted the same run resolves successfully, which is the budget bypass stated. --- sdk/typescript/src/cost.ts | 25 +++++-- sdk/typescript/tests-ts/api.test.ts | 98 ++++++++++++++++++++++++++++ sdk/typescript/tests-ts/cost.test.ts | 70 ++++++++++++++++++++ 3 files changed, 188 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 739319c2..10d88367 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -30,6 +30,7 @@ interface SessionUsage { offset: number; pendingLine: Buffer[]; pendingLineBytes: number; + openFailures: number; unreadable: boolean; threadId: string | null; parentThreadId: string | null; @@ -56,8 +57,9 @@ const MODEL_PRICING_NANODOLLARS: Readonly> = { "gpt-5.6-luna": [1_000, 100, 1_250, 6_000], }; -// Open failures that keep failing until the file itself changes. Every other -// code (EMFILE, ENFILE, EIO and friends) is treated as transient and retried. +// Open failures that cannot succeed again until the file itself changes, so +// retrying them only wastes a syscall per poll. Every other code (EMFILE, +// ENFILE, EBUSY, EIO and friends) may clear on its own and is retried. const PERMANENT_ACCESS_ERROR_CODES: ReadonlySet = new Set([ "EACCES", "EPERM", @@ -67,6 +69,10 @@ const PERMANENT_ACCESS_ERROR_CODES: ReadonlySet = new Set([ "ENOTDIR", ]); +// A retryable code that never clears must still stop somewhere, otherwise the +// "reported once, then skipped" guarantee holds only for the codes listed above. +const MAX_SESSION_OPEN_ATTEMPTS = 5; + const COST_POLL_INTERVAL_MS = 100; const SESSION_READ_SIZE = 64 * 1_024; const MAX_SESSION_EVENT_BYTES = 1 * 1_024 * 1_024; @@ -161,6 +167,7 @@ export class ScanCostTracker { offset: 0, pendingLine: [], pendingLineBytes: 0, + openFailures: 0, unreadable: false, threadId: null, parentThreadId: null, @@ -248,11 +255,19 @@ async function readSessionUsage( } catch (error) { if (isMissingFile(error)) return; // A process-wide shortage such as EMFILE clears on its own, so quarantining - // would stop observing this session's usage for the rest of the scan. Only - // a persistent, file-specific access failure earns a permanent quarantine. - if (isPermanentAccessError(error)) quarantineSession(session); + // on the first failure would stop observing this session's usage for the + // rest of the scan. A file-specific access failure cannot clear on its own, + // and anything else is retried a few times before it is retired. + session.openFailures += 1; + if ( + isPermanentAccessError(error) || + session.openFailures >= MAX_SESSION_OPEN_ATTEMPTS + ) { + quarantineSession(session); + } throw error; } + session.openFailures = 0; try { const buffer = Buffer.alloc(SESSION_READ_SIZE); while (true) { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 6f11f48b..e915a2d7 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1,4 +1,5 @@ import { + appendFile, copyFile, cp, mkdir, @@ -2176,6 +2177,103 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("enforces the cost limit on a completed turn the final refresh cannot read", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const sessionPath = join( + codexHome, + "sessions", + "2026", + "07", + "26", + "rollout-scan-thread.jsonl", + ); + const commands: Array = []; + const costs: number[] = []; + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async (_options: unknown, args: readonly string[]) => { + commands.push(args); + if (args[0] === "register-cli-scan") { + return mockScanRegistration(args); + } + if (args[0] === "get-scan-feedback") { + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + return {}; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + await copyCompletedScan(root); + async function* events(): AsyncGenerator { + yield { type: "thread.started", thread_id: "scan-thread" }; + // Half the completed turn's spend, so the recorded snapshot + // stays below the limit while the turn itself passes it. + await writeUsageSession(codexHome, "scan-thread", { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 10, + }); + for (let attempt = 0; costs.length === 0; attempt += 1) { + if (attempt >= 200) throw new Error("No cost was polled."); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + // The rollout becomes unreadable exactly as the turn completes. + await appendFile( + sessionPath, + "x".repeat(1 * 1_024 * 1_024 + 1), + ); + yield { + type: "turn.completed", + usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + reasoning_output_tokens: 5, + }, + }; + } + return { events: events() }; + }, + }), + }), + }, + ); + + await expect( + client.run(repository, { + maxCostUsd: 0.005, + onCost: (cost) => costs.push(cost.estimatedUsd), + signal: AbortSignal.timeout(10_000), + }), + ).rejects.toMatchObject({ + name: ScanCostLimitExceededError.name, + maxCostUsd: 0.005, + cost: { estimatedUsd: 0.00625, inputTokens: 1_250, outputTokens: 30 }, + }); + expect(costs).toContain(0.00625); + expect(commands.some((args) => args[0] === "complete-scan")).toBe(false); + expect(commands.some((args) => args[0] === "fail-scan")).toBe(true); + await client.close(); + }); + test("saves a budgeted scan with a warning when token usage is unavailable", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 5343ae84..f89f0815 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -625,6 +625,76 @@ describe("live scan cost tracking", () => { } }); + test("retires a session log that keeps failing to open", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + const open = failingOpen(path, "EBUSY"); + + try { + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(tracker.refresh()).rejects.toThrow("EBUSY"); + } + expect((await tracker.refresh()).cost).toBeNull(); + expect((await tracker.refresh()).cost).toBeNull(); + expect(open.attempts()).toBe(5); + } finally { + open.restore(); + } + }); + + test("counts open failures per outage rather than for the whole scan", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + + for (const code of ["EMFILE", "ENFILE"]) { + const open = failingOpen(path, code); + try { + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(tracker.refresh()).rejects.toThrow(code); + } + } finally { + open.restore(); + } + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 100, + }); + } + + await appendFile( + path, + `${JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 250, output_tokens: 20 }, + }, + }, + })}\n`, + ); + + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 250, + outputTokens: 20, + }); + }); + testPosix( "falls back to the completed turn when session logs cannot be read", async () => {