diff --git a/packages/openai/src/hosted-tools.test.ts b/packages/openai/src/hosted-tools.test.ts index f9124e3..0b61688 100644 --- a/packages/openai/src/hosted-tools.test.ts +++ b/packages/openai/src/hosted-tools.test.ts @@ -281,7 +281,15 @@ describe('hosted tool input mapping', () => { type: 'function_approval_response', id: 'mcpr_1', approved: true, - functionCall: { type: 'function_call', callId: 'mcpr_1', name: 'delete', arguments: '{}' }, + // `approvalResponse()` copies the request's call verbatim, so a hosted decision + // carries the `server_label` that marks it as the provider's to settle. + functionCall: { + type: 'function_call', + callId: 'mcpr_1', + name: 'delete', + arguments: '{}', + additionalProperties: { server_label: 'docs' }, + }, }, ], }, diff --git a/packages/openai/src/to-openai.ts b/packages/openai/src/to-openai.ts index 1287973..98e9566 100644 --- a/packages/openai/src/to-openai.ts +++ b/packages/openai/src/to-openai.ts @@ -12,6 +12,7 @@ import type { import { ChatClientError, isFunctionTool, + isHostedApproval, MESSAGE_SOURCE_KEY, resolveResponseFormat, topLevelMediaType, @@ -566,6 +567,11 @@ export function toResponsesInput( break; } case 'function_approval_request': { + // A local tool's approval is resolved in-process: the provider never issued a matching + // approval item, so serializing one would orphan it on the wire. + if (!isHostedApproval(content)) { + break; + } // The framework models a hosted MCP approval as an approval request; the Responses API // models it as an output item that has to be replayed to keep the pairing. items.push({ @@ -578,6 +584,11 @@ export function toResponsesInput( break; } case 'function_approval_response': { + // Only a hosted decision has a matching approval request on the provider; a local + // decision replayed as `mcp_approval_response` would reference an id the API never saw. + if (!isHostedApproval(content)) { + break; + } items.push({ type: 'mcp_approval_response', approval_request_id: content.id, diff --git a/packages/openai/src/to-openai.wire-fallbacks.test.ts b/packages/openai/src/to-openai.wire-fallbacks.test.ts index 26637c2..1fc4784 100644 --- a/packages/openai/src/to-openai.wire-fallbacks.test.ts +++ b/packages/openai/src/to-openai.wire-fallbacks.test.ts @@ -282,6 +282,64 @@ describe('stateless replay validation', () => { }); }); +describe('approval serialization boundaries', () => { + const localCall = { + type: 'function_call', + callId: 'local_1', + name: 'ask_user', + arguments: '{}', + } as const; + const localApprovalMessages: Message[] = [ + { + role: 'assistant', + contents: [{ type: 'function_approval_request', id: 'local_approval_1', functionCall: localCall }], + }, + { + role: 'user', + contents: [ + { + type: 'function_approval_response', + id: 'local_approval_1', + approved: true, + functionCall: localCall, + }, + ], + }, + ]; + + it.each([ + ['stateless', false], + ['storage', true], + ])('drops local approval controls from the input (%s)', (_mode, serviceStorage) => { + // A local tool's approval is resolved in-process; the provider never issued a matching + // MCP approval request, so serializing either half would create an orphaned item. + expect(toResponsesInput(localApprovalMessages, { serviceStorage })).toEqual([]); + }); + + it('keeps a hosted approval pair on the wire', () => { + const hostedCall = { + type: 'function_call', + callId: 'mcpr_1', + name: 'sensitive_action', + arguments: '{}', + additionalProperties: { server_label: 'hosted_server' }, + } as const; + const items = toResponsesInput([ + { + role: 'assistant', + contents: [{ type: 'function_approval_request', id: 'mcpr_1', functionCall: hostedCall }], + }, + { + role: 'user', + contents: [ + { type: 'function_approval_response', id: 'mcpr_1', approved: true, functionCall: hostedCall }, + ], + }, + ]); + expect(items.map((item) => item.type)).toEqual(['mcp_approval_request', 'mcp_approval_response']); + }); +}); + describe('tool declarations and choices', () => { it('passes a non-function tool without a spec through as itself', () => { const opaque = { type: 'web_search' } as unknown as Tool;