Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion src/api/providers/__tests__/vertex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
})
Expand Down Expand Up @@ -95,6 +96,89 @@ 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" }] },
],
}),
)
})

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", () => {
Expand Down
34 changes: 34 additions & 0 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
protected options: ApiHandlerOptions

private client: GoogleGenAI
private readonly isVertex: boolean
private lastThoughtSignature?: string
private lastResponseId?: string
private readonly providerName = "Gemini"
Expand All @@ -183,7 +184,8 @@
super()

this.options = options
this.isVertex = isVertex ?? false

Check warning on line 188 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:188: 2 mutation test gaps; example: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
const apiKey = this.options.geminiApiKey ?? "not-provided"
Expand Down Expand Up @@ -268,6 +270,38 @@
.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.
const lastContent = contents.at(-1)
if (this.isVertex && lastContent?.role === "model") {

Check warning on line 277 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:277: Survived UnaryOperator mutant (replacement: +1). See the job summary for the complete list and resolution guidance.
// If the trailing model turn contains unanswered function calls (e.g. the

Check warning on line 278 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:278: 6 mutation test gaps; example: NoCoverage ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
// 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)

Check warning on line 284 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:284: 3 mutation test gaps; example: NoCoverage MethodExpression mutant (replacement: (lastContent.parts ?? []).map(part => part.functionCall?.name)). See the job summary for the complete list and resolution guidance.
.filter((name): name is string => Boolean(name))

Check warning on line 285 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:285: 2 mutation test gaps; example: NoCoverage ArrowFunction mutant (replacement: () => undefined). See the job summary for the complete list and resolution guidance.
if (functionCallNames.length > 0) {

Check warning on line 286 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:286: NoCoverage ArrowFunction mutant (replacement: () => undefined). See the job summary for the complete list and resolution guidance.
contents.push({

Check warning on line 287 in src/api/providers/gemini.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/gemini.ts:287: 4 mutation test gaps; example: NoCoverage ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
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).
// Google built-in tools (Grounding, URL Context) are mutually exclusive
// with function declarations in the Gemini API, so we always use
Expand Down
Loading