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