mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-17 09:30:05 +00:00
feat: add opencode support
This commit is contained in:
@@ -169,6 +169,93 @@ test('providerMcpService handles codex MCP TOML config and capability validation
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
|
||||
* reads, and validation for unsupported scope/transport combinations.
|
||||
*/
|
||||
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
|
||||
`{
|
||||
// Existing comments should not block OpenCode MCP reads.
|
||||
"mcp": {}
|
||||
}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { API_KEY: 'x' },
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-project-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://opencode.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
|
||||
const userServers = userConfig.mcp as Record<string, unknown>;
|
||||
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
|
||||
assert.equal(userStdio.type, 'local');
|
||||
assert.deepEqual(userStdio.command, ['node', 'server.js']);
|
||||
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
|
||||
|
||||
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
const projectServers = projectConfig.mcp as Record<string, unknown>;
|
||||
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
|
||||
assert.equal(projectHttp.type, 'remote');
|
||||
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
|
||||
|
||||
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
|
||||
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
|
||||
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-local',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
*/
|
||||
@@ -255,7 +342,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 5 : 4);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -267,6 +354,9 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
299
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
299
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -377,6 +377,72 @@ test('providerSkillsService lists codex repository, user, and system skills', {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode skill lookup across cwd-to-git-root project folders
|
||||
* plus the global OpenCode/Claude/Agents compatibility locations.
|
||||
*/
|
||||
test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
const workspacePath = path.join(repoRoot, 'packages', 'app');
|
||||
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.opencode', 'skills'),
|
||||
'opencode-cwd-dir',
|
||||
'opencode-cwd',
|
||||
'OpenCode cwd skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, 'packages', '.claude', 'skills'),
|
||||
'opencode-claude-parent-dir',
|
||||
'opencode-claude-parent',
|
||||
'OpenCode Claude parent skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, '.agents', 'skills'),
|
||||
'opencode-agents-root-dir',
|
||||
'opencode-agents-root',
|
||||
'OpenCode Agents root skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.config', 'opencode', 'skills'),
|
||||
'opencode-user-dir',
|
||||
'opencode-user',
|
||||
'OpenCode user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.claude', 'skills'),
|
||||
'opencode-claude-user-dir',
|
||||
'opencode-claude-user',
|
||||
'OpenCode Claude user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'opencode-agents-user-dir',
|
||||
'opencode-agents-user',
|
||||
'OpenCode Agents user skill',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('opencode-cwd')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini and Cursor skill directory rules, including shared
|
||||
* `.agents/skills` project support.
|
||||
|
||||
Reference in New Issue
Block a user