From 6fd35f67a7fd914d925cf65c4a27a61dfa48b0a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 06:02:42 +0100 Subject: [PATCH] fix: recover multiline codex app-server messages --- CHANGELOG.md | 1 + .../codex/src/app-server/client.test.ts | 27 +++++++ extensions/codex/src/app-server/client.ts | 79 +++++++++++++++++-- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 180ec43f0ae..de57c70db4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Subagents: honor `sessions_spawn` with `expectsCompletionMessage: false` by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw. - Media/completions: treat media-only message-tool sends as delivered async completion output, avoiding duplicate raw `MEDIA:` fallback posts after video or music generation finishes. - Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. +- Codex/app-server: recover JSON-RPC frames split by raw command-output newlines and include a redacted preview when malformed app-server messages still reach the console. Thanks @vincentkoc. - Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se. - ACP/Discord: suppress completion announce delivery for inline thread-bound ACP session runs, so Discord thread-bound ACP replies are not delivered twice. Fixes #60780. Thanks @solavrc. - Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213. diff --git a/extensions/codex/src/app-server/client.test.ts b/extensions/codex/src/app-server/client.test.ts index ea27b545bac..7d00ce18fe2 100644 --- a/extensions/codex/src/app-server/client.test.ts +++ b/extensions/codex/src/app-server/client.test.ts @@ -61,6 +61,7 @@ describe("CodexAppServerClient", () => { expect(warn).toHaveBeenCalledWith( "failed to parse codex app-server message", expect.objectContaining({ + consoleMessage: expect.stringContaining(""), linePreview: '{"token":""} trailing', }), ), @@ -68,6 +69,32 @@ describe("CodexAppServerClient", () => { expect(JSON.stringify(warn.mock.calls)).not.toContain("secret-value"); }); + it("recovers app-server messages split by raw newlines inside JSON strings", async () => { + const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined); + const harness = createClientHarness(); + clients.push(harness.client); + const notifications: unknown[] = []; + harness.client.addNotificationHandler((notification) => { + notifications.push(notification); + }); + + harness.process.stdout.write( + '{"method":"item/commandExecution/outputDelta","params":{"delta":"first' + + "\n" + + 'second"}}\n', + ); + + await vi.waitFor(() => + expect(notifications).toEqual([ + { + method: "item/commandExecution/outputDelta", + params: { delta: "first\nsecond" }, + }, + ]), + ); + expect(warn).not.toHaveBeenCalled(); + }); + it("preserves JSON-RPC error codes", async () => { const harness = createClientHarness(); clients.push(harness.client); diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 47f847c7a47..38e36c756e5 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -25,6 +25,8 @@ import { MIN_CODEX_APP_SERVER_VERSION } from "./version.js"; export { MIN_CODEX_APP_SERVER_VERSION } from "./version.js"; const CODEX_APP_SERVER_PARSE_LOG_MAX = 500; +const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000; +const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000; const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 30_000; type PendingRequest = { @@ -64,6 +66,13 @@ export class CodexAppServerClient { private nextId = 1; private initialized = false; private closed = false; + private pendingParse: + | { + text: string; + lineCount: number; + firstError: unknown; + } + | undefined; private constructor(child: CodexAppServerTransport) { this.child = child; @@ -262,7 +271,12 @@ export class CodexAppServerClient { } private handleLine(line: string): void { - const trimmed = line.trim(); + const rawLine = line.endsWith("\r") ? line.slice(0, -1) : line; + if (this.pendingParse) { + this.handlePendingParseLine(rawLine); + return; + } + const trimmed = rawLine.trim(); if (!trimmed) { return; } @@ -270,12 +284,43 @@ export class CodexAppServerClient { try { parsed = JSON.parse(trimmed); } catch (error) { - embeddedAgentLog.warn("failed to parse codex app-server message", { - error, - linePreview: redactCodexAppServerLinePreview(trimmed), - }); + if (shouldBufferCodexAppServerParseFailure(trimmed, error)) { + this.pendingParse = { text: trimmed, lineCount: 1, firstError: error }; + return; + } + logCodexAppServerParseFailure(trimmed, error, 1); return; } + this.handleParsedMessage(parsed); + } + + private handlePendingParseLine(line: string): void { + const pending = this.pendingParse; + if (!pending) { + return; + } + const candidate = `${pending.text}\\n${line}`; + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch (error) { + const lineCount = pending.lineCount + 1; + if ( + candidate.length <= CODEX_APP_SERVER_PARSE_BUFFER_MAX && + lineCount <= CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES + ) { + this.pendingParse = { text: candidate, lineCount, firstError: pending.firstError }; + return; + } + this.pendingParse = undefined; + logCodexAppServerParseFailure(candidate, error, lineCount); + return; + } + this.pendingParse = undefined; + this.handleParsedMessage(parsed); + } + + private handleParsedMessage(parsed: unknown): void { if (!parsed || typeof parsed !== "object") { return; } @@ -547,6 +592,30 @@ function redactCodexAppServerLinePreview(value: string): string { : redacted; } +function shouldBufferCodexAppServerParseFailure(value: string, error: unknown): boolean { + if (!value.startsWith("{") && !value.startsWith("[")) { + return false; + } + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("Unterminated string") || message.includes("Unexpected end of JSON input") + ); +} + +function logCodexAppServerParseFailure(value: string, error: unknown, fragmentCount: number): void { + const linePreview = redactCodexAppServerLinePreview(value); + const suffix = fragmentCount > 1 ? ` fragments=${fragmentCount}` : ""; + embeddedAgentLog.warn("failed to parse codex app-server message", { + error, + errorMessage: error instanceof Error ? error.message : String(error), + fragmentCount, + linePreview, + consoleMessage: `failed to parse codex app-server message${suffix}: preview=${JSON.stringify( + linePreview, + )}`, + }); +} + const CODEX_APP_SERVER_APPROVAL_REQUEST_METHODS = new Set([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval",