diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0b4b94b64..41f664c62 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -23,6 +23,10 @@ - Fixed explicit request-debug path mode to create missing parent directories before writing request logs - Fixed explicit request-debug mode to overwrite existing `.res.log` files for the requested path instead of failing when they already exist +### Fixed + +- Fixed OpenAI Responses `previous_response_id` chaining on Zero Data Retention orgs: the in-provider retry classifier missed the ZDR-specific 400 ("Previous response cannot be used for this organization due to Zero Data Retention"), so chained turns kept failing every other request after a brief recovery — the chain was reset but not disabled, so the next successful full-replay turn re-armed it. The ZDR phrasing is now classified categorically: one strike disables chaining for the session (skipping the three-strike circuit breaker) and the in-call retry drops `store: true`/`previous_response_id` and replays the full transcript instead ([#2341](https://github.com/can1357/oh-my-pi/issues/2341)). + ## [15.11.4] - 2026-06-12 ### Added diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 856aaef99..8f30cab42 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -288,6 +288,18 @@ function isOpenAIResponsesStalePreviousResponseError(error: unknown): boolean { return /previous[ _]?response/i.test(error.message) && /not[ _]?found|invalid|expired|stale/i.test(error.message); } +/** + * Zero Data Retention orgs accept `store: true` but refuse to resolve any + * `previous_response_id` — the prior response was never persisted server-side. + * The 400 carries a fixed phrasing ("Zero Data Retention") that the generic + * stale-id regex above does not match, so it is classified separately and + * disables chaining categorically (one strike, not three). + */ +function isOpenAIResponsesZeroDataRetentionError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return /previous[ _]?response/i.test(error.message) && /zero[ _-]?data[ _-]?retention/i.test(error.message); +} + function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainState, error: unknown): void { resetOpenAIResponsesChainState(chain); chain.staleFailures += 1; @@ -301,6 +313,19 @@ function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainSta }); } +/** + * One-shot ZDR signal: the org will never resolve a stored response, so skip + * the staleFailures counter and disable chaining immediately for this session. + */ +function markOpenAIResponsesChainZeroDataRetention(chain: OpenAIResponsesChainState, error: unknown): void { + resetOpenAIResponsesChainState(chain); + chain.disabled = true; + chain.staleFailures = OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT; + logger.debug("OpenAI responses chaining disabled (Zero Data Retention)", { + error: error instanceof Error ? error.message : String(error), + }); +} + type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & { top_p?: number; top_k?: number; @@ -433,18 +458,27 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( try { openaiStream = await openResponsesStream(chained.params); } catch (error) { - if ( - !chainState || - !sentPreviousResponseId || - requestSignal.aborted || - !isOpenAIResponsesStalePreviousResponseError(error) - ) { + if (!chainState || !sentPreviousResponseId || requestSignal.aborted) { throw error; } - // Server rejected the chain baseline: reset, count the failure, and - // retry once with the full transcript. Structurally cannot loop — the - // retry carries no previous_response_id. - registerOpenAIResponsesChainStaleFailure(chainState, error); + const zdrRejection = isOpenAIResponsesZeroDataRetentionError(error); + if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) { + throw error; + } + // Server rejected the chain baseline: reset, count the failure (or + // disable categorically on ZDR), and retry once with the full + // transcript. Structurally cannot loop — the retry carries no + // previous_response_id. + if (zdrRejection) { + markOpenAIResponsesChainZeroDataRetention(chainState, error); + // ZDR orgs cannot store responses; the original request forced + // `store: true` for chaining, which is meaningless here and would + // otherwise leave subsequent turns asking the server to retain + // data it must discard. + params.store = false; + } else { + registerOpenAIResponsesChainStaleFailure(chainState, error); + } sentPreviousResponseId = undefined; rawRequestDump.body = params; openaiStream = await openResponsesStream(params); diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 81d56ed8a..fa3fc33a8 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -279,6 +279,56 @@ describe("openai-responses stateful chaining", () => { expect(sentRequests[7]?.store).toBe(false); }); + it("disables chaining categorically when the org has Zero Data Retention enabled", async () => { + const sentRequests: Array> = []; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as Record; + sentRequests.push(request); + if (typeof request.previous_response_id === "string") { + return new Response( + JSON.stringify({ + error: { + message: "Previous response cannot be used for this organization due to Zero Data Retention.", + type: "invalid_request_error", + param: "previous_response_id", + code: "zero_data_retention", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return createStatefulSse(`Answer ${sentRequests.length}`, `resp_${sentRequests.length}`); + }) as FetchImpl; + const providerSessionState = new Map(); + const options = { + apiKey: "test-key", + sessionId: "stateful-zdr-session", + providerSessionState, + statefulResponses: true, + reasoning: "low" as const, + fetch: fetchMock, + }; + + const messages: Context["messages"] = []; + for (let turn = 1; turn <= 3; turn++) { + messages.push({ role: "user", content: `Question ${turn}`, timestamp: 1000 + turn }); + const result = await streamOpenAIResponses(model, { systemPrompt, messages }, options).result(); + expect(result.stopReason).toBe("stop"); + messages.push(result); + } + + // Turn 1: no previous_response_id (cold chain). Turn 2: tries chaining, + // gets a ZDR 400, retries once with full transcript and store: false. + // Turn 3: chain is permanently disabled — no second 400. + expect(sentRequests).toHaveLength(4); + expect(sentRequests[0]?.previous_response_id).toBeUndefined(); + expect(sentRequests[1]?.previous_response_id).toBe("resp_1"); + expect(sentRequests[2]?.previous_response_id).toBeUndefined(); + expect(sentRequests[2]?.store).toBe(false); + expect(sentRequests[3]?.previous_response_id).toBeUndefined(); + expect(sentRequests[3]?.store).toBe(false); + }); + it("chains by default against the official OpenAI API", async () => { const sentRequests: Array> = []; const fetchMock = createCapturingFetch(sentRequests); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b39dd0600..58be64f40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -39,6 +39,10 @@ - Fixed snapcompact `toolResults` imaging confusing models into reporting tool malfunctions: the note prepended to rasterized tool results now tells the model the result is in the PNG frame(s) below and that the image delivery is deliberate, not a tool error, instead of the bare `[Rasterized]` marker - Fixed tool-call boxes rendering "inside themselves" after slow tool runs: a pending collapsed preview that sat byte-static past the stable-prefix window (e.g. an edit's tail-window diff while the apply + LSP pass ran) had its settled head committed to native scrollback, and the result render then stranded that stale call-box fragment above the final block. Pending collapsed tool previews are now provisional (`isTranscriptBlockCommitStable`) and never enter scrollback mid-run; expanded top-anchored streams keep committing mid-stream +### Fixed + +- Fixed the higher-level Responses retry classifier to recognize OpenAI Zero Data Retention rejections (`Previous response cannot be used for this organization due to Zero Data Retention`) as stale-replay errors. The provider-level fix in `@oh-my-pi/pi-ai` covers the common case in a single turn; this layer additionally guarantees that any ZDR error that still bubbles up resets the Responses session and retries at zero backoff instead of falling back to a different model ([#2341](https://github.com/can1357/oh-my-pi/issues/2341)). + ## [15.11.4] - 2026-06-12 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 27843176c..2fbd07226 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8354,7 +8354,8 @@ export class AgentSession { return ( /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) || - (/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale/i.test(errorMessage)) + (/previous[ _]?response/i.test(errorMessage) && + /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage)) ); } diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index a4d82e3f4..68158adda 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -818,6 +818,84 @@ describe("AgentSession retry fallback", () => { }); }); + it("restarts Responses provider state before retrying Zero Data Retention errors", async () => { + const model = getBundledModel("openai", "gpt-4o-mini"); + const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + // Mirrors the live wire error from OpenAI ZDR orgs after the in-provider + // retry has already exhausted itself; the higher-level retry must still + // classify the failure as a stale-replay event so the session reset and + // zero-delay backoff fire instead of a model fallback. + const zdrReplayError = "400 Previous response cannot be used for this organization due to Zero Data Retention."; + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const mock = createMockModel({ + responses: [{ throw: zdrReplayError }, { content: ["Recovered after ZDR reset"] }], + }); + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (requestedModel, context, options) => { + requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`); + return mock.stream(requestedModel, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 1, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + const closeSpy = vi.fn(); + session.providerSessionState.set("openai-responses:openai", { + close: closeSpy, + } satisfies ProviderSessionState); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + + await session.prompt("Retry ZDR replay"); + await session.waitForIdle(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(session.providerSessionState.has("openai-responses:openai")).toBe(false); + expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]); + expect(fallbackAppliedEvents).toHaveLength(0); + expect(retryStartEvents).toHaveLength(1); + expect(retryStartEvents[0]).toMatchObject({ + attempt: 1, + delayMs: 0, + errorMessage: zdrReplayError, + }); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 }); + const lastAssistant = getLastAssistantMessage(session); + expect(lastAssistant.stopReason).toBe("stop"); + expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after ZDR reset" }); + }); + it("auto-retries Anthropic stream-envelope failures before message_start", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) {