fix(coding-agent): preserve codex web search backend error diagnostics

The Codex SSE `type:"error"` branch read only top-level `code`/`message`,
so backend rejections emitted under a nested `error` or `response.error`
object collapsed to `Codex error (): Unknown error`, hiding the cause
(e.g. a regional/model-snapshot rejection). `response.failed` similarly
dropped the error code.

Add a shared `extractCodexSseError` that reads top-level, nested `error`,
and `response.error` envelopes, and wire both error paths through it so
the backend code and message survive in `SearchProviderError`. The
existing `web_search_call` requirement is untouched.

Fixes #7200
This commit is contained in:
roboomp
2026-07-31 20:56:54 +00:00
parent 80627462b4
commit d4cb024b57
3 changed files with 75 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Codex web search collapsing backend errors to `Codex error (): Unknown error`; the SSE error parser now preserves the backend code and message from top-level, nested `error`, and `response.error` envelopes ([#7200](https://github.com/can1357/oh-my-pi/issues/7200)).
## [17.2.2] - 2026-07-31
### Added
@@ -407,6 +407,30 @@ function buildCodexHeaders(
return headers;
}
/**
* Extracts a backend error `{code, message}` from a Codex SSE event, tolerating
* the envelope shapes the ChatGPT Codex backend emits: top-level `{code,message}`,
* a nested `error` object, and a `response.error` object (as in `response.failed`).
* Without this the nested shapes collapse to `Codex error (): Unknown error`,
* discarding the backend diagnostic — e.g. a regional/model-snapshot rejection (#7200).
*/
function extractCodexSseError(rawEvent: Record<string, unknown>): { code: string; message: string } {
const candidates: unknown[] = [
rawEvent,
rawEvent.error,
(rawEvent.response as { error?: unknown } | undefined)?.error,
];
let code = "";
let message = "";
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
const record = candidate as Record<string, unknown>;
if (!code && typeof record.code === "string" && record.code) code = record.code;
if (!message && typeof record.message === "string" && record.message) message = record.message;
}
return { code, message };
}
/**
* Calls the Codex Responses API with web search tool enabled.
* The caller provides the exact model id to send; retry / fallback policy
@@ -558,13 +582,14 @@ async function callCodexSearch(
}
}
} else if (eventType === "error") {
const code = (rawEvent as { code?: string }).code ?? "";
const message = (rawEvent as { message?: string }).message ?? "Unknown error";
throw new SearchProviderError("codex", `Codex error (${code}): ${message}`, 500);
const { code, message } = extractCodexSseError(rawEvent);
throw new SearchProviderError("codex", `Codex error (${code}): ${message || "Unknown error"}`, 500);
} else if (eventType === "response.failed") {
const resp = (rawEvent as { response?: { error?: { message?: string } } }).response;
const errorMessage = resp?.error?.message ?? "Request failed";
throw new SearchProviderError("codex", `Codex request failed: ${errorMessage}`, 500);
const { code, message } = extractCodexSseError(rawEvent);
const detail = code
? `Codex request failed (${code}): ${message || "Request failed"}`
: `Codex request failed: ${message || "Request failed"}`;
throw new SearchProviderError("codex", detail, 500);
}
}
@@ -752,4 +752,44 @@ describe("searchCodex model selection", () => {
expect(result.model).toBe("gpt-5.6-terra");
expect(result.sources).toEqual([{ title: "Example Article", url: "https://example.com/article" }]);
});
it("preserves a nested type:error code and message instead of Unknown error (#7200)", async () => {
delete process.env.PI_CODEX_WEB_SEARCH_MODEL;
const sse = [
`data: ${JSON.stringify({
type: "error",
error: {
code: "unsupported_region",
message: "web_search is not available for this workspace's data residency region.",
},
})}`,
"",
].join("\n");
const fetchMock: FetchImpl = () =>
Promise.resolve(new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }));
await expect(searchCodex(makeSearchParams("nested error envelope", fetchMock))).rejects.toThrow(
"Codex error (unsupported_region): web_search is not available for this workspace's data residency region.",
);
});
it("preserves a structured response.failed error code and message (#7200)", async () => {
delete process.env.PI_CODEX_WEB_SEARCH_MODEL;
const sse = [
`data: ${JSON.stringify({
type: "response.failed",
response: {
id: "resp_failed",
error: { code: "model_snapshot_unavailable", message: "The requested model snapshot is unavailable." },
},
})}`,
"",
].join("\n");
const fetchMock: FetchImpl = () =>
Promise.resolve(new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }));
await expect(searchCodex(makeSearchParams("structured failure", fetchMock))).rejects.toThrow(
"Codex request failed (model_snapshot_unavailable): The requested model snapshot is unavailable.",
);
});
});