fix: recover multiline codex app-server messages

This commit is contained in:
Peter Steinberger
2026-05-02 06:02:42 +01:00
parent 9989512a37
commit 6fd35f67a7
3 changed files with 102 additions and 5 deletions

View File

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

View File

@@ -61,6 +61,7 @@ describe("CodexAppServerClient", () => {
expect(warn).toHaveBeenCalledWith(
"failed to parse codex app-server message",
expect.objectContaining({
consoleMessage: expect.stringContaining("<redacted>"),
linePreview: '{"token":"<redacted>"} 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);

View File

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