fix: preserve codex raw assistant replies

This commit is contained in:
Peter Steinberger
2026-04-23 23:22:06 +01:00
parent 7ec48b24a3
commit 5d3aba2052
4 changed files with 94 additions and 1 deletions

View File

@@ -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":"<redacted>"} 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);

View File

@@ -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<redacted>")
.replace(
/("(?:api_?key|authorization|token|access_token|refresh_token)"\s*:\s*")([^"]+)(")/gi,
"$1<redacted>$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;

View File

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

View File

@@ -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<void> {
if (!this.reasoningStarted || this.reasoningEnded) {
return;
@@ -661,6 +678,24 @@ function collectTextValues(map: Map<string, string>): 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 {