Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string, unknown> | undefined;
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>).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',
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/__tests__/model-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('ModelAdapter stream and error normalization', () => {
toolResults: true,
signedThinking: false,
unsignedThinking: true,
openAiResponsesThinking: false,
openAiResponsesEncryptedThinking: false,
});
});

Expand Down Expand Up @@ -121,7 +121,7 @@ describe('ModelAdapter stream and error normalization', () => {
toolResults: true,
signedThinking: false,
unsignedThinking: false,
openAiResponsesThinking: true,
openAiResponsesEncryptedThinking: true,
});
});

Expand Down
14 changes: 8 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3752,25 +3752,27 @@ 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,
text: item.text,
providerOptions: {
openai: {
itemId,
...(typeof reasoningEncryptedContent === 'string' ||
reasoningEncryptedContent === null
? { reasoningEncryptedContent }
: {}),
reasoningEncryptedContent,
},
},
},
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime/src/model-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand Down Expand Up @@ -634,7 +635,7 @@ export interface ModelAdapterRuntimeEventReplaySupport {
toolResults: boolean;
signedThinking: boolean;
unsignedThinking: boolean;
openAiResponsesThinking: boolean;
openAiResponsesEncryptedThinking: boolean;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions packages/runtime/src/model-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Comment thread
me2seeks marked this conversation as resolved.
// 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'
? {
Expand Down
Loading