From 870abb99c4f869bac11197349ae111381ddfef15 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Sat, 28 Mar 2026 21:13:56 +0800 Subject: [PATCH] Fix token usage handling and developer proxy save UX (#704) --- AGENTS.md | 1 + electron/utils/token-usage-core.ts | 206 +++++++++++++++++++++++------ src/i18n/locales/en/agents.json | 3 + src/i18n/locales/en/dashboard.json | 6 +- src/i18n/locales/ja/agents.json | 3 + src/i18n/locales/ja/dashboard.json | 6 +- src/i18n/locales/zh/agents.json | 3 + src/i18n/locales/zh/dashboard.json | 6 +- src/pages/Agents/index.tsx | 51 ++++++- src/pages/Models/index.tsx | 89 +++++++++++-- src/pages/Models/usage-history.ts | 1 + src/pages/Settings/index.tsx | 59 ++++++--- tests/e2e/settings-proxy.spec.ts | 49 +++++++ tests/e2e/token-usage.spec.ts | 198 +++++++++++++++++++++++++++ tests/unit/token-usage.test.ts | 176 +++++++++++++++++++++++- 15 files changed, 782 insertions(+), 75 deletions(-) create mode 100644 tests/e2e/settings-proxy.spec.ts create mode 100644 tests/e2e/token-usage.spec.ts diff --git a/AGENTS.md b/AGENTS.md index ffae5737a..c9dd56680 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones: - **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. - **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries. - **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly. +- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright. - **Renderer/Main API boundary (important)**: - Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls. - Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead. diff --git a/electron/utils/token-usage-core.ts b/electron/utils/token-usage-core.ts index 304c5243b..c9e09ff24 100644 --- a/electron/utils/token-usage-core.ts +++ b/electron/utils/token-usage-core.ts @@ -5,6 +5,7 @@ export interface TokenUsageHistoryEntry { model?: string; provider?: string; content?: string; + usageStatus: 'available' | 'missing' | 'error'; inputTokens: number; outputTokens: number; cacheReadTokens: number; @@ -22,6 +23,7 @@ export function extractSessionIdFromTranscriptFileName(fileName: string): string } interface TranscriptUsageShape { + [key: string]: unknown; input?: number; output?: number; total?: number; @@ -30,11 +32,169 @@ interface TranscriptUsageShape { promptTokens?: number; completionTokens?: number; totalTokens?: number; + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read?: number; + cache_write?: number; + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + inputTokenCount?: number; + input_token_count?: number; + outputTokenCount?: number; + output_token_count?: number; + promptTokenCount?: number; + prompt_token_count?: number; + completionTokenCount?: number; + completion_token_count?: number; + totalTokenCount?: number; + total_token_count?: number; + cacheReadTokenCount?: number; + cacheReadTokens?: number; + cache_write_token_count?: number; cost?: { total?: number; }; } +type UsageRecordStatus = 'available' | 'missing' | 'error'; + +interface ParsedUsageTokens { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + costUsd?: number; + usageStatus: UsageRecordStatus; +} + +function normalizeUsageNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function firstUsageNumber(usage: TranscriptUsageShape | undefined, candidates: string[]): number | undefined { + if (!usage) return undefined; + for (const key of candidates) { + const value = usage[key]; + const parsed = normalizeUsageNumber(value); + if (parsed !== undefined) return parsed; + } + return undefined; +} + +function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined { + if (usage === undefined) { + return undefined; + } + + if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) { + return { + usageStatus: 'error', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + }; + } + + const usageShape = usage as TranscriptUsageShape; + + const inputTokens = firstUsageNumber(usageShape, [ + 'input', + 'promptTokens', + 'prompt_tokens', + 'input_tokens', + 'inputTokenCount', + 'input_token_count', + 'promptTokenCount', + 'prompt_token_count', + ]); + const outputTokens = firstUsageNumber(usageShape, [ + 'output', + 'completionTokens', + 'completion_tokens', + 'output_tokens', + 'outputTokenCount', + 'output_token_count', + 'completionTokenCount', + 'completion_token_count', + ]); + const cacheReadTokens = firstUsageNumber(usageShape, [ + 'cacheRead', + 'cache_read', + 'cacheReadTokens', + 'cache_read_tokens', + 'cacheReadTokenCount', + 'cache_read_token_count', + ]); + const cacheWriteTokens = firstUsageNumber(usageShape, [ + 'cacheWrite', + 'cache_write', + 'cacheWriteTokens', + 'cache_write_tokens', + 'cacheWriteTokenCount', + 'cache_write_token_count', + ]); + const explicitTotalTokens = firstUsageNumber(usageShape, [ + 'total', + 'totalTokens', + 'total_tokens', + 'totalTokenCount', + 'total_token_count', + ]); + + const hasUsageValue = + inputTokens !== undefined + || outputTokens !== undefined + || cacheReadTokens !== undefined + || cacheWriteTokens !== undefined + || explicitTotalTokens !== undefined + || normalizeUsageNumber(usageShape.cost?.total) !== undefined; + + if (!hasUsageValue) { + return { + usageStatus: 'missing', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + }; + } + + const totalTokens = explicitTotalTokens ?? ( + (inputTokens ?? 0) + + (outputTokens ?? 0) + + (cacheReadTokens ?? 0) + + (cacheWriteTokens ?? 0) + ); + + return { + usageStatus: 'available', + inputTokens: inputTokens ?? 0, + outputTokens: outputTokens ?? 0, + cacheReadTokens: cacheReadTokens ?? 0, + cacheWriteTokens: cacheWriteTokens ?? 0, + totalTokens, + costUsd: normalizeUsageNumber(usageShape.cost?.total), + }; +} + interface TranscriptLineShape { type?: string; timestamp?: string; @@ -121,17 +281,9 @@ export function parseUsageEntriesFromJsonl( continue; } - 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) { - continue; - } + if (message.role === 'assistant' && 'usage' in message) { + const usage = parseUsageFromShape(message.usage); + if (!usage) continue; const contentText = normalizeUsageContent((message as Record).content); entries.push({ @@ -141,12 +293,7 @@ export function parseUsageEntriesFromJsonl( model: message.model ?? message.modelRef, provider: message.provider, ...(contentText ? { content: contentText } : {}), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - costUsd: usage.cost?.total, + ...usage, }); continue; } @@ -156,30 +303,18 @@ export function parseUsageEntriesFromJsonl( } const details = message.details; - if (!details) { + if (!details || !('usage' in 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 usage = parseUsageFromShape(details.usage); + if (!usage) continue; 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; - } - entries.push({ timestamp: parsed.timestamp, sessionId: context.sessionId, @@ -187,12 +322,7 @@ export function parseUsageEntriesFromJsonl( model, provider, ...(contentText ? { content: contentText } : {}), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - costUsd: usage?.cost?.total, + ...usage, }); } diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index 7de598c97..0bf0456b1 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -39,6 +39,9 @@ "modelPreview": "Preview", "modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)", "modelOverrideDescription": "Select provider and model ID for this agent.", + "unsavedChangesTitle": "Unsaved changes", + "unsavedChangesMessage": "You have unsaved changes. If you close now, your changes will be discarded.", + "closeWithoutSaving": "Close without saving", "saveModelOverride": "Save model", "useDefaultModel": "Use default model", "channelsTitle": "Channels", diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 2e6f23812..973bd0046 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -58,6 +58,8 @@ "cost": "Cost ${{amount}}", "viewContent": "View content", "contentDialogTitle": "Usage detail content", - "close": "Close" + "close": "Close", + "noUsage": "No usage", + "usageParseError": "Usage parse error" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json index ade06789b..4632ce0cd 100644 --- a/src/i18n/locales/ja/agents.json +++ b/src/i18n/locales/ja/agents.json @@ -39,6 +39,9 @@ "modelPreview": "プレビュー", "modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.4)", "modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。", + "unsavedChangesTitle": "未保存の変更", + "unsavedChangesMessage": "未保存の変更があります。閉じると変更が破棄されます。", + "closeWithoutSaving": "保存せずに閉じる", "saveModelOverride": "モデルを保存", "useDefaultModel": "デフォルトモデルを使用", "channelsTitle": "Channels", diff --git a/src/i18n/locales/ja/dashboard.json b/src/i18n/locales/ja/dashboard.json index 27175606f..24ce0516b 100644 --- a/src/i18n/locales/ja/dashboard.json +++ b/src/i18n/locales/ja/dashboard.json @@ -58,6 +58,8 @@ "cost": "コスト ${{amount}}", "viewContent": "内容を見る", "contentDialogTitle": "使用量詳細の内容", - "close": "閉じる" + "close": "閉じる", + "noUsage": "使用量なし", + "usageParseError": "使用量解析エラー" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index 511758ae5..0c0f8bfe9 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -39,6 +39,9 @@ "modelPreview": "预览", "modelOverridePlaceholder": "provider/model(例如:openrouter/openai/gpt-5.4)", "modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。", + "unsavedChangesTitle": "未保存的修改", + "unsavedChangesMessage": "你有未保存的修改。关闭后这些修改将被丢弃。", + "closeWithoutSaving": "不保存并关闭", "saveModelOverride": "保存模型", "useDefaultModel": "使用默认模型", "channelsTitle": "频道", diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index 82f3c4458..617dd85cc 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -58,6 +58,8 @@ "cost": "费用 ${{amount}}", "viewContent": "查看内容", "contentDialogTitle": "用量明细内容", - "close": "关闭" + "close": "关闭", + "noUsage": "无用量数据", + "usageParseError": "用量解析失败" } -} \ No newline at end of file +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 87588fff5..91ce74d71 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -493,11 +493,22 @@ function AgentSettingsModal({ const [name, setName] = useState(agent.name); const [savingName, setSavingName] = useState(false); const [showModelModal, setShowModelModal] = useState(false); + const [showCloseConfirm, setShowCloseConfirm] = useState(false); useEffect(() => { setName(agent.name); }, [agent.name]); + const hasNameChanges = name.trim() !== agent.name; + + const handleRequestClose = () => { + if (savingName || hasNameChanges) { + setShowCloseConfirm(true); + return; + } + onClose(); + }; + const handleSaveName = async () => { if (!name.trim() || name.trim() === agent.name) return; setSavingName(true); @@ -540,7 +551,7 @@ function AgentSettingsModal({ +

+ {t('gateway.proxyRestartNote')} +

+ + {proxyEnabledDraft && (
@@ -733,20 +774,6 @@ export function Settings() {

-
- -

- {t('gateway.proxyRestartNote')} -

-
)} diff --git a/tests/e2e/settings-proxy.spec.ts b/tests/e2e/settings-proxy.spec.ts new file mode 100644 index 000000000..9e1e7b2fc --- /dev/null +++ b/tests/e2e/settings-proxy.spec.ts @@ -0,0 +1,49 @@ +import type { Locator, Page } from '@playwright/test'; +import { completeSetup, expect, test } from './fixtures/electron'; + +async function ensureSwitchState(toggle: Locator, checked: boolean): Promise { + const currentState = await toggle.getAttribute('data-state'); + const isChecked = currentState === 'checked'; + if (isChecked !== checked) { + await toggle.click(); + } +} + +async function readProxyEnabled(page: Page): Promise { + return await page.evaluate(async () => { + const settings = await window.electron.ipcRenderer.invoke('settings:getAll'); + return Boolean(settings?.proxyEnabled); + }); +} + +test.describe('ClawX developer proxy settings', () => { + test('keeps proxy save available when disabling proxy in developer mode', async ({ page }) => { + await completeSetup(page); + + await page.getByTestId('sidebar-nav-settings').click(); + await expect(page.getByTestId('settings-page')).toBeVisible(); + + const devModeToggle = page.getByTestId('settings-dev-mode-switch'); + await expect(devModeToggle).toBeVisible(); + await ensureSwitchState(devModeToggle, true); + + const proxySection = page.getByTestId('settings-proxy-section'); + const proxyToggle = page.getByTestId('settings-proxy-toggle'); + const proxySaveButton = page.getByTestId('settings-proxy-save-button'); + + await expect(proxySection).toBeVisible(); + await expect(proxyToggle).toBeVisible(); + await expect(proxySaveButton).toBeVisible(); + + await ensureSwitchState(proxyToggle, true); + await expect(proxySaveButton).toBeEnabled(); + await proxySaveButton.click(); + await expect.poll(async () => await readProxyEnabled(page)).toBe(true); + + await ensureSwitchState(proxyToggle, false); + await expect(proxySaveButton).toBeVisible(); + await expect(proxySaveButton).toBeEnabled(); + await proxySaveButton.click(); + await expect.poll(async () => await readProxyEnabled(page)).toBe(false); + }); +}); diff --git a/tests/e2e/token-usage.spec.ts b/tests/e2e/token-usage.spec.ts new file mode 100644 index 000000000..a5625af84 --- /dev/null +++ b/tests/e2e/token-usage.spec.ts @@ -0,0 +1,198 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { completeSetup, expect, test } from './fixtures/electron'; + +const TEST_AGENT_ID = 'agent'; +const ZERO_TOKEN_SESSION_ID = 'agent-session-zero-token'; +const NONZERO_TOKEN_SESSION_ID = 'agent-session-nonzero-token'; +const GATEWAY_INJECTED_SESSION_ID = 'agent-session-gateway-injected'; +const DELIVERY_MIRROR_SESSION_ID = 'agent-session-delivery-mirror'; + +async function seedTokenUsageTranscripts(homeDir: string): Promise { + const sessionDir = join(homeDir, '.openclaw', 'agents', TEST_AGENT_ID, 'sessions'); + const now = new Date(); + const zeroTimestamp = new Date(now.getTime() - 20_000).toISOString(); + const nonzeroTimestamp = now.toISOString(); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(sessionDir, `${ZERO_TOKEN_SESSION_ID}.jsonl`), + [ + JSON.stringify({ + type: 'message', + timestamp: zeroTimestamp, + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'kimi', + usage: { + total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + }, + }, + }), + '', + ].join('\n'), + 'utf8', + ); + await writeFile( + join(sessionDir, `${NONZERO_TOKEN_SESSION_ID}.jsonl`), + [ + JSON.stringify({ + type: 'message', + timestamp: nonzeroTimestamp, + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'kimi', + usage: { + total_tokens: 27, + input_tokens: 20, + output_tokens: 7, + }, + }, + }), + '', + ].join('\n'), + 'utf8', + ); + await writeFile( + join(sessionDir, `${GATEWAY_INJECTED_SESSION_ID}.jsonl`), + [ + JSON.stringify({ + type: 'message', + timestamp: new Date(now.getTime() - 10_000).toISOString(), + message: { + role: 'assistant', + model: 'gateway-injected', + usage: { + total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + }, + }, + }), + '', + ].join('\n'), + 'utf8', + ); + await writeFile( + join(sessionDir, `${DELIVERY_MIRROR_SESSION_ID}.jsonl`), + [ + JSON.stringify({ + type: 'message', + timestamp: new Date(now.getTime() - 5_000).toISOString(), + message: { + role: 'assistant', + model: 'delivery-mirror', + usage: { + total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + }, + }, + }), + '', + ].join('\n'), + 'utf8', + ); +} + +test.describe('ClawX token usage history', () => { + async function waitForGatewayRunning(page: Page): Promise { + await expect.poll(async () => { + const status = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('gateway:status'); + }); + + if (status?.state === 'running') { + return 'running'; + } + + await page.evaluate(async () => { + try { + await window.electron.ipcRenderer.invoke('gateway:start'); + } catch { + try { + await window.electron.ipcRenderer.invoke('gateway:restart'); + } catch { + // Ignore transient e2e startup failures and let the poll retry. + } + } + }); + + return status?.state ?? 'unknown'; + }, { timeout: 45_000, intervals: [500, 1000, 1500, 2000] }).toBe('running'); + } + + async function validateUsageHistory(page: Page): Promise { + const usageHistory = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 20); + }); + if (!Array.isArray(usageHistory) || usageHistory.length === 0) { + throw new Error('No usage history found in IPC usage:recentTokenHistory'); + } + + const hasSeededEntries = usageHistory.some((entry) => + typeof entry?.sessionId === 'string' && ( + entry.sessionId === ZERO_TOKEN_SESSION_ID + || entry.sessionId === NONZERO_TOKEN_SESSION_ID + ), + ); + if (!hasSeededEntries) { + throw new Error('Seeded transcript session IDs were not found in IPC usage history'); + } + } + + test('displays assistant usage for agent directory with zero and non-zero tokens', async ({ page, homeDir }) => { + await seedTokenUsageTranscripts(homeDir); + await completeSetup(page); + await validateUsageHistory(page); + + const usageHistory = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 20); + }); + + const zeroEntry = usageHistory.find((entry) => entry?.sessionId === ZERO_TOKEN_SESSION_ID); + const nonzeroEntry = usageHistory.find((entry) => entry?.sessionId === NONZERO_TOKEN_SESSION_ID); + expect(zeroEntry).toBeTruthy(); + expect(nonzeroEntry).toBeTruthy(); + expect(nonzeroEntry?.totalTokens).toBe(27); + expect(zeroEntry?.totalTokens).toBe(0); + expect(zeroEntry?.agentId).toBe(TEST_AGENT_ID); + expect(nonzeroEntry?.agentId).toBe(TEST_AGENT_ID); + expect(zeroEntry?.provider).toBe('kimi'); + expect(nonzeroEntry?.provider).toBe('kimi'); + }); + + test('hides gateway internal usage rows from the usage list overview', async ({ page, homeDir }) => { + await seedTokenUsageTranscripts(homeDir); + await completeSetup(page); + await waitForGatewayRunning(page); + await validateUsageHistory(page); + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('models-page')).toBeVisible(); + + const seededSessions = [ + ZERO_TOKEN_SESSION_ID, + NONZERO_TOKEN_SESSION_ID, + GATEWAY_INJECTED_SESSION_ID, + DELIVERY_MIRROR_SESSION_ID, + ]; + const usageEntryRows = page.getByTestId('token-usage-entry'); + await expect.poll(async () => await usageEntryRows.count()).toBe(2); + + for (const sessionId of seededSessions) { + const row = page.locator('[data-testid="token-usage-entry"]', { hasText: sessionId }); + if (sessionId === GATEWAY_INJECTED_SESSION_ID || sessionId === DELIVERY_MIRROR_SESSION_ID) { + await expect(row).toHaveCount(0); + } else { + await expect(row).toBeVisible(); + } + } + + await expect(page.locator('[data-testid="token-usage-entry"]', { hasText: GATEWAY_INJECTED_SESSION_ID })).toHaveCount(0); + await expect(page.locator('[data-testid="token-usage-entry"]', { hasText: DELIVERY_MIRROR_SESSION_ID })).toHaveCount(0); + }); +}); diff --git a/tests/unit/token-usage.test.ts b/tests/unit/token-usage.test.ts index 417c6e5ea..c9d72311f 100644 --- a/tests/unit/token-usage.test.ts +++ b/tests/unit/token-usage.test.ts @@ -49,6 +49,7 @@ describe('parseUsageEntriesFromJsonl', () => { agentId: 'default', model: 'claude-sonnet', provider: 'anthropic', + usageStatus: 'available', inputTokens: 200, outputTokens: 80, cacheReadTokens: 25, @@ -62,6 +63,7 @@ describe('parseUsageEntriesFromJsonl', () => { agentId: 'default', model: 'gpt-5', provider: 'openai', + usageStatus: 'available', inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, @@ -81,7 +83,7 @@ describe('parseUsageEntriesFromJsonl', () => { expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]); }); - it('skips tool result entries without positive token usage', () => { + it('still skips tool result entries without usage payload', () => { const jsonl = [ JSON.stringify({ type: 'message', @@ -100,6 +102,111 @@ describe('parseUsageEntriesFromJsonl', () => { expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]); }); + it('keeps assistant usage entries with zero total tokens when usage is explicitly provided', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T03:00:00.000Z', + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'moonshot', + usage: { + total: 0, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T03:00:00.000Z', + sessionId: 'abc', + agentId: 'default', + model: 'kimi-k2.5', + provider: 'moonshot', + usageStatus: 'available', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUsd: undefined, + }, + ]); + }); + + it('extracts usage fields from snake_case provider payloads', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T03:10:00.000Z', + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'moonshot', + usage: { + input_tokens: 12, + output_tokens: 3, + cache_read: 4, + cache_write: 1, + total_tokens: 20, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T03:10:00.000Z', + sessionId: 'abc', + agentId: 'default', + model: 'kimi-k2.5', + provider: 'moonshot', + usageStatus: 'available', + inputTokens: 12, + outputTokens: 3, + cacheReadTokens: 4, + cacheWriteTokens: 1, + totalTokens: 20, + costUsd: undefined, + }, + ]); + }); + + it('supports tool result usage data without explicit provider/model keys', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T03:20:00.000Z', + message: { + role: 'toolResult', + details: { + usage: { + input_tokens: 10, + output_tokens: 20, + }, + }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T03:20:00.000Z', + sessionId: 'abc', + agentId: 'default', + usageStatus: 'available', + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 30, + costUsd: undefined, + }, + ]); + }); + it('uses tool result usage when provided', () => { const jsonl = [ JSON.stringify({ @@ -129,6 +236,7 @@ describe('parseUsageEntriesFromJsonl', () => { agentId: 'default', model: 'moonshot-v1-128k', provider: 'kimi', + usageStatus: 'available', inputTokens: 120, outputTokens: 30, cacheReadTokens: 10, @@ -163,6 +271,7 @@ describe('parseUsageEntriesFromJsonl', () => { agentId: 'default', model: 'kimi-k2.5', provider: 'moonshot', + usageStatus: 'available', content: '这是一条测试回复内容。', inputTokens: 0, outputTokens: 0, @@ -200,6 +309,7 @@ describe('parseUsageEntriesFromJsonl', () => { agentId: 'default', model: 'moonshot-v1-128k', provider: 'kimi', + usageStatus: 'available', content: '外部搜索原文内容', inputTokens: 0, outputTokens: 0, @@ -211,6 +321,70 @@ describe('parseUsageEntriesFromJsonl', () => { ]); }); + it('maps usage object with no recognized fields to missing state', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T03:30:00.000Z', + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'moonshot', + usage: { notes: 'tool call' }, + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T03:30:00.000Z', + sessionId: 'abc', + agentId: 'default', + model: 'kimi-k2.5', + provider: 'moonshot', + usageStatus: 'missing', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUsd: undefined, + }, + ]); + }); + + it('marks non-object usage payload as error', () => { + const jsonl = [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-10T03:40:00.000Z', + message: { + role: 'assistant', + model: 'kimi-k2.5', + provider: 'moonshot', + usage: 'invalid', + }, + }), + ].join('\n'); + + expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([ + { + timestamp: '2026-03-10T03:40:00.000Z', + sessionId: 'abc', + agentId: 'default', + model: 'kimi-k2.5', + provider: 'moonshot', + usageStatus: 'error', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUsd: undefined, + }, + ]); + }); + it('returns all matching entries when no limit is provided', () => { const jsonl = [ JSON.stringify({