Files
claudecodeui/server/modules/providers/tests/opencode-sessions.test.ts
Haileyesus ed9f0d74aa fix: refine token usage reporting
The old token UI mixed context pressure, cache counters, and dollar estimates.

That made the percentage look precise even when provider data was incomplete or different.

The composer and /cost view now show concrete counts instead of a pie percentage.

Token payloads now share a smaller shape: used, inputTokens, outputTokens, and breakdown.

Claude uses per-step usage where available and Codex reads total_token_usage events.

Gemini reads its tokens object without inventing a context window.

OpenCode reads opencode.db session totals and includes all token columns in used.

The /cost backend no longer returns cache display fields or input/output dollar estimates.

This avoids derived values that look reliable but are not comparable across providers.

Verification: npm run typecheck; targeted eslint; OpenCode session provider test.
2026-05-29 15:30:21 +03:00

335 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import Database from 'better-sqlite3';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
await mkdir(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'opencode.db'));
try {
db.exec(`
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL,
vcs TEXT,
name TEXT,
icon_url TEXT,
icon_color TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_initialized INTEGER,
sandboxes TEXT NOT NULL,
commands TEXT,
icon_url_override TEXT
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER,
workspace_id TEXT,
path TEXT,
agent TEXT,
model TEXT,
cost REAL NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
);
CREATE INDEX part_session_idx ON part (session_id);
CREATE INDEX session_project_idx ON session (project_id);
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
`);
db.prepare(
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
).run(
'project-1',
workspacePath,
1_700_000_000_000,
1_700_000_001_000,
'[]',
);
db.prepare(`
INSERT INTO session (
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'open-session-1',
'project-1',
'open-session-1',
workspacePath,
'OpenCode indexed title',
'0.0.0',
1_700_000_000_000,
1_700_000_004_000,
null,
10,
20,
7,
3,
2,
);
const userMessageData = JSON.stringify({
role: 'user',
time: { created: 1_700_000_001_000 },
agent: 'test',
model: { providerID: 'anthropic', modelID: 'claude' },
});
const assistantMessageData = JSON.stringify({
role: 'assistant',
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
parentID: 'message-user',
modelID: 'anthropic/claude-sonnet-4-5',
providerID: 'anthropic',
mode: 'default',
agent: 'test',
path: { cwd: '.', root: '.' },
cost: 0.01,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 3, write: 2 },
},
});
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
const insertPart = db.prepare(`
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertPart.run(
'part-user-text',
'message-user',
'open-session-1',
1_700_000_001_000,
1_700_000_001_000,
JSON.stringify({
type: 'text',
text: JSON.stringify('Build the OpenCode integration.'),
}),
);
insertPart.run(
'part-reasoning',
'message-assistant',
'open-session-1',
1_700_000_002_000,
1_700_000_002_000,
JSON.stringify({
type: 'reasoning',
text: 'I will inspect the provider shape first.',
time: { start: 0, end: 1 },
}),
);
insertPart.run(
'part-assistant-text',
'message-assistant',
'open-session-1',
1_700_000_002_500,
1_700_000_002_500,
JSON.stringify({
type: 'text',
text: 'The provider is wired.',
}),
);
insertPart.run(
'part-tool',
'message-assistant',
'open-session-1',
1_700_000_003_000,
1_700_000_003_000,
JSON.stringify({
type: 'tool',
tool: 'bash',
callID: 'tool-call-1',
state: {
status: 'completed',
input: { command: 'npm test' },
output: 'ok',
title: 'bash',
metadata: {},
time: { start: 0, end: 1 },
},
}),
);
} finally {
db.close();
}
};
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
const synchronizer = new OpenCodeSessionSynchronizer();
const processed = synchronizer.synchronize();
return Promise.resolve(processed).then((count) => {
assert.equal(count, 1);
const indexed = sessionsDb.getSessionById('open-session-1');
assert.equal(indexed?.provider, 'opencode');
assert.equal(indexed?.project_path, workspacePath);
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
assert.equal(indexed?.jsonl_path, null);
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
text: JSON.stringify('hello bro'),
}, null);
assert.equal(normalized.length, 1);
assert.equal(normalized[0]?.kind, 'stream_delta');
assert.equal(normalized[0]?.content, 'hello bro');
const userEcho = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
role: 'user',
text: 'hello bro',
}, null);
assert.deepEqual(userEcho, []);
});
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
const provider = new OpenCodeSessionsProvider();
const history = await provider.fetchHistory('open-session-1');
assert.equal(history.total, 4);
assert.equal(history.messages[0]?.kind, 'text');
assert.equal(history.messages[0]?.role, 'user');
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
assert.equal(history.messages[1]?.kind, 'thinking');
assert.equal(history.messages[2]?.content, 'The provider is wired.');
assert.equal(history.messages[3]?.kind, 'tool_use');
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
assert.deepEqual(history.tokenUsage, {
used: 42,
inputTokens: 13,
outputTokens: 20,
breakdown: {
input: 13,
output: 20,
},
});
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
assert.equal(paged.messages.length, 2);
assert.equal(paged.hasMore, true);
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});