From 80e89ddc5c7beaff9cbff58b9f8535b6fd91cad0 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Tue, 10 Mar 2026 17:20:10 +0800 Subject: [PATCH] Fix token usage history gaps and add dev detail popup (#386) Co-authored-by: zuolingxuan --- electron/main/ipc-handlers.ts | 3 +- electron/utils/openclaw-auth.ts | 25 ++++++ electron/utils/token-usage-core.ts | 130 ++++++++++++++++++++++++--- electron/utils/token-usage.ts | 17 +++- src/i18n/locales/en/dashboard.json | 9 +- src/i18n/locales/ja/dashboard.json | 9 +- src/i18n/locales/zh/dashboard.json | 9 +- src/pages/Models/index.tsx | 78 +++++++++++++++- src/stores/chat.ts | 2 +- src/stores/chat/session-actions.ts | 2 +- tests/unit/token-usage-files.test.ts | 21 +++++ tests/unit/token-usage.test.ts | 130 +++++++++++++++++++++++++++ 12 files changed, 406 insertions(+), 29 deletions(-) create mode 100644 tests/unit/token-usage-files.test.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 986e941be..879780b64 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -2513,8 +2513,7 @@ function registerFileHandlers(): void { * Performs a soft-delete of a session's JSONL transcript on disk. * sessionKey format: "agent::" — e.g. "agent:main:session-1234567890". * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl - * Renaming to .deleted.jsonl hides it from sessions.list and token-usage - * (both already filter out filenames containing ".deleted."). + * Renaming to .deleted.jsonl hides it from sessions.list. */ function registerSessionHandlers(): void { ipcMain.handle('session:delete', async (_, sessionKey: string) => { diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index b0d147cdc..107d42fd3 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -130,12 +130,37 @@ async function discoverAgentIds(): Promise { // ── OpenClaw Config Helpers ────────────────────────────────────── const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); +const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']); async function readOpenClawJson(): Promise> { return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; } +function normalizeAgentsDefaultsCompactionMode(config: Record): void { + const agents = (config.agents && typeof config.agents === 'object' + ? config.agents as Record + : null); + if (!agents) return; + + const defaults = (agents.defaults && typeof agents.defaults === 'object' + ? agents.defaults as Record + : null); + if (!defaults) return; + + const compaction = (defaults.compaction && typeof defaults.compaction === 'object' + ? defaults.compaction as Record + : null); + if (!compaction) return; + + const mode = compaction.mode; + if (typeof mode === 'string' && mode.length > 0 && !VALID_COMPACTION_MODES.has(mode)) { + compaction.mode = 'default'; + } +} + async function writeOpenClawJson(config: Record): Promise { + normalizeAgentsDefaultsCompactionMode(config); + // Ensure SIGUSR1 graceful reload is authorized by OpenClaw config. const commands = ( config.commands && typeof config.commands === 'object' diff --git a/electron/utils/token-usage-core.ts b/electron/utils/token-usage-core.ts index 275c3d6a0..08fb5a024 100644 --- a/electron/utils/token-usage-core.ts +++ b/electron/utils/token-usage-core.ts @@ -4,6 +4,7 @@ export interface TokenUsageHistoryEntry { agentId: string; model?: string; provider?: string; + content?: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; @@ -12,6 +13,14 @@ export interface TokenUsageHistoryEntry { costUsd?: number; } +export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined { + if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined; + return fileName + .replace(/\.jsonl\.reset\..+$/, '') + .replace(/\.deleted\.jsonl$/, '') + .replace(/\.jsonl$/, ''); +} + interface TranscriptUsageShape { input?: number; output?: number; @@ -35,9 +44,59 @@ interface TranscriptLineShape { modelRef?: string; provider?: string; usage?: TranscriptUsageShape; + details?: { + provider?: string; + model?: string; + usage?: TranscriptUsageShape; + content?: unknown; + externalContent?: { + provider?: string; + }; + }; }; } +function normalizeUsageContent(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + if (Array.isArray(value)) { + const chunks = value + .map((item) => normalizeUsageContent(item)) + .filter((item): item is string => Boolean(item)); + if (chunks.length === 0) return undefined; + return chunks.join('\n\n'); + } + + if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.text === 'string') { + const trimmed = record.text.trim(); + if (trimmed.length > 0) return trimmed; + } + if (typeof record.content === 'string') { + const trimmed = record.content.trim(); + if (trimmed.length > 0) return trimmed; + } + if (Array.isArray(record.content)) { + return normalizeUsageContent(record.content); + } + if (typeof record.thinking === 'string') { + const trimmed = record.thinking.trim(); + if (trimmed.length > 0) return trimmed; + } + try { + return JSON.stringify(record, null, 2); + } catch { + return undefined; + } + } + + return undefined; +} + export function parseUsageEntriesFromJsonl( content: string, context: { sessionId: string; agentId: string }, @@ -58,18 +117,66 @@ export function parseUsageEntriesFromJsonl( } const message = parsed.message; - if (!message || message.role !== 'assistant' || !message.usage || !parsed.timestamp) { + if (!message || !parsed.timestamp) { continue; } - const usage = message.usage; - const inputTokens = usage.input ?? usage.promptTokens ?? 0; - const outputTokens = usage.output ?? usage.completionTokens ?? 0; - const cacheReadTokens = usage.cacheRead ?? 0; - const cacheWriteTokens = usage.cacheWrite ?? 0; - const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + if (message.role === 'assistant' && message.usage) { + const usage = message.usage; + const inputTokens = usage.input ?? usage.promptTokens ?? 0; + const outputTokens = usage.output ?? usage.completionTokens ?? 0; + const cacheReadTokens = usage.cacheRead ?? 0; + const cacheWriteTokens = usage.cacheWrite ?? 0; + const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - if (totalTokens <= 0 && !usage.cost?.total) { + if (totalTokens <= 0) { + continue; + } + + const contentText = normalizeUsageContent((message as Record).content); + entries.push({ + timestamp: parsed.timestamp, + sessionId: context.sessionId, + agentId: context.agentId, + model: message.model ?? message.modelRef, + provider: message.provider, + ...(contentText ? { content: contentText } : {}), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + costUsd: usage.cost?.total, + }); + continue; + } + + if (message.role !== 'toolResult') { + continue; + } + + const details = message.details; + if (!details) { + continue; + } + + const usage = details.usage; + const inputTokens = usage?.input ?? usage?.promptTokens ?? 0; + const outputTokens = usage?.output ?? usage?.completionTokens ?? 0; + const cacheReadTokens = usage?.cacheRead ?? 0; + const cacheWriteTokens = usage?.cacheWrite ?? 0; + const totalTokens = usage?.total ?? usage?.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + + const provider = details.provider ?? details.externalContent?.provider ?? message.provider; + const model = details.model ?? message.model ?? message.modelRef; + const contentText = normalizeUsageContent(details.content) + ?? normalizeUsageContent((message as Record).content); + + if (!provider && !model) { + continue; + } + + if (totalTokens <= 0) { continue; } @@ -77,14 +184,15 @@ export function parseUsageEntriesFromJsonl( timestamp: parsed.timestamp, sessionId: context.sessionId, agentId: context.agentId, - model: message.model ?? message.modelRef, - provider: message.provider, + model, + provider, + ...(contentText ? { content: contentText } : {}), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens, - costUsd: usage.cost?.total, + costUsd: usage?.cost?.total, }); } diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts index 6dcc84e16..62ffe1c81 100644 --- a/electron/utils/token-usage.ts +++ b/electron/utils/token-usage.ts @@ -2,10 +2,18 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join } from 'path'; import { getOpenClawConfigDir } from './paths'; import { logger } from './logger'; +import { + extractSessionIdFromTranscriptFileName, + parseUsageEntriesFromJsonl, + type TokenUsageHistoryEntry, +} from './token-usage-core'; import { listConfiguredAgentIds } from './agent-config'; -import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core'; -export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core'; +export { + extractSessionIdFromTranscriptFileName, + parseUsageEntriesFromJsonl, + type TokenUsageHistoryEntry, +} from './token-usage-core'; async function listRecentSessionFiles(): Promise> { const openclawDir = getOpenClawConfigDir(); @@ -21,13 +29,14 @@ async function listRecentSessionFiles(): Promise state.status); + const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const isGatewayRunning = gatewayStatus.state === 'running'; const [usageHistory, setUsageHistory] = useState([]); const [usageGroupBy, setUsageGroupBy] = useState('model'); const [usageWindow, setUsageWindow] = useState('7d'); const [usagePage, setUsagePage] = useState(1); + const [selectedUsageEntry, setSelectedUsageEntry] = useState(null); useEffect(() => { trackUiEvent('models.page_viewed'); @@ -216,6 +221,16 @@ export function Models() { {typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && ( {t('dashboard:recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })} )} + {devModeUnlocked && entry.content && ( + + )} ))} @@ -255,6 +270,15 @@ export function Models() { + {devModeUnlocked && selectedUsageEntry && ( + setSelectedUsageEntry(null)} + title={t('dashboard:recentTokenHistory.contentDialogTitle')} + closeLabel={t('dashboard:recentTokenHistory.close')} + unknownModelLabel={t('dashboard:recentTokenHistory.unknownModel')} + /> + )} ); } @@ -410,7 +434,11 @@ function UsageBarChart({
0 + ? `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%` + : '0%', + }} > {group.inputTokens > 0 && (
void; + title: string; + closeLabel: string; + unknownModelLabel: string; +}) { + return ( +
+
+
+
+

{title}

+

+ {(entry.model || unknownModelLabel)} • {formatUsageTimestamp(entry.timestamp)} +

+
+ +
+
+
+            {entry.content}
+          
+
+
+ +
+
+
+ ); +} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 1d49182b3..e64a8eb6a 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1093,7 +1093,7 @@ export const useChatStore = create((set, get) => ({ deleteSession: async (key: string) => { // Soft-delete the session's JSONL transcript on disk. // The main process renames .jsonl → .deleted.jsonl so that - // sessions.list and token-usage queries both skip it automatically. + // sessions.list skips it automatically. try { const result = await hostApiFetch<{ success: boolean; diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts index b2c698e2c..05fc4c7ff 100644 --- a/src/stores/chat/session-actions.ts +++ b/src/stores/chat/session-actions.ts @@ -157,7 +157,7 @@ export function createSessionActions( deleteSession: async (key: string) => { // Soft-delete the session's JSONL transcript on disk. // The main process renames .jsonl → .deleted.jsonl so that - // sessions.list and token-usage queries both skip it automatically. + // sessions.list skips it automatically. try { const result = await invokeIpc('session:delete', key) as { success: boolean; diff --git a/tests/unit/token-usage-files.test.ts b/tests/unit/token-usage-files.test.ts new file mode 100644 index 000000000..f202605c0 --- /dev/null +++ b/tests/unit/token-usage-files.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { extractSessionIdFromTranscriptFileName } from '@electron/utils/token-usage-core'; + +describe('extractSessionIdFromTranscriptFileName', () => { + it('parses normal jsonl transcript names', () => { + expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl')).toBe('abc-123'); + }); + + it('parses deleted transcript names', () => { + expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl')).toBe('abc-123'); + }); + + it('parses reset transcript names', () => { + expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123'); + }); + + it('returns undefined for non-transcript files', () => { + expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined(); + expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined(); + }); +}); diff --git a/tests/unit/token-usage.test.ts b/tests/unit/token-usage.test.ts index 57a47952b..417c6e5ea 100644 --- a/tests/unit/token-usage.test.ts +++ b/tests/unit/token-usage.test.ts @@ -81,6 +81,136 @@ describe('parseUsageEntriesFromJsonl', () => { expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]); }); + it('skips tool result entries without positive token usage', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T02:17:04.057Z', + message: { + role: 'toolResult', + toolName: 'web_search', + details: { + provider: 'kimi', + model: 'moonshot-v1-128k', + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]); + }); + + it('uses tool result usage when provided', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T02:17:04.057Z', + message: { + role: 'toolResult', + details: { + provider: 'kimi', + model: 'moonshot-v1-128k', + usage: { + promptTokens: 120, + completionTokens: 30, + cacheRead: 10, + totalTokens: 160, + cost: { total: 0.0009 }, + }, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T02:17:04.057Z', + sessionId: 'abc', + agentId: 'default', + model: 'moonshot-v1-128k', + provider: 'kimi', + inputTokens: 120, + outputTokens: 30, + cacheReadTokens: 10, + cacheWriteTokens: 0, + totalTokens: 160, + costUsd: 0.0009, + }, + ]); + }); + + it('extracts assistant response text into content', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T02:20:04.057Z', + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'moonshot', + content: [{ type: 'text', text: '这是一条测试回复内容。' }], + usage: { + totalTokens: 100, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T02:20:04.057Z', + sessionId: 'abc', + agentId: 'default', + model: 'kimi-k2.5', + provider: 'moonshot', + content: '这是一条测试回复内容。', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 100, + costUsd: undefined, + }, + ]); + }); + + it('extracts tool result details content into content', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T02:21:04.057Z', + message: { + role: 'toolResult', + details: { + provider: 'kimi', + model: 'moonshot-v1-128k', + content: '外部搜索原文内容', + usage: { + totalTokens: 50, + }, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T02:21:04.057Z', + sessionId: 'abc', + agentId: 'default', + model: 'moonshot-v1-128k', + provider: 'kimi', + content: '外部搜索原文内容', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 50, + costUsd: undefined, + }, + ]); + }); + it('returns all matching entries when no limit is provided', () => { const jsonl = [ JSON.stringify({