takt/src/__tests__/opencode-client-cleanup.test.ts

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');
});
});