From d4cb024b5764263e969aea8ef20b8327ad7b9f82 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 31 Jul 2026 20:56:54 +0000 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/web/search/providers/codex.ts | 37 ++++++++++++++--- .../test/tools/web-search-codex.test.ts | 40 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f76ce653..5946d7d4d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 79b5fb412..15a459dfb 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -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): { 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; + 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); } } diff --git a/packages/coding-agent/test/tools/web-search-codex.test.ts b/packages/coding-agent/test/tools/web-search-codex.test.ts index 4dfb2ac85..a5ab1d28b 100644 --- a/packages/coding-agent/test/tools/web-search-codex.test.ts +++ b/packages/coding-agent/test/tools/web-search-codex.test.ts @@ -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.", + ); + }); });