Skip to content

Commit 7d1394e

Browse files
roomote[bot]roomoteedelauna
authored
[Fix] Gemini requests fail when user enables the full MCP tool set (#148)
* test: add Gemini provider e2e coverage * fix(gemini): INVALID_ARGUMENT when loaded too many MCPs * fix(gemini): resolve $ref, deep-merge allOf, align e2e fixtures * refactor: dropping extra command * fix: preserve top-level Gemini schema fields with allOf * fix: guard recursive Gemini schema refs * fix(gemini): preserve keyword-named tool parameters during schema sanitization * test(gemini-e2e): wire aimock recording and use real model id --------- Co-authored-by: Roomote <roomote@roocode.com> Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent 166bc3f commit 7d1394e

7 files changed

Lines changed: 964 additions & 35 deletions

File tree

apps/vscode-e2e/AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ ZAI_API_KEY=<key> TEST_FILE=zai.test pnpm --filter @roo-code/vscode-e2e test:ci
160160
161161
When adding a new test to this suite, add a matching fixture to the `installZAiFetchInterceptor` call in `suiteSetup`. Use a short unique prefix (e.g. `"zai-glm-e2e-mytest:"`) that won't appear in `<environment_details>`.
162162
163+
### Gemini (`suite/providers/gemini.test.ts`)
164+
165+
Gemini routes through aimock via `googleGeminiBaseUrl: aimockUrl`. aimock has native Gemini SSE support and can proxy to `https://generativelanguage.googleapis.com` in record mode. The model ID defaults to `gemini-3-flash-preview` but can be overridden via `GEMINI_MODEL_ID`.
166+
167+
The test only runs when aimock is active (replay or record). Live runs without aimock are not supported because `GEMINI_MODEL_ID` must match the fixture.
168+
169+
**Record** (refresh fixtures from the real Gemini API):
170+
171+
```sh
172+
GEMINI_API_KEY=<key> TEST_FILE=providers/gemini.test pnpm --filter @roo-code/vscode-e2e test:record
173+
```
174+
175+
After recording, inspect the generated `fixtures/gemini-*.json`, extract the response blocks into `fixtures/gemini.json`, then delete the raw files.
176+
177+
**Verify in mock mode** (no key needed):
178+
179+
```sh
180+
TEST_FILE=providers/gemini.test pnpm --filter @roo-code/vscode-e2e test:ci:mock
181+
```
182+
163183
### xAI Grok (`suite/providers/xai.test.ts`)
164184
165185
xAI uses the **Responses API** (`POST https://api.x.ai/v1/responses`), which is not OpenAI-compatible. aimock can't intercept it. The suite instead patches `globalThis.fetch` to intercept requests to that endpoint. By default it replays hand-crafted SSE events; when a local `fixtures/xai.json` recording exists, it can replay recorded real-API SSE events for reference.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"model": "gemini-3-flash-preview",
6+
"userMessage": "gemini-e2e:reasoning-high: what is 2+2? Reply with only the number."
7+
},
8+
"response": {
9+
"toolCalls": [
10+
{
11+
"name": "attempt_completion",
12+
"arguments": "{\"result\":\"4\"}",
13+
"id": "call_gemini_reasoning_high_done"
14+
}
15+
]
16+
}
17+
},
18+
{
19+
"match": {
20+
"model": "gemini-3-flash-preview",
21+
"userMessage": "gemini-e2e:reasoning-low: what is 2+2? Reply with only the number."
22+
},
23+
"response": {
24+
"toolCalls": [
25+
{
26+
"name": "attempt_completion",
27+
"arguments": "{\"result\":\"4\"}",
28+
"id": "call_gemini_reasoning_low_done"
29+
}
30+
]
31+
}
32+
},
33+
{
34+
"match": {
35+
"model": "gemini-3-flash-preview",
36+
"userMessage": "gemini-e2e:reasoning-disable: what is 2+2? Reply with only the number."
37+
},
38+
"response": {
39+
"toolCalls": [
40+
{
41+
"name": "attempt_completion",
42+
"arguments": "{\"result\":\"4\"}",
43+
"id": "call_gemini_reasoning_disable_done"
44+
}
45+
]
46+
}
47+
}
48+
]
49+
}

apps/vscode-e2e/src/runTest.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@ async function main() {
3131
const testGrep = getCliFlagValue("--grep") || process.env.TEST_GREP
3232
const testFile = getCliFlagValue("--file") || process.env.TEST_FILE
3333
const isDeepSeekTest = isDeepSeekTargetedRun(testFile, testGrep)
34+
const isGeminiTest = testFile?.toLowerCase().includes("gemini.test") ?? false
3435

3536
if (isRecord && isDeepSeekTest && !process.env.DEEPSEEK_API_KEY) {
3637
throw new Error("AIMOCK_RECORD=true requires DEEPSEEK_API_KEY to record DeepSeek fixtures")
3738
}
3839

39-
if (isRecord && !isDeepSeekTest && !process.env.OPENROUTER_API_KEY) {
40+
if (isRecord && isGeminiTest && !process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
41+
throw new Error("AIMOCK_RECORD=true requires GEMINI_API_KEY to record Gemini fixtures")
42+
}
43+
44+
if (isRecord && !isDeepSeekTest && !isGeminiTest && !process.env.OPENROUTER_API_KEY) {
4045
throw new Error("AIMOCK_RECORD=true requires OPENROUTER_API_KEY to record fixtures")
4146
}
4247

@@ -78,6 +83,8 @@ async function main() {
7883
openai: isDeepSeekTest ? "https://api.deepseek.com" : "https://openrouter.ai/api",
7984
// aimock forwards the x-api-key header from the Anthropic SDK to the real API.
8085
anthropic: "https://api.anthropic.com",
86+
// aimock forwards the x-goog-api-key header from the Google AI SDK.
87+
...(isGeminiTest && { gemini: "https://generativelanguage.googleapis.com" }),
8188
},
8289
fixturePath: fixturesDir,
8390
},
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import * as assert from "assert"
2+
3+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
4+
5+
import { setDefaultSuiteTimeout } from "../test-utils"
6+
import { waitUntilCompleted } from "../utils"
7+
8+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
9+
const GEMINI_MODEL_ID = process.env.GEMINI_MODEL_ID ?? "gemini-3-flash-preview"
10+
11+
type FunctionDeclaration = {
12+
name: string
13+
parametersJsonSchema?: Record<string, unknown>
14+
}
15+
16+
type GeminiToolConfig = {
17+
functionCallingConfig?: {
18+
mode?: string
19+
allowedFunctionNames?: string[]
20+
}
21+
}
22+
23+
type CapturedGeminiRequest = {
24+
model?: string
25+
lastUserMessage: string
26+
thinkingConfig?: Record<string, unknown>
27+
toolConfig?: GeminiToolConfig
28+
hasTools: boolean
29+
toolDeclarationCount: number
30+
functionDeclarations: FunctionDeclaration[]
31+
}
32+
33+
function findInvalidSchemaPatterns(schema: unknown, path = ""): string[] {
34+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
35+
return []
36+
}
37+
38+
const obj = schema as Record<string, unknown>
39+
const violations: string[] = []
40+
41+
if ("additionalProperties" in obj) {
42+
violations.push(`${path}.additionalProperties (stripped for Gemini compatibility)`)
43+
}
44+
45+
if ("default" in obj) {
46+
violations.push(`${path}.default (stripped for Gemini compatibility)`)
47+
}
48+
49+
if ("$schema" in obj) {
50+
violations.push(`${path}.$schema (JSON Schema metadata stripped for Gemini compatibility)`)
51+
}
52+
53+
if ("type" in obj && Array.isArray(obj.type)) {
54+
violations.push(`${path}.type is an array ${JSON.stringify(obj.type)} (Gemini requires a single string type)`)
55+
}
56+
57+
for (const [key, value] of Object.entries(obj)) {
58+
if (key === "properties" && value && typeof value === "object") {
59+
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
60+
violations.push(...findInvalidSchemaPatterns(propSchema, `${path}.properties.${propName}`))
61+
}
62+
} else if (key === "items") {
63+
violations.push(...findInvalidSchemaPatterns(value, `${path}.items`))
64+
} else if (key === "anyOf" || key === "oneOf" || key === "allOf") {
65+
violations.push(`${path}.${key} (collapsed for Gemini compatibility)`)
66+
if (Array.isArray(value)) {
67+
value.forEach((item, i) => violations.push(...findInvalidSchemaPatterns(item, `${path}.${key}[${i}]`)))
68+
}
69+
}
70+
}
71+
72+
return violations
73+
}
74+
75+
function getRequestUrl(input: RequestInfo | URL): string {
76+
return typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url
77+
}
78+
79+
function isUrlWithOrigin(rawUrl: string, expectedOrigin: string): boolean {
80+
try {
81+
return new URL(rawUrl).origin === expectedOrigin
82+
} catch {
83+
return false
84+
}
85+
}
86+
87+
function isGeminiGenerateContentUrl(rawUrl: string): boolean {
88+
try {
89+
const pathname = new URL(rawUrl).pathname
90+
return pathname.includes(":streamGenerateContent") || pathname.includes(":generateContent")
91+
} catch {
92+
return false
93+
}
94+
}
95+
96+
function extractGeminiModel(rawUrl: string): string | undefined {
97+
try {
98+
const pathname = new URL(rawUrl).pathname
99+
const match = pathname.match(/\/models\/([^:]+):(streamGenerateContent|generateContent)$/)
100+
return match?.[1]
101+
} catch {
102+
return undefined
103+
}
104+
}
105+
106+
function extractLastUserMessage(
107+
contents?: Array<{
108+
role?: string
109+
parts?: Array<{ text?: string }>
110+
}>,
111+
): string {
112+
const lastUser = [...(contents ?? [])].reverse().find((content) => content.role === "user")
113+
114+
if (!lastUser?.parts) {
115+
return ""
116+
}
117+
118+
return lastUser.parts
119+
.map((part) => (typeof part?.text === "string" ? part.text : JSON.stringify(part ?? "")))
120+
.join("")
121+
}
122+
123+
function installGeminiRequestCapture(capture: CapturedGeminiRequest[], baseUrl: string): () => void {
124+
const originalFetch = globalThis.fetch
125+
const targetOrigin = new URL(baseUrl).origin
126+
127+
globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
128+
const url = getRequestUrl(input)
129+
130+
if (isUrlWithOrigin(url, targetOrigin) && isGeminiGenerateContentUrl(url)) {
131+
const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}
132+
const tools = Array.isArray(body.tools) ? body.tools : []
133+
const functionDeclarations: FunctionDeclaration[] = tools.flatMap(
134+
(tool: { functionDeclarations?: FunctionDeclaration[] }) =>
135+
Array.isArray(tool.functionDeclarations) ? tool.functionDeclarations : [],
136+
)
137+
138+
capture.push({
139+
model: extractGeminiModel(url),
140+
lastUserMessage: extractLastUserMessage(body.contents),
141+
thinkingConfig:
142+
body.generationConfig && typeof body.generationConfig === "object"
143+
? (body.generationConfig.thinkingConfig as Record<string, unknown> | undefined)
144+
: undefined,
145+
toolConfig:
146+
body.toolConfig && typeof body.toolConfig === "object"
147+
? (body.toolConfig as GeminiToolConfig)
148+
: undefined,
149+
hasTools: tools.length > 0,
150+
toolDeclarationCount: functionDeclarations.length,
151+
functionDeclarations,
152+
})
153+
}
154+
155+
return originalFetch.call(globalThis, input, init as RequestInit)
156+
} as typeof globalThis.fetch
157+
158+
return () => {
159+
globalThis.fetch = originalFetch
160+
}
161+
}
162+
163+
suite("Gemini provider", function () {
164+
setDefaultSuiteTimeout(this)
165+
166+
let restoreFetch: (() => void) | undefined
167+
const requests: CapturedGeminiRequest[] = []
168+
169+
setup(function () {
170+
const aimockUrl = process.env.AIMOCK_URL
171+
const isReplay = aimockUrl && process.env.AIMOCK_RECORD !== "true"
172+
const isRecordRun = aimockUrl && process.env.AIMOCK_RECORD === "true" && !!GEMINI_API_KEY
173+
// Live runs without aimock are not supported — GEMINI_MODEL_ID must match the fixture.
174+
if (!isReplay && !isRecordRun) {
175+
this.skip()
176+
}
177+
})
178+
179+
suiteSetup(() => {
180+
restoreFetch = installGeminiRequestCapture(
181+
requests,
182+
process.env.AIMOCK_URL || "https://generativelanguage.googleapis.com",
183+
)
184+
})
185+
186+
suiteTeardown(async () => {
187+
restoreFetch?.()
188+
restoreFetch = undefined
189+
190+
const aimockUrl = process.env.AIMOCK_URL
191+
const isRecord = process.env.AIMOCK_RECORD === "true"
192+
await globalThis.api.setConfiguration({
193+
apiProvider: "openrouter" as const,
194+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
195+
openRouterModelId: "openai/gpt-4.1",
196+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
197+
})
198+
})
199+
200+
for (const reasoningEffort of ["high", "low", "disable"] as const) {
201+
test(`Should complete a task end-to-end using ${GEMINI_MODEL_ID} via Gemini provider with reasoning effort "${reasoningEffort}"`, async () => {
202+
requests.length = 0
203+
204+
const api = globalThis.api
205+
const aimockUrl = process.env.AIMOCK_URL
206+
const isRecord = process.env.AIMOCK_RECORD === "true"
207+
const promptTag = `gemini-e2e:reasoning-${reasoningEffort}`
208+
209+
await api.setConfiguration({
210+
apiProvider: "gemini" as const,
211+
geminiApiKey: aimockUrl && !isRecord ? "mock-key" : GEMINI_API_KEY!,
212+
apiModelId: GEMINI_MODEL_ID,
213+
enableReasoningEffort: reasoningEffort !== "disable",
214+
reasoningEffort: reasoningEffort,
215+
...(aimockUrl && { googleGeminiBaseUrl: aimockUrl }),
216+
})
217+
218+
const messages: ClineMessage[] = []
219+
const messageHandler = ({ message }: { message: ClineMessage }) => {
220+
if (message.type === "say" && message.partial === false) {
221+
messages.push(message)
222+
}
223+
}
224+
225+
api.on(RooCodeEventName.Message, messageHandler)
226+
227+
try {
228+
const taskId = await api.startNewTask({
229+
configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
230+
text: `${promptTag}: what is 2+2? Reply with only the number.`,
231+
})
232+
233+
await waitUntilCompleted({ api, taskId })
234+
} finally {
235+
api.off(RooCodeEventName.Message, messageHandler)
236+
}
237+
238+
const firstRequest = requests.find((request) => request.lastUserMessage.includes(promptTag))
239+
assert.ok(firstRequest, "Gemini provider should issue a generate content request for the task prompt")
240+
assert.strictEqual(firstRequest.model, GEMINI_MODEL_ID)
241+
assert.ok(firstRequest.hasTools, "Gemini provider should include tool declarations in the request")
242+
assert.ok(
243+
firstRequest.toolDeclarationCount > 0,
244+
"Gemini provider should declare at least one callable tool",
245+
)
246+
assert.strictEqual(
247+
firstRequest.toolConfig?.functionCallingConfig?.allowedFunctionNames,
248+
undefined,
249+
"Gemini requests should not send allowedFunctionNames; the Gemini backend returns generic INVALID_ARGUMENT for larger or history-incompatible restriction lists",
250+
)
251+
252+
// Verify tool schemas are sanitized for Gemini compatibility. Gemini documents
253+
// function declaration schemas as a selected OpenAPI-style subset with
254+
// single-value `type` plus `nullable`; live testing also showed opaque
255+
// INVALID_ARGUMENT failures from broader third-party MCP schema metadata.
256+
for (const decl of firstRequest.functionDeclarations) {
257+
const violations = findInvalidSchemaPatterns(
258+
decl.parametersJsonSchema,
259+
`${decl.name}.parametersJsonSchema`,
260+
)
261+
assert.strictEqual(
262+
violations.length,
263+
0,
264+
`Tool "${decl.name}" has Gemini-incompatible schema: ${violations.join("; ")}`,
265+
)
266+
}
267+
268+
if (reasoningEffort === "disable") {
269+
assert.strictEqual(
270+
firstRequest.thinkingConfig,
271+
undefined,
272+
"Reasoning-disabled Gemini requests should omit thinkingConfig",
273+
)
274+
} else {
275+
assert.ok(
276+
firstRequest.thinkingConfig,
277+
`Gemini requests with reasoningEffort="${reasoningEffort}" should include thinkingConfig`,
278+
)
279+
}
280+
281+
const completionMessage = messages.find(
282+
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
283+
)
284+
285+
assert.ok(completionMessage, "Task should complete with the expected Gemini provider response")
286+
})
287+
}
288+
})

0 commit comments

Comments
 (0)