fix: surface preserved stale session totals (#67695) (thanks @stainlu)

* 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 <hi@obviy.us>
This commit is contained in:
stain lu
2026-04-19 10:36:36 +08:00
committed by GitHub
parent 8233ca6401
commit 24b915ed41
8 changed files with 121 additions and 18 deletions

View File

@@ -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

View File

@@ -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<string, SessionEntry> = {
[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);
});
});
});

View File

@@ -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;

View File

@@ -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(
{

View File

@@ -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

View File

@@ -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 =

View File

@@ -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" }),

View File

@@ -409,13 +409,23 @@ export function mergeSessionEntryPreserveActivity(
});
}
export function resolveFreshSessionTotalTokens(
export function resolveSessionTotalTokens(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | 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<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): number | undefined {
const total = resolveSessionTotalTokens(entry);
if (total === undefined) {
return undefined;
}
if (entry?.totalTokensFresh === false) {
return undefined;
}