From 2d0c8fe17162dafec3ec8ed241d8f027b8fb2267 Mon Sep 17 00:00:00 2001 From: mrtdurdenthe2 Date: Tue, 28 Jul 2026 23:50:26 +0100 Subject: [PATCH 1/2] Fix OpenAI stable web search action parameters --- .changeset/fix-openai-web-search-action.md | 5 + packages/ai/openai/src/OpenAiLanguageModel.ts | 16 ++- .../openai/test/OpenAiLanguageModel.test.ts | 101 ++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-openai-web-search-action.md diff --git a/.changeset/fix-openai-web-search-action.md b/.changeset/fix-openai-web-search-action.md new file mode 100644 index 00000000000..ac95aedf19c --- /dev/null +++ b/.changeset/fix-openai-web-search-action.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai": patch +--- + +Fix OpenAI stable web search response decoding by preserving the provider action in tool call parameters. diff --git a/packages/ai/openai/src/OpenAiLanguageModel.ts b/packages/ai/openai/src/OpenAiLanguageModel.ts index e8559fea08e..62cc4e55cc3 100644 --- a/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -1615,7 +1615,9 @@ const makeResponse = Effect.fnUntraced( type: "tool-call", id: part.id, name: toolName, - params: {}, + params: webSearchTool?.name === "OpenAiWebSearchPreview" + ? {} + : { action: part.action }, providerExecuted: true }) parts.push({ @@ -1943,6 +1945,9 @@ const makeStreamResponse = Effect.fnUntraced( id: event.item.id, name: toolName } + if (webSearchTool?.name === "OpenAiWebSearch") { + break + } parts.push({ type: "tool-params-start", id: event.item.id, @@ -2256,6 +2261,15 @@ const makeStreamResponse = Effect.fnUntraced( const toolName = toolNameMapper.getCustomName( webSearchTool?.name ?? "web_search" ) + if (webSearchTool?.name === "OpenAiWebSearch") { + parts.push({ + type: "tool-call", + id: event.item.id, + name: toolName, + params: { action: event.item.action }, + providerExecuted: true + }) + } parts.push({ type: "tool-result", id: event.item.id, diff --git a/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 1250b88b922..896d22b74ac 100644 --- a/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -752,6 +752,39 @@ describe("OpenAiLanguageModel", () => { } })))) + for (const model of ["gpt-4.1", "gpt-5.6"] as const) { + it.effect(`maps stable web search action to tool call parameters with ${model}`, () => + Effect.gen(function*() { + const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) + const result = yield* LanguageModel.generateText({ + prompt: "Search the web", + toolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model(model))) + + const toolCall = result.content.find((part) => part.type === "tool-call") + assert.isDefined(toolCall) + if (toolCall?.type === "tool-call") { + deepStrictEqual(toolCall.params, { + action: { type: "search", query: "Effect TypeScript" } + }) + } + + const toolResult = result.content.find((part) => part.type === "tool-result") + assert.isDefined(toolResult) + if (toolResult?.type === "tool-result") { + deepStrictEqual(toolResult.result, { + action: { type: "search", query: "Effect TypeScript" }, + status: "completed" + }) + } + }).pipe(Effect.provide(makeTestLayer({ + body: { + model, + output: [makeWebSearchCall()] + } + })))) + } + it.effect("uses canonical OpenAiMcp name for mcp_approval_request", () => Effect.gen(function*() { const result = yield* LanguageModel.generateText({ @@ -1099,6 +1132,64 @@ describe("OpenAiLanguageModel", () => { assert.isDefined(toolParamsEnd) })) + it.effect("waits for the stable streamed web search action before emitting the tool call", () => + Effect.gen(function*() { + const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) + const streamEvents = [ + { + type: "response.created", + sequence_number: 1, + response: makeDefaultResponse({ status: "in_progress" }) + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { + type: "web_search_call", + id: "ws_123", + status: "in_progress" + } + }, + { + type: "response.output_item.done", + sequence_number: 3, + output_index: 0, + item: makeWebSearchCall() + } + ] as unknown as ReadonlyArray + + const partsChunk = yield* LanguageModel.streamText({ + prompt: "Search the web", + toolkit, + disableToolCallResolution: true + }).pipe( + Stream.runCollect, + Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")), + Effect.provide(makeStreamTestLayer(streamEvents)) + ) + + const parts = globalThis.Array.from(partsChunk) + const toolCalls = parts.filter((part) => part.type === "tool-call") + strictEqual(toolCalls.length, 1) + const toolCall = toolCalls[0] + assert.isDefined(toolCall) + if (toolCall?.type === "tool-call") { + deepStrictEqual(toolCall.params, { + action: { type: "search", query: "Effect TypeScript" } + }) + } + + const toolResult = parts.find((part) => part.type === "tool-result") + assert.isDefined(toolResult) + if (toolResult?.type === "tool-result") { + deepStrictEqual(toolResult.result, { + action: { type: "search", query: "Effect TypeScript" }, + status: "completed" + }) + } + })) + it.effect("handles reasoning summary events when reasoning state is missing", () => Effect.gen(function*() { const streamEvents = [ @@ -1512,6 +1603,16 @@ const makeFunctionCall = ( ...overrides }) +const makeWebSearchCall = ( + overrides: Partial = {} +): Generated.WebSearchToolCall => ({ + type: "web_search_call", + id: "ws_123", + status: "completed", + action: { type: "search", query: "Effect TypeScript" }, + ...overrides +}) + const makeMcpCall = ( name: string, args: Record, From b575c32c396b0317cc9e31be1056bd7a7583276e Mon Sep 17 00:00:00 2001 From: mrtdurdenthe2 Date: Wed, 29 Jul 2026 20:24:25 +0100 Subject: [PATCH 2/2] Address OpenAI test review feedback --- .../openai/test/OpenAiLanguageModel.test.ts | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 896d22b74ac..4a9ab04d607 100644 --- a/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -752,38 +752,37 @@ describe("OpenAiLanguageModel", () => { } })))) - for (const model of ["gpt-4.1", "gpt-5.6"] as const) { - it.effect(`maps stable web search action to tool call parameters with ${model}`, () => - Effect.gen(function*() { - const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) - const result = yield* LanguageModel.generateText({ - prompt: "Search the web", - toolkit - }).pipe(Effect.provide(OpenAiLanguageModel.model(model))) - - const toolCall = result.content.find((part) => part.type === "tool-call") - assert.isDefined(toolCall) - if (toolCall?.type === "tool-call") { - deepStrictEqual(toolCall.params, { + it.each(["gpt-4.1", "gpt-5.6"] as const)( + "maps stable web search action to tool call parameters with %s", + (model) => + Effect.runPromise( + Effect.gen(function*() { + const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) + const result = yield* LanguageModel.generateText({ + prompt: "Search the web", + toolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model(model))) + + const toolCall = result.content.find((part) => part.type === "tool-call") + assert.isDefined(toolCall) + assert.deepStrictEqual(toolCall.params, { action: { type: "search", query: "Effect TypeScript" } }) - } - const toolResult = result.content.find((part) => part.type === "tool-result") - assert.isDefined(toolResult) - if (toolResult?.type === "tool-result") { - deepStrictEqual(toolResult.result, { + const toolResult = result.content.find((part) => part.type === "tool-result") + assert.isDefined(toolResult) + assert.deepStrictEqual(toolResult.result, { action: { type: "search", query: "Effect TypeScript" }, status: "completed" }) - } - }).pipe(Effect.provide(makeTestLayer({ - body: { - model, - output: [makeWebSearchCall()] - } - })))) - } + }).pipe(Effect.provide(makeTestLayer({ + body: { + model, + output: [makeWebSearchCall()] + } + }))) + ) + ) it.effect("uses canonical OpenAiMcp name for mcp_approval_request", () => Effect.gen(function*() { @@ -1159,7 +1158,7 @@ describe("OpenAiLanguageModel", () => { } ] as unknown as ReadonlyArray - const partsChunk = yield* LanguageModel.streamText({ + const parts = yield* LanguageModel.streamText({ prompt: "Search the web", toolkit, disableToolCallResolution: true @@ -1169,25 +1168,20 @@ describe("OpenAiLanguageModel", () => { Effect.provide(makeStreamTestLayer(streamEvents)) ) - const parts = globalThis.Array.from(partsChunk) const toolCalls = parts.filter((part) => part.type === "tool-call") strictEqual(toolCalls.length, 1) const toolCall = toolCalls[0] assert.isDefined(toolCall) - if (toolCall?.type === "tool-call") { - deepStrictEqual(toolCall.params, { - action: { type: "search", query: "Effect TypeScript" } - }) - } + assert.deepStrictEqual(toolCall.params, { + action: { type: "search", query: "Effect TypeScript" } + }) const toolResult = parts.find((part) => part.type === "tool-result") assert.isDefined(toolResult) - if (toolResult?.type === "tool-result") { - deepStrictEqual(toolResult.result, { - action: { type: "search", query: "Effect TypeScript" }, - status: "completed" - }) - } + assert.deepStrictEqual(toolResult.result, { + action: { type: "search", query: "Effect TypeScript" }, + status: "completed" + }) })) it.effect("handles reasoning summary events when reasoning state is missing", () =>