From 2ff818ab663ed55d9c63e99045d49030fe13de6d Mon Sep 17 00:00:00 2001 From: eclipse1228 Date: Fri, 4 Sep 2026 15:12:40 +0900 Subject: [PATCH] fix(responses): reject incomplete terminal streams --- src/adapters/codexResponses.test.ts | 27 +++++++++++++++++++++++++++ src/adapters/codexResponses.ts | 10 ++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/adapters/codexResponses.test.ts b/src/adapters/codexResponses.test.ts index d0483aa4..12dc467a 100644 --- a/src/adapters/codexResponses.test.ts +++ b/src/adapters/codexResponses.test.ts @@ -138,6 +138,33 @@ describe('unsupported-model fallback', () => { }); }); +describe('Responses stream terminal events', () => { + it('propagates an incomplete 200 stream instead of returning an empty successful response', async () => { + const fetchMock = vi.fn(async () => new Response( + [ + 'data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}', + 'data: [DONE]', + '', + ].join('\n'), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + )); + vi.stubGlobal('fetch', fetchMock); + + type CreateApiCaller = ( + initialToken: string, + accountId: string, + store: unknown, + model: string, + ) => (messages: ChatMessage[], tools: ToolDefinition[]) => Promise; + const adapter = new CodexResponsesAdapter() as unknown as { createApiCaller: CreateApiCaller }; + + await expect(adapter.createApiCaller('token', 'account', {}, 'gpt-5.6-terra')( + [{ role: 'user', content: 'Return a short answer.' }], + [], + )).rejects.toThrow('incomplete'); + }); +}); + describe('chatToResponsesInput', () => { it('lifts system messages into instructions and keeps user/assistant as input', () => { const messages: ChatMessage[] = [ diff --git a/src/adapters/codexResponses.ts b/src/adapters/codexResponses.ts index e097244e..2fa65e62 100644 --- a/src/adapters/codexResponses.ts +++ b/src/adapters/codexResponses.ts @@ -133,6 +133,8 @@ interface SseEvent { arguments?: string; response?: { model?: string; + incomplete_details?: { reason?: string }; + error?: { message?: string }; usage?: { input_tokens?: number; output_tokens?: number; input_tokens_details?: { cached_tokens?: number } }; }; } @@ -241,6 +243,7 @@ async function consumeResponsesStream( const decoder = new TextDecoder(); let buffer = ''; + let terminalError: string | undefined; // Reasoning summary streams token-by-token; buffer and emit whole lines so the // live log shows readable thoughts instead of one-word-per-line spam. let reasoningBuf = ''; @@ -257,6 +260,11 @@ async function consumeResponsesStream( const handle = (ev: SseEvent | null) => { if (!ev) return; events.push(ev); + if (ev.type === 'response.incomplete') { + terminalError = `Responses stream incomplete${ev.response?.incomplete_details?.reason ? `: ${ev.response.incomplete_details.reason}` : ''}`; + } else if (ev.type === 'response.failed') { + terminalError = `Responses stream failed${ev.response?.error?.message ? `: ${ev.response.error.message}` : ''}`; + } if (onToken && ev.type === 'response.output_text.delta' && ev.delta) onToken(ev.delta); if (onReasoning && ev.type === 'response.reasoning_summary_text.delta' && ev.delta) { reasoningBuf += ev.delta; @@ -278,6 +286,8 @@ async function consumeResponsesStream( handle(parseSseLine(buffer)); flushReasoning(true); + if (terminalError) throw new Error(terminalError); + return reduceResponsesEvents(events); }