mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
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
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:
parent
22304cd4a4
commit
782f70cc07
9 changed files with 538 additions and 25 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 v1:https://{resource}.openai.azure.com/openai/v1',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue