From 8ecf55b36a7636bb331e183457a325ddbfa2c878 Mon Sep 17 00:00:00 2001 From: snowzlm Date: Sun, 7 Jun 2026 22:47:15 +0800 Subject: [PATCH] fix(OpenAI Responses): gate replay when store is stripped --- .../embedded-agent-runner-extraparams.test.ts | 27 ++++++ .../openai-responses.reasoning-replay.test.ts | 96 ++++++++++++++++++- src/agents/openai-transport-stream.ts | 3 +- src/llm/providers/stream-wrappers/openai.ts | 8 +- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/agents/embedded-agent-runner-extraparams.test.ts b/src/agents/embedded-agent-runner-extraparams.test.ts index e15cfb985358..2f4aab680347 100644 --- a/src/agents/embedded-agent-runner-extraparams.test.ts +++ b/src/agents/embedded-agent-runner-extraparams.test.ts @@ -3100,6 +3100,33 @@ describe("applyExtraParamsToAgent", () => { expect(capturedOptions?.replayResponsesItemIds).toBe(true); }); + it("disables Responses replay item ids when custom Responses routes strip store", () => { + let capturedOptions: + | (SimpleStreamOptions & { + replayResponsesItemIds?: boolean; + }) + | undefined; + const baseStreamFn: StreamFn = (_model, _context, options) => { + capturedOptions = options; + return {} as ReturnType; + }; + const streamFn = createOpenAIResponsesContextManagementWrapper(baseStreamFn, undefined); + + void streamFn( + { + api: "openai-responses", + provider: "custom-openai-responses", + id: "gpt-5.5", + baseUrl: "https://custom.example.invalid/v1", + compat: { supportsStore: false }, + } as unknown as Model<"openai-responses">, + { messages: [] }, + {}, + ); + + expect(capturedOptions?.replayResponsesItemIds).toBe(false); + }); + it("forces store=true for azure-openai provider with openai-responses API (#42800)", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "azure-openai", diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 6a66e36c478a..5d51b5d6eecb 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -20,6 +20,17 @@ function buildModel(): Model<"openai-responses"> { }; } +function buildStorelessCustomModel(): Model<"openai-responses"> { + return { + ...buildModel(), + provider: "custom-openai-responses", + baseUrl: "https://custom.example.invalid/v1", + compat: { + supportsStore: false, + } as never, + }; +} + function extractInput(payload: Record | undefined) { return Array.isArray(payload?.input) ? payload.input : []; } @@ -89,6 +100,8 @@ async function runAbortedOpenAIResponsesStream(params: { parameters: ReturnType; }>; replayResponsesItemIds?: boolean; + usePolicyReplayDefault?: boolean; + model?: Model<"openai-responses">; }) { // Abort after payload capture so tests inspect serialization without network I/O. const controller = new AbortController(); @@ -96,7 +109,7 @@ async function runAbortedOpenAIResponsesStream(params: { let payload: Record | undefined; const responseStream = stream( - buildModel(), + params.model ?? buildModel(), { systemPrompt: "system", messages: params.messages, @@ -104,7 +117,9 @@ async function runAbortedOpenAIResponsesStream(params: { }, { apiKey: "test", - replayResponsesItemIds: params.replayResponsesItemIds ?? true, + ...(params.usePolicyReplayDefault + ? {} + : { replayResponsesItemIds: params.replayResponsesItemIds ?? true }), signal: controller.signal, onPayload: (nextPayload: unknown) => { payload = nextPayload as Record; @@ -121,6 +136,83 @@ async function runAbortedOpenAIResponsesStream(params: { } describe("openai-responses reasoning replay", () => { + it("omits Responses item ids for storeless custom providers while preserving tool call ids", async () => { + const assistantToolOnly = buildAssistantMessage({ + stopReason: "toolUse", + content: [ + buildReasoningPart("rs_storeless"), + { + type: "text", + text: "Checking.", + textSignature: JSON.stringify({ v: 1, id: "msg_storeless", phase: "final_answer" }), + }, + { + type: "toolCall", + id: "call_storeless|fc_storeless", + name: "noop", + arguments: {}, + }, + ], + }); + + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "call_storeless|fc_storeless", + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: Date.now(), + }; + + const { input, types } = await runAbortedOpenAIResponsesStream({ + model: buildStorelessCustomModel(), + usePolicyReplayDefault: true, + messages: [ + { role: "user", content: "Call noop.", timestamp: Date.now() }, + assistantToolOnly, + toolResult, + { role: "user", content: "Now reply with ok.", timestamp: Date.now() }, + ], + tools: [ + { + name: "noop", + description: "no-op", + parameters: Type.Object({}, { additionalProperties: false }), + }, + ], + }); + + expect(types).toContain("message"); + expect(types).toContain("function_call"); + expect(types).toContain("function_call_output"); + + const replayedItemIds = input.filter( + (item): item is Record => + Boolean(item) && + typeof item === "object" && + ["reasoning", "message", "function_call"].includes( + String((item as Record).type), + ) && + typeof (item as Record).id === "string", + ); + expect(replayedItemIds).toEqual([]); + + const functionCall = input.find( + (item) => + item && + typeof item === "object" && + (item as Record).type === "function_call", + ) as Record | undefined; + const functionCallOutput = input.find( + (item) => + item && + typeof item === "object" && + (item as Record).type === "function_call_output", + ) as Record | undefined; + expect(functionCall?.call_id).toBeTruthy(); + expect(functionCallOutput?.call_id).toBe(functionCall?.call_id); + }); + it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => { const assistantToolOnly = buildAssistantMessage({ stopReason: "toolUse", diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 87908785e39f..7c5385853733 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -2208,7 +2208,8 @@ export function buildOpenAIResponsesParams( const payloadPolicy = resolveOpenAIResponsesPayloadPolicy(model, { storeMode: "disable", }); - const policyAllowsReplayIds = payloadPolicy.explicitStore !== false; + const policyAllowsReplayIds = + payloadPolicy.explicitStore !== false && !payloadPolicy.shouldStripStore; const replayResponsesItemIds = !isNativeCodexResponses && (options?.replayResponsesItemIds ?? policyAllowsReplayIds); const messages = convertResponsesMessages( diff --git a/src/llm/providers/stream-wrappers/openai.ts b/src/llm/providers/stream-wrappers/openai.ts index 47a494fe202b..ed16d909763a 100644 --- a/src/llm/providers/stream-wrappers/openai.ts +++ b/src/llm/providers/stream-wrappers/openai.ts @@ -399,9 +399,11 @@ export function createOpenAIResponsesContextManagementWrapper( const originalOnPayload = options?.onPayload; const replayResponsesItemIds = - policy.explicitStore === undefined - ? (options as OpenAIResponsesReplayOptions | undefined)?.replayResponsesItemIds - : policy.explicitStore; + policy.explicitStore === true + ? true + : policy.explicitStore === false || policy.shouldStripStore + ? false + : (options as OpenAIResponsesReplayOptions | undefined)?.replayResponsesItemIds; const nextOptions: OpenAIResponsesReplayOptions = { ...options, ...(replayResponsesItemIds === undefined ? {} : { replayResponsesItemIds }),