diff --git a/plugins/provider-codex/src/bridge/app-server-connection.line-separator.test.ts b/plugins/provider-codex/src/bridge/app-server-connection.line-separator.test.ts new file mode 100644 index 0000000000..ed3a00ebc4 --- /dev/null +++ b/plugins/provider-codex/src/bridge/app-server-connection.line-separator.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createCodexAppServerConnection } from "./app-server-connection.js"; + +// Echoes every request as a result whose string contains a raw U+2028. +const FAKE_SERVER = ` + process.stdin.setEncoding("utf8"); + let buffered = ""; + process.stdin.on("data", (chunk) => { + buffered += chunk; + let index; + while ((index = buffered.indexOf("\\n")) !== -1) { + const line = buffered.slice(0, index); + buffered = buffered.slice(index + 1); + const { id } = JSON.parse(line); + const text = "before\\u2028after"; + process.stdout.write(JSON.stringify({ id, result: { text } }) + "\\n"); + } + }); +`; + +it("keeps a JSON line intact when it contains U+2028", async () => { + const connection = createCodexAppServerConnection({ + command: process.execPath, + args: ["-e", FAKE_SERVER], + cwd: process.cwd(), + env: process.env, + recordThreadId: null, + onNotification: () => {}, + onRequest: () => {}, + onExit: () => {}, + }); + try { + const result = await connection.request({ + method: "thread/resume", + params: {}, + resultSchema: z.object({ text: z.string() }), + timeoutMs: 5_000, + }); + expect(result.text).toBe("before\u2028after"); + } finally { + connection.kill(); + } +}); diff --git a/plugins/provider-codex/src/bridge/app-server-connection.ts b/plugins/provider-codex/src/bridge/app-server-connection.ts index d055b8e54e..eb8081da3a 100644 --- a/plugins/provider-codex/src/bridge/app-server-connection.ts +++ b/plugins/provider-codex/src/bridge/app-server-connection.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { createInterface, type Interface } from "node:readline"; +import { createInterface } from "node:readline"; +import { StringDecoder } from "node:string_decoder"; import { experimental_recordProviderChildIo } from "@get-bb/plugin-sdk/provider-bridge"; import type { z } from "zod"; @@ -111,7 +112,7 @@ export function createCodexAppServerConnection( signal: NodeJS.Signals | null; } | null = null; let closeGraceTimer: NodeJS.Timeout | null = null; - let stdoutLines: Interface | null = null; + let stdoutLines: { close(): void } | null = null; function writeLine(message: object): void { const stdin = child.stdin; @@ -159,8 +160,15 @@ export function createCodexAppServerConnection( } if (child.stdout) { - stdoutLines = createInterface({ input: child.stdout, terminal: false }); - stdoutLines.on("line", (line) => { + // Split strictly on "\n". `node:readline` also treats U+2028 and U+2029 as + // line breaks, which codex may emit unescaped inside JSON strings. That + // fragmented large responses (for example `thread/resume` for a thread whose + // history contained U+2028) into unparseable pieces, so the request never + // resolved and timed out. + const stdout = child.stdout; + const decoder = new StringDecoder("utf8"); + let buffered = ""; + const handleStdoutLine = (line: string) => { if (finalized) { return; } @@ -222,7 +230,23 @@ export function createCodexAppServerConnection( } options.onNotification(message.method, message.params); - }); + }; + const onStdoutData = (chunk: Buffer) => { + buffered += decoder.write(chunk); + let newlineIndex = buffered.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffered.slice(0, newlineIndex); + buffered = buffered.slice(newlineIndex + 1); + handleStdoutLine(line.endsWith("\r") ? line.slice(0, -1) : line); + newlineIndex = buffered.indexOf("\n"); + } + }; + stdout.on("data", onStdoutData); + stdoutLines = { + close() { + stdout.off("data", onStdoutData); + }, + }; } if (child.stderr) {