takt/src/__tests__/opencode-client-cleanup.test.ts
nrslib 67f6fc685c fix: opencodeの2ターン目ハングを修正し会話継続を実現
streamAbortController.signalをcreateOpencodeに渡していたため、
各callのfinallyでabortするとサーバーが停止し2ターン目がハングしていた。
signalをサーバー起動から除外し、sessionIdの引き継ぎを復元することで
複数ターンの会話継続を実現した。
2026-02-20 12:40:17 +09:00

740 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from 'vitest';
class MockEventStream implements AsyncGenerator<unknown, void, unknown> {
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<unknown, void, unknown> {
return this;
}
async next(): Promise<IteratorResult<unknown, void>> {
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<IteratorResult<unknown, void>> {
return this.returnSpy();
}
async throw(e?: unknown): Promise<IteratorResult<unknown, void>> {
throw e;
}
}
const { createOpencodeMock } = vi.hoisted(() => ({
createOpencodeMock: vi.fn(),
}));
vi.mock('node:net', () => ({
createServer: () => {
const handlers = new Map<string, (...args: unknown[]) => 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(async () => {
vi.clearAllMocks();
const { resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
});
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(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(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<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), 500)),
]);
expect(result.status).toBe('done');
expect(result.content).toBe('done more');
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 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<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)),
]);
expect(result.status).toBe('error');
expect(result.content).toContain('permission reply timed out');
});
it('should reuse shared server for parallel calls with same config', async () => {
const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
let callCount = 0;
const sessionCreate = vi.fn().mockImplementation(() => {
callCount += 1;
return Promise.resolve({ data: { id: `session-${callCount}` } });
});
const promptAsync = vi.fn().mockResolvedValue(undefined);
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const serverClose = vi.fn();
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockImplementation(() => {
const events = [{ type: 'session.idle', properties: { sessionID: `session-${callCount}` } }];
return Promise.resolve({ stream: new MockEventStream(events) });
}) },
permission: { reply: vi.fn() },
},
server: { close: serverClose },
});
const client = new OpenCodeClient();
const [result1, result2, result3] = await Promise.all([
client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/big-pickle' }),
client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/big-pickle' }),
client.call('coder', 'task3', { cwd: '/tmp', model: 'opencode/big-pickle' }),
]);
expect(createOpencodeMock).toHaveBeenCalledTimes(1);
expect(sessionCreate).toHaveBeenCalledTimes(3);
expect(result1.status).toBe('done');
expect(result2.status).toBe('done');
expect(result3.status).toBe('done');
expect(serverClose).not.toHaveBeenCalled();
});
it('should create new server when model changes', async () => {
const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } });
const promptAsync = vi.fn().mockResolvedValue(undefined);
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const serverClose1 = vi.fn();
const serverClose2 = vi.fn();
createOpencodeMock.mockResolvedValueOnce({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-1' } }]) }) },
permission: { reply: vi.fn() },
},
server: { close: serverClose1 },
}).mockResolvedValueOnce({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-2' } }]) }) },
permission: { reply: vi.fn() },
},
server: { close: serverClose2 },
});
const client = new OpenCodeClient();
const result1 = await client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/model-a' });
const result2 = await client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/model-b' });
expect(createOpencodeMock).toHaveBeenCalledTimes(2);
expect(serverClose1).toHaveBeenCalled();
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');
});
});