Skip to content

Commit d15f236

Browse files
fix(ai): add OpenCode agent support with correct CLI configuration
Update AI runner to support OpenCode agent with proper configuration and JSON parsing: - Configure OpenCode with correct CLI syntax: ['run', '--format', 'json'] - Add markdown-wrapped JSON parsing via parseStringResult helper - Handle multiple response formats: raw JSON, markdown-wrapped JSON, plain text - Refactor parseOpenCodeNDJSON with functional .reduce() pattern - Replace plain Error with structured createError() for rich error metadata - Maintain backward compatibility with Claude and Cursor agents - Update dependencies (npm install) - Update tests to verify OpenCode configuration, parsing, and error handling - Archive completed OpenCode agent task documentation - Update plan.md to reflect OpenCode support status Tested with OpenCode v1.1.50 CLI. All 184 tests passing (78 main suite + 103 Vitest + 3 bin tests).
1 parent 742a9fc commit d15f236

File tree

7 files changed

+590
-28
lines changed

7 files changed

+590
-28
lines changed

bin/riteway.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import minimist from 'minimist';
77
import { globSync } from 'glob';
88
import dotignore from 'dotignore';
99
import { errorCauses, createError } from 'error-causes';
10-
import { runAITests, verifyAgentAuthentication, validateFilePath } from '../source/ai-runner.js';
10+
import { runAITests, verifyAgentAuthentication, validateFilePath, parseOpenCodeNDJSON } from '../source/ai-runner.js';
1111
import { recordTestOutput, generateLogFilePath } from '../source/test-output.js';
1212

1313
const resolveModule = resolve.sync;
@@ -48,7 +48,8 @@ export const getAgentConfig = (agentName = 'claude') => {
4848
},
4949
opencode: {
5050
command: 'opencode',
51-
args: ['--output-format', 'json']
51+
args: ['run', '--format', 'json'],
52+
parseOutput: (stdout, logger) => parseOpenCodeNDJSON(stdout, logger)
5253
},
5354
cursor: {
5455
command: 'agent',

bin/riteway.test.js

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -408,50 +408,90 @@ describe('getAgentConfig()', async assert => {
408408
assert({
409409
given: 'agent name "claude"',
410410
should: 'return claude agent configuration',
411-
actual: getAgentConfig('claude'),
411+
actual: (() => {
412+
const config = getAgentConfig('claude');
413+
return {
414+
command: config.command,
415+
args: config.args,
416+
hasParseOutput: config.parseOutput !== undefined
417+
};
418+
})(),
412419
expected: {
413420
command: 'claude',
414-
args: ['-p', '--output-format', 'json', '--no-session-persistence']
421+
args: ['-p', '--output-format', 'json', '--no-session-persistence'],
422+
hasParseOutput: false
415423
}
416424
});
417425

418426
assert({
419427
given: 'agent name "opencode"',
420-
should: 'return opencode agent configuration',
421-
actual: getAgentConfig('opencode'),
428+
should: 'return opencode agent configuration with run subcommand',
429+
actual: (() => {
430+
const config = getAgentConfig('opencode');
431+
return {
432+
command: config.command,
433+
args: config.args,
434+
hasParseOutput: typeof config.parseOutput === 'function'
435+
};
436+
})(),
422437
expected: {
423438
command: 'opencode',
424-
args: ['--output-format', 'json']
439+
args: ['run', '--format', 'json'],
440+
hasParseOutput: true
425441
}
426442
});
427443

428444
assert({
429445
given: 'agent name "cursor"',
430446
should: 'return cursor agent configuration using OAuth',
431-
actual: getAgentConfig('cursor'),
447+
actual: (() => {
448+
const config = getAgentConfig('cursor');
449+
return {
450+
command: config.command,
451+
args: config.args,
452+
hasParseOutput: config.parseOutput !== undefined
453+
};
454+
})(),
432455
expected: {
433456
command: 'agent',
434-
args: ['--print', '--output-format', 'json']
457+
args: ['--print', '--output-format', 'json'],
458+
hasParseOutput: false
435459
}
436460
});
437461

438462
assert({
439463
given: 'no agent name (undefined)',
440464
should: 'return default claude configuration',
441-
actual: getAgentConfig(),
465+
actual: (() => {
466+
const config = getAgentConfig();
467+
return {
468+
command: config.command,
469+
args: config.args,
470+
hasParseOutput: config.parseOutput !== undefined
471+
};
472+
})(),
442473
expected: {
443474
command: 'claude',
444-
args: ['-p', '--output-format', 'json', '--no-session-persistence']
475+
args: ['-p', '--output-format', 'json', '--no-session-persistence'],
476+
hasParseOutput: false
445477
}
446478
});
447479

448480
assert({
449481
given: 'agent name in mixed case',
450-
should: 'handle case-insensitive lookup',
451-
actual: getAgentConfig('OpenCode'),
482+
should: 'handle case-insensitive lookup with correct args',
483+
actual: (() => {
484+
const config = getAgentConfig('OpenCode');
485+
return {
486+
command: config.command,
487+
args: config.args,
488+
hasParseOutput: typeof config.parseOutput === 'function'
489+
};
490+
})(),
452491
expected: {
453492
command: 'opencode',
454-
args: ['--output-format', 'json']
493+
args: ['run', '--format', 'json'],
494+
hasParseOutput: true
455495
}
456496
});
457497

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plan.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
## Completed Epics
1111

12+
### ✅ OpenCode Agent Support Fix Epic
13+
**Status**: ✅ COMPLETED (2026-02-05)
14+
**File**: [`tasks/archive/2026-01-22-riteway-ai-testing-framework/2026-02-04-opencode-agent-support-fix.md`](./tasks/archive/2026-01-22-riteway-ai-testing-framework/2026-02-04-opencode-agent-support-fix.md)
15+
**Goal**: Fix OpenCode agent integration to properly handle NDJSON streaming output format
16+
**Result**: Successfully fixed OpenCode agent to parse NDJSON streaming output and handle markdown-wrapped JSON responses. Implemented structured error handling following error-causes.mdc standard. All 184 tests passing, live testing confirmed working.
17+
1218
### ✅ RiteWay AI Testing Framework Epic
1319
**Status**: ✅ COMPLETED (2026-01-23)
1420
**File**: [`tasks/archive/2026-01-22-riteway-ai-testing-framework.md`](./tasks/archive/2026-01-22-riteway-ai-testing-framework.md)

source/ai-runner.js

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,48 @@ export const parseStringResult = (result, logger) => {
7070
return result;
7171
};
7272

73+
/**
74+
* Parse OpenCode's NDJSON (newline-delimited JSON) output format.
75+
* OpenCode emits multiple JSON objects separated by newlines, with different event types.
76+
* We extract and concatenate all "text" events to get the final response.
77+
* @param {string} ndjson - NDJSON output from OpenCode
78+
* @param {Object} logger - Debug logger instance
79+
* @returns {string} Concatenated text from all text events
80+
*/
81+
export const parseOpenCodeNDJSON = (ndjson, logger) => {
82+
logger.log('Parsing OpenCode NDJSON output...');
83+
84+
const lines = ndjson.trim().split('\n').filter(line => line.trim());
85+
86+
const textEvents = lines.reduce((acc, line) => {
87+
try {
88+
const event = JSON.parse(line);
89+
if (event.type === 'text' && event.part?.text) {
90+
logger.log(`Found text event with ${event.part.text.length} characters`);
91+
acc.push(event.part.text);
92+
}
93+
} catch (err) {
94+
logger.log(`Warning: Failed to parse NDJSON line: ${err.message}`);
95+
}
96+
return acc;
97+
}, []);
98+
99+
if (textEvents.length === 0) {
100+
throw createError({
101+
name: 'ParseError',
102+
message: 'No text events found in OpenCode output',
103+
code: 'NO_TEXT_EVENTS',
104+
ndjsonLength: ndjson.length,
105+
linesProcessed: lines.length
106+
});
107+
}
108+
109+
const combinedText = textEvents.join('');
110+
logger.log(`Combined ${textEvents.length} text event(s) into ${combinedText.length} characters`);
111+
112+
return combinedText;
113+
};
114+
73115
/**
74116
* Read the contents of a test file.
75117
* @param {string} filePath - Path to the test file
@@ -160,6 +202,7 @@ export const verifyAgentAuthentication = async ({ agentConfig, timeout = 30000,
160202
* @param {Object} options.agentConfig - Agent configuration
161203
* @param {string} options.agentConfig.command - Command to execute
162204
* @param {Array<string>} [options.agentConfig.args=[]] - Command arguments
205+
* @param {Function} [options.agentConfig.parseOutput] - Optional function to preprocess stdout before JSON parsing
163206
* @param {string} options.prompt - Prompt to send to the agent
164207
* @param {number} [options.timeout=300000] - Timeout in milliseconds (default: 5 minutes)
165208
* @param {boolean} [options.debug=false] - Enable debug logging
@@ -169,7 +212,7 @@ export const verifyAgentAuthentication = async ({ agentConfig, timeout = 30000,
169212
*/
170213
export const executeAgent = ({ agentConfig, prompt, timeout = 300000, debug = false, logFile }) => {
171214
return new Promise((resolve, reject) => {
172-
const { command, args = [] } = agentConfig;
215+
const { command, args = [], parseOutput } = agentConfig;
173216
const allArgs = [...args, prompt];
174217
const logger = createDebugLogger({ debug, logFile });
175218

@@ -225,10 +268,23 @@ export const executeAgent = ({ agentConfig, prompt, timeout = 300000, debug = fa
225268
}
226269

227270
try {
228-
const parsed = JSON.parse(stdout);
271+
// Apply parseOutput function if provided (e.g., for NDJSON preprocessing)
272+
const processedOutput = parseOutput ? parseOutput(stdout, logger) : stdout;
273+
274+
// Parse the processed output - handles both raw JSON and markdown-wrapped JSON
275+
let result = parseStringResult(processedOutput, logger);
276+
277+
// If result is still a string (not parsed as JSON), that's an error
278+
if (typeof result === 'string') {
279+
throw new Error(`Agent output is not valid JSON: ${result.slice(0, 100)}`);
280+
}
281+
229282
// Claude CLI wraps response in envelope with "result" field
230-
let result = parsed.result !== undefined ? parsed.result : parsed;
283+
if (result.result !== undefined) {
284+
result = result.result;
285+
}
231286

287+
// If result is a string after unwrapping, try to parse it again
232288
logger.log(`Parsed result type: ${typeof result}`);
233289
if (typeof result === 'string') {
234290
logger.log('Result is string, attempting to parse as JSON');
@@ -244,11 +300,15 @@ export const executeAgent = ({ agentConfig, prompt, timeout = 300000, debug = fa
244300
logger.log('JSON parsing failed:', err.message);
245301
logger.flush();
246302

247-
reject(new Error(
248-
`Failed to parse agent output as JSON: ${err.message}\n` +
249-
`Command: ${command} ${args.join(' ')}\n` +
250-
`Stdout preview: ${truncatedStdout}`
251-
));
303+
reject(createError({
304+
name: 'ParseError',
305+
message: `Failed to parse agent output as JSON: ${err.message}`,
306+
code: 'AGENT_OUTPUT_PARSE_ERROR',
307+
command,
308+
args: args.join(' '),
309+
stdoutPreview: truncatedStdout,
310+
cause: err
311+
}));
252312
}
253313
});
254314

0 commit comments

Comments
 (0)