diff --git a/packages/ai/src/protocols/open-responses-channel.ts b/packages/ai/src/protocols/open-responses-channel.ts index 163437c5a67..5dbd54c0f4d 100644 --- a/packages/ai/src/protocols/open-responses-channel.ts +++ b/packages/ai/src/protocols/open-responses-channel.ts @@ -112,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => { responseID = created return { type: "frame", frame } } + // Keepalives carry no response state and may arrive before response.created. + if (event.type === "keepalive") return { type: "frame", frame } if (!responseID) return yield* ProviderShared.eventError( options.id, diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index d5c5d39f8ca..4ea2d81f183 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -582,8 +582,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques flushText() const id = itemID(part.providerMetadata, providerMetadataKey) if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id }) - if (store === false && part.result.type === "content") { - const content: ReadonlyArray = part.result.value + if (store === false) { + // The server is not storing this exchange, so the tool outcome has to + // travel in the input. Non-content results degrade to their text form. + const content: ReadonlyArray = + part.result.type === "content" + ? part.result.value + : [{ type: "text", text: ProviderShared.toolResultText(part) }] input.push({ role: "user", content: yield* Effect.forEach(content, (item) => diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index b3995b51395..08735d828f7 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -401,6 +401,38 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("tolerates keepalive frames before response.created", () => + Effect.gen(function* () { + const webSocket = WebSocketTransport.makeDirect({ + open: () => + Effect.succeed({ + sendText: () => Effect.void, + messages: Stream.fromArray([ + ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }), + ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }), + ProviderShared.encodeJson({ + type: "response.completed", + response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } }, + }), + ]), + close: Effect.void, + }), + }) + const deps = Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }), + ) + const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses( + "gpt-4.1-mini", + ) + + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe( + Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), + ) + expect(response.finishReason?.normalized).toBe("stop") + }), + ) + it.effect("continues a tool call with only the new tool output", () => Effect.gen(function* () { const firstRequest = { @@ -2279,6 +2311,44 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("continues stateless hosted tool results with their text form", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.user("Search."), + Message.assistant([ + ToolCallPart.make({ + id: "ws_1", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }), + { + type: "tool-result", + id: "ws_1", + name: "web_search", + result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + ]), + Message.user("Continue."), + ], + providerOptions: { store: false }, + }), + ) + + expect(prepared.body.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Search." }] }, + { role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] }, + { role: "user", content: [{ type: "input_text", text: "Continue." }] }, + ]) + }), + ) + it.effect("continues stateless hosted image generation with the generated image", () => Effect.gen(function* () { const imageTool = OpenAI.imageGeneration({ action: "edit" })