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
This commit is contained in:
David Brailovsky 2026-09-03 11:25:40 +02:00
parent 55adba4f24
commit 64eeb82a46
3 changed files with 37 additions and 2 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;