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(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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user