Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
84 changes: 84 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10809,6 +10809,90 @@ describe('AiSdkBackend thinking persistence', () => {
assert.ok(prompt.indexOf('reasoning about the tool result') < prompt.indexOf('tool-1'));
});

test('does not replay plaintext Responses reasoning through the encrypted OpenAI shape', async () => {
const ctx = {
sessionId: 'session-1',
invocationId: 'inv-1',
runId: 'run-prev',
turnId: 'turn-prev',
now: () => 7,
newId: idGenerator(),
} as unknown as InvocationContext;
const memory = createSessionEventMapMemory();
const priorEvents: SessionEvent[] = [
{
type: 'tool_start',
id: 'e1',
turnId: 'turn-prev',
ts: 1,
toolUseId: 'tool-1',
toolName: 'Read',
args: { path: 'package.json' },
stepId: 'm1',
},
{
type: 'tool_result',
id: 'e2',
turnId: 'turn-prev',
ts: 2,
toolUseId: 'tool-1',
isError: false,
content: { kind: 'text', text: 'file contents' },
},
{
type: 'thinking_complete',
id: 'e3',
turnId: 'turn-prev',
ts: 3,
messageId: 'm1',
text: 'plaintext reasoning from DeepSeek',
providerOptions: { openai: { itemId: 'rs_deepseek' } },
},
{
type: 'text_complete',
id: 'e4',
turnId: 'turn-prev',
ts: 4,
messageId: 'm1',
text: '',
},
];
const runtimeContext = priorEvents.map((event) =>
mapSessionEventToRuntimeEvent(event, ctx, memory),
);
const secondModel = completionModel();
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: () => secondModel,
tools: [],
newId: idGenerator(),
now: monotonicClock(),
});

await drain(
secondBackend.send({
turnId: 'turn-current',
text: 'follow up',
context: [],
runtimeContext,
}),
);

const prompt = JSON.stringify(compactPrompt(secondModel));
Comment thread
me2seeks marked this conversation as resolved.
Resolved
assert.doesNotMatch(prompt, /plaintext reasoning from DeepSeek|rs_deepseek/);
assert.match(prompt, /"toolName":"Read"|"toolCallId":"tool-1"/);
assert.match(prompt, /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
7 changes: 5 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,10 @@ 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.
Resolved
// reasoning when store=false. Open Responses plaintext reasoning is read
// by a separate response transport, but has no request codec here yet.
return { kind: 'openai-responses-encrypted' };
case 'openai-chat':
return adapter.kind === 'openai-compatible'
? {
Expand Down
Loading