mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-17 01:22:45 +00:00
300 lines
9.4 KiB
TypeScript
300 lines
9.4 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 } from '@/modules/database/connection.js';
|
|
import { 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,
|
|
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
|
|
)
|
|
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,
|
|
);
|
|
|
|
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: '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 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[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: 35,
|
|
total: 35,
|
|
inputTokens: 10,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 3,
|
|
cacheCreationTokens: 2,
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|