diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 5824967b..23a7dd30 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2856,3 +2856,91 @@ test('classifies chat-completions endpoint 404 failures with endpoint_not_found }), ).rejects.toThrow('openai_category=endpoint_not_found') }) + +test('preserves valid tool_result and drops orphan tool_result', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'mistral-large-latest', + choices: [ + { + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'mistral-large-latest', + system: 'test system', + messages: [ + { role: 'user', content: 'Search and then I will interrupt' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'valid_call_1', + name: 'Search', + input: { query: 'openclaude' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'valid_call_1', + content: 'Found it!', + }, + { + type: 'tool_result', + tool_use_id: 'orphan_call_2', + content: 'Interrupted result', + }, + { + role: 'user', + content: 'What happened?', + } + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + + // Should have: system, user, assistant (tool_use), tool (valid_call_1), user + // Should NOT have: tool (orphan_call_2) + + const toolMessages = messages.filter(m => m.role === 'tool') + expect(toolMessages.length).toBe(1) + expect(toolMessages[0].tool_call_id).toBe('valid_call_1') + + const orphanMessage = toolMessages.find(m => m.tool_call_id === 'orphan_call_2') + expect(orphanMessage).toBeUndefined() +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index e2bb665f..c263a093 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -349,6 +349,7 @@ function convertMessages( system: unknown, ): OpenAIMessage[] { const result: OpenAIMessage[] = [] + const knownToolCallIds = new Set() // System message first const sysText = convertSystemPrompt(system) @@ -368,13 +369,21 @@ function convertMessages( const toolResults = content.filter((b: { type?: string }) => b.type === 'tool_result') const otherContent = content.filter((b: { type?: string }) => b.type !== 'tool_result') - // Emit tool results as tool messages + // Emit tool results as tool messages, but ONLY if we have a matching tool_use ID. + // Mistral/OpenAI strictly require tool messages to follow an assistant message with tool_calls. + // If the user interrupted (ESC) and a synthetic tool_result was generated without a recorded tool_use, + // emitting it here would cause a "role must alternate" or "unexpected role" error. for (const tr of toolResults) { - result.push({ - role: 'tool', - tool_call_id: tr.tool_use_id ?? 'unknown', - content: convertToolResultContent(tr.content, tr.is_error), - }) + const id = tr.tool_use_id ?? 'unknown' + if (knownToolCallIds.has(id)) { + result.push({ + role: 'tool', + tool_call_id: id, + content: convertToolResultContent(tr.content, tr.is_error), + }) + } else { + logForDebugging(`Dropping orphan tool_result for ID: ${id} to prevent API error`) + } } // Emit remaining user content @@ -415,9 +424,11 @@ function convertMessages( input?: unknown extra_content?: Record signature?: string - }, index) => { + }) => { + const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}` + knownToolCallIds.add(id) const toolCall: NonNullable[number] = { - id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`, + id, type: 'function' as const, function: { name: tu.name ?? 'unknown', @@ -442,7 +453,6 @@ function convertMessages( // Merge into existing google-specific metadata if present const existingGoogle = (toolCall.extra_content?.google as Record) ?? {} - toolCall.extra_content = { ...toolCall.extra_content, google: {