From 24b915ed413ed0925a4cfefd4be6b715b759459b Mon Sep 17 00:00:00 2001 From: stain lu <109842185+stainlu@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:36:36 +0800 Subject: [PATCH] fix: surface preserved stale session totals (#67695) (thanks @stainlu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): preserve session totalTokens when provider omits usage data Fixes #67667 When a provider (e.g. MiniMax via Anthropic endpoint) does not return usage data in its API response, hasNonzeroUsage() is false and the entire totalTokens update block in persistSessionAfterRun is skipped. This resets totalTokens to undefined, causing /status to show 0% context usage even after compaction has calculated real token counts. The fix preserves the previous totalTokens value when the current run has no usage data, marking it as stale (totalTokensFresh: false) so display layers know it is from a prior run. This is strictly better than null — the user sees the last known context usage instead of 0%. * ci: retrigger after flaky gateway shutdown test * test(agents): port totalTokens regression test to withTempSessionStore helper post-rebase * fix(status): surface preserved stale session totals * fix: surface preserved stale session totals (#67695) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/agents/command/session-store.test.ts | 47 ++++++++++++++++++++++++ src/agents/command/session-store.ts | 7 ++++ src/commands/sessions.test.ts | 23 ++++++++++++ src/commands/sessions.ts | 6 +-- src/commands/status.summary.ts | 4 +- src/commands/status.test.ts | 39 ++++++++++++++------ src/config/sessions/types.ts | 12 +++++- 8 files changed, 121 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f46d8d439c4..e3c5697f7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Agents/openai-completions: always send `stream_options.include_usage` on streaming requests, so local and custom OpenAI-compatible backends report real context usage instead of showing 0%. (#68746) Thanks @kagura-agent. - Agents/nested lanes: scope nested agent work per target session so a long-running nested run on one session no longer head-of-line blocks unrelated sessions across the gateway. (#67785) Thanks @stainlu. +- Agents/status: preserve carried-forward session token totals for providers that omit usage metadata, so `/status` and `openclaw sessions` keep showing the last known context usage instead of dropping back to unknown/0%. (#67695) Thanks @stainlu. ## 2026.4.19-beta.1 diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 91be2e4e7e5..b0905b45e14 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -270,4 +270,51 @@ describe("updateSessionStoreAfterAgentRun", () => { }); }); }); + + it("preserves previous totalTokens when provider returns no usage data (#67667)", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-no-usage"; + const sessionId = "test-session"; + + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + totalTokens: 21225, + totalTokensFresh: true, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "minimax", + model: "MiniMax-M2.7", + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "minimax", + defaultModel: "MiniMax-M2.7", + result, + }); + + expect(sessionStore[sessionKey]?.totalTokens).toBe(21225); + expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false); + + const persisted = loadSessionStore(storePath); + expect(persisted[sessionKey]?.totalTokens).toBe(21225); + expect(persisted[sessionKey]?.totalTokensFresh).toBe(false); + }); + }); }); diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index cda04c72774..5975bc0e6bf 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -134,6 +134,13 @@ export async function updateSessionStoreAfterAgentRun(params: { next.estimatedCostUsd = (resolveNonNegativeNumber(entry.estimatedCostUsd) ?? 0) + runEstimatedCostUsd; } + } else if ( + typeof entry.totalTokens === "number" && + Number.isFinite(entry.totalTokens) && + entry.totalTokens > 0 + ) { + next.totalTokens = entry.totalTokens; + next.totalTokensFresh = false; } if (compactionsThisRun > 0) { next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun; diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 4c82b36b9c3..3a1571bb91a 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -106,6 +106,29 @@ describe("sessionsCommand", () => { expect(group?.totalTokensFresh).toBe(false); }); + it("shows preserved stale totals in JSON output", async () => { + const store = writeStore({ + main: { + sessionId: "abc123", + updatedAt: Date.now() - 10 * 60_000, + totalTokens: 2000, + totalTokensFresh: false, + model: "pi:opus", + }, + }); + + const payload = await runSessionsJson<{ + sessions?: Array<{ + key: string; + totalTokens: number | null; + totalTokensFresh: boolean; + }>; + }>(sessionsCommand, store); + const main = payload.sessions?.find((row) => row.key === "main"); + expect(main?.totalTokens).toBe(2000); + expect(main?.totalTokensFresh).toBe(false); + }); + it("applies --active filtering in JSON output", async () => { const store = writeStore( { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index a42547d755f..e812e9d99a3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js"; +import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions.js"; import { info } from "../globals.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -183,7 +183,7 @@ export async function sessionsCommand( const model = resolveSessionDisplayModel(cfg, r); return { ...r, - totalTokens: resolveFreshSessionTotalTokens(r) ?? null, + totalTokens: resolveSessionTotalTokens(r) ?? null, totalTokensFresh: typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, contextTokens: @@ -237,7 +237,7 @@ export async function sessionsCommand( configuredContextTokens ?? (await lookupContextTokensForDisplay(model)) ?? configContextTokens; - const total = resolveFreshSessionTotalTokens(row); + const total = resolveSessionTotalTokens(row); const line = [ ...(showAgentColumn diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 76d5e7c59bb..2e567d39b7e 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -3,7 +3,7 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; -import { resolveFreshSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js"; +import { resolveSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; @@ -197,7 +197,7 @@ export async function getStatusSummary( fallbackContextTokens: configContextTokens ?? undefined, allowAsyncLoad: false, }) ?? null; - const total = resolveFreshSessionTotalTokens(entry); + const total = resolveSessionTotalTokens(entry); const totalTokensFresh = typeof entry?.totalTokens === "number" ? entry?.totalTokensFresh !== false : false; const remaining = diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index d1ceaa12514..fbf964a66e3 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -202,10 +202,7 @@ function createSessionStatusRows() { >; const recent = Object.entries(store).map(([key, entry]) => { const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null; - const freshTotal = - typeof entry.totalTokens === "number" && (entry.totalTokensFresh ?? true) - ? entry.totalTokens - : null; + const total = typeof entry.totalTokens === "number" ? entry.totalTokens : null; return { agentId: agent.id, key, @@ -217,18 +214,14 @@ function createSessionStatusRows() { verboseLevel: entry.verboseLevel, inputTokens: entry.inputTokens, outputTokens: entry.outputTokens, - totalTokens: freshTotal, - totalTokensFresh: freshTotal !== null, + totalTokens: total, + totalTokensFresh: typeof entry.totalTokens === "number" ? entry.totalTokensFresh : false, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite, remainingTokens: - freshTotal !== null && contextTokens !== null - ? Math.max(0, contextTokens - freshTotal) - : null, + total !== null && contextTokens !== null ? Math.max(0, contextTokens - total) : null, percentUsed: - freshTotal !== null && contextTokens - ? Math.round((freshTotal / contextTokens) * 100) - : null, + total !== null && contextTokens ? Math.round((total / contextTokens) * 100) : null, model: typeof entry.model === "string" ? entry.model : null, contextTokens, flags: [ @@ -530,6 +523,9 @@ vi.mock("../config/sessions/store-read.js", () => ({ readSessionStoreReadOnly: mocks.loadSessionStore, })); vi.mock("../config/sessions/types.js", () => ({ + resolveSessionTotalTokens: vi.fn((entry?: { totalTokens?: number }) => + typeof entry?.totalTokens === "number" ? entry.totalTokens : undefined, + ), resolveFreshSessionTotalTokens: vi.fn( (entry?: { totalTokens?: number; totalTokensFresh?: boolean }) => typeof entry?.totalTokens === "number" && entry?.totalTokensFresh !== false @@ -1020,6 +1016,25 @@ describe("statusCommand", () => { }); }); + it("surfaces stale usage when totalTokens is preserved but not fresh", async () => { + mocks.loadSessionStore.mockReturnValue({ + "+1000": { + updatedAt: Date.now() - 60_000, + totalTokens: 5_000, + totalTokensFresh: false, + contextTokens: 10_000, + model: "pi:opus", + }, + }); + runtimeLogMock.mockClear(); + await statusCommand({ json: true }, runtime as never); + const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); + expect(payload.sessions.recent[0].totalTokens).toBe(5000); + expect(payload.sessions.recent[0].totalTokensFresh).toBe(false); + expect(payload.sessions.recent[0].percentUsed).toBe(50); + expect(payload.sessions.recent[0].remainingTokens).toBe(5000); + }); + it("prints formatted lines with verbose cache details", async () => { mocks.buildPluginCompatibilityNotices.mockReturnValue([ createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c7742854f2d..149a10f43dd 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -409,13 +409,23 @@ export function mergeSessionEntryPreserveActivity( }); } -export function resolveFreshSessionTotalTokens( +export function resolveSessionTotalTokens( entry?: Pick | null, ): number | undefined { const total = entry?.totalTokens; if (typeof total !== "number" || !Number.isFinite(total) || total < 0) { return undefined; } + return total; +} + +export function resolveFreshSessionTotalTokens( + entry?: Pick | null, +): number | undefined { + const total = resolveSessionTotalTokens(entry); + if (total === undefined) { + return undefined; + } if (entry?.totalTokensFresh === false) { return undefined; }