diff --git a/e2e/specs/opencode-conversation.e2e.ts b/e2e/specs/opencode-conversation.e2e.ts new file mode 100644 index 0000000..affc279 --- /dev/null +++ b/e2e/specs/opencode-conversation.e2e.ts @@ -0,0 +1,99 @@ +/** + * OpenCode real E2E conversation test. + * + * Tests the full stack with a real OpenCode server: + * OpenCodeProvider → callOpenCode → OpenCodeClient → createOpencode (real server) + * + * Skipped automatically if the opencode binary is not found. + * Run with: npm run test:e2e:opencode + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { execSync } from 'node:child_process'; +import { resetSharedServer } from '../../src/infra/opencode/client.js'; +import { OpenCodeProvider } from '../../src/infra/providers/opencode.js'; + +function isOpencodeAvailable(): boolean { + try { + execSync('which opencode', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const MODEL = process.env.OPENCODE_E2E_MODEL ?? 'minimax/MiniMax-M2.5-highspeed'; +const enabled = isOpencodeAvailable(); + +describe.skipIf(!enabled)('OpenCode real E2E conversation', () => { + afterAll(() => { + resetSharedServer(); + }); + + it('should complete a two-turn conversation with sessionId inheritance', async () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ + name: 'coder', + systemPrompt: 'You are a concise assistant. Keep all responses under 20 words.', + }); + + // 1ターン目 + const result1 = await agent.call('Say only the word "apple".', { + cwd: process.cwd(), + model: MODEL, + }); + + expect(result1.status).toBe('done'); + expect(result1.sessionId).toBeDefined(); + + // 2ターン目: sessionId を引き継いで送る(conversationLoop と同じ) + const result2 = await agent.call('What fruit did I ask you about?', { + cwd: process.cwd(), + model: MODEL, + sessionId: result1.sessionId, + }); + + expect(result2.status).toBe('done'); + // 同じセッションを再利用している + expect(result2.sessionId).toBe(result1.sessionId); + // 会話が引き継がれていれば "apple" に言及するはず + expect(result2.content.toLowerCase()).toContain('apple'); + }, 120_000); + + it('should complete a three-turn conversation without hanging', async () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ + name: 'coder', + systemPrompt: 'You are a concise assistant. Keep all responses under 20 words.', + }); + + const results = []; + let prevSessionId: string | undefined; + + const prompts = [ + 'Remember the number 42.', + 'What number did I ask you to remember?', + 'Double that number.', + ]; + + for (const prompt of prompts) { + const result = await agent.call(prompt, { + cwd: process.cwd(), + model: MODEL, + sessionId: prevSessionId, + }); + + expect(result.status).toBe('done'); + results.push(result); + prevSessionId = result.sessionId; + } + + // すべてのターンが同じセッションを使っている + expect(results[1].sessionId).toBe(results[0].sessionId); + expect(results[2].sessionId).toBe(results[0].sessionId); + + // 会話が引き継がれている + expect(results[1].content).toMatch(/42/); + expect(results[2].content).toMatch(/84/); + }, 180_000); +}); diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index ab40943..4adb3d4 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -95,10 +95,6 @@ describe('OpenCodeClient stream cleanup', () => { 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) }), @@ -141,10 +137,6 @@ describe('OpenCodeClient stream cleanup', () => { 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) }), @@ -210,10 +202,6 @@ describe('OpenCodeClient stream cleanup', () => { 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) }), @@ -615,4 +603,137 @@ describe('OpenCodeClient stream cleanup', () => { expect(result1.status).toBe('done'); expect(result2.status).toBe('done'); }); + +}); + +describe('OpenCode conversation via provider (E2E)', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); + }); + + function makeClientMock(sessionId: string, responses: string[]) { + let turnIndex = 0; + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: sessionId } }); + const promptAsync = vi.fn().mockResolvedValue(undefined); + const subscribe = vi.fn().mockImplementation(() => { + const text = responses[turnIndex] ?? ''; + const events: unknown[] = []; + if (text) { + events.push({ + type: 'message.part.updated', + properties: { part: { id: `p-${turnIndex}`, type: 'text', text }, delta: text }, + }); + } + events.push({ type: 'session.idle', properties: { sessionID: sessionId } }); + turnIndex += 1; + return Promise.resolve({ stream: new MockEventStream(events) }); + }); + return { sessionCreate, promptAsync, subscribe }; + } + + it('should carry sessionId across turns and reuse server', async () => { + const { OpenCodeProvider } = await import('../infra/providers/opencode.js'); + const { resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); + + const { sessionCreate, promptAsync, subscribe } = makeClientMock('conv-session', [ + 'Hello!', + 'I remember our conversation.', + ]); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: vi.fn() }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const provider = new OpenCodeProvider(); + const agent = provider.setup({ name: 'coder', systemPrompt: 'You are a helpful assistant.' }); + + // 1ターン目 + const result1 = await agent.call('Hi', { cwd: '/tmp', model: 'opencode/big-pickle' }); + expect(result1.status).toBe('done'); + expect(result1.content).toBe('Hello!'); + expect(result1.sessionId).toBe('conv-session'); + + // 2ターン目: conversationLoop と同様に前ターンの sessionId を引き継ぐ + const result2 = await agent.call('Do you remember me?', { + cwd: '/tmp', + model: 'opencode/big-pickle', + sessionId: result1.sessionId, + }); + expect(result2.status).toBe('done'); + expect(result2.content).toBe('I remember our conversation.'); + expect(result2.sessionId).toBe('conv-session'); + + // サーバーは1回だけ起動(再利用) + expect(createOpencodeMock).toHaveBeenCalledTimes(1); + // sessionId を引き継いだので session.create は1回だけ + expect(sessionCreate).toHaveBeenCalledTimes(1); + // 両ターンでプロンプトが送られた + expect(promptAsync).toHaveBeenCalledTimes(2); + expect(subscribe).toHaveBeenCalledTimes(2); + }); + + it('should carry sessionId across three turns (multi-turn conversation)', async () => { + const { OpenCodeProvider } = await import('../infra/providers/opencode.js'); + const { resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); + + const { sessionCreate, promptAsync, subscribe } = makeClientMock('multi-session', [ + 'Turn 1 response', + 'Turn 2 response', + 'Turn 3 response', + ]); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: vi.fn() }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const provider = new OpenCodeProvider(); + const agent = provider.setup({ name: 'coder' }); + + const results = []; + let prevSessionId: string | undefined; + + for (let i = 0; i < 3; i++) { + const result = await agent.call(`message ${i + 1}`, { + cwd: '/tmp', + model: 'opencode/big-pickle', + sessionId: prevSessionId, + }); + results.push(result); + prevSessionId = result.sessionId; + } + + expect(results[0].status).toBe('done'); + expect(results[1].status).toBe('done'); + expect(results[2].status).toBe('done'); + expect(results[0].content).toBe('Turn 1 response'); + expect(results[1].content).toBe('Turn 2 response'); + expect(results[2].content).toBe('Turn 3 response'); + + // サーバーは1回だけ起動 + expect(createOpencodeMock).toHaveBeenCalledTimes(1); + // sessionId を引き継いでいるので session.create は1回のみ + expect(sessionCreate).toHaveBeenCalledTimes(1); + // 3ターン分のプロンプトが送られた + expect(promptAsync).toHaveBeenCalledTimes(3); + // すべてのターンで同じ sessionId + expect(results[0].sessionId).toBe('multi-session'); + expect(results[1].sessionId).toBe('multi-session'); + expect(results[2].sessionId).toBe('multi-session'); + }); }); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index a0cf429..748912a 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -62,7 +62,7 @@ interface SharedServer { let sharedServer: SharedServer | null = null; let initPromise: Promise | null = null; -async function acquireClient(model: string, apiKey?: string, signal?: AbortSignal): Promise<{ client: OpencodeClient; release: () => void }> { +async function acquireClient(model: string, apiKey?: string): Promise<{ client: OpencodeClient; release: () => void }> { if (initPromise) { await initPromise; } @@ -85,7 +85,6 @@ async function acquireClient(model: string, apiKey?: string, signal?: AbortSigna const port = await getFreePort(); const { client, server } = await createOpencode({ port, - signal, config: { model, small_model: model, @@ -94,7 +93,15 @@ async function acquireClient(model: string, apiKey?: string, signal?: AbortSigna timeout: OPENCODE_SERVER_START_TIMEOUT_MS, }); - sharedServer = { client, close: server.close, model, apiKey, queue: [] }; + const closeServer = (): void => { + try { + server.close(); + } catch { + // Ignore close errors + } + }; + + sharedServer = { client, close: closeServer, model, apiKey, queue: [] }; log.debug('OpenCode server started', { model, port }); return { client, release: () => releaseClient() }; @@ -380,7 +387,7 @@ export class OpenCodeClient { const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; - const acquired = await acquireClient(fullModel, options.opencodeApiKey, streamAbortController.signal); + const acquired = await acquireClient(fullModel, options.opencodeApiKey); opencodeApiClient = acquired.client; release = acquired.release; @@ -707,22 +714,6 @@ export class OpenCodeClient { if (options.abortSignal) { options.abortSignal.removeEventListener('abort', onExternalAbort); } - if (opencodeApiClient) { - const disposeAbortController = new AbortController(); - const disposeTimeoutId = setTimeout(() => { - disposeAbortController.abort(); - }, 3000); - try { - await opencodeApiClient.instance.dispose( - { directory: options.cwd }, - { signal: disposeAbortController.signal }, - ); - } catch { - // Ignore dispose errors during cleanup. - } finally { - clearTimeout(disposeTimeoutId); - } - } release?.(); if (!streamAbortController.signal.aborted) { streamAbortController.abort(); diff --git a/vitest.config.e2e.provider.ts b/vitest.config.e2e.provider.ts index a35d436..8ca9ea4 100644 --- a/vitest.config.e2e.provider.ts +++ b/vitest.config.e2e.provider.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'e2e/specs/pipeline.e2e.ts', 'e2e/specs/github-issue.e2e.ts', 'e2e/specs/structured-output.e2e.ts', + 'e2e/specs/opencode-conversation.e2e.ts', ], }, });