diff --git a/CHANGELOG.md b/CHANGELOG.md index f46543534d0..8ed341ef9ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,9 @@ Docs: https://docs.openclaw.ai - Media delivery: avoid sending generated image attachments twice when the assistant reply already includes explicit `MEDIA:` lines for the same turn, and reject unsafe remote `MEDIA:` URLs before delivery. Thanks @pashpashpash. +- Codex harness: ignore retryable app-server error notifications after Codex + recovers, and preserve the real nested error message for terminal app-server + failures instead of replacing it with a generic failure. Thanks @pashpashpash. - Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 7454c188e3c..c6997ea0a31 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -129,6 +129,17 @@ function agentMessageDelta(delta: string, itemId = "msg-1"): ProjectorNotificati return forCurrentTurn("item/agentMessage/delta", { itemId, delta }); } +function appServerError(params: { message: string; willRetry: boolean }): ProjectorNotification { + return forCurrentTurn("error", { + error: { + message: params.message, + codexErrorInfo: null, + additionalDetails: null, + }, + willRetry: params.willRetry, + }); +} + function turnCompleted(items: unknown[] = []): ProjectorNotification { return { method: "turn/completed", @@ -235,6 +246,40 @@ describe("CodexAppServerEventProjector", () => { expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "OK from raw" }]); }); + it("does not fail a completed reply after a retryable app-server error notification", async () => { + const projector = await createProjector(); + + await projector.handleNotification(agentMessageDelta("still working")); + await projector.handleNotification( + appServerError({ message: "stream disconnected", willRetry: true }), + ); + await projector.handleNotification( + turnCompleted([{ type: "agentMessage", id: "msg-1", text: "final answer" }]), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.assistantTexts).toEqual(["final answer"]); + expect(result.promptError).toBeNull(); + expect(result.promptErrorSource).toBeNull(); + expect(result.lastAssistant?.stopReason).toBe("stop"); + expect(result.lastAssistant?.errorMessage).toBeUndefined(); + }); + + it("uses nested app-server error messages for terminal errors", async () => { + const projector = await createProjector(); + + await projector.handleNotification( + appServerError({ message: "stream failed permanently", willRetry: false }), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.promptError).toBe("stream failed permanently"); + expect(result.promptErrorSource).toBe("prompt"); + expect(result.lastAssistant).toBeUndefined(); + }); + 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 129d9f14b35..6b5d4805d57 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -153,7 +153,10 @@ export class CodexAppServerEventProjector { this.handleRawResponseItemCompleted(params); break; case "error": - this.promptError = readString(params, "message") ?? "codex app-server error"; + if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) { + break; + } + this.promptError = readCodexErrorNotificationMessage(params) ?? "codex app-server error"; this.promptErrorSource = "prompt"; break; default: @@ -844,6 +847,29 @@ function readNumber(record: JsonObject, key: string): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function readBoolean(record: JsonObject, key: string): boolean | undefined { + const value = record[key]; + return typeof value === "boolean" ? value : undefined; +} + +function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined { + for (const key of keys) { + const value = readBoolean(record, key); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +function readCodexErrorNotificationMessage(record: JsonObject): string | undefined { + const error = record.error; + if (isJsonObject(error)) { + return readString(error, "message") ?? readString(error, "error"); + } + return readString(record, "message"); +} + function readHookOutputEntries( value: JsonValue | undefined, ): Array<{ kind?: string; text: string }> {