From 89308c0454dcb5a3b8cf5a56405f5bafcd1b15b2 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 20 Feb 2026 22:53:57 +0000 Subject: [PATCH] fix: preserve reasoning.text type in consolidateReasoningDetails for Gemini models Fixes two bugs causing assistant content[] to be stripped on Turn 2 for google/gemini-3.1-pro-preview via OpenRouter: 1. consolidateReasoningDetails() used a shared type variable across all entries in a group. When reasoning.text and reasoning.encrypted entries shared the same index, the text entry type was overwritten to "reasoning.encrypted". Now tracks types separately for text vs encrypted entries. 2. The corrupted block filter dropped any reasoning.encrypted entry without a data field, including mislabeled text entries that had a text field. Now only drops entries that have neither data nor text. Closes #11629 --- .../transform/__tests__/openai-format.spec.ts | 148 ++++++++++++++++++ src/api/transform/openai-format.ts | 82 ++++++---- 2 files changed, 201 insertions(+), 29 deletions(-) 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)