diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1a4c7f6518d..7b9aff29557 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -1125,6 +1125,85 @@ describe("consolidateReasoningDetails", () => { expect(result).toHaveLength(1) expect(result[0].summary).toBe("Summary part 1Summary part 2") }) + + // Regression tests for https://github.com/RooCodeInc/Roo-Code/issues/11629 + + it("should preserve reasoning.text type when grouped with reasoning.encrypted at same index", () => { + // This is the exact scenario from the bug report: Gemini returns both + // reasoning.text and reasoning.encrypted at index 0. The text entry + // must keep its original type and not be overwritten by the encrypted type. + const details: ReasoningDetail[] = [ + { + type: "reasoning.text", + text: "**Initiating the Analysis**...", + id: "rs_text_1", + format: "google-gemini-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "EtwECtkEAb4+9vu...", + id: "rs_enc_1", + format: "google-gemini-v1", + index: 0, + }, + ] + + const result = consolidateReasoningDetails(details) + + // Should have two entries: one text, one encrypted + expect(result).toHaveLength(2) + + const textEntry = result.find((r) => r.text) + const encryptedEntry = result.find((r) => r.data) + + // Text entry must retain type "reasoning.text", NOT "reasoning.encrypted" + expect(textEntry?.type).toBe("reasoning.text") + expect(textEntry?.text).toBe("**Initiating the Analysis**...") + + // Encrypted entry should keep its correct type + expect(encryptedEntry?.type).toBe("reasoning.encrypted") + expect(encryptedEntry?.data).toBe("EtwECtkEAb4+9vu...") + }) + + it("should preserve mislabeled text entries (type=reasoning.encrypted with text but no data)", () => { + // If a text entry was previously stored with the wrong type (reasoning.encrypted) + // but has a text field and no data field, it should NOT be dropped as corrupted. + const details: ReasoningDetail[] = [ + { + type: "reasoning.encrypted", + text: "Some reasoning text", + // No data field - previously this was dropped as "corrupted" + id: "rs_mislabeled", + format: "google-gemini-v1", + index: 0, + }, + ] + + const result = consolidateReasoningDetails(details) + + // Should preserve the entry and fix its type to reasoning.text + expect(result).toHaveLength(1) + expect(result[0].text).toBe("Some reasoning text") + expect(result[0].type).toBe("reasoning.text") + }) + + it("should still drop truly corrupted encrypted blocks (no data AND no text)", () => { + const details: ReasoningDetail[] = [ + { + type: "reasoning.encrypted", + // No data, no text - truly corrupted + id: "rs_corrupted", + format: "google-gemini-v1", + index: 0, + }, + ] + + const result = consolidateReasoningDetails(details) + + // Truly corrupted block should still be dropped + expect(result).toHaveLength(0) + }) }) describe("sanitizeGeminiMessages", () => { @@ -1302,4 +1381,73 @@ describe("sanitizeGeminiMessages", () => { expect(result).toEqual(messages) }) + + // Regression test for https://github.com/RooCodeInc/Roo-Code/issues/11629 + it("should preserve tool_calls when reasoning_details contain mislabeled text entries", () => { + // This reproduces the exact bug: reasoning_details has a text entry mislabeled + // as "reasoning.encrypted" (no data field) and a real encrypted entry. + // Previously, the mislabeled text entry was dropped as "corrupted", and if + // the encrypted entry's ID didn't match the tool call, all tool_calls were dropped. + const messages = [ + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I", + type: "function", + function: { + name: "attempt_completion", + arguments: '{"result":"Test received successfully."}', + }, + }, + ], + reasoning_details: [ + { + // Mislabeled: type says encrypted but has text, no data + type: "reasoning.encrypted", + text: "**Acknowledging the Input**\nI see this as a test message...", + id: "tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I", + format: "google-gemini-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "EtwECtkEAb4+9vuFAKED...", + id: "tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I", + format: "google-gemini-v1", + index: 0, + }, + ], + }, + { + role: "tool", + tool_call_id: "tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I", + content: "The user is satisfied with the result.", + }, + ] as any + + const result = sanitizeGeminiMessages(messages, "google/gemini-3.1-pro-preview") + + // Assistant message should preserve tool_calls + expect(result).toHaveLength(2) + const assistantMsg = result[0] as any + expect(assistantMsg.tool_calls).toBeDefined() + expect(assistantMsg.tool_calls).toHaveLength(1) + expect(assistantMsg.tool_calls[0].id).toBe("tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I") + + // reasoning_details should have both entries (text with corrected type + encrypted) + expect(assistantMsg.reasoning_details).toBeDefined() + expect(assistantMsg.reasoning_details.length).toBeGreaterThanOrEqual(1) + + // The text entry should have its type corrected to reasoning.text + const textDetail = assistantMsg.reasoning_details.find((d: any) => d.text) + if (textDetail) { + expect(textDetail.type).toBe("reasoning.text") + } + + // Tool result message should be preserved + expect(result[1].role).toBe("tool") + expect((result[1] as any).tool_call_id).toBe("tool_attempt_completion_BI9Id6PH8KtbhYBnuB4I") + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 8974dd599ba..a7bd89e0e94 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -50,7 +50,11 @@ export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]) // Drop corrupted encrypted reasoning blocks that would otherwise trigger: // "Invalid input: expected string, received undefined" for reasoning_details.*.data // See: https://github.com/cline/cline/issues/8214 - if (detail.type === "reasoning.encrypted" && !detail.data) { + // Only drop if it truly has no usable content (no `data` AND no `text`). + // A mislabeled entry with `type: "reasoning.encrypted"` but a valid `text` + // field should be preserved (it's a text entry with incorrect type). + // See: https://github.com/RooCodeInc/Roo-Code/issues/11629 + if (detail.type === "reasoning.encrypted" && !detail.data && !detail.text) { continue } @@ -65,46 +69,66 @@ export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]) const consolidated: ReasoningDetail[] = [] for (const [index, details] of groupedByIndex.entries()) { - // Concatenate all text parts + // Concatenate all text parts. + // Track types separately for text/summary entries vs encrypted entries, + // because a group can contain both a reasoning.text and a reasoning.encrypted + // entry at the same index. Using a single shared `type` variable would cause + // the last entry's type to overwrite earlier ones, mislabeling text entries + // as "reasoning.encrypted". + // See: https://github.com/RooCodeInc/Roo-Code/issues/11629 let concatenatedText = "" let concatenatedSummary = "" - let signature: string | undefined - let id: string | undefined - let format = "unknown" - let type = "reasoning.text" + let textSignature: string | undefined + let textId: string | undefined + let textFormat = "unknown" + let textType = "reasoning.text" for (const detail of details) { if (detail.text) { concatenatedText += detail.text + // Track type/metadata from text-bearing entries only + if (detail.type) { + textType = detail.type === "reasoning.encrypted" ? "reasoning.text" : detail.type + } + if (detail.signature) { + textSignature = detail.signature + } + if (detail.id) { + textId = detail.id + } + if (detail.format) { + textFormat = detail.format + } } if (detail.summary) { concatenatedSummary += detail.summary - } - // Keep the signature from the last item that has one - if (detail.signature) { - signature = detail.signature - } - // Keep the id from the last item that has one - if (detail.id) { - id = detail.id - } - // Keep format and type from any item (they should all be the same) - if (detail.format) { - format = detail.format - } - if (detail.type) { - type = detail.type + // Use text entry metadata for summaries too, but don't overwrite + // if we already got it from a text entry + if (!concatenatedText) { + if (detail.type) { + textType = detail.type + } + if (detail.signature) { + textSignature = detail.signature + } + if (detail.id) { + textId = detail.id + } + if (detail.format) { + textFormat = detail.format + } + } } } // Create consolidated entry for text if (concatenatedText) { const consolidatedEntry: ReasoningDetail = { - type: type, + type: textType, text: concatenatedText, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, + signature: textSignature ?? undefined, + id: textId ?? undefined, + format: textFormat, index: index, } consolidated.push(consolidatedEntry) @@ -113,11 +137,11 @@ export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]) // Create consolidated entry for summary (used by some providers) if (concatenatedSummary && !concatenatedText) { const consolidatedEntry: ReasoningDetail = { - type: type, + type: textType, summary: concatenatedSummary, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, + signature: textSignature ?? undefined, + id: textId ?? undefined, + format: textFormat, index: index, } consolidated.push(consolidatedEntry)