Merge branch 'v0.10.0rc' of github.com:AutoMaker-Org/automaker into v0.10.0rc

This commit is contained in:
webdevcody
2026-01-10 15:41:50 -05:00
10 changed files with 768 additions and 438 deletions

View File

@@ -41,95 +41,103 @@ export interface OpenCodeAuthStatus {
/**
* Base interface for all OpenCode stream events
* OpenCode uses underscore format: step_start, step_finish, text
*/
interface OpenCodeBaseEvent {
/** Event type identifier */
type: string;
/** Optional session identifier */
session_id?: string;
/** Timestamp of the event */
timestamp?: number;
/** Session ID */
sessionID?: string;
/** Part object containing the actual event data */
part?: Record<string, unknown>;
}
/**
* Text delta event - Incremental text output from the model
* Text event - Text output from the model
* Format: {"type":"text","part":{"text":"content",...}}
*/
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
type: 'text-delta';
/** The incremental text content */
text: string;
}
/**
* Text end event - Signals completion of text generation
*/
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
type: 'text-end';
export interface OpenCodeTextEvent extends OpenCodeBaseEvent {
type: 'text';
part: {
type: 'text';
text: string;
[key: string]: unknown;
};
}
/**
* Tool call event - Request to execute a tool
*/
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
type: 'tool-call';
/** Unique identifier for this tool call */
call_id?: string;
/** Tool name to invoke */
name: string;
/** Arguments to pass to the tool */
args: unknown;
type: 'tool_call';
part: {
type: 'tool-call';
name: string;
call_id?: string;
args: unknown;
[key: string]: unknown;
};
}
/**
* Tool result event - Output from a tool execution
*/
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
type: 'tool-result';
/** The tool call ID this result corresponds to */
call_id?: string;
/** Output from the tool execution */
output: string;
type: 'tool_result';
part: {
type: 'tool-result';
call_id?: string;
output: string;
[key: string]: unknown;
};
}
/**
* Tool error event - Tool execution failed
*/
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
type: 'tool-error';
/** The tool call ID that failed */
call_id?: string;
/** Error message describing the failure */
error: string;
type: 'tool_error';
part: {
type: 'tool-error';
call_id?: string;
error: string;
[key: string]: unknown;
};
}
/**
* Start step event - Begins an agentic loop iteration
* Format: {"type":"step_start","part":{...}}
*/
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
type: 'start-step';
/** Step number in the agentic loop */
step?: number;
type: 'step_start';
part?: {
type: 'step-start';
[key: string]: unknown;
};
}
/**
* Finish step event - Completes an agentic loop iteration
* Format: {"type":"step_finish","part":{"reason":"stop",...}}
*/
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
type: 'finish-step';
/** Step number that completed */
step?: number;
/** Whether the step completed successfully */
success?: boolean;
/** Optional result data */
result?: string;
/** Optional error if step failed */
error?: string;
type: 'step_finish';
part?: {
type: 'step-finish';
reason?: string;
error?: string;
[key: string]: unknown;
};
}
/**
* Union type of all OpenCode stream events
*/
export type OpenCodeStreamEvent =
| OpenCodeTextDeltaEvent
| OpenCodeTextEndEvent
| OpenCodeTextEvent
| OpenCodeToolCallEvent
| OpenCodeToolResultEvent
| OpenCodeToolErrorEvent
@@ -219,14 +227,12 @@ export class OpencodeProvider extends CliProvider {
*
* Arguments built:
* - 'run' subcommand for executing queries
* - '--format', 'stream-json' for JSONL streaming output
* - '-q' / '--quiet' to suppress spinner and interactive elements
* - '-c', '<cwd>' for working directory
* - '--format', 'json' for JSON streaming output
* - '--model', '<model>' for model selection (if specified)
* - '-' as final arg to read prompt from stdin
* - Message passed via stdin (no positional args needed)
*
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
* shell escaping issues with special characters in content.
* The prompt is passed via stdin to avoid shell escaping issues.
* OpenCode will read from stdin when no positional message arguments are provided.
*
* @param options - Execution options containing model, cwd, etc.
* @returns Array of CLI arguments for opencode run
@@ -234,27 +240,18 @@ export class OpencodeProvider extends CliProvider {
buildCliArgs(options: ExecuteOptions): string[] {
const args: string[] = ['run'];
// Add streaming JSON output format for JSONL parsing
args.push('--format', 'stream-json');
// Suppress spinner and interactive elements for non-TTY usage
args.push('-q');
// Set working directory
if (options.cwd) {
args.push('-c', options.cwd);
}
// Add JSON output format for streaming
args.push('--format', 'json');
// Handle model selection
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
// Strip 'opencode-' prefix if present, OpenCode uses native format
if (options.model) {
const model = stripProviderPrefix(options.model);
args.push('--model', model);
}
// Use '-' to indicate reading prompt from stdin
// This avoids shell escaping issues with special characters
args.push('-');
// Note: Working directory is set via subprocess cwd option, not CLI args
// Note: Message is passed via stdin, OpenCode reads from stdin automatically
return args;
}
@@ -314,14 +311,12 @@ export class OpencodeProvider extends CliProvider {
* Normalize a raw CLI event to ProviderMessage format
*
* Maps OpenCode event types to the standard ProviderMessage structure:
* - text-delta -> type: 'assistant', content with type: 'text'
* - text-end -> null (informational, no message needed)
* - tool-call -> type: 'assistant', content with type: 'tool_use'
* - tool-result -> type: 'assistant', content with type: 'tool_result'
* - tool-error -> type: 'error'
* - start-step -> null (informational, no message needed)
* - finish-step with success -> type: 'result', subtype: 'success'
* - finish-step with error -> type: 'error'
* - text -> type: 'assistant', content with type: 'text'
* - step_start -> null (informational, no message needed)
* - step_finish -> type: 'result', subtype: 'success' (or error if failed)
* - tool_call -> type: 'assistant', content with type: 'tool_use'
* - tool_result -> type: 'assistant', content with type: 'tool_result'
* - tool_error -> type: 'error'
*
* @param event - Raw event from OpenCode CLI JSONL output
* @returns Normalized ProviderMessage or null to skip the event
@@ -334,24 +329,24 @@ export class OpencodeProvider extends CliProvider {
const openCodeEvent = event as OpenCodeStreamEvent;
switch (openCodeEvent.type) {
case 'text-delta': {
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
case 'text': {
const textEvent = openCodeEvent as OpenCodeTextEvent;
// Skip empty text deltas
if (!textEvent.text) {
// Skip if no text content
if (!textEvent.part?.text) {
return null;
}
const content: ContentBlock[] = [
{
type: 'text',
text: textEvent.text,
text: textEvent.part.text,
},
];
return {
type: 'assistant',
session_id: textEvent.session_id,
session_id: textEvent.sessionID,
message: {
role: 'assistant',
content,
@@ -359,90 +354,105 @@ export class OpencodeProvider extends CliProvider {
};
}
case 'text-end': {
// Text end is informational - no message needed
return null;
}
case 'tool-call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
// Generate a tool use ID if not provided
const toolUseId = toolEvent.call_id || generateToolUseId();
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolEvent.name,
tool_use_id: toolUseId,
input: toolEvent.args,
},
];
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-result': {
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
const content: ContentBlock[] = [
{
type: 'tool_result',
tool_use_id: resultEvent.call_id,
content: resultEvent.output,
},
];
return {
type: 'assistant',
session_id: resultEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-error': {
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Tool execution failed',
};
}
case 'start-step': {
case 'step_start': {
// Start step is informational - no message needed
return null;
}
case 'finish-step': {
case 'step_finish': {
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
// Check if the step failed
if (finishEvent.success === false || finishEvent.error) {
// Check if the step failed (either has error field or reason is 'error')
if (finishEvent.part?.error || finishEvent.part?.reason === 'error') {
return {
type: 'error',
session_id: finishEvent.session_id,
error: finishEvent.error || 'Step execution failed',
session_id: finishEvent.sessionID,
error: finishEvent.part?.error || 'Step execution failed',
};
}
// Successful completion
const result: { type: 'result'; subtype: 'success'; session_id?: string; result?: string } =
{
type: 'result',
subtype: 'success',
};
if (finishEvent.sessionID) {
result.session_id = finishEvent.sessionID;
}
// Safely handle arbitrary result payloads from CLI: ensure we assign a string.
const rawResult =
(finishEvent.part && (finishEvent.part as Record<string, unknown>).result) ?? undefined;
if (rawResult !== undefined) {
result.result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
}
return result;
}
case 'tool_call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
if (!toolEvent.part) {
return null;
}
// Generate a tool use ID if not provided
const toolUseId = toolEvent.part.call_id || generateToolUseId();
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolEvent.part.name,
tool_use_id: toolUseId,
input: toolEvent.part.args,
},
];
return {
type: 'result',
subtype: 'success',
session_id: finishEvent.session_id,
result: finishEvent.result,
type: 'assistant',
session_id: toolEvent.sessionID,
message: {
role: 'assistant',
content,
},
};
}
case 'tool_result': {
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
if (!resultEvent.part) {
return null;
}
const content: ContentBlock[] = [
{
type: 'tool_result',
tool_use_id: resultEvent.part.call_id,
content: resultEvent.part.output,
},
];
return {
type: 'assistant',
session_id: resultEvent.sessionID,
message: {
role: 'assistant',
content,
},
};
}
case 'tool_error': {
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
return {
type: 'error',
session_id: errorEvent.sessionID,
error: errorEvent.part?.error || 'Tool execution failed',
};
}

View File

@@ -168,41 +168,23 @@ describe('opencode-provider.ts', () => {
it('should build correct args with run subcommand', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'opencode/big-pickle',
cwd: '/tmp/project',
});
expect(args[0]).toBe('run');
});
it('should include --format stream-json for streaming output', () => {
it('should include --format json for streaming output', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'opencode/big-pickle',
cwd: '/tmp/project',
});
const formatIndex = args.indexOf('--format');
expect(formatIndex).toBeGreaterThan(-1);
expect(args[formatIndex + 1]).toBe('stream-json');
});
it('should include -q flag for quiet mode', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
cwd: '/tmp/project',
});
expect(args).toContain('-q');
});
it('should include working directory with -c flag', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
cwd: '/tmp/my-project',
});
const cwdIndex = args.indexOf('-c');
expect(cwdIndex).toBeGreaterThan(-1);
expect(args[cwdIndex + 1]).toBe('/tmp/my-project');
expect(args[formatIndex + 1]).toBe('json');
});
it('should include model with --model flag', () => {
@@ -228,30 +210,24 @@ describe('opencode-provider.ts', () => {
expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
});
it('should include dash as final arg for stdin prompt', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
cwd: '/tmp/project',
});
expect(args[args.length - 1]).toBe('-');
});
it('should handle missing cwd', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'opencode/big-pickle',
});
expect(args).not.toContain('-c');
});
it('should handle missing model', () => {
it('should handle model from opencode provider', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'opencode/big-pickle',
cwd: '/tmp/project',
});
expect(args).not.toContain('--model');
expect(args).toContain('--model');
expect(args).toContain('opencode/big-pickle');
});
});
@@ -260,12 +236,15 @@ describe('opencode-provider.ts', () => {
// ==========================================================================
describe('normalizeEvent', () => {
describe('text-delta events', () => {
it('should convert text-delta to assistant message with text content', () => {
describe('text events (new OpenCode format)', () => {
it('should convert text to assistant message with text content', () => {
const event = {
type: 'text-delta',
text: 'Hello, world!',
session_id: 'test-session',
type: 'text',
part: {
type: 'text',
text: 'Hello, world!',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -285,10 +264,13 @@ describe('opencode-provider.ts', () => {
});
});
it('should return null for empty text-delta', () => {
it('should return null for empty text', () => {
const event = {
type: 'text-delta',
text: '',
type: 'text',
part: {
type: 'text',
text: '',
},
};
const result = provider.normalizeEvent(event);
@@ -296,9 +278,10 @@ describe('opencode-provider.ts', () => {
expect(result).toBeNull();
});
it('should return null for text-delta with undefined text', () => {
it('should return null for text with undefined text', () => {
const event = {
type: 'text-delta',
type: 'text',
part: {},
};
const result = provider.normalizeEvent(event);
@@ -307,27 +290,17 @@ describe('opencode-provider.ts', () => {
});
});
describe('text-end events', () => {
it('should return null for text-end events (informational)', () => {
describe('tool_call events', () => {
it('should convert tool_call to assistant message with tool_use content', () => {
const event = {
type: 'text-end',
session_id: 'test-session',
};
const result = provider.normalizeEvent(event);
expect(result).toBeNull();
});
});
describe('tool-call events', () => {
it('should convert tool-call to assistant message with tool_use content', () => {
const event = {
type: 'tool-call',
call_id: 'call-123',
name: 'Read',
args: { file_path: '/tmp/test.txt' },
session_id: 'test-session',
type: 'tool_call',
part: {
type: 'tool-call',
call_id: 'call-123',
name: 'Read',
args: { file_path: '/tmp/test.txt' },
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -351,9 +324,12 @@ describe('opencode-provider.ts', () => {
it('should generate tool_use_id when call_id is missing', () => {
const event = {
type: 'tool-call',
name: 'Write',
args: { content: 'test' },
type: 'tool_call',
part: {
type: 'tool-call',
name: 'Write',
args: { content: 'test' },
},
};
const result = provider.normalizeEvent(event);
@@ -363,21 +339,27 @@ describe('opencode-provider.ts', () => {
// Second call should increment
const result2 = provider.normalizeEvent({
type: 'tool-call',
name: 'Edit',
args: {},
type: 'tool_call',
part: {
type: 'tool-call',
name: 'Edit',
args: {},
},
});
expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2');
});
});
describe('tool-result events', () => {
it('should convert tool-result to assistant message with tool_result content', () => {
describe('tool_result events', () => {
it('should convert tool_result to assistant message with tool_result content', () => {
const event = {
type: 'tool-result',
call_id: 'call-123',
output: 'File contents here',
session_id: 'test-session',
type: 'tool_result',
part: {
type: 'tool-result',
call_id: 'call-123',
output: 'File contents here',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -398,10 +380,13 @@ describe('opencode-provider.ts', () => {
});
});
it('should handle tool-result without call_id', () => {
it('should handle tool_result without call_id', () => {
const event = {
type: 'tool-result',
output: 'Result without ID',
type: 'tool_result',
part: {
type: 'tool-result',
output: 'Result without ID',
},
};
const result = provider.normalizeEvent(event);
@@ -411,13 +396,16 @@ describe('opencode-provider.ts', () => {
});
});
describe('tool-error events', () => {
it('should convert tool-error to error message', () => {
describe('tool_error events', () => {
it('should convert tool_error to error message', () => {
const event = {
type: 'tool-error',
call_id: 'call-123',
error: 'File not found',
session_id: 'test-session',
type: 'tool_error',
part: {
type: 'tool-error',
call_id: 'call-123',
error: 'File not found',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -431,8 +419,11 @@ describe('opencode-provider.ts', () => {
it('should provide default error message when error is missing', () => {
const event = {
type: 'tool-error',
call_id: 'call-123',
type: 'tool_error',
part: {
type: 'tool-error',
call_id: 'call-123',
},
};
const result = provider.normalizeEvent(event);
@@ -442,12 +433,14 @@ describe('opencode-provider.ts', () => {
});
});
describe('start-step events', () => {
it('should return null for start-step events (informational)', () => {
describe('step_start events', () => {
it('should return null for step_start events (informational)', () => {
const event = {
type: 'start-step',
step: 1,
session_id: 'test-session',
type: 'step_start',
part: {
type: 'step-start',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -456,14 +449,16 @@ describe('opencode-provider.ts', () => {
});
});
describe('finish-step events', () => {
it('should convert successful finish-step to result message', () => {
describe('step_finish events', () => {
it('should convert successful step_finish to result message', () => {
const event = {
type: 'finish-step',
step: 1,
success: true,
result: 'Task completed successfully',
session_id: 'test-session',
type: 'step_finish',
part: {
type: 'step-finish',
reason: 'stop',
result: 'Task completed successfully',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -476,13 +471,15 @@ describe('opencode-provider.ts', () => {
});
});
it('should convert finish-step with success=false to error message', () => {
it('should convert step_finish with error to error message', () => {
const event = {
type: 'finish-step',
step: 1,
success: false,
error: 'Something went wrong',
session_id: 'test-session',
type: 'step_finish',
part: {
type: 'step-finish',
reason: 'error',
error: 'Something went wrong',
},
sessionID: 'test-session',
};
const result = provider.normalizeEvent(event);
@@ -494,11 +491,13 @@ describe('opencode-provider.ts', () => {
});
});
it('should convert finish-step with error property to error message', () => {
it('should convert step_finish with error property to error message', () => {
const event = {
type: 'finish-step',
step: 1,
error: 'Process failed',
type: 'step_finish',
part: {
type: 'step-finish',
error: 'Process failed',
},
};
const result = provider.normalizeEvent(event);
@@ -509,9 +508,11 @@ describe('opencode-provider.ts', () => {
it('should provide default error message for failed step without error text', () => {
const event = {
type: 'finish-step',
step: 1,
success: false,
type: 'step_finish',
part: {
type: 'step-finish',
reason: 'error',
},
};
const result = provider.normalizeEvent(event);
@@ -520,11 +521,14 @@ describe('opencode-provider.ts', () => {
expect(result?.error).toBe('Step execution failed');
});
it('should treat finish-step without success flag as success', () => {
it('should treat step_finish with reason=stop as success', () => {
const event = {
type: 'finish-step',
step: 1,
result: 'Done',
type: 'step_finish',
part: {
type: 'step-finish',
reason: 'stop',
result: 'Done',
},
};
const result = provider.normalizeEvent(event);
@@ -586,13 +590,12 @@ describe('opencode-provider.ts', () => {
return mockedProvider;
}
it('should stream text-delta events as assistant messages', async () => {
it('should stream text events as assistant messages', async () => {
const mockedProvider = setupMockedProvider();
const mockEvents = [
{ type: 'text-delta', text: 'Hello ' },
{ type: 'text-delta', text: 'World!' },
{ type: 'text-end' },
{ type: 'text', part: { type: 'text', text: 'Hello ' } },
{ type: 'text', part: { type: 'text', text: 'World!' } },
];
vi.mocked(spawnJSONLProcess).mockReturnValue(
@@ -611,7 +614,6 @@ describe('opencode-provider.ts', () => {
})
);
// text-end should be filtered out (returns null)
expect(results).toHaveLength(2);
expect(results[0].type).toBe('assistant');
expect(results[0].message?.content[0].text).toBe('Hello ');
@@ -623,15 +625,21 @@ describe('opencode-provider.ts', () => {
const mockEvents = [
{
type: 'tool-call',
call_id: 'tool-1',
name: 'Read',
args: { file_path: '/tmp/test.txt' },
type: 'tool_call',
part: {
type: 'tool-call',
call_id: 'tool-1',
name: 'Read',
args: { file_path: '/tmp/test.txt' },
},
},
{
type: 'tool-result',
call_id: 'tool-1',
output: 'File contents',
type: 'tool_result',
part: {
type: 'tool-result',
call_id: 'tool-1',
output: 'File contents',
},
},
];
@@ -718,10 +726,7 @@ describe('opencode-provider.ts', () => {
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
expect(call.args).toContain('run');
expect(call.args).toContain('--format');
expect(call.args).toContain('stream-json');
expect(call.args).toContain('-q');
expect(call.args).toContain('-c');
expect(call.args).toContain('/tmp/workspace');
expect(call.args).toContain('json');
expect(call.args).toContain('--model');
expect(call.args).toContain('anthropic/claude-opus-4-5');
});
@@ -731,9 +736,9 @@ describe('opencode-provider.ts', () => {
const mockEvents = [
{ type: 'unknown-internal-event', data: 'ignored' },
{ type: 'text-delta', text: 'Valid text' },
{ type: 'text', part: { type: 'text', text: 'Valid text' } },
{ type: 'another-unknown', foo: 'bar' },
{ type: 'finish-step', result: 'Done' },
{ type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } },
];
vi.mocked(spawnJSONLProcess).mockReturnValue(
@@ -747,6 +752,7 @@ describe('opencode-provider.ts', () => {
const results = await collectAsyncGenerator<ProviderMessage>(
mockedProvider.executeQuery({
prompt: 'Test',
model: 'opencode/big-pickle',
cwd: '/test',
})
);
@@ -1039,10 +1045,22 @@ describe('opencode-provider.ts', () => {
const sessionId = 'test-session-123';
const mockEvents = [
{ type: 'text-delta', text: 'Hello ', session_id: sessionId },
{ type: 'tool-call', name: 'Read', args: {}, call_id: 'c1', session_id: sessionId },
{ type: 'tool-result', call_id: 'c1', output: 'file content', session_id: sessionId },
{ type: 'finish-step', result: 'Done', session_id: sessionId },
{ type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId },
{
type: 'tool_call',
part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' },
sessionID: sessionId,
},
{
type: 'tool_result',
part: { type: 'tool-result', call_id: 'c1', output: 'file content' },
sessionID: sessionId,
},
{
type: 'step_finish',
part: { type: 'step-finish', reason: 'stop', result: 'Done' },
sessionID: sessionId,
},
];
vi.mocked(spawnJSONLProcess).mockReturnValue(
@@ -1056,6 +1074,7 @@ describe('opencode-provider.ts', () => {
const results = await collectAsyncGenerator<ProviderMessage>(
mockedProvider.executeQuery({
prompt: 'Test',
model: 'opencode/big-pickle',
cwd: '/tmp',
})
);
@@ -1069,12 +1088,15 @@ describe('opencode-provider.ts', () => {
});
describe('normalizeEvent additional edge cases', () => {
it('should handle tool-call with empty args object', () => {
it('should handle tool_call with empty args object', () => {
const event = {
type: 'tool-call',
call_id: 'call-123',
name: 'Glob',
args: {},
type: 'tool_call',
part: {
type: 'tool-call',
call_id: 'call-123',
name: 'Glob',
args: {},
},
};
const result = provider.normalizeEvent(event);
@@ -1083,12 +1105,15 @@ describe('opencode-provider.ts', () => {
expect(result?.message?.content[0].input).toEqual({});
});
it('should handle tool-call with null args', () => {
it('should handle tool_call with null args', () => {
const event = {
type: 'tool-call',
call_id: 'call-123',
name: 'Glob',
args: null,
type: 'tool_call',
part: {
type: 'tool-call',
call_id: 'call-123',
name: 'Glob',
args: null,
},
};
const result = provider.normalizeEvent(event);
@@ -1097,18 +1122,21 @@ describe('opencode-provider.ts', () => {
expect(result?.message?.content[0].input).toBeNull();
});
it('should handle tool-call with complex nested args', () => {
it('should handle tool_call with complex nested args', () => {
const event = {
type: 'tool-call',
call_id: 'call-123',
name: 'Edit',
args: {
file_path: '/tmp/test.ts',
changes: [
{ line: 10, old: 'foo', new: 'bar' },
{ line: 20, old: 'baz', new: 'qux' },
],
options: { replace_all: true },
type: 'tool_call',
part: {
type: 'tool-call',
call_id: 'call-123',
name: 'Edit',
args: {
file_path: '/tmp/test.ts',
changes: [
{ line: 10, old: 'foo', new: 'bar' },
{ line: 20, old: 'baz', new: 'qux' },
],
options: { replace_all: true },
},
},
};
@@ -1125,11 +1153,14 @@ describe('opencode-provider.ts', () => {
});
});
it('should handle tool-result with empty output', () => {
it('should handle tool_result with empty output', () => {
const event = {
type: 'tool-result',
call_id: 'call-123',
output: '',
type: 'tool_result',
part: {
type: 'tool-result',
call_id: 'call-123',
output: '',
},
};
const result = provider.normalizeEvent(event);
@@ -1138,10 +1169,13 @@ describe('opencode-provider.ts', () => {
expect(result?.message?.content[0].content).toBe('');
});
it('should handle text-delta with whitespace-only text', () => {
it('should handle text with whitespace-only text', () => {
const event = {
type: 'text-delta',
text: ' ',
type: 'text',
part: {
type: 'text',
text: ' ',
},
};
const result = provider.normalizeEvent(event);
@@ -1151,10 +1185,13 @@ describe('opencode-provider.ts', () => {
expect(result?.message?.content[0].text).toBe(' ');
});
it('should handle text-delta with newlines', () => {
it('should handle text with newlines', () => {
const event = {
type: 'text-delta',
text: 'Line 1\nLine 2\nLine 3',
type: 'text',
part: {
type: 'text',
text: 'Line 1\nLine 2\nLine 3',
},
};
const result = provider.normalizeEvent(event);
@@ -1162,12 +1199,15 @@ describe('opencode-provider.ts', () => {
expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3');
});
it('should handle finish-step with both result and error (error takes precedence)', () => {
it('should handle step_finish with both result and error (error takes precedence)', () => {
const event = {
type: 'finish-step',
result: 'Some result',
error: 'But also an error',
success: false,
type: 'step_finish',
part: {
type: 'step-finish',
reason: 'stop',
result: 'Some result',
error: 'But also an error',
},
};
const result = provider.normalizeEvent(event);
@@ -1231,13 +1271,14 @@ describe('opencode-provider.ts', () => {
const longPrompt = 'a'.repeat(10000);
const args = provider.buildCliArgs({
prompt: longPrompt,
model: 'opencode/big-pickle',
cwd: '/tmp',
});
// The prompt is NOT in args (it's passed via stdin)
// Just verify the args structure is correct
expect(args).toContain('run');
expect(args).toContain('-');
expect(args).not.toContain('-');
expect(args.join(' ')).not.toContain(longPrompt);
});
@@ -1245,22 +1286,25 @@ describe('opencode-provider.ts', () => {
const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\'';
const args = provider.buildCliArgs({
prompt: specialPrompt,
model: 'opencode/big-pickle',
cwd: '/tmp',
});
// Special chars in prompt should not affect args (prompt is via stdin)
expect(args).toContain('run');
expect(args).toContain('-');
expect(args).not.toContain('-');
});
it('should handle cwd with spaces', () => {
const args = provider.buildCliArgs({
prompt: 'Test',
model: 'opencode/big-pickle',
cwd: '/tmp/path with spaces/project',
});
const cwdIndex = args.indexOf('-c');
expect(args[cwdIndex + 1]).toBe('/tmp/path with spaces/project');
// cwd is set at subprocess level, not via CLI args
expect(args).not.toContain('-c');
expect(args).not.toContain('/tmp/path with spaces/project');
});
it('should handle model with unusual characters', () => {