From 5d3aba205246a04c3d86c784f059618dbf688518 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 23:22:06 +0100 Subject: [PATCH] fix: preserve codex raw assistant replies --- .../codex/src/app-server/client.test.ts | 19 ++++++++++ extensions/codex/src/app-server/client.ts | 20 ++++++++++- .../src/app-server/event-projector.test.ts | 21 +++++++++++ .../codex/src/app-server/event-projector.ts | 35 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/extensions/codex/src/app-server/client.test.ts b/extensions/codex/src/app-server/client.test.ts index 8db9e440430..07ed75950ad 100644 --- a/extensions/codex/src/app-server/client.test.ts +++ b/extensions/codex/src/app-server/client.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; +import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { __testing, @@ -49,6 +50,24 @@ describe("CodexAppServerClient", () => { expect(outbound.method).toBe("model/list"); }); + it("logs a redacted preview for malformed app-server messages", async () => { + const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined); + const harness = createClientHarness(); + clients.push(harness.client); + + harness.process.stdout.write('{"token":"secret-value"} trailing\n'); + + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + "failed to parse codex app-server message", + expect.objectContaining({ + linePreview: '{"token":""} trailing', + }), + ), + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("secret-value"); + }); + 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 ea8e9abbf31..801bde25bc5 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -15,6 +15,7 @@ import { createWebSocketTransport } from "./transport-websocket.js"; import { closeCodexAppServerTransport, type CodexAppServerTransport } from "./transport.js"; export const MIN_CODEX_APP_SERVER_VERSION = "0.118.0"; +const CODEX_APP_SERVER_PARSE_LOG_MAX = 500; type PendingRequest = { method: string; @@ -234,7 +235,10 @@ export class CodexAppServerClient { try { parsed = JSON.parse(trimmed); } catch (error) { - embeddedAgentLog.warn("failed to parse codex app-server message", { error }); + embeddedAgentLog.warn("failed to parse codex app-server message", { + error, + linePreview: redactCodexAppServerLinePreview(trimmed), + }); return; } if (!parsed || typeof parsed !== "object") { @@ -416,6 +420,19 @@ function numericVersionParts(version: string): number[] { .map((part) => (Number.isFinite(part) ? part : 0)); } +function redactCodexAppServerLinePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + const redacted = compact + .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, "$1") + .replace( + /("(?:api_?key|authorization|token|access_token|refresh_token)"\s*:\s*")([^"]+)(")/gi, + "$1$3", + ); + return redacted.length > CODEX_APP_SERVER_PARSE_LOG_MAX + ? `${redacted.slice(0, CODEX_APP_SERVER_PARSE_LOG_MAX)}...` + : redacted; +} + const CODEX_APP_SERVER_APPROVAL_REQUEST_METHODS = new Set([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", @@ -438,4 +455,5 @@ function formatExitValue(value: unknown): string { export const __testing = { closeCodexAppServerTransport, + redactCodexAppServerLinePreview, } as const; diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 29fb98e123e..3ac9b2854eb 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -208,6 +208,27 @@ describe("CodexAppServerEventProjector", () => { }); }); + it("uses raw assistant response items when turn completion omits items", async () => { + const projector = await createProjector(); + + await projector.handleNotification( + forCurrentTurn("rawResponseItem/completed", { + item: { + type: "message", + id: "raw-1", + role: "assistant", + content: [{ type: "output_text", text: "OK from raw" }], + }, + }), + ); + await projector.handleNotification(turnCompleted()); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.assistantTexts).toEqual(["OK from raw"]); + expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "OK from raw" }]); + }); + it("normalizes snake_case current token usage fields", async () => { const projector = await createProjector(); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 99c313a964b..f7821ba35e9 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -115,6 +115,9 @@ export class CodexAppServerEventProjector { case "turn/completed": await this.handleTurnCompleted(params); break; + case "rawResponseItem/completed": + this.handleRawResponseItemCompleted(params); + break; case "error": this.promptError = readString(params, "message") ?? "codex app-server error"; this.promptErrorSource = "prompt"; @@ -424,6 +427,20 @@ export class CodexAppServerEventProjector { await this.maybeEndReasoning(); } + private handleRawResponseItemCompleted(params: JsonObject): void { + const item = isJsonObject(params.item) ? params.item : undefined; + if (!item || readString(item, "role") !== "assistant") { + return; + } + const text = extractRawAssistantText(item); + if (!text) { + return; + } + const itemId = readString(item, "id") ?? `raw-assistant-${this.assistantItemOrder.length + 1}`; + this.rememberAssistantItem(itemId); + this.assistantTextByItem.set(itemId, text); + } + private async maybeEndReasoning(): Promise { if (!this.reasoningStarted || this.reasoningEnded) { return; @@ -661,6 +678,24 @@ function collectTextValues(map: Map): string[] { return [...map.values()].filter((text) => text.trim().length > 0); } +function extractRawAssistantText(item: JsonObject): string | undefined { + const content = Array.isArray(item.content) ? item.content : []; + const text = content + .flatMap((entry) => { + if (!isJsonObject(entry)) { + return []; + } + const type = readString(entry, "type"); + if (type !== "output_text" && type !== "text") { + return []; + } + const value = readString(entry, "text"); + return value ? [value] : []; + }) + .join(""); + return text.trim() || undefined; +} + function itemKind( item: CodexThreadItem, ): "tool" | "command" | "patch" | "search" | "analysis" | undefined {