Skip to content

Commit a91881b

Browse files
roomoteedelauna
authored andcommitted
test: add Gemini provider e2e coverage
1 parent 4a8e5f2 commit a91881b

2 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"model": "gemini-3.1-pro-preview",
6+
"userMessage": "gemini-e2e:reasoning-on: 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_on_done"
14+
}
15+
]
16+
}
17+
},
18+
{
19+
"match": {
20+
"model": "gemini-3.1-pro-preview",
21+
"userMessage": "gemini-e2e:reasoning-off: 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_off_done"
29+
}
30+
]
31+
}
32+
}
33+
]
34+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 = "gemini-3.1-pro-preview"
10+
11+
type CapturedGeminiRequest = {
12+
model?: string
13+
lastUserMessage: string
14+
thinkingConfig?: Record<string, unknown>
15+
hasTools: boolean
16+
toolDeclarationCount: number
17+
}
18+
19+
function getRequestUrl(input: RequestInfo | URL): string {
20+
return typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url
21+
}
22+
23+
function isUrlWithOrigin(rawUrl: string, expectedOrigin: string): boolean {
24+
try {
25+
return new URL(rawUrl).origin === expectedOrigin
26+
} catch {
27+
return false
28+
}
29+
}
30+
31+
function isGeminiGenerateContentUrl(rawUrl: string): boolean {
32+
try {
33+
const pathname = new URL(rawUrl).pathname
34+
return pathname.includes(":streamGenerateContent") || pathname.includes(":generateContent")
35+
} catch {
36+
return false
37+
}
38+
}
39+
40+
function extractGeminiModel(rawUrl: string): string | undefined {
41+
try {
42+
const pathname = new URL(rawUrl).pathname
43+
const match = pathname.match(/\/models\/([^:]+):(streamGenerateContent|generateContent)$/)
44+
return match?.[1]
45+
} catch {
46+
return undefined
47+
}
48+
}
49+
50+
function extractLastUserMessage(
51+
contents?: Array<{
52+
role?: string
53+
parts?: Array<{ text?: string }>
54+
}>,
55+
): string {
56+
const lastUser = [...(contents ?? [])].reverse().find((content) => content.role === "user")
57+
58+
if (!lastUser?.parts) {
59+
return ""
60+
}
61+
62+
return lastUser.parts
63+
.map((part) => (typeof part?.text === "string" ? part.text : JSON.stringify(part ?? "")))
64+
.join("")
65+
}
66+
67+
function installGeminiRequestCapture(capture: CapturedGeminiRequest[], baseUrl: string): () => void {
68+
const originalFetch = globalThis.fetch
69+
const targetOrigin = new URL(baseUrl).origin
70+
71+
globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
72+
const url = getRequestUrl(input)
73+
74+
if (isUrlWithOrigin(url, targetOrigin) && isGeminiGenerateContentUrl(url)) {
75+
const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}
76+
const tools = Array.isArray(body.tools) ? body.tools : []
77+
const toolDeclarationCount = tools.reduce((count: number, tool: { functionDeclarations?: unknown[] }) => {
78+
return count + (Array.isArray(tool.functionDeclarations) ? tool.functionDeclarations.length : 0)
79+
}, 0)
80+
81+
capture.push({
82+
model: extractGeminiModel(url),
83+
lastUserMessage: extractLastUserMessage(body.contents),
84+
thinkingConfig:
85+
body.generationConfig && typeof body.generationConfig === "object"
86+
? (body.generationConfig.thinkingConfig as Record<string, unknown> | undefined)
87+
: undefined,
88+
hasTools: tools.length > 0,
89+
toolDeclarationCount,
90+
})
91+
}
92+
93+
return originalFetch.call(globalThis, input, init as RequestInit)
94+
} as typeof globalThis.fetch
95+
96+
return () => {
97+
globalThis.fetch = originalFetch
98+
}
99+
}
100+
101+
suite("Gemini provider", function () {
102+
setDefaultSuiteTimeout(this)
103+
104+
let restoreFetch: (() => void) | undefined
105+
const requests: CapturedGeminiRequest[] = []
106+
107+
setup(function () {
108+
if (!process.env.AIMOCK_URL && !GEMINI_API_KEY) {
109+
this.skip()
110+
}
111+
})
112+
113+
suiteSetup(() => {
114+
restoreFetch = installGeminiRequestCapture(
115+
requests,
116+
process.env.AIMOCK_URL || "https://generativelanguage.googleapis.com",
117+
)
118+
})
119+
120+
suiteTeardown(async () => {
121+
restoreFetch?.()
122+
restoreFetch = undefined
123+
124+
const aimockUrl = process.env.AIMOCK_URL
125+
const isRecord = process.env.AIMOCK_RECORD === "true"
126+
await globalThis.api.setConfiguration({
127+
apiProvider: "openrouter" as const,
128+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
129+
openRouterModelId: "openai/gpt-4.1",
130+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
131+
})
132+
})
133+
134+
for (const reasoningEnabled of [true, false] as const) {
135+
test(`Should complete a task end-to-end using ${GEMINI_MODEL_ID} via Gemini provider with reasoning ${
136+
reasoningEnabled ? "enabled" : "disabled"
137+
}`, async () => {
138+
requests.length = 0
139+
140+
const api = globalThis.api
141+
const aimockUrl = process.env.AIMOCK_URL
142+
const isRecord = process.env.AIMOCK_RECORD === "true"
143+
const promptTag = reasoningEnabled ? "gemini-e2e:reasoning-on" : "gemini-e2e:reasoning-off"
144+
145+
await api.setConfiguration({
146+
apiProvider: "gemini" as const,
147+
geminiApiKey: aimockUrl && !isRecord ? "mock-key" : GEMINI_API_KEY!,
148+
apiModelId: GEMINI_MODEL_ID,
149+
enableReasoningEffort: reasoningEnabled,
150+
reasoningEffort: reasoningEnabled ? ("high" as const) : ("disable" as const),
151+
...(aimockUrl && { googleGeminiBaseUrl: aimockUrl }),
152+
})
153+
154+
const messages: ClineMessage[] = []
155+
const messageHandler = ({ message }: { message: ClineMessage }) => {
156+
if (message.type === "say" && message.partial === false) {
157+
messages.push(message)
158+
}
159+
}
160+
161+
api.on(RooCodeEventName.Message, messageHandler)
162+
163+
try {
164+
const taskId = await api.startNewTask({
165+
configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
166+
text: `${promptTag}: what is 2+2? Reply with only the number.`,
167+
})
168+
169+
await waitUntilCompleted({ api, taskId })
170+
} finally {
171+
api.off(RooCodeEventName.Message, messageHandler)
172+
}
173+
174+
const firstRequest = requests.find((request) => request.lastUserMessage.includes(promptTag))
175+
assert.ok(firstRequest, "Gemini provider should issue a generate content request for the task prompt")
176+
assert.strictEqual(firstRequest.model, GEMINI_MODEL_ID)
177+
assert.ok(firstRequest.hasTools, "Gemini provider should include tool declarations in the request")
178+
assert.ok(
179+
firstRequest.toolDeclarationCount > 0,
180+
"Gemini provider should declare at least one callable tool",
181+
)
182+
183+
if (reasoningEnabled) {
184+
assert.ok(
185+
firstRequest.thinkingConfig,
186+
"Reasoning-enabled Gemini requests should include thinkingConfig",
187+
)
188+
} else {
189+
assert.strictEqual(
190+
firstRequest.thinkingConfig,
191+
undefined,
192+
"Reasoning-disabled Gemini requests should omit thinkingConfig",
193+
)
194+
}
195+
196+
const completionMessage = messages.find(
197+
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
198+
)
199+
200+
assert.ok(completionMessage, "Task should complete with the expected Gemini provider response")
201+
})
202+
}
203+
})

0 commit comments

Comments
 (0)