Persist context window usage across turns from last task_progress

Stop resetting lastContextWindowUsedTokens between turns. The
result.usage fallback contains accumulated session totals which grow
incorrectly across turns. Once a task_progress arrives, its value is
the accurate context fill level and should be retained until the next
task_progress supersedes it.
This commit is contained in:
Mohamed Boudra
2026-04-07 18:56:58 +07:00
parent 5ec25687cd
commit 76c6253ae0
2 changed files with 14 additions and 11 deletions
@@ -358,7 +358,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
}
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => {
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage(
@@ -388,7 +388,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
});
test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => {
test("contextWindowUsedTokens falls back to result usage when no task_progress was received", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
@@ -508,7 +508,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
});
test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => {
test("contextWindowUsedTokens persists across turns from last task_progress", async () => {
const queryFactory = createQueryFactoryForTurns([
[
{
@@ -600,20 +600,23 @@ describe("ClaudeAgentSession context window usage", () => {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
});
// Turn 2 has no task_progress, so contextWindowUsedTokens retains the
// last known value from turn 1 rather than deriving from accumulated
// result.usage (which would be incorrect — those are session-level totals).
expect(secondTurn.usage).toEqual({
inputTokens: 11,
cachedInputTokens: 6,
outputTokens: 8,
totalCostUsd: 0.1,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 28,
contextWindowUsedTokens: 999,
});
} finally {
await session.close();
}
});
test("convertUsage derives used tokens even when modelUsage is missing", async () => {
test("convertUsage derives used tokens from result usage as fallback when task_progress is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
@@ -1460,7 +1460,6 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt);
this.lastContextWindowUsedTokens = undefined;
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
@@ -2304,7 +2303,6 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
}
@@ -2320,12 +2318,10 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn terminal");
}
}
@@ -2338,7 +2334,6 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.lastContextWindowUsedTokens = undefined;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -2349,7 +2344,6 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn completed");
}
@@ -2920,8 +2914,14 @@ class ClaudeAgentSession implements AgentSession {
usage.contextWindowMaxTokens = contextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but
// for the first turn they equal the per-call values so the estimate is
// reasonable. Once a task_progress arrives it takes over permanently.
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};