From 2a678f3a755d17fb445a375018607208e3a6c8d4 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:54:18 +0900 Subject: [PATCH] =?UTF-8?q?opencode=E3=81=AE=E7=B5=82=E4=BA=86=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=8C=E8=AA=A4=E3=81=A3=E3=81=A6=E3=81=84=E3=81=9F?= =?UTF-8?q?=E3=81=AE=E3=81=A7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/opencode-client-cleanup.test.ts | 56 +++++-------------- src/infra/opencode/client.ts | 44 ++++++++++++++- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 673398e..5dc0705 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -31,45 +31,6 @@ class MockEventStream implements AsyncGenerator { } } -class HangingAfterEventsStream implements AsyncGenerator { - private index = 0; - private closed = false; - private pendingResolve: ((value: IteratorResult) => void) | undefined; - readonly returnSpy = vi.fn(async () => { - this.closed = true; - this.pendingResolve?.({ done: true, value: undefined }); - return { done: true as const, value: undefined }; - }); - - constructor(private readonly events: unknown[]) {} - - [Symbol.asyncIterator](): AsyncGenerator { - return this; - } - - async next(): Promise> { - if (this.closed) { - return { done: true, value: undefined }; - } - if (this.index < this.events.length) { - const value = this.events[this.index]; - this.index += 1; - return { done: false, value }; - } - return new Promise>((resolve) => { - this.pendingResolve = resolve; - }); - } - - async return(): Promise> { - return this.returnSpy(); - } - - async throw(e?: unknown): Promise> { - throw e; - } -} - const { createOpencodeMock } = vi.hoisted(() => ({ createOpencodeMock: vi.fn(), })); @@ -188,9 +149,9 @@ describe('OpenCodeClient stream cleanup', () => { ); }); - it('should complete without hanging when assistant message is completed', async () => { + it('should continue after assistant message completed and finish on session.idle', async () => { const { OpenCodeClient } = await import('../infra/opencode/client.js'); - const stream = new HangingAfterEventsStream([ + const stream = new MockEventStream([ { type: 'message.part.updated', properties: { @@ -208,6 +169,17 @@ describe('OpenCodeClient stream cleanup', () => { }, }, }, + { + 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); @@ -235,7 +207,7 @@ describe('OpenCodeClient stream cleanup', () => { ]); expect(result.status).toBe('done'); - expect(result.content).toBe('done'); + expect(result.content).toBe('done more'); expect(disposeInstance).toHaveBeenCalledWith( { directory: '/tmp' }, expect.objectContaining({ signal: expect.any(AbortSignal) }), diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index cb6271b..5db6152 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -455,13 +455,51 @@ export class OpenCodeClient { }; const info = messageProps.info; const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; - const isCompleted = typeof info?.time?.completed === 'number'; - if (isCurrentAssistantMessage && isCompleted) { - const streamError = extractOpenCodeErrorMessage(info.error); + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); if (streamError) { success = false; failureMessage = streamError; + break; } + } + continue; + } + + if (sseEvent.type === 'message.completed') { + const completedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = completedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); + if (streamError) { + success = false; + failureMessage = streamError; + break; + } + } + continue; + } + + if (sseEvent.type === 'message.failed') { + const failedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = failedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + success = false; + failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; break; } continue;