From cbbab813e9860fddb0c22f6b81fdce5925893fa6 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 20 Aug 2026 07:58:03 +0900 Subject: [PATCH 01/11] fix(vertex): support gemini 3.7 flash thinking and clean tool result turns --- src/api/providers/__tests__/gemini.spec.ts | 39 ++++++ src/api/providers/__tests__/vertex.spec.ts | 82 ++++++++++- src/api/providers/gemini.ts | 7 + src/api/providers/vertex.ts | 48 ++++++- .../transform/__tests__/gemini-format.spec.ts | 127 +++++++++++++++++- src/api/transform/gemini-format.ts | 80 +++++++---- 6 files changed, 355 insertions(+), 28 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 2f19028eb7..16f7086c33 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -332,6 +332,45 @@ describe("GeminiHandler", () => { }) }) + it("appends a user continuation when history ends with an assistant turn", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + + await collectStream(handler.createMessage(systemPrompt, mockMessages)) + + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there!" }] }, + { role: "user", parts: [{ text: "Continue." }] }, + ], + }), + ) + }) + + it("does not append a continuation when history ends with a user turn", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + const userEndingMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "Please continue" }, + ] + + await collectStream(handler.createMessage(systemPrompt, userEndingMessages)) + + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there!" }] }, + { role: "user", parts: [{ text: "Please continue" }] }, + ], + }), + ) + }) + it("should handle API errors", async () => { const mockError = new Error("Gemini API error") ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index a304518ca7..00be4cca27 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -22,6 +22,7 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import { ApiStreamChunk } from "../../transform/stream" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { t } from "i18next" import { VertexHandler } from "../vertex" @@ -38,7 +39,7 @@ describe("VertexHandler", () => { const mockGetGenerativeModel = vitest.fn() handler = new VertexHandler({ - apiModelId: "gemini-1.5-pro-001", + apiModelId: "gemini-3.7-flash", vertexProjectId: "test-project", vertexRegion: "us-central1", }) @@ -95,6 +96,45 @@ describe("VertexHandler", () => { // Since we're directly mocking createMessage, we don't need to verify // that generateContentStream was called }) + + it("appends a user continuation when Vertex history ends with an assistant turn", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + + await collectStream(handler.createMessage(systemPrompt, mockMessages)) + + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there!" }] }, + { role: "user", parts: [{ text: "Continue." }] }, + ], + }), + ) + }) + + it("does not append a continuation when Vertex history ends with a user turn", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + const userEndingMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "Please continue" }, + ] + + await collectStream(handler.createMessage(systemPrompt, userEndingMessages)) + + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there!" }] }, + { role: "user", parts: [{ text: "Please continue" }] }, + ], + }), + ) + }) }) describe("completePrompt", () => { @@ -181,5 +221,45 @@ describe("VertexHandler", () => { expect(excludedCount).toBe(1) expect(includedCount).toBe(1) }) + + it("should correctly handle :thinking suffix for gemini-3.7-flash", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-3.7-flash:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-3.7-flash") + expect(modelInfo.info).toBeDefined() + expect(modelInfo.info.excludedTools).toContain("apply_diff") + expect(modelInfo.info.includedTools).toContain("edit") + expect(modelInfo.reasoning).toBeDefined() + }) + + it("should handle custom unlisted gemini models with :thinking suffix", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-future-model:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-future-model") + expect(modelInfo.info).toBeDefined() + expect(modelInfo.info.excludedTools).toContain("apply_diff") + expect(modelInfo.info.includedTools).toContain("edit") + }) + + it("should fall back to a default Gemini model instead of Claude when apiModelId is undefined", () => { + const testHandler = new VertexHandler({ + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-3.7-flash") + expect(modelInfo.id).not.toContain("claude") + }) }) }) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index ec0d14e4c9..d293eb31b4 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -269,6 +269,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl .map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName })) .flat() + // Gemini and Vertex AI reject requests that end with a model turn, which can occur when + // resuming after an interrupted response. Preserve that turn and explicitly + // ask the model to continue rather than dropping conversation history. + if (contents.at(-1)?.role === "model") { + contents.push({ role: "user", parts: [{ text: "Continue." }] }) + } + // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS). // Google built-in tools (Grounding, URL Context) are mutually exclusive // with function declarations in the Gemini API, so we always use diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index be08019576..e5d64319b3 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -14,8 +14,52 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand override getModel() { const modelId = this.options.apiModelId - const id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId - let info: ModelInfo = vertexModels[id] + let id: string + let info: ModelInfo + + if (modelId && Object.hasOwn(vertexModels, modelId)) { + id = modelId + info = vertexModels[modelId as VertexModelId] + } else if ( + modelId?.endsWith(":thinking") && + Object.hasOwn(vertexModels, modelId.slice(0, -":thinking".length)) + ) { + const baseModelId = modelId.slice(0, -":thinking".length) as VertexModelId + id = modelId + info = vertexModels[baseModelId] + } else if (modelId && modelId.toLowerCase().startsWith("gemini-")) { + id = modelId + const baseModelId = modelId.endsWith(":thinking") ? modelId.slice(0, -":thinking".length) : modelId + const fallbackModelId: VertexModelId = ( + Object.hasOwn(vertexModels, baseModelId) + ? baseModelId + : "gemini-3.7-flash" in vertexModels + ? "gemini-3.7-flash" + : "gemini-3.1-pro-preview" in vertexModels + ? "gemini-3.1-pro-preview" + : vertexDefaultModelId + ) as VertexModelId + const baseInfo = vertexModels[fallbackModelId] || vertexModels[vertexDefaultModelId] + info = { + ...baseInfo, + inputPrice: undefined, + outputPrice: undefined, + cacheReadsPrice: undefined, + cacheWritesPrice: undefined, + tiers: undefined, + } + } else { + const defaultGeminiModel: VertexModelId = ( + "gemini-3.7-flash" in vertexModels + ? "gemini-3.7-flash" + : "gemini-3.1-pro-preview" in vertexModels + ? "gemini-3.1-pro-preview" + : vertexDefaultModelId + ) as VertexModelId + id = defaultGeminiModel + info = vertexModels[defaultGeminiModel] + } + const params = getModelParams({ format: "gemini", modelId: id, diff --git a/src/api/transform/__tests__/gemini-format.spec.ts b/src/api/transform/__tests__/gemini-format.spec.ts index 407984956a..3c67e22b85 100644 --- a/src/api/transform/__tests__/gemini-format.spec.ts +++ b/src/api/transform/__tests__/gemini-format.spec.ts @@ -188,13 +188,12 @@ describe("convertAnthropicMessageToGemini", () => { { role: "user", parts: [ - { text: "Here's the result:" }, { functionResponse: { name: "calculator", response: { name: "calculator", - content: "The result is 5", + content: "The result is 5\n\nHere's the result:", }, }, }, @@ -233,6 +232,38 @@ describe("convertAnthropicMessageToGemini", () => { ]) }) + it("should handle null tool result content safely", () => { + const toolIdToName = new Map([["calculator-123", "calculator"]]) + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "calculator-123", + // The runtime tolerates a null content field; the declared type + // excludes null, so double-assert the invalid fixture shape. + content: null as unknown as Anthropic.Messages.ToolResultBlockParam["content"], + }, + ], + } + + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) + + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { + name: "calculator", + response: { name: "calculator", content: "(empty)" }, + }, + }, + ], + }, + ]) + }) + it("should preserve an empty tool result array as a user function response", () => { const toolIdToName = new Map([["calculator-123", "calculator"]]) const anthropicMessage: Anthropic.Messages.MessageParam = { @@ -263,6 +294,98 @@ describe("convertAnthropicMessageToGemini", () => { ]) }) + it("should merge environment_details into tool_result response content without polluting functionResponse parts", () => { + const toolIdToName = new Map([["fetch-1", "fetch_url"]]) + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "fetch-1", + content: "Response data from endpoint", + }, + { + type: "text", + text: "\nVSCode Workspace: /workspace\n", + }, + ], + } + + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) + + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { + name: "fetch_url", + response: { + name: "fetch_url", + content: + "Response data from endpoint\n\n\nVSCode Workspace: /workspace\n", + }, + }, + }, + ], + }, + ]) + }) + + it("should merge sibling text into the last tool_result for parallel function calls", () => { + const toolIdToName = new Map([ + ["call-1", "tool_a"], + ["call-2", "tool_b"], + ]) + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call-1", + content: "Result A", + }, + { + type: "tool_result", + tool_use_id: "call-2", + content: "Result B", + }, + { + type: "text", + text: "Context", + }, + ], + } + + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) + + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { + name: "tool_a", + response: { + name: "tool_a", + content: "Result A", + }, + }, + }, + { + functionResponse: { + name: "tool_b", + response: { + name: "tool_b", + content: "Result B\n\nContext", + }, + }, + }, + ], + }, + ]) + }) + it("should convert a message with tool result as array with text only", () => { const toolIdToName = new Map() toolIdToName.set("search-123", "search") diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts index d45e9f27ec..0053166278 100644 --- a/src/api/transform/gemini-format.ts +++ b/src/api/transform/gemini-format.ts @@ -59,7 +59,38 @@ export function convertAnthropicContentToGemini( return [{ text: content }] } - const parts = content.flatMap((block): Part | Part[] => { + const hasToolResults = + Array.isArray(content) && + content.some( + (block) => typeof block === "object" && block !== null && "type" in block && block.type === "tool_result", + ) + + let siblingText: string | undefined + let lastToolResultIndex = -1 + + if (hasToolResults && Array.isArray(content)) { + const textBlocks = content.filter( + (block): block is Anthropic.Messages.TextBlockParam => + typeof block === "object" && block !== null && "type" in block && block.type === "text", + ) + const joinedText = textBlocks + .map((b) => b.text) + .filter(Boolean) + .join("\n\n") + if (joinedText) { + siblingText = joinedText + } + + for (let i = content.length - 1; i >= 0; i--) { + const block = content[i] + if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") { + lastToolResultIndex = i + break + } + } + } + + const parts = content.flatMap((block, index): Part | Part[] => { // Handle thoughtSignature blocks first if (isThoughtSignatureContentBlock(block)) { // We process thought signatures globally and attach them to the relevant parts @@ -69,6 +100,12 @@ export function convertAnthropicContentToGemini( switch (block.type) { case "text": + // If the turn contains tool_result blocks, sibling text (such as environment_details) + // is merged into the last functionResponse part to avoid polluting function call turns + // with invalid text parts. + if (hasToolResults) { + return [] + } return { text: block.text } case "image": if (block.source.type !== "base64") { @@ -105,35 +142,32 @@ export function convertAnthropicContentToGemini( ) } + let contentText = "" + const imageParts: Part[] = [] + if (typeof block.content === "string") { - return { - functionResponse: { - name: toolName, - response: { name: toolName, content: block.content || "(empty)" }, - }, + contentText = block.content + } else if (Array.isArray(block.content)) { + const textParts: string[] = [] + + for (const item of block.content) { + if (item.type === "text") { + textParts.push(item.text) + } else if (item.type === "image" && item.source.type === "base64") { + const { data, media_type } = item.source + imageParts.push({ inlineData: { data, mimeType: media_type } }) + } } - } - if (!Array.isArray(block.content)) { - return [] + contentText = + textParts.join("\n\n") + (imageParts.length > 0 ? "\n\n(See next part for image)" : "") } - const textParts: string[] = [] - const imageParts: Part[] = [] - - for (const item of block.content) { - if (item.type === "text") { - textParts.push(item.text) - } else if (item.type === "image" && item.source.type === "base64") { - const { data, media_type } = item.source - imageParts.push({ inlineData: { data, mimeType: media_type } }) - } + // If this is the last tool result in the message, append any sibling text (e.g. environment_details) + if (index === lastToolResultIndex && siblingText) { + contentText = contentText ? `${contentText}\n\n${siblingText}` : siblingText } - // Create content text with a note about images if present - const contentText = - textParts.join("\n\n") + (imageParts.length > 0 ? "\n\n(See next part for image)" : "") - // Return function response followed by any images return [ { From f6a39c2aa70cc70294c35ff68ac20ade4e426cd5 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 20 Aug 2026 15:00:10 +0900 Subject: [PATCH 02/11] fix(gemini): preserve status codes using handleProviderError for 429 rate limit backoff --- src/api/providers/gemini.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index d293eb31b4..d78f2741ca 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -27,6 +27,7 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, Complete import { BaseProvider } from "./base-provider" import { NOT_PROVIDED } from "./constants" import { parseVertexJsonCredentials } from "./utils/vertex-credentials" +import { handleProviderError } from "./utils/error-handler" type GeminiHandlerOptions = ApiHandlerOptions & { isVertex?: boolean @@ -488,11 +489,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage") TelemetryService.instance.captureException(apiError) - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_stream", { error: error.message })) - } - - throw error + throw handleProviderError(error, "Gemini", { + messageTransformer: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), + }) } } @@ -624,11 +623,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt") TelemetryService.instance.captureException(apiError) - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message })) - } - - throw error + throw handleProviderError(error, "Gemini", { + messageTransformer: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), + }) } } From 8fc888c05f0be046cd941a5d46eaf60e1c2625d0 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 01:16:12 +0900 Subject: [PATCH 03/11] fix(vertex): resolve unset model id to the shared vertex default --- src/api/providers/__tests__/vertex.spec.ts | 35 ++++++++++++++++++++-- src/api/providers/vertex.ts | 14 ++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 00be4cca27..8b9c05c470 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -21,6 +21,8 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" +import { vertexDefaultModelId, vertexModels } from "@roo-code/types" + import { ApiStreamChunk } from "../../transform/stream" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -249,17 +251,44 @@ describe("VertexHandler", () => { expect(modelInfo.info).toBeDefined() expect(modelInfo.info.excludedTools).toContain("apply_diff") expect(modelInfo.info.includedTools).toContain("edit") + expect(modelInfo.info.inputPrice).toBeUndefined() + expect(modelInfo.info.outputPrice).toBeUndefined() + expect(modelInfo.info.cacheReadsPrice).toBeUndefined() + expect(modelInfo.info.cacheWritesPrice).toBeUndefined() + expect(modelInfo.info.tiers).toBeUndefined() }) - it("should fall back to a default Gemini model instead of Claude when apiModelId is undefined", () => { + it("should resolve to the shared vertex default when apiModelId is undefined", () => { const testHandler = new VertexHandler({ vertexProjectId: "test-project", vertexRegion: "us-central1", }) const modelInfo = testHandler.getModel() - expect(modelInfo.id).toBe("gemini-3.7-flash") - expect(modelInfo.id).not.toContain("claude") + expect(modelInfo.id).toBe(vertexDefaultModelId) + expect(modelInfo.info).toEqual( + expect.objectContaining({ + ...vertexModels[vertexDefaultModelId], + excludedTools: expect.arrayContaining(["apply_diff"]), + includedTools: expect.arrayContaining(["edit"]), + }), + ) + }) + + it("should honor an unknown unsuffixed gemini-* id and drop pricing fields", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-9.9-flash-exp", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-9.9-flash-exp") + expect(modelInfo.info.inputPrice).toBeUndefined() + expect(modelInfo.info.outputPrice).toBeUndefined() + expect(modelInfo.info.cacheReadsPrice).toBeUndefined() + expect(modelInfo.info.cacheWritesPrice).toBeUndefined() + expect(modelInfo.info.tiers).toBeUndefined() }) }) }) diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index e5d64319b3..20fd11998a 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -49,15 +49,11 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand tiers: undefined, } } else { - const defaultGeminiModel: VertexModelId = ( - "gemini-3.7-flash" in vertexModels - ? "gemini-3.7-flash" - : "gemini-3.1-pro-preview" in vertexModels - ? "gemini-3.1-pro-preview" - : vertexDefaultModelId - ) as VertexModelId - id = defaultGeminiModel - info = vertexModels[defaultGeminiModel] + // An absent (or unrecognized non-Gemini) model id resolves to the + // shared vertex default so persisted-config consumers and the UI + // keep a single source of truth for the fallback. + id = vertexDefaultModelId + info = vertexModels[vertexDefaultModelId] } const params = getModelParams({ From 71540aba147aaa8d53d87a4f13f733041f98dcc1 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 01:16:40 +0900 Subject: [PATCH 04/11] test(gemini): cover status preservation through createMessage and completePrompt --- src/api/providers/__tests__/gemini.spec.ts | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 16f7086c33..b769ed42b7 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -24,6 +24,7 @@ const GEMINI_MODEL_NAME = geminiDefaultModelId describe("GeminiHandler", () => { let handler: GeminiHandler let mockGenerateContentStream: ReturnType + let mockGenerateContent: ReturnType beforeEach(() => { // Reset mocks @@ -31,7 +32,7 @@ describe("GeminiHandler", () => { // Create mock functions mockGenerateContentStream = vitest.fn() - const mockGenerateContent = vitest.fn() + mockGenerateContent = vitest.fn() const mockGetGenerativeModel = vitest.fn() handler = new GeminiHandler({ @@ -379,6 +380,22 @@ describe("GeminiHandler", () => { await expect(collectStream(stream)).rejects.toThrow() }) + + it("preserves status and errorDetails when the stream call rejects with a 429", async () => { + const rateLimitError = Object.assign(new Error("rate limit exceeded"), { + status: 429, + errorDetails: { retryAfter: 30 }, + }) + mockGenerateContentStream.mockRejectedValue(rateLimitError) + + const error = (await collectStream(handler.createMessage(systemPrompt, mockMessages)).catch( + (e: unknown) => e, + )) as Error & { status?: number; errorDetails?: unknown } + + expect(error).toBeInstanceOf(Error) + expect(error.status).toBe(429) + expect(error.errorDetails).toEqual({ retryAfter: 30 }) + }) }) describe("completePrompt", () => { @@ -411,6 +428,23 @@ describe("GeminiHandler", () => { ) }) + it("preserves status and errorDetails when the completion call rejects with a 403", async () => { + const forbiddenError = Object.assign(new Error("permission denied"), { + status: 403, + errorDetails: { reason: "PERMISSION_DENIED" }, + }) + mockGenerateContent.mockRejectedValue(forbiddenError) + + const error = (await handler.completePrompt("Test prompt").catch((e: unknown) => e)) as Error & { + status?: number + errorDetails?: unknown + } + + expect(error).toBeInstanceOf(Error) + expect(error.status).toBe(403) + expect(error.errorDetails).toEqual({ reason: "PERMISSION_DENIED" }) + }) + it("should handle empty response", async () => { // Mock the response with empty text ;(handler["client"].models.generateContent as any).mockResolvedValue({ From 2556ac0560f0b24a025e61a24b6e30a306575bed Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 03:40:07 +0900 Subject: [PATCH 05/11] test(vertex): cover mixed-case unknown gemini model id --- src/api/providers/__tests__/vertex.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 8b9c05c470..02ebf6817f 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -290,5 +290,21 @@ describe("VertexHandler", () => { expect(modelInfo.info.cacheWritesPrice).toBeUndefined() expect(modelInfo.info.tiers).toBeUndefined() }) + + it("should match a mixed-case unknown gemini-* id case-insensitively and keep the configured casing", () => { + const testHandler = new VertexHandler({ + apiModelId: "Gemini-9.9-flash-exp", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("Gemini-9.9-flash-exp") + expect(modelInfo.info.inputPrice).toBeUndefined() + expect(modelInfo.info.outputPrice).toBeUndefined() + expect(modelInfo.info.cacheReadsPrice).toBeUndefined() + expect(modelInfo.info.cacheWritesPrice).toBeUndefined() + expect(modelInfo.info.tiers).toBeUndefined() + }) }) }) From 9b06a0d34bd55f6e21965778db11be625e2a75c9 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:29:03 +0900 Subject: [PATCH 06/11] fix(gemini): close dangling functionCalls on crash resume What: When converted Gemini history ends on a model turn whose functionCall parts have no matching functionResponse (crash between functionCall emit and tool execution), synthesize a closing functionResponse per dangling call using the same "(empty)" sentinel as empty/null tool results. Wire the fix into GeminiHandler.createMessage before the role-based "Continue." continuation. Why: Gemini and Vertex AI reject requests ending on unanswered functionCall turns, so resuming after an interrupt at exactly that point still 400s even with the trailing-model-turn "Continue." handling this PR adds. Impact: Crash-resumed Vertex/Gemini sessions with a pending tool call no longer fail on the next request; history ends on a completed tool turn. --- src/api/providers/__tests__/gemini.spec.ts | 52 +++++++++ src/api/providers/gemini.ts | 8 +- .../transform/__tests__/gemini-format.spec.ts | 100 +++++++++++++++++- src/api/transform/gemini-format.ts | 33 ++++++ 4 files changed, 191 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 66a82a2dac..3a53c0b6c6 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -350,6 +350,58 @@ describe("GeminiHandler", () => { ) }) + it("synthesizes an empty function response when history ends with an unanswered function call", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + + // Crash-resume shape: the model emitted a functionCall but the task was + // interrupted before the tool ran, so no tool_result exists for it. + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read foo.ts" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "foo.ts" } }], + }, + ] + const metadata = { + taskId: "test-task", + tools: [{ type: "function", function: { name: "read_file", description: "", parameters: {} } }], + } satisfies ApiHandlerCreateMessageMetadata + + await collectStream(handler.createMessage(systemPrompt, messages, metadata)) + + // Without the synthesized functionResponse the request would end on an + // unanswered model functionCall turn and Gemini/Vertex would reject it. + const expectedBypassToken = Buffer.from("skip_thought_signature_validator").toString("base64") + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + { role: "user", parts: [{ text: "Read foo.ts" }] }, + { + role: "model", + parts: [ + { + functionCall: { name: "read_file", args: { path: "foo.ts" } }, + thoughtSignature: expectedBypassToken, + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + name: "read_file", + response: { name: "read_file", content: "(empty)" }, + }, + }, + ], + }, + ], + }), + ) + }) + it("does not append a continuation when history ends with a user turn", async () => { const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) generateContentStream.mockResolvedValue(asyncStreamFrom([])) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 2ca64b6072..eacb753959 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -18,7 +18,7 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" -import { convertAnthropicMessageToGemini } from "../transform/gemini-format" +import { closeDanglingFunctionCalls, convertAnthropicMessageToGemini } from "../transform/gemini-format" import { t } from "i18next" import type { ApiStream, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -270,6 +270,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl .map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName })) .flat() + // A crash between the model's functionCall emit and tool execution leaves a + // trailing model turn whose functionCall parts have no matching functionResponse; + // Gemini and Vertex AI reject such a resumed request outright. Synthesize the + // closing responses first so the history ends on a completed tool turn. + closeDanglingFunctionCalls(contents) + // Gemini and Vertex AI reject requests that end with a model turn, which can occur when // resuming after an interrupted response. Preserve that turn and explicitly // ask the model to continue rather than dropping conversation history. diff --git a/src/api/transform/__tests__/gemini-format.spec.ts b/src/api/transform/__tests__/gemini-format.spec.ts index 3c67e22b85..c355289316 100644 --- a/src/api/transform/__tests__/gemini-format.spec.ts +++ b/src/api/transform/__tests__/gemini-format.spec.ts @@ -1,8 +1,9 @@ // npx vitest run src/api/transform/__tests__/gemini-format.spec.ts import { Anthropic } from "@anthropic-ai/sdk" +import type { Content } from "@google/genai" -import { convertAnthropicMessageToGemini } from "../gemini-format" +import { closeDanglingFunctionCalls, convertAnthropicMessageToGemini } from "../gemini-format" describe("convertAnthropicMessageToGemini", () => { it("should convert a simple text message", () => { @@ -655,3 +656,100 @@ describe("convertAnthropicMessageToGemini", () => { ]) }) }) + +describe("closeDanglingFunctionCalls", () => { + it("synthesizes an '(empty)' functionResponse for a trailing dangling functionCall", () => { + const contents: Content[] = [ + { role: "user", parts: [{ text: "Read foo.ts" }] }, + { + role: "model", + parts: [{ functionCall: { name: "read_file", args: { path: "foo.ts" } } }], + }, + ] + + const result = closeDanglingFunctionCalls(contents) + + // The synthesized response reuses the empty/null tool-result convention: + // identical part shape, "(empty)" sentinel content. + expect(result).toEqual([ + { role: "user", parts: [{ text: "Read foo.ts" }] }, + { + role: "model", + parts: [{ functionCall: { name: "read_file", args: { path: "foo.ts" } } }], + }, + { + role: "user", + parts: [ + { + functionResponse: { + name: "read_file", + response: { name: "read_file", content: "(empty)" }, + }, + }, + ], + }, + ]) + }) + + it("synthesizes one response per call for parallel dangling functionCalls", () => { + const contents: Content[] = [ + { role: "user", parts: [{ text: "Run both tools" }] }, + { + role: "model", + parts: [ + { functionCall: { name: "tool_a", args: { a: 1 } } }, + { functionCall: { name: "tool_b", args: { b: 2 } } }, + ], + }, + ] + + const result = closeDanglingFunctionCalls(contents) + + expect(result.at(-1)).toEqual({ + role: "user", + parts: [ + { + functionResponse: { + name: "tool_a", + response: { name: "tool_a", content: "(empty)" }, + }, + }, + { + functionResponse: { + name: "tool_b", + response: { name: "tool_b", content: "(empty)" }, + }, + }, + ], + }) + }) + + it("leaves a text-only trailing model turn unchanged", () => { + const contents: Content[] = [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there!" }] }, + ] + + expect(closeDanglingFunctionCalls(contents)).toEqual(contents) + }) + + it("leaves a completed tool turn unchanged", () => { + const contents: Content[] = [ + { role: "user", parts: [{ text: "Run the tool" }] }, + { role: "model", parts: [{ functionCall: { name: "read_file", args: { path: "foo.ts" } } }] }, + { + role: "user", + parts: [ + { + functionResponse: { + name: "read_file", + response: { name: "read_file", content: "file contents" }, + }, + }, + ], + }, + ] + + expect(closeDanglingFunctionCalls(contents)).toEqual(contents) + }) +}) diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts index 0053166278..10f2708e91 100644 --- a/src/api/transform/gemini-format.ts +++ b/src/api/transform/gemini-format.ts @@ -242,3 +242,36 @@ export function convertAnthropicMessageToGemini( }, ] } + +// Gemini and Vertex AI reject contents that end on a model turn whose functionCall +// parts have no matching functionResponse — the exact shape left behind when a task +// crashes between the model's functionCall emit and tool execution. Synthesize a +// closing functionResponse per dangling call so the history ends on a completed tool +// turn and the role-based continuation logic in the Gemini handler applies as usual. +// Uses the same "(empty)" sentinel as empty/null tool results above: the tool never +// ran, so there is no real output to report. +export function closeDanglingFunctionCalls(contents: Content[]): Content[] { + const last = contents.at(-1) + if (!last || last.role !== "model" || !last.parts) { + return contents + } + + const danglingCalls = last.parts.flatMap((part) => (part.functionCall?.name ? [part.functionCall] : [])) + if (danglingCalls.length === 0) { + return contents + } + + // The dangling turn is the final content, so none of its calls can have a + // response anywhere later in the history. + contents.push({ + role: "user", + parts: danglingCalls.map((call) => ({ + functionResponse: { + name: call.name, + response: { name: call.name, content: "(empty)" }, + }, + })), + }) + + return contents +} From 7303a46045df7289511115e15b5dcb1f644e9d48 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:32:00 +0900 Subject: [PATCH 07/11] fix(vertex): strip only a trailing :thinking model suffix What: Replace String.replace (first occurrence anywhere) with endsWith + slice so only a TRAILING :thinking suffix is removed from the resolved model id. Why: A mid-ID ":thinking" occurrence (e.g. "gemini-:thinking-flash:thinking") would be eaten by replace while the functional trailing suffix stayed on the wire id. Harmless with today's model table, but imprecise. Impact: Resolved Vertex model ids strip exactly one trailing suffix; pinned by a getModel regression test. --- src/api/providers/__tests__/vertex.spec.ts | 13 +++++++++++++ src/api/providers/vertex.ts | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 02ebf6817f..37c392e0a0 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -239,6 +239,19 @@ describe("VertexHandler", () => { expect(modelInfo.reasoning).toBeDefined() }) + it("should strip only a trailing :thinking suffix, preserving mid-ID occurrences", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-:thinking-flash:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + // String.replace eats the FIRST occurrence anywhere, which would turn this + // id into "gemini--flash:thinking" and leave a trailing suffix on the wire. + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-:thinking-flash") + }) + it("should handle custom unlisted gemini models with :thinking suffix", () => { const testHandler = new VertexHandler({ apiModelId: "gemini-future-model:thinking", diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 20fd11998a..4448223185 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -74,7 +74,8 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. // The actual model ID honored by Gemini's API does not have this - // suffix. - return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params } + // suffix. Strip only a TRAILING suffix (endsWith + slice) so a mid-ID + // occurrence such as "gemini-:thinking-flash:thinking" survives intact. + return { id: id.endsWith(":thinking") ? id.slice(0, -":thinking".length) : id, info, ...params } } } From 85fdd82bd81edc1650783abb082d74f3f256818b Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:33:39 +0900 Subject: [PATCH 08/11] refactor(vertex): drop unreachable fallback arm for unknown gemini ids What: Remove the Object.hasOwn(vertexModels, baseModelId) first arm (and the now-unused baseModelId local) from the unknown-gemini-* fallback chain in VertexHandler.getModel. Why: Reach arm 3 only when the id is truthy, not a vertexModels key (arm 1 failed), and - for :thinking-suffixed ids - its sliced base is also not a key (arm 2 failed). In every reachable case baseModelId is therefore NOT a key, so the first arm could never win. Verified dead, not merely unlikely. Impact: Behavior unchanged (all vertex getModel tests pass); the fallback intent is documented at the chain instead. --- src/api/providers/vertex.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 4448223185..7dde347a2b 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -29,16 +29,14 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand info = vertexModels[baseModelId] } else if (modelId && modelId.toLowerCase().startsWith("gemini-")) { id = modelId - const baseModelId = modelId.endsWith(":thinking") ? modelId.slice(0, -":thinking".length) : modelId - const fallbackModelId: VertexModelId = ( - Object.hasOwn(vertexModels, baseModelId) - ? baseModelId - : "gemini-3.7-flash" in vertexModels - ? "gemini-3.7-flash" - : "gemini-3.1-pro-preview" in vertexModels - ? "gemini-3.1-pro-preview" - : vertexDefaultModelId - ) as VertexModelId + // Arms above guarantee the id (and its :thinking base, if any) is NOT a + // vertexModels key, so resolve params from the newest Gemini entry. + const fallbackModelId: VertexModelId = + "gemini-3.7-flash" in vertexModels + ? "gemini-3.7-flash" + : "gemini-3.1-pro-preview" in vertexModels + ? "gemini-3.1-pro-preview" + : vertexDefaultModelId const baseInfo = vertexModels[fallbackModelId] || vertexModels[vertexDefaultModelId] info = { ...baseInfo, From 1d8a20d6749da464ef8265e82ee8bbc251a1a034 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:35:20 +0900 Subject: [PATCH 09/11] test(gemini): align errorDetails fixtures with Task.ts consumer contract What: Change the 429/403 rejection fixtures from object-shaped errorDetails to arrays of google.rpc detail objects (RetryInfo with retryDelay "30s"; ErrorInfo with reason PERMISSION_DENIED), matching what Task.ts backoffAndAnnounce actually consumes via errorDetails.find(). Why: The object fixture pinned a shape the real consumer never sees, so the pass-through tests could not guard the retry-delay parsing path. Impact: Test-only; pins the array contract both rejection paths preserve. --- src/api/providers/__tests__/gemini.spec.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 3a53c0b6c6..bd0708d65b 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -481,9 +481,11 @@ describe("GeminiHandler", () => { }) it("preserves status and errorDetails when the stream call rejects with a 429", async () => { + // Task.ts backoffAndAnnounce consumes errorDetails as an ARRAY of google.rpc + // detail objects and parses RetryInfo.retryDelay ("s") on 429s. const rateLimitError = Object.assign(new Error("rate limit exceeded"), { status: 429, - errorDetails: { retryAfter: 30 }, + errorDetails: [{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "30s" }], }) mockGenerateContentStream.mockRejectedValue(rateLimitError) @@ -493,7 +495,9 @@ describe("GeminiHandler", () => { expect(error).toBeInstanceOf(Error) expect(error.status).toBe(429) - expect(error.errorDetails).toEqual({ retryAfter: 30 }) + expect(error.errorDetails).toEqual([ + { "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "30s" }, + ]) }) }) @@ -528,9 +532,11 @@ describe("GeminiHandler", () => { }) it("preserves status and errorDetails when the completion call rejects with a 403", async () => { + // Same consumer contract as the 429 case: errorDetails is an array of + // google.rpc detail objects (ErrorInfo carries the denial reason). const forbiddenError = Object.assign(new Error("permission denied"), { status: 403, - errorDetails: { reason: "PERMISSION_DENIED" }, + errorDetails: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "PERMISSION_DENIED" }], }) mockGenerateContent.mockRejectedValue(forbiddenError) @@ -541,7 +547,9 @@ describe("GeminiHandler", () => { expect(error).toBeInstanceOf(Error) expect(error.status).toBe(403) - expect(error.errorDetails).toEqual({ reason: "PERMISSION_DENIED" }) + expect(error.errorDetails).toEqual([ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "PERMISSION_DENIED" }, + ]) }) it("should handle empty response", async () => { From 9718cb8f53ad46fb072285e3ca65daca6f825ba1 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:21:09 +0900 Subject: [PATCH 10/11] fix(gemini): strip only a trailing :thinking model suffix What: Replace String.replace (first occurrence anywhere) with endsWith + slice in GeminiHandler.getModel, matching the VertexHandler fix, so only a TRAILING :thinking suffix is removed from the resolved model id. Why: A custom id with a mid-string ":thinking" occurrence (e.g. "gemini-:thinking-flash:thinking") was mangled at the wrong position while the functional trailing suffix stayed on the wire id. Flagged by both security reviewers as a consistency nit with vertex.ts. Impact: Resolved Gemini model ids strip exactly one trailing suffix; pinned by a getModel regression test that fails under the old replace behavior. --- src/api/providers/__tests__/gemini.spec.ts | 12 ++++++++++++ src/api/providers/gemini.ts | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index bd0708d65b..1cf52d18e2 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -599,6 +599,18 @@ describe("GeminiHandler", () => { expect(modelInfo.info.tiers).toBeUndefined() }) + it("should strip only a trailing :thinking suffix, preserving mid-ID occurrences", () => { + const thinkingHandler = new GeminiHandler({ + apiModelId: "gemini-:thinking-flash:thinking", + geminiApiKey: "test-key", + }) + + // String.replace eats the FIRST occurrence anywhere, which would turn this + // id into "gemini--flash:thinking" and leave a trailing suffix on the wire. + const modelInfo = thinkingHandler.getModel() + expect(modelInfo.id).toBe("gemini-:thinking-flash") + }) + it("should not treat Object prototype keys as known models", () => { // `"toString" in geminiModels` is true via the prototype chain, which would // otherwise resolve `info` to a function. An own-property check avoids this. diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index eacb753959..da42f56617 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -555,8 +555,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. // The actual model ID honored by Gemini's API does not have this - // suffix. - return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params } + // suffix. Strip only a TRAILING suffix (endsWith + slice) so a mid-ID + // occurrence such as "gemini-:thinking-flash:thinking" survives intact. + return { id: id.endsWith(":thinking") ? id.slice(0, -":thinking".length) : id, info, ...params } } private extractGroundingSources(groundingMetadata?: GroundingMetadata): GroundingSource[] { From 0867de60019fb737765f9b4e6d405fc4b8449234 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 01:50:01 +0900 Subject: [PATCH 11/11] test(gemini-format): snapshot input in closeDanglingFunctionCalls no-op tests What: In the two 'unchanged' tests, structuredClone the input contents before calling closeDanglingFunctionCalls and assert both the returned value and the input against that pre-call snapshot. Why: The helper mutates its input array in place and returns the same reference, so comparing the result to the (already mutated) input array was a self-comparison that could never fail, even if the helper appended an unwanted turn (CodeRabbit review on PR #1753). Impact: The no-op tests now genuinely pin 'no turn appended, input unmodified'; all synthesis assertions kept unchanged. --- src/api/transform/__tests__/gemini-format.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/api/transform/__tests__/gemini-format.spec.ts b/src/api/transform/__tests__/gemini-format.spec.ts index c355289316..96e4647f4a 100644 --- a/src/api/transform/__tests__/gemini-format.spec.ts +++ b/src/api/transform/__tests__/gemini-format.spec.ts @@ -729,8 +729,15 @@ describe("closeDanglingFunctionCalls", () => { { role: "user", parts: [{ text: "Hello" }] }, { role: "model", parts: [{ text: "Hi there!" }] }, ] + // The helper mutates in place and returns the same array, so a + // self-comparison against `contents` would be tautological. Snapshot + // first, then pin both the returned parts and the untouched input. + const snapshot = structuredClone(contents) - expect(closeDanglingFunctionCalls(contents)).toEqual(contents) + const result = closeDanglingFunctionCalls(contents) + + expect(result).toEqual(snapshot) + expect(contents).toEqual(snapshot) }) it("leaves a completed tool turn unchanged", () => { @@ -749,7 +756,11 @@ describe("closeDanglingFunctionCalls", () => { ], }, ] + const snapshot = structuredClone(contents) + + const result = closeDanglingFunctionCalls(contents) - expect(closeDanglingFunctionCalls(contents)).toEqual(contents) + expect(result).toEqual(snapshot) + expect(contents).toEqual(snapshot) }) })