diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 695db11a2..8ac420a76 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,9 @@ ### Fixed - Fixed the live Ask dialog crashing the whole session with a `replaceTabs` TypeError when a question reached `AskDialogComponent` without a string `question` field; questions are now normalized at dialog entry, mirroring the transcript renderer ([#7211](https://github.com/can1357/oh-my-pi/issues/7211)). +### 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 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.", + ); + }); });