diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 736640765..8563b4e0d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed stateful OpenAI Responses explicit cache breakpoints being restored onto edited historical messages, ensuring full replays recompute the latest stable cache boundary. + ## [17.1.0] - 2026-07-24 ### Added diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 7d856bd72..48a07d8f1 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -930,6 +930,25 @@ function isStableStringResponsesInstruction(item: unknown): item is ResponsesStr ); } +function matchesResponsesCacheBaseline( + baseline: ResponsesPromptCacheableMessage, + current: ResponsesPromptCacheableMessage, +): boolean { + if (baseline.role !== current.role || baseline.content.length !== current.content.length) return false; + for (let index = 0; index < baseline.content.length; index++) { + const baselineBlock = baseline.content[index]; + const currentBlock = current.content[index]; + if (!baselineBlock || !currentBlock) return false; + const breakpoint = baselineBlock.prompt_cache_breakpoint; + if (breakpoint) { + if (!Bun.deepEquals(baselineBlock, { ...currentBlock, prompt_cache_breakpoint: breakpoint })) return false; + } else if (!Bun.deepEquals(baselineBlock, currentBlock)) { + return false; + } + } + return true; +} + function restoreResponsesCacheBreakpointsFromBaseline( input: ResponseInput | undefined, baseline: ResponseInput | undefined, @@ -944,7 +963,7 @@ function restoreResponsesCacheBreakpointsFromBaseline( if (isStableStringResponsesInstruction(message)) { const [baselineBlock] = baselineMessage.content; if ( - baselineMessage.content.length === 1 && + baselineMessage.role === message.role && baselineBlock?.type === "input_text" && baselineBlock.text === message.content && baselineBlock.prompt_cache_breakpoint @@ -963,11 +982,12 @@ function restoreResponsesCacheBreakpointsFromBaseline( continue; } - if (!isResponsesPromptCacheableMessage(message)) continue; - for (let j = 0; j < baselineMessage.content.length && j < message.content.length; j++) { + if (!isResponsesPromptCacheableMessage(message) || !matchesResponsesCacheBaseline(baselineMessage, message)) + continue; + for (let j = 0; j < baselineMessage.content.length; j++) { const baselineBlock = baselineMessage.content[j]; const block = message.content[j]; - if (!baselineBlock?.prompt_cache_breakpoint || !isResponsesPromptCacheableContentBlock(block)) continue; + if (!baselineBlock?.prompt_cache_breakpoint || !block) continue; Object.assign(block, { prompt_cache_breakpoint: baselineBlock.prompt_cache_breakpoint }); restored = true; } diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 5fceb090e..cda78abbb 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -329,6 +329,59 @@ describe("openai-responses stateful chaining", () => { ]); }); + it("recomputes the cache breakpoint when the marked message content changes", async () => { + const sentRequests: Array> = []; + const fetchMock = createCapturingFetch(sentRequests); + const providerSessionState = new Map(); + const options = { + apiKey: "test-key", + sessionId: "stateful-edited-marked-message-session", + providerSessionState, + statefulResponses: true, + promptCache: { mode: "explicit" as const }, + fetch: fetchMock, + }; + const oldestUser = { role: "user" as const, content: "Oldest stable question", timestamp: 1000 }; + const firstUser = { role: "user" as const, content: "First question", timestamp: 1001 }; + const firstResponse = await streamOpenAIResponses( + explicitPromptCacheModel, + { messages: [oldestUser, firstUser] }, + options, + ).result(); + + await streamOpenAIResponses( + explicitPromptCacheModel, + { + messages: [ + { ...oldestUser, content: "Edited oldest question" }, + firstUser, + firstResponse, + { role: "user", content: "Second question", timestamp: 1002 }, + ], + }, + options, + ).result(); + + expect(sentRequests).toHaveLength(2); + expect(sentRequests[1]?.previous_response_id).toBeUndefined(); + const replayInput = sentRequests[1]?.input; + if (!Array.isArray(replayInput)) throw new Error("Expected a full Responses replay"); + expect(replayInput[0]).toEqual({ + role: "user", + content: [{ type: "input_text", text: "Edited oldest question" }], + }); + expect(replayInput[1]).toEqual({ + role: "user", + content: [ + { + type: "input_text", + text: "First question", + prompt_cache_breakpoint: { mode: "explicit" }, + }, + ], + }); + }); + it("chains turns without appending an extra no-reasoning developer item", async () => { const sentRequests: Array> = []; const fetchMock = createCapturingFetch(sentRequests);