diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 97e7c4c76c..51901a2e81 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -79,6 +79,7 @@ import { import type { MemoryExtractionSourceSnapshot } from '../memory-extraction.js'; import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; +import { getAIModel } from '../model-factory.js'; describe('AiSdkBackend ApplyPatch routing', () => { test('advertises apply_patch only to supported native OpenAI models', async () => { @@ -10809,6 +10810,178 @@ describe('AiSdkBackend thinking persistence', () => { assert.ok(prompt.indexOf('reasoning about the tool result') < prompt.indexOf('tool-1')); }); + test('omits Responses reasoning without encrypted content from the wire request', async (t) => { + for (const replayCase of [ + { name: 'missing', openai: { itemId: 'rs_deepseek' } }, + { + name: 'null', + openai: { itemId: 'rs_deepseek', reasoningEncryptedContent: null }, + }, + { + name: 'empty string', + openai: { itemId: 'rs_deepseek', reasoningEncryptedContent: '' }, + }, + ] as const) { + await t.test(replayCase.name, async () => { + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'plaintext reasoning from DeepSeek', + providerOptions: { openai: replayCase.openai }, + }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'answer before the tool call' }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e3', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { toolCallId: 'tool-1', stepId: 'm1' }, + }), + runtimeEvent({ + id: 'e4', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: 'file contents' }, + }, + refs: { toolCallId: 'tool-1' }, + }), + ]; + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const events = [ + { type: 'response.created', response: { id: 'response-current' } }, + { + type: 'response.completed', + response: { + id: 'response-current', + object: 'response', + created_at: 8, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + const body = `${events + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as unknown as typeof globalThis.fetch; + const secondBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-test-token', + modelId: 'deepseek-v4-flash', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + + const input = requestBody?.input; + assert.ok(Array.isArray(input)); + assert.equal( + input.some((item) => item?.type === 'reasoning'), + false, + ); + assert.deepEqual( + input.slice(0, 3).map((item) => { + assert.ok(item && typeof item === 'object' && !Array.isArray(item)); + const record = item as Record; + const content = Array.isArray(record.content) ? record.content : []; + const firstContent = content[0]; + return { + type: record.type ?? (typeof record.role === 'string' ? 'message' : undefined), + role: record.role, + text: + firstContent && typeof firstContent === 'object' && !Array.isArray(firstContent) + ? (firstContent as Record).text + : undefined, + callId: record.call_id, + name: record.name, + arguments: record.arguments, + output: record.output, + }; + }), + [ + { + type: 'message', + role: 'assistant', + text: 'answer before the tool call', + callId: undefined, + name: undefined, + arguments: undefined, + output: undefined, + }, + { + type: 'function_call', + role: undefined, + text: undefined, + callId: 'tool-1', + name: 'Read', + arguments: '{"path":"package.json"}', + output: undefined, + }, + { + type: 'function_call_output', + role: undefined, + text: undefined, + callId: 'tool-1', + name: undefined, + arguments: undefined, + output: '{"kind":"text","text":"file contents"}', + }, + ], + ); + }); + } + }); + test('OpenAI Responses reasoning from a tool step is replayed with its encrypted content', async () => { const ctx = { sessionId: 'session-1', diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 04dc18dd0d..f7dd926252 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -67,7 +67,7 @@ describe('ModelAdapter stream and error normalization', () => { toolResults: true, signedThinking: false, unsignedThinking: true, - openAiResponsesThinking: false, + openAiResponsesEncryptedThinking: false, }); }); @@ -121,7 +121,7 @@ describe('ModelAdapter stream and error normalization', () => { toolResults: true, signedThinking: false, unsignedThinking: false, - openAiResponsesThinking: true, + openAiResponsesEncryptedThinking: true, }); }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index ef72a73ad7..b9293f0834 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3752,14 +3752,19 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.openAiResponsesThinking) { + if (replaySupport.openAiResponsesEncryptedThinking) { const openai = item.providerOptions?.openai; if (openai && typeof openai === 'object' && !Array.isArray(openai)) { const { itemId, reasoningEncryptedContent } = openai as { itemId?: unknown; reasoningEncryptedContent?: unknown; }; - if (typeof itemId === 'string' && itemId.length > 0) { + if ( + typeof itemId === 'string' && + itemId.length > 0 && + typeof reasoningEncryptedContent === 'string' && + reasoningEncryptedContent.length > 0 + ) { return { part: { type: 'reasoning' as const, @@ -3767,10 +3772,7 @@ export class AiSdkBackend implements AgentBackend { providerOptions: { openai: { itemId, - ...(typeof reasoningEncryptedContent === 'string' || - reasoningEncryptedContent === null - ? { reasoningEncryptedContent } - : {}), + reasoningEncryptedContent, }, }, }, diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index eee32bfdea..01b5782f7f 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -185,7 +185,8 @@ export class ModelAdapter { // relays that don't need the field ignore it. Reasoning is still // recorded to the event log and rendered regardless. unsignedThinking: this.runtime.reasoningReplay.kind === 'openai-chat-plaintext', - openAiResponsesThinking: this.runtime.reasoningReplay.kind === 'openai-responses-item', + openAiResponsesEncryptedThinking: + this.runtime.reasoningReplay.kind === 'openai-responses-encrypted', }; } @@ -634,7 +635,7 @@ export interface ModelAdapterRuntimeEventReplaySupport { toolResults: boolean; signedThinking: boolean; unsignedThinking: boolean; - openAiResponsesThinking: boolean; + openAiResponsesEncryptedThinking: boolean; } /** diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index f33ff92eb4..0d49a2bc64 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -20,7 +20,7 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'openai-responses-item' }; + | { kind: 'openai-responses-encrypted' }; export interface ResolvedModelRuntime { adapter: ProviderRuntimeAdapter; @@ -161,7 +161,11 @@ function reasoningReplayContract( case 'anthropic-messages': return { kind: 'anthropic-signed' }; case 'openai-responses': - return { kind: 'openai-responses-item' }; + // The native OpenAI serializer can replay only provider-issued encrypted + // reasoning when store=false. Open Responses plaintext reasoning is read + // by a separate response transport, but has no request codec here yet; + // @ai-sdk/open-responses unlocks an open-responses-plaintext sibling. + return { kind: 'openai-responses-encrypted' }; case 'openai-chat': return adapter.kind === 'openai-compatible' ? {