From 58204e72bdb30d504674612b0c34ce6d1dd2d90e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 22:16:22 +0200 Subject: [PATCH] fix(ai): a ChatGPT turn that searched the web goes on after a tool call The output items of a step were replayed as they came, web search calls included. With store false the provider cannot resolve such a call again and refused the next step with HTTP 400, which ended the turn with an error right after a hike was proposed. The search calls are left out of the replay; what they found is in the answer's text and its sources. --- .../providers/openai-responses.client.ts | 9 ++++++-- .../ai-connections/provider-adapters.test.ts | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/server/src/nest/ai-connections/providers/openai-responses.client.ts b/server/src/nest/ai-connections/providers/openai-responses.client.ts index d12800d3f..eef85e312 100644 --- a/server/src/nest/ai-connections/providers/openai-responses.client.ts +++ b/server/src/nest/ai-connections/providers/openai-responses.client.ts @@ -38,7 +38,8 @@ type ChatgptTokenSource = (rejected?: string) => Promise; * The provider-neutral history as Responses API input items. An assistant * message this adapter wrote carries its output items, which are replayed as * they came (reasoning with its encrypted content, the function calls with - * their namespace), since `store: false` keeps nothing on the provider's side. + * their namespace), since `store: false` keeps nothing on the provider's side; + * its web search calls are not, as the provider cannot resolve them again. * One written by another adapter is rebuilt from its text and its calls. */ export function toResponsesInput(request: AiChatRequest): Record[] { @@ -70,7 +71,11 @@ function toItems(message: AiChatMessage): Record[] { if (message.role === 'tool') { return [{ type: 'function_call_output', call_id: message.toolCallId, output: message.output }]; } - if (message.providerState?.adapter === 'openai-responses') return message.providerState.items; + // A web search the provider ran is left out: with nothing stored it cannot find the call again and + // refuses the whole request, and what the search found is in the answer's text and its sources. + if (message.providerState?.adapter === 'openai-responses') { + return message.providerState.items.filter((item) => item.type !== 'web_search_call'); + } return [ ...(message.text ? [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: message.text }] }] diff --git a/server/tests/unit/nest/ai-connections/provider-adapters.test.ts b/server/tests/unit/nest/ai-connections/provider-adapters.test.ts index b52712413..060870df6 100644 --- a/server/tests/unit/nest/ai-connections/provider-adapters.test.ts +++ b/server/tests/unit/nest/ai-connections/provider-adapters.test.ts @@ -474,6 +474,29 @@ describe('OpenAiResponsesClient', () => { ]); }); + it('OARS-003b: leaves its own web search calls out of a replay, which the provider cannot resolve without storage', () => { + const search = { + type: 'web_search_call', + id: 'ws_1', + status: 'completed', + action: { type: 'search', query: 'Harz' }, + }; + const input = toResponsesInput({ + ...REQUEST, + context: undefined, + messages: [ + { + role: 'assistant', + text: 'ignored', + toolCalls: [], + providerState: { adapter: 'openai-responses', items: [REASONING, search, CALL] }, + }, + { role: 'tool', toolCallId: 'call_x', name: 'create_place', output: 'ok' }, + ], + }); + expect(input).toEqual([REASONING, CALL, { type: 'function_call_output', call_id: 'call_x', output: 'ok' }]); + }); + it('OARS-003: replays its own items verbatim and rebuilds another adapter turn from text and calls', () => { const input = toResponsesInput({ ...REQUEST,