582 lines
18 KiB
TypeScript
582 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
class MockEventStream implements AsyncGenerator<unknown, void, unknown> {
|
|
private index = 0;
|
|
private readonly events: unknown[];
|
|
readonly returnSpy = vi.fn(async () => ({ done: true as const, value: undefined }));
|
|
|
|
constructor(events: unknown[]) {
|
|
this.events = events;
|
|
}
|
|
|
|
[Symbol.asyncIterator](): AsyncGenerator<unknown, void, unknown> {
|
|
return this;
|
|
}
|
|
|
|
async next(): Promise<IteratorResult<unknown, void>> {
|
|
if (this.index >= this.events.length) {
|
|
return { done: true, value: undefined };
|
|
}
|
|
const value = this.events[this.index];
|
|
this.index += 1;
|
|
return { done: false, value };
|
|
}
|
|
|
|
async return(): Promise<IteratorResult<unknown, void>> {
|
|
return this.returnSpy();
|
|
}
|
|
|
|
async throw(e?: unknown): Promise<IteratorResult<unknown, void>> {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const { createOpencodeMock } = vi.hoisted(() => ({
|
|
createOpencodeMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('node:net', () => ({
|
|
createServer: () => {
|
|
const handlers = new Map<string, (...args: unknown[]) => void>();
|
|
return {
|
|
unref: vi.fn(),
|
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
handlers.set(event, handler);
|
|
}),
|
|
listen: vi.fn((_port: number, _host: string, cb: () => void) => {
|
|
cb();
|
|
}),
|
|
address: vi.fn(() => ({ port: 62000 })),
|
|
close: vi.fn((cb?: (err?: Error) => void) => cb?.()),
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock('@opencode-ai/sdk/v2', () => ({
|
|
createOpencode: createOpencodeMock,
|
|
}));
|
|
|
|
describe('OpenCodeClient stream cleanup', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should close SSE stream when session.idle is received', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'session.idle',
|
|
properties: { sessionID: 'session-1' },
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('interactive', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
});
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(stream.returnSpy).toHaveBeenCalled();
|
|
expect(disposeInstance).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
expect(subscribe).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should close SSE stream when session.error is received', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'session.error',
|
|
properties: {
|
|
sessionID: 'session-2',
|
|
error: { name: 'Error', data: { message: 'boom' } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-2' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('interactive', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
});
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.content).toContain('boom');
|
|
expect(stream.returnSpy).toHaveBeenCalled();
|
|
expect(disposeInstance).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
expect(subscribe).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should continue after assistant message completed and finish on session.idle', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'message.part.updated',
|
|
properties: {
|
|
part: { id: 'p-1', type: 'text', text: 'done' },
|
|
delta: 'done',
|
|
},
|
|
},
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-3',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'message.part.updated',
|
|
properties: {
|
|
part: { id: 'p-1', type: 'text', text: 'done more' },
|
|
delta: ' more',
|
|
},
|
|
},
|
|
{
|
|
type: 'session.idle',
|
|
properties: { sessionID: 'session-3' },
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-3' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await Promise.race([
|
|
client.call('interactive', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
}),
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), 500)),
|
|
]);
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(result.content).toBe('done more');
|
|
expect(disposeInstance).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
expect(subscribe).toHaveBeenCalledWith(
|
|
{ directory: '/tmp' },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should reject question.asked without handler and continue processing', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'question.asked',
|
|
properties: {
|
|
id: 'q-1',
|
|
sessionID: 'session-4',
|
|
questions: [
|
|
{
|
|
question: 'Select one',
|
|
header: 'Question',
|
|
options: [{ label: 'A', description: 'A desc' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: 'message.part.updated',
|
|
properties: {
|
|
part: { id: 'p-q1', type: 'text', text: 'continued response' },
|
|
delta: 'continued response',
|
|
},
|
|
},
|
|
{
|
|
type: 'session.idle',
|
|
properties: { sessionID: 'session-4' },
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-4' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const questionReject = vi.fn().mockResolvedValue({ data: true });
|
|
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
question: { reject: questionReject, reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('interactive', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
});
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(result.content).toBe('continued response');
|
|
expect(questionReject).toHaveBeenCalledWith(
|
|
{
|
|
requestID: 'q-1',
|
|
directory: '/tmp',
|
|
},
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should answer question.asked when handler is configured', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'question.asked',
|
|
properties: {
|
|
id: 'q-2',
|
|
sessionID: 'session-5',
|
|
questions: [
|
|
{
|
|
question: 'Select one',
|
|
header: 'Question',
|
|
options: [{ label: 'A', description: 'A desc' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-5',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-5' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const questionReply = vi.fn().mockResolvedValue({ data: true });
|
|
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
question: { reject: vi.fn(), reply: questionReply },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('interactive', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
onAskUserQuestion: async () => ({ Question: 'A' }),
|
|
});
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(questionReply).toHaveBeenCalledWith(
|
|
{
|
|
requestID: 'q-2',
|
|
directory: '/tmp',
|
|
answers: [['A']],
|
|
},
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should pass mapped tools to promptAsync when allowedTools is set', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-tools',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-tools' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('coder', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch', 'mcp__github__search'],
|
|
});
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(promptAsync).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools: {
|
|
read: true,
|
|
edit: true,
|
|
bash: true,
|
|
websearch: true,
|
|
webfetch: true,
|
|
mcp__github__search: true,
|
|
},
|
|
}),
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should pass empty tools object to promptAsync when allowedTools is an explicit empty array', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-empty-tools',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-empty-tools' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await client.call('coder', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
allowedTools: [],
|
|
});
|
|
|
|
expect(result.status).toBe('done');
|
|
expect(promptAsync).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools: {},
|
|
}),
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
it('should configure allow permissions for edit mode', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-perm',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
await client.call('coder', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
permissionMode: 'edit',
|
|
});
|
|
|
|
const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record<string, unknown> };
|
|
const permission = createCallArgs.config?.permission as Record<string, string>;
|
|
expect(permission.read).toBe('allow');
|
|
expect(permission.edit).toBe('allow');
|
|
expect(permission.write).toBe('allow');
|
|
expect(permission.bash).toBe('allow');
|
|
expect(permission.question).toBe('deny');
|
|
});
|
|
|
|
it('should pass permission ruleset to session.create', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: 'session-ruleset',
|
|
role: 'assistant',
|
|
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-ruleset' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: vi.fn() },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
await client.call('coder', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
permissionMode: 'edit',
|
|
});
|
|
|
|
expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({
|
|
directory: '/tmp',
|
|
permission: expect.arrayContaining([
|
|
expect.objectContaining({ permission: 'edit', action: 'allow' }),
|
|
expect.objectContaining({ permission: 'question', action: 'deny' }),
|
|
]),
|
|
}));
|
|
});
|
|
|
|
it('should fail fast when permission reply times out', async () => {
|
|
const { OpenCodeClient } = await import('../infra/opencode/client.js');
|
|
const stream = new MockEventStream([
|
|
{
|
|
type: 'permission.asked',
|
|
properties: {
|
|
id: 'perm-1',
|
|
sessionID: 'session-perm-timeout',
|
|
},
|
|
},
|
|
]);
|
|
|
|
const promptAsync = vi.fn().mockResolvedValue(undefined);
|
|
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm-timeout' } });
|
|
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
|
|
const subscribe = vi.fn().mockResolvedValue({ stream });
|
|
const permissionReply = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
|
|
createOpencodeMock.mockResolvedValue({
|
|
client: {
|
|
instance: { dispose: disposeInstance },
|
|
session: { create: sessionCreate, promptAsync },
|
|
event: { subscribe },
|
|
permission: { reply: permissionReply },
|
|
},
|
|
server: { close: vi.fn() },
|
|
});
|
|
|
|
const client = new OpenCodeClient();
|
|
const result = await Promise.race([
|
|
client.call('coder', 'hello', {
|
|
cwd: '/tmp',
|
|
model: 'opencode/big-pickle',
|
|
permissionMode: 'edit',
|
|
}),
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)),
|
|
]);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.content).toContain('permission reply timed out');
|
|
});
|
|
});
|