From 12da5c0198ef2607dbf5280793fa850c1293f432 Mon Sep 17 00:00:00 2001 From: "@navedmerchant" <14171946+navedmerchant@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:44:13 +0000 Subject: [PATCH 1/2] fix(api): continue Vertex Gemini model turns --- src/api/providers/__tests__/vertex.spec.ts | 42 +++++++++++++++++++++- src/api/providers/gemini.ts | 9 +++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index a304518ca7..15397abcb1 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", () => { diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 44065d8801..82e49d6246 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -175,6 +175,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl protected options: ApiHandlerOptions private client: GoogleGenAI + private readonly isVertex: boolean private lastThoughtSignature?: string private lastResponseId?: string private readonly providerName = "Gemini" @@ -183,6 +184,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl super() this.options = options + this.isVertex = isVertex ?? false const project = this.options.vertexProjectId ?? "not-provided" const location = this.options.vertexRegion ?? "not-provided" @@ -268,6 +270,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl .map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName })) .flat() + // Vertex rejects 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 (this.isVertex && 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 From f64b508c8156417213c2927c9fbd7cabff7ef0ec Mon Sep 17 00:00:00 2001 From: "@taltas" <6816042+taltas@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:06:18 +0000 Subject: [PATCH 2/2] fix(api): pair interrupted Vertex Gemini function calls with synthesized responses --- src/api/providers/__tests__/vertex.spec.ts | 44 ++++++++++++++++++++++ src/api/providers/gemini.ts | 29 +++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 15397abcb1..d6dfdf9aa6 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -135,6 +135,50 @@ describe("VertexHandler", () => { }), ) }) + + it("synthesizes a functionResponse when Vertex history ends with an unanswered functionCall", async () => { + const generateContentStream = vitest.mocked(handler["client"].models.generateContentStream) + generateContentStream.mockResolvedValue(asyncStreamFrom([])) + const interruptedToolCallMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: [ + { type: "text", text: "Reading the file." }, + { type: "tool_use", id: "toolu_1", name: "read_file", input: { path: "foo.ts" } }, + ], + }, + ] + + await collectStream(handler.createMessage(systemPrompt, interruptedToolCallMessages)) + + const contents = generateContentStream.mock.calls[0][0].contents as Array<{ + role?: string + parts?: Array<{ functionCall?: { name?: string }; functionResponse?: unknown; text?: string }> + }> + + // The trailing model turn ends with the unanswered functionCall. + const lastModelTurn = contents.at(-2) + expect(lastModelTurn?.role).toBe("model") + expect(lastModelTurn?.parts?.at(-1)?.functionCall?.name).toBe("read_file") + + // The continuation pairs that functionCall with a synthesized functionResponse + // instead of a plain-text turn, preserving Gemini's required pairing. + expect(contents.at(-1)).toEqual({ + role: "user", + parts: [ + { + functionResponse: { + name: "read_file", + response: { + name: "read_file", + content: expect.any(String), + }, + }, + }, + ], + }) + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 82e49d6246..a604dd3232 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -273,8 +273,33 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Vertex rejects 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 (this.isVertex && contents.at(-1)?.role === "model") { - contents.push({ role: "user", parts: [{ text: "Continue." }] }) + const lastContent = contents.at(-1) + if (this.isVertex && lastContent?.role === "model") { + // If the trailing model turn contains unanswered function calls (e.g. the + // response was interrupted mid-tool-call), a plain-text turn would break + // Gemini's functionCall -> functionResponse pairing, so synthesize benign + // function responses instead. The functionCall parts already carry the tool + // name from convertAnthropicMessageToGemini (via toolIdToName). + const functionCallNames = (lastContent.parts ?? []) + .map((part) => part.functionCall?.name) + .filter((name): name is string => Boolean(name)) + if (functionCallNames.length > 0) { + contents.push({ + role: "user", + parts: functionCallNames.map((name) => ({ + functionResponse: { + name, + response: { + name, + content: + "Tool execution was interrupted before a result was recorded. Continue without the result of this call.", + }, + }, + })), + }) + } else { + contents.push({ role: "user", parts: [{ text: "Continue." }] }) + } } // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS).