feat(wiki): add opencode local provider (#2039)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* feat(wiki): add opencode local provider

* style(wiki): format local cli client

* fix(wiki): harden opencode event parsing

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Anton Fedotov 2026-06-05 11:24:00 +03:00 committed by GitHub
parent 22304cd4a4
commit 782f70cc07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 538 additions and 25 deletions

View file

@ -189,7 +189,7 @@ export const en = {
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider':
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',

View file

@ -178,7 +178,7 @@ export const zhCN = {
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider':
'LLM 提供商openai、openrouter、azure、custom、cursor、claude 或 codex默认openai',
'LLM 提供商openai、openrouter、azure、custom、cursor、claude、codex 或 opencode默认openai',
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称默认minimax/minimax-m2.5',
'help.option.wiki.baseUrl':
'LLM API base URL。Azure v1https://{resource}.openai.azure.com/openai/v1',

View file

@ -155,7 +155,7 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <provider>',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
)
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
.option(

View file

@ -58,14 +58,21 @@ function parsePositiveIntegerOption(
function isLocalProvider(
provider: LLMProvider | undefined,
): provider is 'cursor' | 'claude' | 'codex' {
return provider === 'cursor' || provider === 'claude' || provider === 'codex';
): provider is 'cursor' | 'claude' | 'codex' | 'opencode' {
return (
provider === 'cursor' ||
provider === 'claude' ||
provider === 'codex' ||
provider === 'opencode'
);
}
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') {
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode') {
if (provider === 'cursor') return 'cursorModel';
if (provider === 'claude') return 'claudeModel';
return 'codexModel';
if (provider === 'codex') return 'codexModel';
if (provider === 'opencode') return 'opencodeModel';
throw new Error(`Unsupported local provider: ${provider satisfies never}`);
}
/**
@ -248,7 +255,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
console.log(' Error: No LLM API key found.');
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex.\n');
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode.\n');
process.exitCode = 1;
return;
}
@ -256,16 +263,17 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
} else {
console.log(" No LLM configured. Let's set it up.\n");
console.log(
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n',
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n',
);
// Check if local agent CLIs are available.
const hasCursor = detectCursorCLI();
const hasClaude = detectLocalCLI('claude');
const hasCodex = detectLocalCLI('codex');
const hasOpenCode = detectLocalCLI('opencode');
const localChoices: Array<{
choice: string;
provider: 'cursor' | 'claude' | 'codex';
provider: 'cursor' | 'claude' | 'codex' | 'opencode';
}> = [];
// Provider selection
@ -298,6 +306,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
});
console.log(` [${choice}] Codex CLI (local, uses your Codex login)`);
}
if (hasOpenCode) {
const choice = String(nextChoice++);
localChoices.push({
choice,
provider: 'opencode',
});
console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`);
}
console.log('');
const maxChoice = String(nextChoice - 1);

View file

@ -39,7 +39,12 @@ import {
} from './llm-client.js';
import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js';
import {
callClaudeLLM,
callCodexLLM,
callOpenCodeLLM,
resolveLocalCLIConfig,
} from './local-cli-client.js';
import {
GROUPING_SYSTEM_PROMPT,
@ -219,15 +224,25 @@ export class WikiGenerator {
});
return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
}
if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') {
if (
this.llmConfig.provider === 'claude' ||
this.llmConfig.provider === 'codex' ||
this.llmConfig.provider === 'opencode'
) {
const localConfig = resolveLocalCLIConfig({
model: this.llmConfig.model,
workingDirectory: this.repoPath,
requestTimeoutMs: this.llmConfig.requestTimeoutMs,
});
return this.llmConfig.provider === 'claude'
? callClaudeLLM(prompt, localConfig, systemPrompt, options)
: callCodexLLM(prompt, localConfig, systemPrompt, options);
if (this.llmConfig.provider === 'claude') {
return callClaudeLLM(prompt, localConfig, systemPrompt, options);
}
if (this.llmConfig.provider === 'codex') {
return callCodexLLM(prompt, localConfig, systemPrompt, options);
}
if (this.llmConfig.provider === 'opencode') {
return callOpenCodeLLM(prompt, localConfig, systemPrompt, options);
}
}
return callLLM(prompt, this.llmConfig, systemPrompt, options);
}

View file

@ -16,7 +16,8 @@ export type LLMProvider =
| 'custom'
| 'cursor'
| 'claude'
| 'codex';
| 'codex'
| 'opencode';
export interface LLMConfig {
apiKey: string;
@ -59,9 +60,14 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
? savedConfig.claudeModel
: savedProvider === 'codex'
? savedConfig.codexModel
: undefined;
: savedProvider === 'opencode'
? savedConfig.opencodeModel
: undefined;
const localProvider =
savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex';
savedProvider === 'cursor' ||
savedProvider === 'claude' ||
savedProvider === 'codex' ||
savedProvider === 'opencode';
const apiKey =
overrides?.apiKey ||

View file

@ -15,7 +15,7 @@ import type { LLMResponse, CallLLMOptions } from './llm-client.js';
import { logger } from '../logger.js';
export type LocalAgentProvider = 'claude' | 'codex';
export type LocalAgentProvider = 'claude' | 'codex' | 'opencode';
export interface LocalCLIConfig {
model?: string;
@ -26,6 +26,7 @@ export interface LocalCLIConfig {
const COMMANDS: Record<LocalAgentProvider, string> = {
claude: 'claude',
codex: 'codex',
opencode: 'opencode',
};
interface LocalCommand {
@ -162,6 +163,102 @@ export async function callCodexLLM(
}
}
interface OpenCodeEvent {
type?: string;
message?: string;
error?: {
message?: string;
name?: string;
data?: {
message?: string;
};
};
part?: {
type?: string;
text?: string;
};
}
function parseOpenCodeEventStream(output: string): string {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const textParts: string[] = [];
for (const line of lines) {
let event: OpenCodeEvent;
try {
event = JSON.parse(line) as OpenCodeEvent;
} catch {
continue;
}
if (event.type === 'error') {
const message =
event.error?.data?.message ||
event.error?.name ||
event.message ||
event.part?.text ||
line;
throw new Error(`OpenCode CLI returned error event: ${message}`);
}
if (event.type === 'text' && typeof event.part?.text === 'string') {
textParts.push(event.part.text);
}
}
const content = textParts.join('').trim();
if (!content) {
throw new Error('OpenCode CLI returned no text output');
}
return content;
}
function buildChildEnv(provider: LocalAgentProvider): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
CI: '1',
};
if (provider === 'opencode') {
delete env.OPENCODE_SERVER_PASSWORD;
delete env.OPENCODE_SERVER_USERNAME;
}
return env;
}
export async function callOpenCodeLLM(
prompt: string,
config: LocalCLIConfig,
systemPrompt?: string,
options?: CallLLMOptions,
): Promise<LLMResponse> {
const commandInfo = getDetectedCommand('opencode');
if (!commandInfo) {
throw new Error(
'OpenCode CLI not found. Install OpenCode CLI and ensure `opencode` is on PATH.',
);
}
const workingDirectory = config.workingDirectory || process.cwd();
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
// OpenCode does not expose a Codex-style read-only sandbox / no-tools flag,
// so we rely on its non-interactive permission model and tolerate any
// non-JSON stdout warnings in the parser.
const args = ['run', '--format', 'json', '--dir', workingDirectory];
if (config.model) {
args.push('--model', config.model);
}
const response = await runLocalCLI('opencode', commandInfo, args, config, fullPrompt, options);
return { content: parseOpenCodeEventStream(response.content) };
}
function runLocalCLI(
provider: LocalAgentProvider,
commandInfo: LocalCommand,
@ -191,10 +288,7 @@ function runLocalCLI(
cwd: config.workingDirectory || process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env: {
...process.env,
CI: '1',
},
env: buildChildEnv(provider),
});
verboseLog(provider, 'Process spawned with PID:', child.pid);

View file

@ -933,10 +933,19 @@ export interface CLIConfig {
apiKey?: string;
model?: string;
baseUrl?: string;
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'claude' | 'codex';
provider?:
| 'openai'
| 'openrouter'
| 'azure'
| 'custom'
| 'cursor'
| 'claude'
| 'codex'
| 'opencode';
cursorModel?: string;
claudeModel?: string;
codexModel?: string;
opencodeModel?: string;
/** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */
apiVersion?: string;
/** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */

View file

@ -1,5 +1,5 @@
/**
* Unit tests for wiki CLI flags: --provider cursor/claude/codex, --review, --verbose
* Unit tests for wiki CLI flags: --provider cursor/claude/codex/opencode, --review, --verbose
*
* Tests the new wiki provider infrastructure without requiring an actual
* local agent CLI binary or LLM API key. All external dependencies are mocked.
@ -192,6 +192,36 @@ describe('resolveLLMConfig', () => {
expect(config.model).toBe('gpt-5.4');
});
it('uses opencodeModel when provider is opencode', async () => {
vi.doMock('../../src/storage/repo-manager.js', () => ({
loadCLIConfig: vi.fn().mockResolvedValue({
provider: 'opencode',
opencodeModel: 'openai/gpt-5.4-mini',
}),
}));
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
const config = await resolveLLMConfig({ provider: 'opencode' });
expect(config.provider).toBe('opencode');
expect(config.model).toBe('openai/gpt-5.4-mini');
});
it('does not inherit HTTP model defaults for OpenCode local provider', async () => {
vi.doMock('../../src/storage/repo-manager.js', () => ({
loadCLIConfig: vi.fn().mockResolvedValue({
provider: 'openai',
model: 'minimax/minimax-m2.5',
}),
}));
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
const config = await resolveLLMConfig({ provider: 'opencode' });
expect(config.provider).toBe('opencode');
expect(config.model).toBe('');
});
it('does not inherit HTTP model defaults for local CLI providers', async () => {
vi.doMock('../../src/storage/repo-manager.js', () => ({
loadCLIConfig: vi.fn().mockResolvedValue({
@ -760,6 +790,16 @@ describe('CLI config round-trip with cursor provider', () => {
expect(loaded.apiKey).toBeUndefined();
});
it('saves and loads opencode provider config correctly', async () => {
const config = { provider: 'opencode', opencodeModel: 'openai/gpt-5.4-mini' };
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8'));
expect(loaded.provider).toBe('opencode');
expect(loaded.opencodeModel).toBe('openai/gpt-5.4-mini');
expect(loaded.apiKey).toBeUndefined();
});
it('saves openai provider config with model and apiKey', async () => {
const config = {
provider: 'openai',
@ -941,6 +981,55 @@ describe('WikiGenerator invokeLLM routing', () => {
expect(result.content).toBe('codex response');
});
it('routes to callOpenCodeLLM when provider is opencode', async () => {
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
const localClient = await import('../../src/core/wiki/local-cli-client.js');
const llmClient = await import('../../src/core/wiki/llm-client.js');
const cursorSpy = vi
.spyOn(cursorClient, 'callCursorLLM')
.mockResolvedValue({ content: 'cursor response' });
const claudeSpy = vi
.spyOn(localClient, 'callClaudeLLM')
.mockResolvedValue({ content: 'claude response' });
const codexSpy = vi
.spyOn(localClient, 'callCodexLLM')
.mockResolvedValue({ content: 'codex response' });
const opencodeSpy = vi
.spyOn(localClient, 'callOpenCodeLLM')
.mockResolvedValue({ content: 'opencode response' });
const openaiSpy = vi
.spyOn(llmClient, 'callLLM')
.mockResolvedValue({ content: 'openai response' });
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
const storagePath = path.join(tmpDir, 'storage');
const wikiDir = path.join(storagePath, 'wiki');
await fs.mkdir(wikiDir, { recursive: true });
const repoPath = path.join(tmpDir, 'repo');
await fs.mkdir(repoPath, { recursive: true });
const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), {
apiKey: '',
baseUrl: '',
model: 'openai/gpt-5.4-mini',
maxTokens: 1000,
temperature: 0,
provider: 'opencode',
});
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
expect(opencodeSpy).toHaveBeenCalledTimes(1);
expect(codexSpy).not.toHaveBeenCalled();
expect(claudeSpy).not.toHaveBeenCalled();
expect(cursorSpy).not.toHaveBeenCalled();
expect(openaiSpy).not.toHaveBeenCalled();
expect(result.content).toBe('opencode response');
});
it('routes to callLLM when provider is openai', async () => {
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
const localClient = await import('../../src/core/wiki/local-cli-client.js');
@ -1017,6 +1106,13 @@ describe('local agent CLI calls', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.OPENCODE;
delete process.env.OPENCODE_PID;
delete process.env.OPENCODE_PROCESS_ROLE;
delete process.env.OPENCODE_RUN_ID;
delete process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS;
delete process.env.OPENCODE_SERVER_PASSWORD;
delete process.env.OPENCODE_SERVER_USERNAME;
});
it('throws when Claude CLI is not in PATH', async () => {
@ -1045,6 +1141,283 @@ describe('local agent CLI calls', () => {
await expect(callCodexLLM('hello', {})).rejects.toThrow('Codex CLI not found');
});
it('throws when OpenCode CLI is not in PATH', async () => {
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockImplementation(() => {
throw new Error('not found');
}),
spawn: vi.fn(),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
await expect(callOpenCodeLLM('hello', {})).rejects.toThrow('OpenCode CLI not found');
});
it('spawns OpenCode run, strips only credential env vars, and parses JSON text events', async () => {
process.env.OPENCODE = '1';
process.env.OPENCODE_PID = '4242';
process.env.OPENCODE_PROCESS_ROLE = 'worker';
process.env.OPENCODE_RUN_ID = 'run-123';
process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS = 'true';
process.env.OPENCODE_SERVER_PASSWORD = 'secret';
process.env.OPENCODE_SERVER_USERNAME = 'opencode';
const jsonOutput = [
JSON.stringify({ type: 'step_start', part: { type: 'step-start' } }),
JSON.stringify({ type: 'text', part: { type: 'text', text: 'Hello' } }),
JSON.stringify({ type: 'text', part: { type: 'text', text: ' world' } }),
JSON.stringify({ type: 'step_finish', part: { type: 'step-finish', reason: 'stop' } }),
].join('\n');
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn((stdinText?: string) => {
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from(jsonOutput));
child.emit('close', 0);
});
return stdinText;
});
const spawnSpy = vi.fn(() => child);
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: spawnSpy,
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
const response = await callOpenCodeLLM(
'hello',
{ model: 'openai/gpt-5.4-mini', workingDirectory: process.cwd() },
'system prompt',
);
expect(response.content).toBe('Hello world');
expect(child.stdin.end).toHaveBeenCalledWith('system prompt\n\n---\n\nhello');
const args = spawnSpy.mock.calls[0][1] as string[];
expect(args).toContain('run');
expect(args).toContain('--format');
expect(args).toContain('json');
expect(args).toContain('--dir');
expect(args).toContain(process.cwd());
expect(args).toContain('--model');
expect(args).toContain('openai/gpt-5.4-mini');
const spawnOptions = spawnSpy.mock.calls[0][2] as { env: Record<string, string | undefined> };
expect(spawnOptions.env.OPENCODE).toBe('1');
expect(spawnOptions.env.OPENCODE_PID).toBe('4242');
expect(spawnOptions.env.OPENCODE_PROCESS_ROLE).toBe('worker');
expect(spawnOptions.env.OPENCODE_RUN_ID).toBe('run-123');
expect(spawnOptions.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS).toBe('true');
expect(spawnOptions.env.OPENCODE_SERVER_PASSWORD).toBeUndefined();
expect(spawnOptions.env.OPENCODE_SERVER_USERNAME).toBeUndefined();
});
it('omits --model when OpenCode config does not specify one', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ type: 'text', part: { type: 'text', text: 'OK' } })),
);
child.emit('close', 0);
});
});
const spawnSpy = vi.fn(() => child);
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: spawnSpy,
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() });
expect(response.content).toBe('OK');
const args = spawnSpy.mock.calls[0][1] as string[];
expect(args).not.toContain('--model');
});
it('parses OpenCode text events even when part.type is omitted', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ type: 'text', part: { text: 'fallback text' } })),
);
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() });
expect(response.content).toBe('fallback text');
});
it('ignores non-JSON stdout lines when OpenCode text events are present', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(
[
'permission requested: write access denied',
JSON.stringify({ type: 'text', part: { text: 'Hello from opencode' } }),
'~ https://opencode.ai/share/abc123',
].join('\n'),
),
);
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() });
expect(response.content).toBe('Hello from opencode');
});
it('fails with no text output when OpenCode only writes non-JSON stdout lines', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from('not-json'));
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow(
'OpenCode CLI returned no text output',
);
});
it('surfaces OpenCode error events', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(
JSON.stringify({
type: 'error',
error: { name: 'PermissionDenied', data: { message: 'permission denied' } },
}),
),
);
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow(
'OpenCode CLI returned error event: permission denied',
);
});
it('fails when OpenCode returns no text events', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ type: 'step_finish', part: { type: 'step-finish' } })),
);
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow(
'OpenCode CLI returned no text output',
);
});
it('falls back to the OpenCode error name when the nested message is missing', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = new EventEmitter() as any;
child.stdin.end = vi.fn(() => {
queueMicrotask(() => {
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ type: 'error', error: { name: 'PermissionDenied' } })),
);
child.emit('close', 0);
});
});
vi.doMock('child_process', () => ({
execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'),
spawn: vi.fn(() => child),
}));
const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js');
await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow(
'OpenCode CLI returned error event: PermissionDenied',
);
});
it('uses Codex config overrides instead of removed approval flags', async () => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();