diff --git a/.changeset/nine-starfishes-relax.md b/.changeset/nine-starfishes-relax.md new file mode 100644 index 000000000000..c26b3dfd1f49 --- /dev/null +++ b/.changeset/nine-starfishes-relax.md @@ -0,0 +1,17 @@ +--- +"@ai-sdk/openai-compatible": patch +"@ai-sdk/amazon-bedrock": patch +"@ai-sdk/open-responses": patch +"@ai-sdk/anthropic": patch +"@ai-sdk/deepseek": patch +"@ai-sdk/alibaba": patch +"@ai-sdk/mistral": patch +"@ai-sdk/cohere": patch +"@ai-sdk/google": patch +"@ai-sdk/openai": patch +"@ai-sdk/groq": patch +"@ai-sdk/xai": patch +"ai": patch +--- + +fix: more precise default message for tool execution denial diff --git a/packages/ai/src/ui/convert-to-model-messages.test.ts b/packages/ai/src/ui/convert-to-model-messages.test.ts index 75a2d29d90aa..0d5a3d1c2a07 100644 --- a/packages/ai/src/ui/convert-to-model-messages.test.ts +++ b/packages/ai/src/ui/convert-to-model-messages.test.ts @@ -1401,6 +1401,167 @@ describe('convertToModelMessages', () => { { role: 'assistant', parts: [ +<<<<<<< HEAD +======= + { + input: { + city: 'Tokyo', + }, + state: 'output-denied', + toolCallId: 'call-1', + type: 'tool-weather', + }, + ], + }, + ] as unknown as UIMessage[]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "What is the weather in Tokyo?", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "input": { + "city": "Tokyo", + }, + "providerExecuted": undefined, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-call", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "output": { + "type": "error-text", + "value": "Tool call execution denied.", + }, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-result", + }, + ], + "role": "tool", + }, + ] + `); + }); + + it('should convert tool output denied (dynamic tool)', async () => { + const result = await convertToModelMessages([ + { + parts: [ + { + text: 'What is the weather in Tokyo?', + type: 'text', + }, + ], + role: 'user', + }, + { + parts: [ + { + type: 'step-start', + }, + { + approval: { + approved: false, + id: 'approval-1', + reason: "I don't want to approve this", + }, + input: { + city: 'Tokyo', + }, + state: 'output-denied', + toolCallId: 'call-1', + type: 'dynamic-tool', + toolName: 'weather', + }, + ], + role: 'assistant', + }, + ]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "What is the weather in Tokyo?", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "input": { + "city": "Tokyo", + }, + "providerExecuted": undefined, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-call", + }, + { + "approvalId": "approval-1", + "toolCallId": "call-1", + "type": "tool-approval-request", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "approvalId": "approval-1", + "approved": false, + "providerExecuted": undefined, + "reason": "I don't want to approve this", + "type": "tool-approval-response", + }, + { + "output": { + "type": "error-text", + "value": "I don't want to approve this", + }, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-result", + }, + ], + "role": "tool", + }, + ] + `); + }); + + it('should convert tool output result with approval and follow up text (static tool)', async () => { + const result = await convertToModelMessages([ + { + parts: [ + { + text: 'What is the weather in Tokyo?', + type: 'text', + }, + ], + role: 'user', + }, + { + parts: [ +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) { type: 'step-start' }, { type: 'dynamic-tool', diff --git a/packages/ai/src/ui/convert-to-model-messages.ts b/packages/ai/src/ui/convert-to-model-messages.ts index 36a6f0587408..2cf6ed44c736 100644 --- a/packages/ai/src/ui/convert-to-model-messages.ts +++ b/packages/ai/src/ui/convert-to-model-messages.ts @@ -266,8 +266,45 @@ export function convertToModelMessages( case 'output-available': { const toolName = getToolOrDynamicToolName(toolPart); +<<<<<<< HEAD return { type: 'tool-result', +======= + // For provider-executed tools, the tool result is already in the + // assistant content. Skip adding to tool message to avoid duplicates + // (which would create orphaned function_call_output entries). + if (toolPart.providerExecuted === true) { + continue; + } + + switch (toolPart.state) { + case 'output-denied': { + content.push({ + type: 'tool-result', + toolCallId: toolPart.toolCallId, + toolName: getToolName(toolPart), + output: { + type: 'error-text' as const, + value: + toolPart.approval?.reason ?? + 'Tool call execution denied.', + }, + ...(toolPart.callProviderMetadata != null + ? { providerOptions: toolPart.callProviderMetadata } + : {}), + }); + break; + } + + case 'output-error': + case 'output-available': { + const toolName = getToolName(toolPart); + content.push({ + type: 'tool-result', + toolCallId: toolPart.toolCallId, + toolName, + output: await createToolModelOutput({ +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) toolCallId: toolPart.toolCallId, toolName, output: createToolModelOutput({ diff --git a/packages/alibaba/src/convert-to-alibaba-chat-messages.ts b/packages/alibaba/src/convert-to-alibaba-chat-messages.ts index 6f889587299b..0d76e0d23e24 100644 --- a/packages/alibaba/src/convert-to-alibaba-chat-messages.ts +++ b/packages/alibaba/src/convert-to-alibaba-chat-messages.ts @@ -163,6 +163,12 @@ export function convertToAlibabaChatMessages({ case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/amazon-bedrock/src/convert-to-bedrock-chat-messages.ts b/packages/amazon-bedrock/src/convert-to-bedrock-chat-messages.ts index f615ab5876e3..b3ad7d4da63b 100644 --- a/packages/amazon-bedrock/src/convert-to-bedrock-chat-messages.ts +++ b/packages/amazon-bedrock/src/convert-to-bedrock-chat-messages.ts @@ -180,6 +180,14 @@ export async function convertToBedrockChatMessages( case 'error-text': toolResultContent = [{ text: output.value }]; break; +<<<<<<< HEAD +======= + case 'execution-denied': + toolResultContent = [ + { text: output.reason ?? 'Tool call execution denied.' }, + ]; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'json': case 'error-json': default: diff --git a/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts b/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts index 4b722ca92dd0..e9b58b11600c 100644 --- a/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts +++ b/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts @@ -319,6 +319,13 @@ export async function convertToAnthropicMessagesPrompt({ case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = + output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'json': case 'error-json': default: diff --git a/packages/cohere/src/convert-to-cohere-chat-prompt.ts b/packages/cohere/src/convert-to-cohere-chat-prompt.ts index 51f46130bde1..2aafa35d1d5b 100644 --- a/packages/cohere/src/convert-to-cohere-chat-prompt.ts +++ b/packages/cohere/src/convert-to-cohere-chat-prompt.ts @@ -154,6 +154,7 @@ export async function convertToCohereChatPrompt( ...content.map(toolResult => { const output = toolResult.output; +<<<<<<< HEAD let contentValue: string; switch (output.type) { case 'text': @@ -166,6 +167,23 @@ export async function convertToCohereChatPrompt( contentValue = JSON.stringify(output.value); break; } +======= + let contentValue: string; + switch (output.type) { + case 'text': + case 'error-text': + contentValue = output.value; + break; + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; + case 'content': + case 'json': + case 'error-json': + contentValue = JSON.stringify(output.value); + break; + } +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) return { role: 'tool' as const, diff --git a/packages/deepseek/src/chat/convert-to-deepseek-chat-messages.ts b/packages/deepseek/src/chat/convert-to-deepseek-chat-messages.ts index 0068dd484fea..22646bc6ca99 100644 --- a/packages/deepseek/src/chat/convert-to-deepseek-chat-messages.ts +++ b/packages/deepseek/src/chat/convert-to-deepseek-chat-messages.ts @@ -142,6 +142,12 @@ export function convertToDeepSeekChatMessages({ case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/google/src/convert-to-google-generative-ai-messages.ts b/packages/google/src/convert-to-google-generative-ai-messages.ts index cb334e6c50d0..025414d78560 100644 --- a/packages/google/src/convert-to-google-generative-ai-messages.ts +++ b/packages/google/src/convert-to-google-generative-ai-messages.ts @@ -172,7 +172,14 @@ export function convertToGoogleGenerativeAIMessages( name: part.toolName, response: { name: part.toolName, +<<<<<<< HEAD content: output.value, +======= + content: + output.type === 'execution-denied' + ? (output.reason ?? 'Tool call execution denied.') + : output.value, +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) }, }, }); diff --git a/packages/groq/src/convert-to-groq-chat-messages.ts b/packages/groq/src/convert-to-groq-chat-messages.ts index 53738e32ff73..6cce148ac33a 100644 --- a/packages/groq/src/convert-to-groq-chat-messages.ts +++ b/packages/groq/src/convert-to-groq-chat-messages.ts @@ -114,6 +114,12 @@ export function convertToGroqChatMessages( case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/mistral/src/convert-to-mistral-chat-messages.ts b/packages/mistral/src/convert-to-mistral-chat-messages.ts index 46e2d0c7f77a..c79f3fd04816 100644 --- a/packages/mistral/src/convert-to-mistral-chat-messages.ts +++ b/packages/mistral/src/convert-to-mistral-chat-messages.ts @@ -117,6 +117,12 @@ export function convertToMistralChatMessages( case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/open-responses/src/responses/convert-to-open-responses-input.ts b/packages/open-responses/src/responses/convert-to-open-responses-input.ts new file mode 100644 index 000000000000..2e06c35c5d13 --- /dev/null +++ b/packages/open-responses/src/responses/convert-to-open-responses-input.ts @@ -0,0 +1,211 @@ +import type { LanguageModelV3Prompt, SharedV3Warning } from '@ai-sdk/provider'; +import { convertToBase64 } from '@ai-sdk/provider-utils'; +import type { + FunctionCallItemParam, + FunctionCallOutputItemParam, + InputFileContentParam, + InputImageContentParam, + InputTextContentParam, + OpenResponsesRequestBody, + OutputTextContentParam, + RefusalContentParam, +} from './open-responses-api'; + +export async function convertToOpenResponsesInput({ + prompt, +}: { + prompt: LanguageModelV3Prompt; +}): Promise<{ + input: OpenResponsesRequestBody['input']; + instructions: string | undefined; + warnings: Array; +}> { + const input: OpenResponsesRequestBody['input'] = []; + const warnings: Array = []; + const systemMessages: string[] = []; + + for (const { role, content } of prompt) { + switch (role) { + case 'system': { + systemMessages.push(content); + break; + } + + case 'user': { + const userContent: Array< + InputTextContentParam | InputImageContentParam | InputFileContentParam + > = []; + + for (const part of content) { + switch (part.type) { + case 'text': { + userContent.push({ type: 'input_text', text: part.text }); + break; + } + case 'file': { + const mediaType = + part.mediaType === 'image/*' ? 'image/jpeg' : part.mediaType; + + if (part.mediaType.startsWith('image/')) { + userContent.push({ + type: 'input_image', + ...(part.data instanceof URL + ? { image_url: part.data.toString() } + : { + image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`, + }), + }); + } else if (part.data instanceof URL) { + userContent.push({ + type: 'input_file', + file_url: part.data.toString(), + }); + } else { + userContent.push({ + type: 'input_file', + filename: part.filename ?? 'data', + file_data: `data:${mediaType};base64,${convertToBase64(part.data)}`, + }); + } + break; + } + } + } + + input.push({ type: 'message', role: 'user', content: userContent }); + break; + } + + case 'assistant': { + const assistantContent: Array< + OutputTextContentParam | RefusalContentParam + > = []; + const toolCalls: Array = []; + + for (const part of content) { + switch (part.type) { + case 'text': { + assistantContent.push({ type: 'output_text', text: part.text }); + break; + } + case 'tool-call': { + const argumentsValue = + typeof part.input === 'string' + ? part.input + : JSON.stringify(part.input); + toolCalls.push({ + type: 'function_call', + call_id: part.toolCallId, + name: part.toolName, + arguments: argumentsValue, + }); + break; + } + } + } + + // Push assistant message with text content if any + if (assistantContent.length > 0) { + input.push({ + type: 'message', + role: 'assistant', + content: assistantContent, + }); + } + + // Push function calls as separate items + for (const toolCall of toolCalls) { + input.push(toolCall); + } + + break; + } + + case 'tool': { + for (const part of content) { + if (part.type === 'tool-result') { + const output = part.output; + let contentValue: FunctionCallOutputItemParam['output']; + + switch (output.type) { + case 'text': + case 'error-text': + contentValue = output.value; + break; + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; + case 'json': + case 'error-json': + contentValue = JSON.stringify(output.value); + break; + case 'content': { + const contentParts: Array< + | InputTextContentParam + | InputImageContentParam + | InputFileContentParam + > = []; + for (const item of output.value) { + switch (item.type) { + case 'text': { + contentParts.push({ + type: 'input_text', + text: item.text, + }); + break; + } + case 'image-data': { + contentParts.push({ + type: 'input_image', + image_url: `data:${item.mediaType};base64,${item.data}`, + }); + break; + } + case 'image-url': { + contentParts.push({ + type: 'input_image', + image_url: item.url, + }); + break; + } + case 'file-data': { + contentParts.push({ + type: 'input_file', + filename: item.filename ?? 'data', + file_data: `data:${item.mediaType};base64,${item.data}`, + }); + break; + } + default: { + warnings.push({ + type: 'other', + message: `unsupported tool content part type: ${(item as { type: string }).type}`, + }); + break; + } + } + } + contentValue = contentParts; + break; + } + } + + input.push({ + type: 'function_call_output', + call_id: part.toolCallId, + output: contentValue, + }); + } + } + break; + } + } + } + + return { + input, + instructions: + systemMessages.length > 0 ? systemMessages.join('\n') : undefined, + warnings, + }; +} diff --git a/packages/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts b/packages/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts index 422410c4f6fa..d18407a3f488 100644 --- a/packages/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts @@ -129,6 +129,12 @@ export function convertToOpenAICompatibleChatMessages( case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/openai/src/chat/convert-to-openai-chat-messages.ts b/packages/openai/src/chat/convert-to-openai-chat-messages.ts index 1270e564f0ab..69bed748e911 100644 --- a/packages/openai/src/chat/convert-to-openai-chat-messages.ts +++ b/packages/openai/src/chat/convert-to-openai-chat-messages.ts @@ -192,6 +192,12 @@ export function convertToOpenAIChatMessages({ case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json': diff --git a/packages/openai/src/responses/convert-to-openai-responses-input.test.ts b/packages/openai/src/responses/convert-to-openai-responses-input.test.ts index a96e08bfc281..af290ccff99e 100644 --- a/packages/openai/src/responses/convert-to-openai-responses-input.test.ts +++ b/packages/openai/src/responses/convert-to-openai-responses-input.test.ts @@ -1820,6 +1820,39 @@ describe('convertToOpenAIResponsesInput', () => { `); }); + it('should convert execution-denied tool result to function_call_output', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_denied_123', + toolName: 'search', + output: { + type: 'execution-denied', + reason: 'User denied the tool execution', + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toEqual([ + { + type: 'function_call_output', + call_id: 'call_denied_123', + output: 'User denied the tool execution', + }, + ]); + }); + it('should convert single tool result part with multipart that contains text', async () => { const result = await convertToOpenAIResponsesInput({ prompt: [ @@ -2187,6 +2220,127 @@ describe('convertToOpenAIResponsesInput', () => { `); }); + it('should skip provider-executed execution-denied tool results in assistant messages', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'I need approval before running that tool.', + }, + { + type: 'tool-result', + toolCallId: 'ws_denied_123', + toolName: 'web_search', + output: { + type: 'execution-denied', + reason: 'User denied the tool execution', + }, + }, + { + type: 'text', + text: 'The tool was not run.', + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: false, + }); + + expect(result).toEqual({ + input: [ + { + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'I need approval before running that tool.', + }, + ], + id: undefined, + }, + { + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'The tool was not run.', + }, + ], + id: undefined, + }, + ], + warnings: [], + }); + }); + + it('should skip json-wrapped execution-denied tool results in assistant messages', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'I need approval before running that tool.', + }, + { + type: 'tool-result', + toolCallId: 'ws_denied_json_123', + toolName: 'web_search', + output: { + type: 'json', + value: { + type: 'execution-denied', + reason: 'User denied the tool execution', + }, + }, + }, + { + type: 'text', + text: 'The tool was not run.', + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: false, + }); + + expect(result).toEqual({ + input: [ + { + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'I need approval before running that tool.', + }, + ], + id: undefined, + }, + { + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'The tool was not run.', + }, + ], + id: undefined, + }, + ], + warnings: [], + }); + }); + describe('local shell', () => { it('should convert local shell tool call and result into item reference with store: true', async () => { const result = await convertToOpenAIResponsesInput({ @@ -2342,4 +2496,1046 @@ describe('convertToOpenAIResponsesInput', () => { `); }); }); +<<<<<<< HEAD +======= + + describe('MCP tool approval responses', () => { + it('should convert approved tool-approval-response to mcp_approval_response with store: true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'mcp-approval-123', + approved: true, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "mcp-approval-123", + "type": "item_reference", + }, + { + "approval_request_id": "mcp-approval-123", + "approve": true, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should convert denied tool-approval-response to mcp_approval_response with store: true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'mcp-approval-456', + approved: false, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "mcp-approval-456", + "type": "item_reference", + }, + { + "approval_request_id": "mcp-approval-456", + "approve": false, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should convert tool-approval-response to mcp_approval_response without item_reference when store: false', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'mcp-approval-789', + approved: true, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: false, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "approval_request_id": "mcp-approval-789", + "approve": true, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should skip duplicate tool-approval-response with same approvalId', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'duplicate-approval', + approved: true, + }, + { + type: 'tool-approval-response', + approvalId: 'duplicate-approval', + approved: true, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "duplicate-approval", + "type": "item_reference", + }, + { + "approval_request_id": "duplicate-approval", + "approve": true, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should handle multiple different tool-approval-responses', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + }, + { + type: 'tool-approval-response', + approvalId: 'approval-2', + approved: false, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "approval-1", + "type": "item_reference", + }, + { + "approval_request_id": "approval-1", + "approve": true, + "type": "mcp_approval_response", + }, + { + "id": "approval-2", + "type": "item_reference", + }, + { + "approval_request_id": "approval-2", + "approve": false, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should skip execution-denied output when it has approvalId in providerOptions', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'denied-approval', + approved: false, + }, + { + type: 'tool-result', + toolCallId: 'call-123', + toolName: 'mcp_tool', + output: { + type: 'execution-denied', + reason: 'User denied the tool execution', + providerOptions: { + openai: { + approvalId: 'denied-approval', + }, + }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + // Only the mcp_approval_response should be present, not a function_call_output + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "denied-approval", + "type": "item_reference", + }, + { + "approval_request_id": "denied-approval", + "approve": false, + "type": "mcp_approval_response", + }, + ] + `); + }); + + it('should handle tool-approval-response mixed with regular tool results', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'approval-for-mcp', + approved: true, + }, + { + type: 'tool-result', + toolCallId: 'regular-call-1', + toolName: 'calculator', + output: { + type: 'json', + value: { result: 42 }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "approval-for-mcp", + "type": "item_reference", + }, + { + "approval_request_id": "approval-for-mcp", + "approve": true, + "type": "mcp_approval_response", + }, + { + "call_id": "regular-call-1", + "output": "{"result":42}", + "type": "function_call_output", + }, + ] + `); + }); + }); + + describe('hasConversation', () => { + it('should skip assistant text messages with item IDs when hasConversation is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hi there!', + providerOptions: { openai: { itemId: 'msg_existing_123' } }, + }, + ], + }, + { + role: 'user', + content: [{ type: 'text', text: 'What is the weather?' }], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasConversation: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "text": "What is the weather?", + "type": "input_text", + }, + ], + "role": "user", + }, + ] + `); + }); + + it('should skip assistant tool-call messages with item IDs when hasConversation is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'What is the weather?' }], + }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_123', + toolName: 'getWeather', + input: { location: 'San Francisco' }, + providerOptions: { + openai: { itemId: 'fc_existing_456' }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_123', + toolName: 'getWeather', + output: { type: 'json', value: { temp: 72 } }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasConversation: true, + }); + + // Tool call with itemId should be skipped, but tool output should remain + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "What is the weather?", + "type": "input_text", + }, + ], + "role": "user", + }, + { + "call_id": "call_123", + "output": "{"temp":72}", + "type": "function_call_output", + }, + ] + `); + }); + + it('should include assistant messages without item IDs when hasConversation is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hi there!', + // No itemId - this is a new message + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasConversation: true, + }); + + // Assistant message without itemId should be included + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "text": "Hi there!", + "type": "output_text", + }, + ], + "id": undefined, + "role": "assistant", + }, + ] + `); + }); + + it('should include assistant messages with item IDs when hasConversation is false', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hi there!', + providerOptions: { openai: { itemId: 'msg_existing_123' } }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasConversation: false, + }); + + // With hasConversation false, should use item_reference + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + { + "id": "msg_existing_123", + "type": "item_reference", + }, + ] + `); + }); + + it('should skip reasoning parts with item IDs when hasConversation is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'Let me think...', + providerOptions: { + openai: { itemId: 'reasoning_existing_789' }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasConversation: true, + }); + + // Reasoning with itemId should be skipped + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + ] + `); + }); + }); + + describe('hasPreviousResponseId', () => { + it('should keep text item references and skip function call item references when hasPreviousResponseId is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hi there!', + providerOptions: { openai: { itemId: 'msg_existing_123' } }, + }, + { + type: 'tool-call', + toolCallId: 'call_123', + toolName: 'getWeather', + input: { location: 'San Francisco' }, + providerOptions: { + openai: { itemId: 'fc_existing_456' }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_123', + toolName: 'getWeather', + output: { type: 'json', value: { temp: 72 } }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasPreviousResponseId: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + { + "id": "msg_existing_123", + "type": "item_reference", + }, + { + "call_id": "call_123", + "output": "{"temp":72}", + "type": "function_call_output", + }, + ] + `); + }); + + it('should skip reasoning parts with item IDs when hasPreviousResponseId is true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'Let me think...', + providerOptions: { + openai: { itemId: 'rs_existing_789' }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + hasPreviousResponseId: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Hello", + "type": "input_text", + }, + ], + "role": "user", + }, + ] + `); + }); + }); + + describe('custom tool calls', () => { + const customProviderToolNames = new Set(['write_sql']); + + it('should convert custom tool call to custom_tool_call input item', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_custom_001', + toolName: 'write_sql', + input: 'SELECT * FROM users WHERE age > 25', + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_001", + "id": undefined, + "input": "SELECT * FROM users WHERE age > 25", + "name": "write_sql", + "type": "custom_tool_call", + }, + ] + `); + }); + + it('should JSON.stringify non-string custom tool call input', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_custom_002', + toolName: 'write_sql', + input: { query: 'test' }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_002", + "id": undefined, + "input": "{"query":"test"}", + "name": "write_sql", + "type": "custom_tool_call", + }, + ] + `); + }); + + it('should convert custom tool call with itemId to item_reference when store: true', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_custom_003', + toolName: 'write_sql', + input: 'SELECT 1', + providerOptions: { + openai: { + itemId: 'ct_ref_123', + }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "id": "ct_ref_123", + "type": "item_reference", + }, + ] + `); + }); + + it('should convert custom tool result to custom_tool_call_output with text value', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_custom_001', + toolName: 'write_sql', + output: { + type: 'text', + value: 'Query executed successfully. 42 rows returned.', + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_001", + "output": "Query executed successfully. 42 rows returned.", + "type": "custom_tool_call_output", + }, + ] + `); + }); + + it('should convert custom tool result to custom_tool_call_output with json value', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_custom_002', + toolName: 'write_sql', + output: { + type: 'json', + value: { rows: 42, status: 'ok' }, + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_002", + "output": "{"rows":42,"status":"ok"}", + "type": "custom_tool_call_output", + }, + ] + `); + }); + + it('should convert aliased tool name to provider custom tool name', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: { + toProviderToolName: name => + name === 'alias_name' ? 'write_sql' : name, + toCustomToolName: name => + name === 'write_sql' ? 'alias_name' : name, + }, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_custom_004', + toolName: 'alias_name', + input: 'SELECT 1', + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_004", + "id": undefined, + "input": "SELECT 1", + "name": "write_sql", + "type": "custom_tool_call", + }, + ] + `); + }); + + it('should convert execution-denied custom tool result to custom_tool_call_output', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_custom_denied_001', + toolName: 'write_sql', + output: { + type: 'execution-denied', + reason: 'User denied the tool execution', + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toEqual([ + { + type: 'custom_tool_call_output', + call_id: 'call_custom_denied_001', + output: 'User denied the tool execution', + }, + ]); + }); + + it('should convert custom tool result content output', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_custom_005', + toolName: 'write_sql', + output: { + type: 'content', + value: [{ type: 'text', text: 'hello' }], + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_005", + "output": [ + { + "text": "hello", + "type": "input_text", + }, + ], + "type": "custom_tool_call_output", + }, + ] + `); + }); + + it('should convert custom tool result content output with file-url', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_custom_006', + toolName: 'write_sql', + output: { + type: 'content', + value: [ + { type: 'text', text: 'Here is the file:' }, + { type: 'file-url', url: 'https://example.com/test.pdf' }, + ], + }, + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + customProviderToolNames, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "call_id": "call_custom_006", + "output": [ + { + "text": "Here is the file:", + "type": "input_text", + }, + { + "file_url": "https://example.com/test.pdf", + "type": "input_file", + }, + ], + "type": "custom_tool_call_output", + }, + ] + `); + expect(result.warnings).toEqual([]); + }); + + it('should not emit custom_tool_call when customProviderToolNames is not provided', async () => { + const result = await convertToOpenAIResponsesInput({ + toolNameMapping: testToolNameMapping, + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_custom_001', + toolName: 'write_sql', + input: 'SELECT 1', + }, + ], + }, + ], + systemMessageMode: 'system', + providerOptionsName: 'openai', + store: true, + }); + + expect(result.input).toMatchInlineSnapshot(` + [ + { + "arguments": ""SELECT 1"", + "call_id": "call_custom_001", + "name": "write_sql", + "type": "function_call", + }, + ] + `); + }); + }); +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) }); diff --git a/packages/openai/src/responses/convert-to-openai-responses-input.ts b/packages/openai/src/responses/convert-to-openai-responses-input.ts index 7bd5bc7d1695..fa03721bc272 100644 --- a/packages/openai/src/responses/convert-to-openai-responses-input.ts +++ b/packages/openai/src/responses/convert-to-openai-responses-input.ts @@ -325,7 +325,126 @@ export async function convertToOpenAIResponsesInput({ call_id: part.toolCallId, output: parsedOutput.output, }); +<<<<<<< HEAD break; +======= + continue; + } + + if ( + hasShellTool && + resolvedToolName === 'shell' && + output.type === 'json' + ) { + const parsedOutput = await validateTypes({ + value: output.value, + schema: shellOutputSchema, + }); + + input.push({ + type: 'shell_call_output', + call_id: part.toolCallId, + output: parsedOutput.output.map(item => ({ + stdout: item.stdout, + stderr: item.stderr, + outcome: + item.outcome.type === 'timeout' + ? { type: 'timeout' as const } + : { + type: 'exit' as const, + exit_code: item.outcome.exitCode, + }, + })), + }); + continue; + } + + if ( + hasApplyPatchTool && + part.toolName === 'apply_patch' && + output.type === 'json' + ) { + const parsedOutput = await validateTypes({ + value: output.value, + schema: applyPatchOutputSchema, + }); + + input.push({ + type: 'apply_patch_call_output', + call_id: part.toolCallId, + status: parsedOutput.status, + output: parsedOutput.output, + }); + continue; + } + + if (customProviderToolNames?.has(resolvedToolName)) { + let outputValue: OpenAIResponsesCustomToolCallOutput['output']; + switch (output.type) { + case 'text': + case 'error-text': + outputValue = output.value; + break; + case 'execution-denied': + outputValue = output.reason ?? 'Tool call execution denied.'; + break; + case 'json': + case 'error-json': + outputValue = JSON.stringify(output.value); + break; + case 'content': + outputValue = output.value + .map(item => { + switch (item.type) { + case 'text': + return { type: 'input_text' as const, text: item.text }; + case 'image-data': + return { + type: 'input_image' as const, + image_url: `data:${item.mediaType};base64,${item.data}`, + detail: + item.providerOptions?.[providerOptionsName] + ?.imageDetail, + }; + case 'image-url': + return { + type: 'input_image' as const, + image_url: item.url, + detail: + item.providerOptions?.[providerOptionsName] + ?.imageDetail, + }; + case 'file-data': + return { + type: 'input_file' as const, + filename: item.filename ?? 'data', + file_data: `data:${item.mediaType};base64,${item.data}`, + }; + case 'file-url': + return { + type: 'input_file' as const, + file_url: item.url, + }; + default: + warnings.push({ + type: 'other', + message: `unsupported custom tool content part type: ${item.type}`, + }); + return undefined; + } + }) + .filter(isNonNullable); + break; + default: + outputValue = ''; + } + input.push({ + type: 'custom_tool_call_output', + call_id: part.toolCallId, + output: outputValue, + } satisfies OpenAIResponsesCustomToolCallOutput); + continue; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) } let contentValue: OpenAIResponsesFunctionCallOutput['output']; @@ -334,6 +453,12 @@ export async function convertToOpenAIResponsesInput({ case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'json': case 'error-json': contentValue = JSON.stringify(output.value); diff --git a/packages/xai/src/convert-to-xai-chat-messages.ts b/packages/xai/src/convert-to-xai-chat-messages.ts index f8b271214b65..3e113d3c3b0d 100644 --- a/packages/xai/src/convert-to-xai-chat-messages.ts +++ b/packages/xai/src/convert-to-xai-chat-messages.ts @@ -109,6 +109,12 @@ export function convertToXaiChatMessages(prompt: LanguageModelV2Prompt): { case 'error-text': contentValue = output.value; break; +<<<<<<< HEAD +======= + case 'execution-denied': + contentValue = output.reason ?? 'Tool call execution denied.'; + break; +>>>>>>> 327642b278 ([v6.0] fix: more precise default message for tool execution denial (#16804)) case 'content': case 'json': case 'error-json':