Respect retryable Codex app-server errors

Codex app-server sends retryable stream error notifications while a turn is still recovering. OpenClaw now ignores retryable app-server errors and preserves nested terminal error messages instead of replacing them with a generic fallback.
This commit is contained in:
pashpashpash
2026-04-25 18:26:27 -07:00
committed by GitHub
parent a35d259719
commit e989f3c868
3 changed files with 75 additions and 1 deletions

View File

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

View File

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

View File

@@ -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 }> {