From b6a5e0d8ff3327cac0037a1d71bd61f8a79259b8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 1 Sep 2026 14:17:15 +0200 Subject: [PATCH 1/6] fix(ai): replay OpenAI reasoning items on tool follow-up --- .changeset/openai-reasoning-replay.md | 7 + packages/ai-openai/src/adapters/text.ts | 5 + .../ai-openai/tests/openai-adapter.test.ts | 42 +++ .../references/openai-adapter.md | 4 + packages/ai/src/activities/chat/index.ts | 2 +- packages/ai/src/activities/chat/messages.ts | 30 +- packages/ai/tests/ag-ui-wire.test.ts | 33 ++ packages/ai/tests/messages.test.ts | 24 ++ .../src/adapters/responses-text.ts | 105 ++++++ .../openai-base/tests/responses-text.test.ts | 346 ++++++++++++++++++ 10 files changed, 583 insertions(+), 15 deletions(-) create mode 100644 .changeset/openai-reasoning-replay.md diff --git a/.changeset/openai-reasoning-replay.md b/.changeset/openai-reasoning-replay.md new file mode 100644 index 0000000000..1c8123df68 --- /dev/null +++ b/.changeset/openai-reasoning-replay.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': patch +'@tanstack/openai-base': patch +'@tanstack/ai-openai': patch +--- + +Replay OpenAI Responses reasoning items with function_call on the next tool turn. diff --git a/packages/ai-openai/src/adapters/text.ts b/packages/ai-openai/src/adapters/text.ts index 2e646d1df6..d1b89785a1 100644 --- a/packages/ai-openai/src/adapters/text.ts +++ b/packages/ai-openai/src/adapters/text.ts @@ -148,6 +148,11 @@ export class OpenAITextAdapter< delete request.top_p } + // gpt-5.x pairs each function_call with a reasoning item. Request the + // encrypted blob so convertMessagesToInput can replay it on the next turn. + // Callers can still override include in modelOptions. + request.include = request.include ?? ['reasoning.encrypted_content'] + return request } } diff --git a/packages/ai-openai/tests/openai-adapter.test.ts b/packages/ai-openai/tests/openai-adapter.test.ts index ed48924671..ee6e90416a 100644 --- a/packages/ai-openai/tests/openai-adapter.test.ts +++ b/packages/ai-openai/tests/openai-adapter.test.ts @@ -131,6 +131,48 @@ describe('OpenAI adapter option mapping', () => { expect(payload.tools).toBeDefined() expect(Array.isArray(payload.tools)).toBe(true) expect(payload.tools.length).toBeGreaterThan(0) + expect(payload.include).toEqual(['reasoning.encrypted_content']) + }) + + it('lets callers override the default reasoning include list', async () => { + const mockStream = createMockChatCompletionsStream([ + { + type: 'response.created', + response: { + id: 'resp-include', + model: 'gpt-4o-mini', + status: 'in_progress', + created_at: 1234567890, + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-include', + status: 'completed', + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + ]) + + const responsesCreate = vi.fn().mockResolvedValueOnce(mockStream) + const adapter = createAdapter('gpt-4o-mini') + ;(adapter as any).client = { + responses: { + create: responsesCreate, + }, + } + + for await (const _chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + modelOptions: { include: [] }, + })) { + // consume + } + + const [payload] = responsesCreate.mock.calls[0]! + expect(payload.include).toEqual([]) }) it('accepts mixed string + object-form systemPrompts and joins .content into instructions', async () => { diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md index 8c19fb030f..81dbfba106 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md @@ -95,3 +95,7 @@ OPENAI_API_KEY `effort: 'low'` or higher to enable reasoning. - `o3-pro` only supports `high` reasoning effort. - `conversation` and `previous_response_id` cannot be used together. +- Reasoning models pair each `function_call` with a `reasoning` item. The + adapter requests `include: ['reasoning.encrypted_content']` and replays that + item on the next turn. If you persist history by hand, keep + `thinking[].signature`. diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 1b56828d4e..37d3a73939 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -1870,7 +1870,7 @@ class TextEngine< } private finalizeCurrentThinkingStep(): void { - if (this.currentThinkingContent) { + if (this.currentThinkingContent || this.currentThinkingSignature) { this.accumulatedThinking.push({ content: this.currentThinkingContent, ...(this.currentThinkingSignature && { diff --git a/packages/ai/src/activities/chat/messages.ts b/packages/ai/src/activities/chat/messages.ts index 63061754af..b98a3a572e 100644 --- a/packages/ai/src/activities/chat/messages.ts +++ b/packages/ai/src/activities/chat/messages.ts @@ -173,10 +173,10 @@ export function convertMessagesToModelMessages( if (role === 'reasoning') { const content = (msg as { content?: string }).content - if (content) { - const signature = encryptedValueFrom(msg) + const signature = encryptedValueFrom(msg) + if (content || signature !== undefined) { pendingThinking.push({ - content, + content: typeof content === 'string' ? content : '', ...(signature !== undefined ? { signature } : {}), }) } @@ -593,7 +593,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { break case 'thinking': - if (part.content) { + if (part.content || part.signature) { // Provider-executed tools have no tool-result part, so thinking // after them has to start the next segment or it replays first. if (current.toolCalls.some(isProviderExecutedToolCall)) { @@ -712,7 +712,7 @@ export function modelMessageToUIMessage( if (modelMessage.role === 'assistant' && modelMessage.thinking?.length) { for (const thinking of modelMessage.thinking) { - if (!thinking.content) continue + if (!thinking.content && !thinking.signature) continue parts.push({ type: 'thinking', content: thinking.content, @@ -917,18 +917,20 @@ export function aguiSnapshotMessageToUIMessage( }) case 'reasoning': { const signature = encryptedValueFrom(message) + const content = typeof message.content === 'string' ? message.content : '' return applySnapshotMetadata(message, { id, role: 'assistant', - parts: message.content - ? [ - { - type: 'thinking' as const, - content: message.content, - ...(signature !== undefined ? { signature } : {}), - }, - ] - : [], + parts: + content || signature !== undefined + ? [ + { + type: 'thinking' as const, + content, + ...(signature !== undefined ? { signature } : {}), + }, + ] + : [], }) } case 'activity': diff --git a/packages/ai/tests/ag-ui-wire.test.ts b/packages/ai/tests/ag-ui-wire.test.ts index f381943952..423b27e0be 100644 --- a/packages/ai/tests/ag-ui-wire.test.ts +++ b/packages/ai/tests/ag-ui-wire.test.ts @@ -596,6 +596,39 @@ describe('uiMessagesToWire', () => { }) }) + it('round-trips empty thinking content when signature is present', () => { + const messages: Array = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { + type: 'thinking', + content: '', + signature: '{"id":"rs_1","encrypted_content":"enc"}', + }, + { + type: 'tool-call', + id: 'call_1', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + state: 'input-complete', + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + const model = convertMessagesToModelMessages( + wire as Array, + ) + expect(model[0]?.thinking).toEqual([ + { + content: '', + signature: '{"id":"rs_1","encrypted_content":"enc"}', + }, + ]) + }) + it('round-trips ThinkingPart.signature on spec encryptedValue', () => { const messages: Array = [ { diff --git a/packages/ai/tests/messages.test.ts b/packages/ai/tests/messages.test.ts index 242fc1a50e..8c8b5991e5 100644 --- a/packages/ai/tests/messages.test.ts +++ b/packages/ai/tests/messages.test.ts @@ -73,6 +73,30 @@ describe('convertMessagesToModelMessages — AG-UI dedup pre-pass', () => { expect(result[0]?.role).toBe('user') }) + it('keeps reasoning encryptedValue when content is empty', () => { + const result = convertMessagesToModelMessages([ + { + role: 'reasoning', + content: '', + encryptedValue: 'sig-empty', + } as unknown as ModelMessage, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + ]) + expect(result[0]?.thinking).toEqual([ + { content: '', signature: 'sig-empty' }, + ]) + }) + it('attaches reasoning encryptedValue as thinking signature on the next assistant', () => { const result = convertMessagesToModelMessages([ { diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 88e42f57f3..298923d39e 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -37,6 +37,58 @@ import type { // these bytes, so inline document data must begin with this prefix. const PDF_BASE64_MAGIC = 'JVBERi' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function packResponsesReasoningSignature( + id: string | undefined, + encryptedContent: string | undefined, +): string | undefined { + if (!id && !encryptedContent) return undefined + return JSON.stringify({ + ...(id ? { id } : {}), + ...(encryptedContent ? { encrypted_content: encryptedContent } : {}), + }) +} + +function unpackResponsesReasoningSignature( + signature: string, +): { id?: string; encrypted_content?: string } | undefined { + try { + const parsed: unknown = JSON.parse(signature) + if (!isRecord(parsed)) return undefined + const id = typeof parsed.id === 'string' ? parsed.id : undefined + const encrypted_content = + typeof parsed.encrypted_content === 'string' + ? parsed.encrypted_content + : undefined + if (!id && !encrypted_content) return undefined + return { + ...(id ? { id } : {}), + ...(encrypted_content ? { encrypted_content } : {}), + } + } catch { + return undefined + } +} + +function readReasoningItem( + item: unknown, +): { id?: string; encrypted_content?: string } | undefined { + if (!isRecord(item) || item.type !== 'reasoning') return undefined + const id = typeof item.id === 'string' ? item.id : undefined + const encrypted_content = + typeof item.encrypted_content === 'string' + ? item.encrypted_content + : undefined + if (!id && !encrypted_content) return undefined + return { + ...(id ? { id } : {}), + ...(encrypted_content ? { encrypted_content } : {}), + } +} + /** * Provider-specific metadata that preserves the Responses API output item ID. * @@ -836,6 +888,8 @@ export abstract class OpenAIBaseResponsesTextAdapter< let stepId: string | null = null let hasEmittedTextMessageStart = false let reasoningMessageId: string | undefined + let reasoningItemId: string | undefined + let reasoningEncryptedContent: string | undefined let hasClosedReasoning = false // Track whether we've emitted a terminal RUN_FINISHED so the // end-of-stream fallback below knows to synthesise one when the upstream @@ -874,11 +928,24 @@ export abstract class OpenAIBaseResponsesTextAdapter< } } + const captureReasoningItem = (item: unknown) => { + const parsed = readReasoningItem(item) + if (!parsed) return + if (parsed.id) reasoningItemId = parsed.id + if (parsed.encrypted_content) { + reasoningEncryptedContent = parsed.encrypted_content + } + } + const closeReasoning = function* (): Generator { if (!reasoningMessageId || hasClosedReasoning) return hasClosedReasoning = true const timestamp = Date.now() const currentModel = emitModel() + const signature = packResponsesReasoningSignature( + reasoningItemId, + reasoningEncryptedContent, + ) yield { type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, @@ -899,9 +966,12 @@ export abstract class OpenAIBaseResponsesTextAdapter< model: currentModel, timestamp, content: accumulatedReasoning, + ...(signature ? { signature } : {}), } } reasoningMessageId = undefined + reasoningItemId = undefined + reasoningEncryptedContent = undefined stepId = null hasClosedReasoning = false accumulatedReasoning = '' @@ -1224,6 +1294,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< // handle output_item.added to capture function call metadata (name) if (chunk.type === 'response.output_item.added') { const item = chunk.item + if (item.type === 'reasoning') { + captureReasoningItem(item) + yield* openReasoning() + } if (item.type === 'function_call' && item.id) { // Track the item as soon as we see it so subsequent arg deltas // aren't logged as orphans, but only emit TOOL_CALL_START when @@ -1388,6 +1462,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< // whose START + END therefore never fired). if (chunk.type === 'response.output_item.done') { const item = chunk.item + if (item.type === 'reasoning') { + captureReasoningItem(item) + yield* openReasoning() + } if (item.type === 'function_call' && item.id) { const metadata = toolCallMetadata.get(item.id) ?? { callId: item.call_id || item.id, @@ -1502,6 +1580,15 @@ export abstract class OpenAIBaseResponsesTextAdapter< } } + if (Array.isArray(chunk.response.output)) { + for (const item of chunk.response.output) { + captureReasoningItem(item) + } + } + if (reasoningItemId && !reasoningMessageId) { + yield* openReasoning() + } + // Final backstop for function_call lifecycle: if a function_call // appears in `response.output[]` but was never matched by an // output_item.added/done with a name, recover the missing START @@ -1832,6 +1919,24 @@ export abstract class OpenAIBaseResponsesTextAdapter< // Handle assistant messages if (message.role === 'assistant') { + if (message.thinking) { + for (const thinking of message.thinking) { + if (!thinking.signature) continue + const packed = unpackResponsesReasoningSignature(thinking.signature) + if (!packed?.id) continue + result.push({ + type: 'reasoning', + id: packed.id, + ...(packed.encrypted_content + ? { encrypted_content: packed.encrypted_content } + : {}), + summary: thinking.content + ? [{ type: 'summary_text', text: thinking.content }] + : [], + }) + } + } + // If the assistant message has tool calls, add them as FunctionToolCall objects // Responses API expects arguments as a string (JSON string) if (message.toolCalls && message.toolCalls.length > 0) { diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 44fa4c635c..b2c2913340 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -2579,6 +2579,352 @@ describe('OpenAIBaseResponsesTextAdapter', () => { ) }) + it('replays a reasoning item before function_call on tool follow-up', async () => { + setupMockResponsesClient([ + { + type: 'response.created', + response: { + id: 'resp-replay', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-replay', + model: 'test-model', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + + for await (const _chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [ + { + role: 'assistant', + content: null, + thinking: [ + { + content: 'pick a city', + signature: JSON.stringify({ + id: 'rs_required', + encrypted_content: 'enc-blob', + }), + }, + ], + toolCalls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + metadata: { itemId: 'fc_123' }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_123', + content: '{"temp":72}', + }, + ], + })) { + // consume the follow-up request + } + + const [payload] = mockResponsesCreate.mock.calls[0]! + const input = payload.input as Array<{ type: string; id?: string }> + const reasoningIndex = input.findIndex( + (item) => item.type === 'reasoning' && item.id === 'rs_required', + ) + const functionCallIndex = input.findIndex( + (item) => item.type === 'function_call' && item.id === 'fc_123', + ) + expect(input[reasoningIndex]).toEqual({ + type: 'reasoning', + id: 'rs_required', + encrypted_content: 'enc-blob', + summary: [{ type: 'summary_text', text: 'pick a city' }], + }) + expect(functionCallIndex).toBeGreaterThan(reasoningIndex) + }) + + it('round-trips encrypted reasoning with no reasoning text through a server tool', async () => { + const firstTurn = [ + { + type: 'response.created', + response: { + id: 'resp-rs-empty-1', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_empty_1', + }, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_empty_1', + encrypted_content: 'enc-empty', + summary: [], + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'function_call', + id: 'fc_empty_1', + call_id: 'call_empty', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_empty_1', + output_index: 1, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-rs-empty-1', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'reasoning', + id: 'rs_empty_1', + encrypted_content: 'enc-empty', + summary: [], + }, + { + type: 'function_call', + id: 'fc_empty_1', + call_id: 'call_empty', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + const secondTurn = [ + { + type: 'response.created', + response: { + id: 'resp-rs-empty-2', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 0, + content_index: 0, + delta: 'Sunny', + }, + { + type: 'response.completed', + response: { + id: 'resp-rs-empty-2', + model: 'test-model', + status: 'completed', + output: [], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }, + }, + ] + + mockResponsesCreate = vi + .fn() + .mockResolvedValueOnce(createAsyncIterable(firstTurn)) + .mockResolvedValueOnce(createAsyncIterable(secondTurn)) + const execute = vi.fn().mockReturnValue({ temperature: 72 }) + + for await (const _chunk of chat({ + adapter: new TestResponsesAdapter(testConfig, 'test-model'), + messages: [{ role: 'user', content: 'How is the weather?' }], + tools: [{ ...weatherTool, execute }], + })) { + // consume both agent-loop turns + } + + expect(execute).toHaveBeenCalledOnce() + expect(mockResponsesCreate).toHaveBeenCalledTimes(2) + + const secondRequest = mockResponsesCreate.mock.calls[1]![0] + const input = secondRequest.input as Array<{ type: string; id?: string }> + const reasoningIndex = input.findIndex( + (item) => item.type === 'reasoning' && item.id === 'rs_empty_1', + ) + const functionCallIndex = input.findIndex( + (item) => item.type === 'function_call' && item.id === 'fc_empty_1', + ) + expect(input[reasoningIndex]).toEqual({ + type: 'reasoning', + id: 'rs_empty_1', + encrypted_content: 'enc-empty', + summary: [], + }) + expect(functionCallIndex).toBeGreaterThan(reasoningIndex) + }) + + it('round-trips encrypted reasoning with a function_call through a server tool', async () => { + const firstTurn = [ + { + type: 'response.created', + response: { + id: 'resp-rs-1', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_item_1', + }, + }, + { + type: 'response.reasoning_text.delta', + delta: 'need the weather', + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_item_1', + encrypted_content: 'enc-blob', + summary: [{ type: 'summary_text', text: 'need the weather' }], + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'function_call', + id: 'fc_item_1', + call_id: 'call_abc', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_item_1', + output_index: 1, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-rs-1', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'reasoning', + id: 'rs_item_1', + encrypted_content: 'enc-blob', + summary: [{ type: 'summary_text', text: 'need the weather' }], + }, + { + type: 'function_call', + id: 'fc_item_1', + call_id: 'call_abc', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + const secondTurn = [ + { + type: 'response.created', + response: { + id: 'resp-rs-2', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 0, + content_index: 0, + delta: 'Sunny', + }, + { + type: 'response.completed', + response: { + id: 'resp-rs-2', + model: 'test-model', + status: 'completed', + output: [], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }, + }, + ] + + mockResponsesCreate = vi + .fn() + .mockResolvedValueOnce(createAsyncIterable(firstTurn)) + .mockResolvedValueOnce(createAsyncIterable(secondTurn)) + const execute = vi.fn().mockReturnValue({ temperature: 72 }) + + for await (const _chunk of chat({ + adapter: new TestResponsesAdapter(testConfig, 'test-model'), + messages: [{ role: 'user', content: 'How is the weather?' }], + tools: [{ ...weatherTool, execute }], + })) { + // consume both agent-loop turns + } + + expect(execute).toHaveBeenCalledOnce() + expect(mockResponsesCreate).toHaveBeenCalledTimes(2) + + const secondRequest = mockResponsesCreate.mock.calls[1]![0] + const input = secondRequest.input as Array<{ type: string; id?: string }> + const reasoningIndex = input.findIndex( + (item) => item.type === 'reasoning' && item.id === 'rs_item_1', + ) + const functionCallIndex = input.findIndex( + (item) => item.type === 'function_call' && item.id === 'fc_item_1', + ) + expect(input[reasoningIndex]).toEqual({ + type: 'reasoning', + id: 'rs_item_1', + encrypted_content: 'enc-blob', + summary: [{ type: 'summary_text', text: 'need the weather' }], + }) + expect(functionCallIndex).toBeGreaterThan(reasoningIndex) + }) + it('converts a multimodal tool result to a structured function_call_output', async () => { const streamChunks = [ { From 9c942f1a97456e3d202398e36194a1b1e91c6d2d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 1 Sep 2026 15:17:37 +0200 Subject: [PATCH 2/6] fix(ai-openai): request encrypted reasoning only on reasoning models --- .changeset/openai-reasoning-replay.md | 2 +- packages/ai-openai/src/adapters/text.ts | 14 ++++-- .../ai-openai/tests/openai-adapter.test.ts | 44 ++++++++++++++++++- .../references/openai-adapter.md | 9 ++-- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.changeset/openai-reasoning-replay.md b/.changeset/openai-reasoning-replay.md index 1c8123df68..82f9c37205 100644 --- a/.changeset/openai-reasoning-replay.md +++ b/.changeset/openai-reasoning-replay.md @@ -4,4 +4,4 @@ '@tanstack/ai-openai': patch --- -Replay OpenAI Responses reasoning items with function_call on the next tool turn. +Replay OpenAI Responses reasoning items with function_call on the next tool turn. Default `include: ['reasoning.encrypted_content']` only on reasoning models. diff --git a/packages/ai-openai/src/adapters/text.ts b/packages/ai-openai/src/adapters/text.ts index d1b89785a1..bde0df23c2 100644 --- a/packages/ai-openai/src/adapters/text.ts +++ b/packages/ai-openai/src/adapters/text.ts @@ -148,10 +148,16 @@ export class OpenAITextAdapter< delete request.top_p } - // gpt-5.x pairs each function_call with a reasoning item. Request the - // encrypted blob so convertMessagesToInput can replay it on the next turn. - // Callers can still override include in modelOptions. - request.include = request.include ?? ['reasoning.encrypted_content'] + // Reasoning models pair each function_call with a reasoning item. Request + // the encrypted blob so convertMessagesToInput can replay it. Pre-5 chat + // models do not emit those items, so leave include unset for them. + // Callers can still set include in modelOptions. + if ( + request.include === undefined && + openAIModelRejectsSamplingParams(options.model) + ) { + request.include = ['reasoning.encrypted_content'] + } return request } diff --git a/packages/ai-openai/tests/openai-adapter.test.ts b/packages/ai-openai/tests/openai-adapter.test.ts index ee6e90416a..396cca7146 100644 --- a/packages/ai-openai/tests/openai-adapter.test.ts +++ b/packages/ai-openai/tests/openai-adapter.test.ts @@ -131,6 +131,46 @@ describe('OpenAI adapter option mapping', () => { expect(payload.tools).toBeDefined() expect(Array.isArray(payload.tools)).toBe(true) expect(payload.tools.length).toBeGreaterThan(0) + expect(payload.include).toBeUndefined() + }) + + it('requests encrypted reasoning only on reasoning models', async () => { + const mockStream = createMockChatCompletionsStream([ + { + type: 'response.created', + response: { + id: 'resp-reasoning-include', + model: 'gpt-5.6', + status: 'in_progress', + created_at: 1234567890, + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-reasoning-include', + status: 'completed', + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + ]) + + const responsesCreate = vi.fn().mockResolvedValueOnce(mockStream) + const adapter = new OpenAITextAdapter({ apiKey: 'test-key' }, 'gpt-5.6') + ;(adapter as any).client = { + responses: { + create: responsesCreate, + }, + } + + for await (const _chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + })) { + // consume + } + + const [payload] = responsesCreate.mock.calls[0]! expect(payload.include).toEqual(['reasoning.encrypted_content']) }) @@ -140,7 +180,7 @@ describe('OpenAI adapter option mapping', () => { type: 'response.created', response: { id: 'resp-include', - model: 'gpt-4o-mini', + model: 'gpt-5.6', status: 'in_progress', created_at: 1234567890, }, @@ -156,7 +196,7 @@ describe('OpenAI adapter option mapping', () => { ]) const responsesCreate = vi.fn().mockResolvedValueOnce(mockStream) - const adapter = createAdapter('gpt-4o-mini') + const adapter = new OpenAITextAdapter({ apiKey: 'test-key' }, 'gpt-5.6') ;(adapter as any).client = { responses: { create: responsesCreate, diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md index 81dbfba106..2017bdd6fc 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md @@ -95,7 +95,8 @@ OPENAI_API_KEY `effort: 'low'` or higher to enable reasoning. - `o3-pro` only supports `high` reasoning effort. - `conversation` and `previous_response_id` cannot be used together. -- Reasoning models pair each `function_call` with a `reasoning` item. The - adapter requests `include: ['reasoning.encrypted_content']` and replays that - item on the next turn. If you persist history by hand, keep - `thinking[].signature`. +- Reasoning models (`o*`, `gpt-5*` except `*-chat-latest`, `codex-mini-latest`) + pair each `function_call` with a `reasoning` item. The adapter requests + `include: ['reasoning.encrypted_content']` for those models and replays that + item on the next turn. Pre-5 chat models are left unchanged. If you persist + history by hand, keep `thinking[].signature`. From ad660ac53b9165e5eafbbd0134117f97aa277fe2 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:27:55 +1000 Subject: [PATCH 3/6] fix(openai-base): keep one thinking step after encrypted reasoning replay Do not open a second empty reasoning message when response.completed carries the encrypted blob after output_text already closed the step. --- .../src/adapters/responses-text.ts | 29 ++++- .../openai-base/tests/responses-text.test.ts | 107 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 298923d39e..551e208452 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -890,6 +890,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< let reasoningMessageId: string | undefined let reasoningItemId: string | undefined let reasoningEncryptedContent: string | undefined + let closedReasoningStepId: string | undefined let hasClosedReasoning = false // Track whether we've emitted a terminal RUN_FINISHED so the // end-of-stream fallback below knows to synthesise one when the upstream @@ -959,6 +960,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< timestamp, } if (stepId) { + closedReasoningStepId = stepId yield { type: EventType.STEP_FINISHED, stepName: stepId, @@ -1585,8 +1587,31 @@ export abstract class OpenAIBaseResponsesTextAdapter< captureReasoningItem(item) } } - if (reasoningItemId && !reasoningMessageId) { - yield* openReasoning() + // output_text already closed the streamed reasoning item. A second + // openReasoning() would emit an empty thinking part. Attach the + // completed item's id/blob to that step instead. Open only when + // this turn never started reasoning (encrypted-only output). + if ( + !reasoningMessageId && + (reasoningItemId || reasoningEncryptedContent) + ) { + const signature = packResponsesReasoningSignature( + reasoningItemId, + reasoningEncryptedContent, + ) + if (closedReasoningStepId && signature) { + yield { + type: EventType.STEP_FINISHED, + stepName: closedReasoningStepId, + stepId: closedReasoningStepId, + model: emitModel(), + timestamp: Date.now(), + content: '', + signature, + } + } else if (!closedReasoningStepId) { + yield* openReasoning() + } } // Final backstop for function_call lifecycle: if a function_call diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index b2c2913340..d87f4425f2 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -2657,6 +2657,113 @@ describe('OpenAIBaseResponsesTextAdapter', () => { expect(functionCallIndex).toBeGreaterThan(reasoningIndex) }) + it('keeps one thinking step when encrypted reasoning arrives after output text', async () => { + setupMockResponsesClient([ + { + type: 'response.created', + response: { + id: 'resp-rs-text', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'rs_text_1' }, + }, + { + type: 'response.reasoning_text.delta', + delta: 'The user is asking for a beginner guitar recommendation.', + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_text_1', + summary: [ + { + type: 'summary_text', + text: 'The user is asking for a beginner guitar recommendation.', + }, + ], + }, + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + delta: 'Fender Stratocaster', + }, + { + type: 'response.completed', + response: { + id: 'resp-rs-text', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'reasoning', + id: 'rs_text_1', + encrypted_content: 'enc-after-text', + summary: [ + { + type: 'summary_text', + text: 'The user is asking for a beginner guitar recommendation.', + }, + ], + }, + { + type: 'message', + id: 'msg_1', + role: 'assistant', + content: [ + { type: 'output_text', text: 'Fender Stratocaster' }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [ + { + role: 'user', + content: '[reasoning] recommend a guitar for a beginner', + }, + ], + })) { + chunks.push(chunk) + } + + expect( + chunks.filter((chunk) => chunk.type === EventType.REASONING_START), + ).toHaveLength(1) + expect( + chunks.filter( + (chunk) => + chunk.type === EventType.STEP_STARTED && + chunk.stepType === 'thinking', + ), + ).toHaveLength(1) + + const signedSteps = chunks.filter( + (chunk) => + chunk.type === EventType.STEP_FINISHED && + typeof chunk.signature === 'string' && + chunk.signature.includes('enc-after-text'), + ) + expect(signedSteps).toHaveLength(1) + }) + it('round-trips encrypted reasoning with no reasoning text through a server tool', async () => { const firstTurn = [ { From ed68dc3b82dc38565e7af4f85f425ace59acc070 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:29:55 +0000 Subject: [PATCH 4/6] ci: apply automated fixes --- packages/openai-base/tests/responses-text.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index d87f4425f2..043f0e4d43 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -2719,9 +2719,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { type: 'message', id: 'msg_1', role: 'assistant', - content: [ - { type: 'output_text', text: 'Fender Stratocaster' }, - ], + content: [{ type: 'output_text', text: 'Fender Stratocaster' }], }, ], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, From 4f1dd91338de91a3495bd6b79cf47e59ee8561f6 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:15:21 +1000 Subject: [PATCH 5/6] fix(e2e): fill generate prompts after hydration clickGenerate waited for networkidle after fill, so React remounted the controlled input empty and generate-button stayed disabled until timeout. --- testing/e2e/tests/helpers.ts | 48 +++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/testing/e2e/tests/helpers.ts b/testing/e2e/tests/helpers.ts index de8ddfb72c..7723130f68 100644 --- a/testing/e2e/tests/helpers.ts +++ b/testing/e2e/tests/helpers.ts @@ -185,36 +185,39 @@ export async function getTranscriptionResult(page: Page): Promise { return page.getByTestId('transcription-result').innerText() } -export async function fillPrompt(page: Page, text: string) { - const input = page.getByTestId('prompt-input') - await input.click() - await input.fill(text) - await input.dispatchEvent('input', { bubbles: true }) - // If fill() didn't trigger React onChange, fall back to pressSequentially +async function fillControlledGenerateInput( + page: Page, + testId: 'prompt-input' | 'text-input', + text: string, +) { + // Hydrate first. Waiting for networkidle after fill used to remount the + // controlled input empty, so generate-button stayed disabled for 30s. + await page.waitForLoadState('networkidle') + const input = page.getByTestId(testId) const btn = page.getByTestId('generate-button') - if (await btn.isDisabled()) { - await input.clear() - await input.pressSequentially(text, { delay: 30 }) - } + await expect(async () => { + await input.click() + await input.fill(text) + await input.dispatchEvent('input', { bubbles: true }) + if (await btn.isDisabled()) { + await input.clear() + await input.pressSequentially(text, { delay: 20 }) + } + await expect(btn).toBeEnabled({ timeout: 2_000 }) + }).toPass({ timeout: 15_000, intervals: [250, 500, 1000] }) +} + +export async function fillPrompt(page: Page, text: string) { + await fillControlledGenerateInput(page, 'prompt-input', text) } export async function fillTextInput(page: Page, text: string) { - const input = page.getByTestId('text-input') - await input.click() - await input.fill(text) - await input.dispatchEvent('input', { bubbles: true }) - // If fill() didn't trigger React onChange, fall back to pressSequentially - const btn = page.getByTestId('generate-button') - if (await btn.isDisabled()) { - await input.clear() - await input.pressSequentially(text, { delay: 30 }) - } + await fillControlledGenerateInput(page, 'text-input', text) } export async function clickGenerate(page: Page) { - // Wait for full page load (including hydration scripts) - await page.waitForLoadState('networkidle') const btn = page.getByTestId('generate-button') + await expect(btn).toBeEnabled() await btn.click() // Verify the click actually triggered React — status should leave 'idle' // If still idle after a short wait, the click missed hydration; retry @@ -223,7 +226,6 @@ export async function clickGenerate(page: Page) { timeout: 3_000, }) } catch { - // Retry click — hydration likely wasn't complete on first attempt await btn.click() } } From f08446d45b09bdaee4621763052f0cdafa41be1b Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:02:46 +1000 Subject: [PATCH 6/6] fix(ai-client): give resume-join tests 15s so CI load cannot race 5s waitFor({ timeout: 5_000 }) sits inside Vitest's default 5s testTimeout. Nx runs this suite in parallel with the rest of test:pr, so the 2s rejoin deadline plus setup can lose that race. --- packages/ai-client/vite.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ai-client/vite.config.ts b/packages/ai-client/vite.config.ts index 2e3210f99b..f945e6a579 100644 --- a/packages/ai-client/vite.config.ts +++ b/packages/ai-client/vite.config.ts @@ -10,6 +10,10 @@ const config = defineConfig({ globals: true, environment: 'node', include: ['tests/**/*.test.ts'], + // Resume-join tests wait REJOIN_CONNECT_DEADLINE_MS (2s) inside + // waitFor({ timeout: 5_000 }). Default 5s testTimeout loses that race + // when nx runs this suite in parallel with the rest of test:pr. + testTimeout: 15_000, // Re-route the no-op devtools factories to the real implementations // for the whole test suite. The shipping default is no-op (so the // heavy bridge classes stay out of `@tanstack/ai-client`'s main