fix: opencodeの2ターン目ハングを修正し会話継続を実現

streamAbortController.signalをcreateOpencodeに渡していたため、
各callのfinallyでabortするとサーバーが停止し2ターン目がハングしていた。
signalをサーバー起動から除外し、sessionIdの引き継ぎを復元することで
複数ターンの会話継続を実現した。
This commit is contained in:
nrslib 2026-02-20 12:40:17 +09:00
parent b9dfe93d85
commit 67f6fc685c
4 changed files with 244 additions and 32 deletions

View File

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

View File

@ -95,10 +95,6 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result.status).toBe('done'); expect(result.status).toBe('done');
expect(stream.returnSpy).toHaveBeenCalled(); expect(stream.returnSpy).toHaveBeenCalled();
expect(disposeInstance).toHaveBeenCalledWith(
{ directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(subscribe).toHaveBeenCalledWith( expect(subscribe).toHaveBeenCalledWith(
{ directory: '/tmp' }, { directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }), expect.objectContaining({ signal: expect.any(AbortSignal) }),
@ -141,10 +137,6 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result.status).toBe('error'); expect(result.status).toBe('error');
expect(result.content).toContain('boom'); expect(result.content).toContain('boom');
expect(stream.returnSpy).toHaveBeenCalled(); expect(stream.returnSpy).toHaveBeenCalled();
expect(disposeInstance).toHaveBeenCalledWith(
{ directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(subscribe).toHaveBeenCalledWith( expect(subscribe).toHaveBeenCalledWith(
{ directory: '/tmp' }, { directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }), expect.objectContaining({ signal: expect.any(AbortSignal) }),
@ -210,10 +202,6 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result.status).toBe('done'); expect(result.status).toBe('done');
expect(result.content).toBe('done more'); expect(result.content).toBe('done more');
expect(disposeInstance).toHaveBeenCalledWith(
{ directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(subscribe).toHaveBeenCalledWith( expect(subscribe).toHaveBeenCalledWith(
{ directory: '/tmp' }, { directory: '/tmp' },
expect.objectContaining({ signal: expect.any(AbortSignal) }), expect.objectContaining({ signal: expect.any(AbortSignal) }),
@ -615,4 +603,137 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result1.status).toBe('done'); expect(result1.status).toBe('done');
expect(result2.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');
});
}); });

View File

@ -62,7 +62,7 @@ interface SharedServer {
let sharedServer: SharedServer | null = null; let sharedServer: SharedServer | null = null;
let initPromise: Promise<void> | null = null; let initPromise: Promise<void> | 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) { if (initPromise) {
await initPromise; await initPromise;
} }
@ -85,7 +85,6 @@ async function acquireClient(model: string, apiKey?: string, signal?: AbortSigna
const port = await getFreePort(); const port = await getFreePort();
const { client, server } = await createOpencode({ const { client, server } = await createOpencode({
port, port,
signal,
config: { config: {
model, model,
small_model: model, small_model: model,
@ -94,7 +93,15 @@ async function acquireClient(model: string, apiKey?: string, signal?: AbortSigna
timeout: OPENCODE_SERVER_START_TIMEOUT_MS, 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 }); log.debug('OpenCode server started', { model, port });
return { client, release: () => releaseClient() }; return { client, release: () => releaseClient() };
@ -380,7 +387,7 @@ export class OpenCodeClient {
const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; 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; opencodeApiClient = acquired.client;
release = acquired.release; release = acquired.release;
@ -707,22 +714,6 @@ export class OpenCodeClient {
if (options.abortSignal) { if (options.abortSignal) {
options.abortSignal.removeEventListener('abort', onExternalAbort); 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?.(); release?.();
if (!streamAbortController.signal.aborted) { if (!streamAbortController.signal.aborted) {
streamAbortController.abort(); streamAbortController.abort();

View File

@ -10,6 +10,7 @@ export default defineConfig({
'e2e/specs/pipeline.e2e.ts', 'e2e/specs/pipeline.e2e.ts',
'e2e/specs/github-issue.e2e.ts', 'e2e/specs/github-issue.e2e.ts',
'e2e/specs/structured-output.e2e.ts', 'e2e/specs/structured-output.e2e.ts',
'e2e/specs/opencode-conversation.e2e.ts',
], ],
}, },
}); });