import { describe, it, expect, vi, beforeEach } from 'vitest'; class MockEventStream implements AsyncGenerator { 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 { return this; } async next(): Promise> { 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> { return this.returnSpy(); } async throw(e?: unknown): Promise> { throw e; } } const { createOpencodeMock } = vi.hoisted(() => ({ createOpencodeMock: vi.fn(), })); vi.mock('node:net', () => ({ createServer: () => { const handlers = new Map 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((_, 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 }; const permission = createCallArgs.config?.permission as Record; 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((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)), ]); expect(result.status).toBe('error'); expect(result.content).toContain('permission reply timed out'); }); });