Fix token usage handling and developer proxy save UX (#704)
This commit is contained in:
committed by
GitHub
Unverified
parent
2668082809
commit
870abb99c4
49
tests/e2e/settings-proxy.spec.ts
Normal file
49
tests/e2e/settings-proxy.spec.ts
Normal file
@@ -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<void> {
|
||||
const currentState = await toggle.getAttribute('data-state');
|
||||
const isChecked = currentState === 'checked';
|
||||
if (isChecked !== checked) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function readProxyEnabled(page: Page): Promise<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
198
tests/e2e/token-usage.spec.ts
Normal file
198
tests/e2e/token-usage.spec.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user