From 8f62ec6177aadde8e33fc8ea197c3471e697f1c7 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Sun, 14 Jun 2026 05:37:08 +0530 Subject: [PATCH] fix(status): avoid cumulative usage for context percent (#92604) * fix(status): avoid cumulative usage for context percent * fix(status): preserve legacy context totals --- src/auto-reply/status.test.ts | 91 +++++++++++++++++++++++++++++++++++ src/status/status-message.ts | 25 ++++++---- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e732b7eac6c7..a6b9d2501112 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -109,6 +109,7 @@ describe("buildStatusMessage", () => { inputTokens: 1200, outputTokens: 800, totalTokens: 16_000, + totalTokensFresh: true, contextTokens: 32_000, thinkingLevel: "low", verboseLevel: "on", @@ -221,6 +222,30 @@ describe("buildStatusMessage", () => { expect(normalized).not.toContain("Context: 3.8m/1.0m"); }); + it("preserves legacy unknown-freshness totalTokens as context usage", () => { + const text = buildStatusMessage({ + agent: { + model: "anthropic/test:opus", + contextTokens: 1_000_000, + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + totalTokens: 25_000, + contextTokens: 1_000_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", + now: 10 * 60_000, + }); + const normalized = normalizeTestText(text); + + expect(normalized).toContain("Context: 25k/1.0m"); + expect(normalized).not.toContain("Context: ?/1.0m"); + }); + it("uses estimated context budget status when fresh totalTokens are unavailable", () => { const text = buildStatusMessage({ agent: { @@ -856,6 +881,7 @@ describe("buildStatusMessage", () => { channel: "discord", groupId: "123", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 1_048_576, }, sessionKey: "agent:main:main", @@ -891,6 +917,7 @@ describe("buildStatusMessage", () => { sessionId: "ctx1m", updatedAt: 0, totalTokens: 200_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -909,6 +936,7 @@ describe("buildStatusMessage", () => { sessionId: "opus47", updatedAt: 0, totalTokens: 200_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -928,6 +956,7 @@ describe("buildStatusMessage", () => { modelOverride: "small-model", contextTokens: 4_096, totalTokens: 1_024, + totalTokensFresh: true, }; applyModelOverrideToSessionEntry({ @@ -981,6 +1010,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 1_048_576, }, sessionKey: "agent:main:main", @@ -1113,6 +1143,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 1_048_576, }, sessionKey: "agent:main:main", @@ -1157,6 +1188,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 123_456, }, sessionKey: "agent:main:main", @@ -1203,6 +1235,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -1248,6 +1281,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -1292,6 +1326,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -1333,6 +1368,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 128_000, }, sessionKey: "agent:main:main", @@ -1981,6 +2017,54 @@ describe("buildStatusMessage", () => { ); }); + it("does not let legacy cumulative session totals override fresh transcript context usage", async () => { + await withTempHome( + async (dir) => { + const sessionId = "sess-legacy-cumulative-context"; + writeTranscriptUsageLog({ + dir, + agentId: "main", + sessionId, + usage: { + input: 10_000, + output: 1_000, + cacheRead: 26_000, + cacheWrite: 0, + totalTokens: 36_000, + }, + }); + + const text = buildStatusMessage({ + agent: { + model: "anthropic/claude-opus-4-6", + contextTokens: 1_000_000, + }, + sessionEntry: { + sessionId, + updatedAt: 0, + inputTokens: 16, + outputTokens: 5_100, + cacheRead: 2_300_000, + cacheWrite: 11_000, + totalTokens: 2_300_000, + contextTokens: 1_000_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); + const normalized = normalizeTestText(text); + + expect(normalized).toContain("Cache: 100% hit ยท 2.3m cached, 11k new"); + expect(normalized).toContain("Context: 36k/1.0m (4%)"); + expect(normalized).not.toContain("Context: 2.3m/1.0m"); + }, + { prefix: "openclaw-status-" }, + ); + }); + it("reads transcript usage for non-default agents", async () => { await withTempHome( async (dir) => { @@ -2237,6 +2321,7 @@ describe("buildStatusMessage", () => { sessionId: "sess-runtime-slash-id", updatedAt: 0, totalTokens: 1205, + totalTokensFresh: true, model: "google/gemini-2.5-pro", }, sessionKey: "agent:main:main", @@ -2279,6 +2364,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "fake-minimax/FakeMiniMax-M2.5", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2314,6 +2400,7 @@ describe("buildStatusMessage", () => { updatedAt: 0, model: "openai/gpt-4o", totalTokens: 49_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2384,6 +2471,7 @@ describe("buildStatusMessage", () => { sessionId: "sess-anthropic-qualified-context", updatedAt: 0, totalTokens: 25_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2408,6 +2496,7 @@ describe("buildStatusMessage", () => { sessionId: "sess-openai-chatgpt-cap-context", updatedAt: 0, totalTokens: 25_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2432,6 +2521,7 @@ describe("buildStatusMessage", () => { sessionId: "sess-openai-chatgpt-runtime-cap-context", updatedAt: 0, totalTokens: 25_000, + totalTokensFresh: true, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2469,6 +2559,7 @@ describe("buildStatusMessage", () => { fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", fallbackNoticeReason: "model not allowed", totalTokens: 49_000, + totalTokensFresh: true, contextTokens: 128_000, }, sessionKey: "agent:main:main", diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 6a983c7ae483..5dfdfce5fca7 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -293,6 +293,7 @@ const readUsageFromSessionLog = ( cacheWrite: number; promptTokens: number; total: number; + totalTokensFresh: boolean; model?: string; } | undefined => { @@ -350,6 +351,7 @@ const readUsageFromSessionLog = ( cacheWrite, promptTokens, total, + totalTokensFresh: snapshot.totalTokensFresh === true, model, }; } catch { @@ -631,15 +633,13 @@ export function buildStatusMessage(args: StatusArgs): string { let cacheRead = entry?.cacheRead; let cacheWrite = entry?.cacheWrite; const freshTotalTokens = resolveFreshSessionTotalTokens(entry); + // Undefined freshness is legacy, not stale: keep persisted totals for /status, + // but let a fresh transcript prompt snapshot replace them when available. const allowTranscriptContextUsage = entry?.totalTokensFresh !== false; - let totalTokens = - freshTotalTokens ?? - (entry?.totalTokensFresh === false - ? undefined - : (entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0))); + let totalTokens = freshTotalTokens; - // Prefer prompt-size tokens from the session transcript when it looks larger - // (cached prompt tokens are often missing from agent meta/store). + // Explicitly stale session/cache usage can still hydrate Tokens/Cache lines + // but must not become Context. if (args.includeTranscriptUsage) { const logUsage = readUsageFromSessionLog( entry?.sessionId, @@ -649,10 +649,17 @@ export function buildStatusMessage(args: StatusArgs): string { args.sessionStorePath, ); if (logUsage) { - const candidate = logUsage.promptTokens || logUsage.total; + const candidate = logUsage.totalTokensFresh + ? logUsage.promptTokens || logUsage.total + : undefined; if ( allowTranscriptContextUsage && - (!totalTokens || totalTokens === 0 || candidate > totalTokens) + candidate !== undefined && + candidate > 0 && + (entry?.totalTokensFresh !== true || + !totalTokens || + totalTokens === 0 || + candidate > totalTokens) ) { totalTokens = candidate; }