fix: opencodeの2ターン目ハングを修正し会話継続を実現
streamAbortController.signalをcreateOpencodeに渡していたため、 各callのfinallyでabortするとサーバーが停止し2ターン目がハングしていた。 signalをサーバー起動から除外し、sessionIdの引き継ぎを復元することで 複数ターンの会話継続を実現した。
This commit is contained in:
parent
b9dfe93d85
commit
67f6fc685c
99
e2e/specs/opencode-conversation.e2e.ts
Normal file
99
e2e/specs/opencode-conversation.e2e.ts
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -62,7 +62,7 @@ interface SharedServer {
|
||||
let sharedServer: SharedServer | 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) {
|
||||
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();
|
||||
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user