mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user