From 64eeb82a4694335ed9f6a4dc2fa6fb198d71ef26 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 3 Sep 2026 11:25:40 +0200 Subject: [PATCH] fix(ai): process unterminated Codex SSE terminal events Flush the SSE decoder at EOF and treat the residual buffer as a complete frame. fixes #9047 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/openai-codex-responses.ts | 7 +++-- packages/ai/test/openai-codex-stream.test.ts | 31 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b579e9283..1b01d27e5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - Removed the unnecessary Chord dependency from pi-ai by defining its exported `JsonValue` type directly. - Fixed GitHub Copilot Claude Fable 5 requests to use the Anthropic Messages adapter so selected reasoning levels are sent ([#8961](https://github.com/earendil-works/pi/issues/8961)). +- Fixed OpenAI Codex SSE parsing to process terminal events that are not followed by a blank line ([#9047](https://github.com/earendil-works/pi/issues/9047)). ## [0.84.4] - 2026-08-28 diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 8ee5b0271..815bba489 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -779,8 +779,9 @@ async function* parseSSE(response: Response, signal?: AbortSignal): AsyncGenerat if (signal?.aborted) { throw new Error("Request was aborted"); } - if (done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + // Treat EOF as terminating the residual SSE frame. + if (done && buffer.trim()) buffer += "\n\n"; let idx = buffer.indexOf("\n\n"); while (idx !== -1) { @@ -806,6 +807,8 @@ async function* parseSSE(response: Response, signal?: AbortSignal): AsyncGenerat } idx = buffer.indexOf("\n\n"); } + + if (done) break; } } finally { signal?.removeEventListener("abort", onAbort); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 1ef93f3be..672948825 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -209,6 +209,37 @@ describe("openai-codex streaming", () => { expect(sawDone).toBe(true); }); + // Regression test for https://github.com/earendil-works/pi/issues/9047 + it("processes a terminal SSE event without a trailing blank line", async () => { + const token = mockToken(); + const sse = buildSSEPayload({ status: "completed" }).trimEnd(); + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + const resultStream = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + fetch: async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }), + }); + const result = await resultStream.result(); + + expect(result.stopReason).toBe("stop"); + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + }); + it("completes after response.completed even when the SSE body stays open", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir;