From 96552dab1c1ae7bc62f416f8def7f6a265ebd171 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 6 Oct 2025 21:57:31 -0500 Subject: [PATCH 1/2] Strip reasoning parts from message history before sending to OpenAI The previous fix added 'reasoning.encrypted_content' to the include option, but the root cause was that reasoning parts from history were being sent back to OpenAI's Responses API. When reasoning parts are included in messages sent to OpenAI, the SDK creates separate reasoning items with IDs (e.g., rs_*). These orphaned reasoning items cause errors: 'Item rs_* of type reasoning was provided without its required following item.' Solution: Strip reasoning parts from CmuxMessages BEFORE converting to ModelMessages. Reasoning content is only for display/debugging and should never be sent back to the API in subsequent turns. This happens in filterEmptyAssistantMessages() which runs before convertToModelMessages(), ensuring reasoning parts never reach the API. --- src/utils/messages/modelMessageTransform.ts | 42 +++++++++++++++------ 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/utils/messages/modelMessageTransform.ts b/src/utils/messages/modelMessageTransform.ts index cd993f22e4..bf9176510f 100644 --- a/src/utils/messages/modelMessageTransform.ts +++ b/src/utils/messages/modelMessageTransform.ts @@ -10,21 +10,41 @@ import type { CmuxMessage } from "@/types/message"; * Filter out assistant messages that only contain reasoning parts (no text or tool parts). * These messages are invalid for the API and provide no value to the model. * This happens when a message is interrupted during thinking before producing any text. + * + * Also strips reasoning parts from OpenAI messages to prevent orphaned reasoning items. + * OpenAI's Responses API creates separate reasoning items with IDs that must be properly + * linked. When reasoning parts come from history, they cause "reasoning without following item" errors. */ export function filterEmptyAssistantMessages(messages: CmuxMessage[]): CmuxMessage[] { - return messages.filter((msg) => { - // Keep all non-assistant messages - if (msg.role !== "assistant") { - return true; - } + return messages + .map((msg) => { + // Only process assistant messages + if (msg.role !== "assistant") { + return msg; + } - // Keep assistant messages that have at least one text or tool part - const hasContent = msg.parts.some( - (part) => (part.type === "text" && part.text) || part.type === "dynamic-tool" - ); + // Strip reasoning parts - they should never be sent back to the API + // Reasoning is only for display/debugging, not for model context + const filteredParts = msg.parts.filter((part) => part.type !== "reasoning"); - return hasContent; - }); + return { + ...msg, + parts: filteredParts, + }; + }) + .filter((msg) => { + // Keep all non-assistant messages + if (msg.role !== "assistant") { + return true; + } + + // Keep assistant messages that have at least one text or tool part + const hasContent = msg.parts.some( + (part) => (part.type === "text" && part.text) || part.type === "dynamic-tool" + ); + + return hasContent; + }); } /** From 5ad3be794bf354268bb9905a7791b7538c04847e Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 6 Oct 2025 22:02:34 -0500 Subject: [PATCH 2/2] Make reasoning stripping OpenAI-specific Per Anthropic documentation, reasoning content SHOULD be sent back to Anthropic models via the sendReasoning option (defaults to true). However, OpenAI's Responses API uses encrypted reasoning items (IDs like rs_*) that are managed automatically via previous_response_id. Anthropic-style text-based reasoning parts sent to OpenAI create orphaned reasoning items that cause 'reasoning without following item' errors. Changes: - Reverted filterEmptyAssistantMessages() to only filter reasoning-only messages - Added new stripReasoningForOpenAI() function for OpenAI-specific stripping - Apply reasoning stripping only for OpenAI provider in aiService.ts - Added detailed comments explaining the provider-specific differences --- src/services/aiService.ts | 18 ++++-- src/utils/messages/modelMessageTransform.ts | 72 ++++++++++++--------- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 5ddb67b5c2..669b745bc5 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -19,6 +19,7 @@ import { validateAnthropicCompliance, addInterruptedSentinel, filterEmptyAssistantMessages, + stripReasoningForOpenAI, } from "@/utils/messages/modelMessageTransform"; import { applyCacheControl } from "@/utils/ai/cacheStrategy"; import type { HistoryService } from "./historyService"; @@ -278,11 +279,23 @@ export class AIService extends EventEmitter { // Dump original messages for debugging log.debug_obj(`${workspaceId}/1_original_messages.json`, messages); + // Extract provider name from modelString (e.g., "anthropic:claude-opus-4-1" -> "anthropic") + const [providerName] = modelString.split(":"); + // Filter out assistant messages with only reasoning (no text/tools) - const filteredMessages = filterEmptyAssistantMessages(messages); + let filteredMessages = filterEmptyAssistantMessages(messages); log.debug(`Filtered ${messages.length - filteredMessages.length} empty assistant messages`); log.debug_obj(`${workspaceId}/1a_filtered_messages.json`, filteredMessages); + // OpenAI-specific: Strip reasoning parts from history + // OpenAI manages reasoning via previousResponseId; sending Anthropic-style reasoning + // parts creates orphaned reasoning items that cause API errors + if (providerName === "openai") { + filteredMessages = stripReasoningForOpenAI(filteredMessages); + log.debug("Stripped reasoning parts for OpenAI"); + log.debug_obj(`${workspaceId}/1b_openai_stripped.json`, filteredMessages); + } + // Add [INTERRUPTED] sentinel to partial messages (for model context) const messagesWithSentinel = addInterruptedSentinel(filteredMessages); @@ -293,9 +306,6 @@ export class AIService extends EventEmitter { log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages); - // Extract provider name from modelString (e.g., "anthropic:claude-opus-4-1" -> "anthropic") - const [providerName] = modelString.split(":"); - // Apply ModelMessage transforms based on provider requirements const transformedMessages = transformModelMessages(modelMessages, providerName); diff --git a/src/utils/messages/modelMessageTransform.ts b/src/utils/messages/modelMessageTransform.ts index bf9176510f..6fdab7509d 100644 --- a/src/utils/messages/modelMessageTransform.ts +++ b/src/utils/messages/modelMessageTransform.ts @@ -10,41 +10,55 @@ import type { CmuxMessage } from "@/types/message"; * Filter out assistant messages that only contain reasoning parts (no text or tool parts). * These messages are invalid for the API and provide no value to the model. * This happens when a message is interrupted during thinking before producing any text. - * - * Also strips reasoning parts from OpenAI messages to prevent orphaned reasoning items. - * OpenAI's Responses API creates separate reasoning items with IDs that must be properly - * linked. When reasoning parts come from history, they cause "reasoning without following item" errors. + * + * Note: This function filters out reasoning-only messages but does NOT strip reasoning + * parts from messages that have other content. Reasoning parts are handled differently + * per provider (see stripReasoningForOpenAI). */ export function filterEmptyAssistantMessages(messages: CmuxMessage[]): CmuxMessage[] { - return messages - .map((msg) => { - // Only process assistant messages - if (msg.role !== "assistant") { - return msg; - } + return messages.filter((msg) => { + // Keep all non-assistant messages + if (msg.role !== "assistant") { + return true; + } - // Strip reasoning parts - they should never be sent back to the API - // Reasoning is only for display/debugging, not for model context - const filteredParts = msg.parts.filter((part) => part.type !== "reasoning"); + // Keep assistant messages that have at least one text or tool part + const hasContent = msg.parts.some( + (part) => (part.type === "text" && part.text) || part.type === "dynamic-tool" + ); - return { - ...msg, - parts: filteredParts, - }; - }) - .filter((msg) => { - // Keep all non-assistant messages - if (msg.role !== "assistant") { - return true; - } + return hasContent; + }); +} + +/** + * Strip reasoning parts from messages for OpenAI. + * + * OpenAI's Responses API uses encrypted reasoning items (with IDs like rs_*) that are + * managed automatically via previous_response_id. When reasoning parts from history + * (which are Anthropic-style text-based reasoning) are sent to OpenAI, they create + * orphaned reasoning items that cause "reasoning without following item" errors. + * + * Anthropic's reasoning (text-based) is different and SHOULD be sent back via sendReasoning. + * + * @param messages - Messages that may contain reasoning parts + * @returns Messages with reasoning parts stripped (for OpenAI only) + */ +export function stripReasoningForOpenAI(messages: CmuxMessage[]): CmuxMessage[] { + return messages.map((msg) => { + // Only process assistant messages + if (msg.role !== "assistant") { + return msg; + } - // Keep assistant messages that have at least one text or tool part - const hasContent = msg.parts.some( - (part) => (part.type === "text" && part.text) || part.type === "dynamic-tool" - ); + // Strip reasoning parts - OpenAI manages reasoning via previousResponseId + const filteredParts = msg.parts.filter((part) => part.type !== "reasoning"); - return hasContent; - }); + return { + ...msg, + parts: filteredParts, + }; + }); } /**