Skip to content

Commit 6a32b2e

Browse files
fix: restore Gemini thought signature round-tripping after AI SDK migration (RooCodeInc#11237)
PR RooCodeInc#11180 migrated Gemini/Vertex providers to the AI SDK and deleted gemini-format.ts which contained the working thought signature round-trip logic (originally added in PR RooCodeInc#10590). This broke all Gemini 3 tool use with a 400 error: 'Function call is missing a thought_signature'. Changes: - Gemini/Vertex handlers: capture thoughtSignature from providerMetadata on tool-call stream events, expose via getThoughtSignature() - convertToAiSdkMessages(): extract thoughtSignature content blocks from history, attach as providerOptions on first tool-call part (per Gemini 3 parallel call rules) - Add 3 tests verifying thought signature round-trip behavior
1 parent a266834 commit 6a32b2e

4 files changed

Lines changed: 175 additions & 3 deletions

File tree

src/api/providers/gemini.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
3131
protected options: ApiHandlerOptions
3232
protected provider: GoogleGenerativeAIProvider
3333
private readonly providerName = "Gemini"
34+
private lastThoughtSignature: string | undefined
3435

3536
constructor(options: ApiHandlerOptions) {
3637
super()
@@ -124,11 +125,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
124125
}
125126

126127
try {
128+
// Reset thought signature for this request
129+
this.lastThoughtSignature = undefined
130+
127131
// Use streamText for streaming responses
128132
const result = streamText(requestOptions)
129133

130134
// Process the full stream to get all events including reasoning
131135
for await (const part of result.fullStream) {
136+
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
137+
// The AI SDK's tool-call event includes providerMetadata with the signature
138+
if (part.type === "tool-call") {
139+
const googleMeta = (part as any).providerMetadata?.google
140+
if (googleMeta?.thoughtSignature) {
141+
this.lastThoughtSignature = googleMeta.thoughtSignature
142+
}
143+
}
144+
132145
for (const chunk of processAiSdkStreamPart(part)) {
133146
yield chunk
134147
}
@@ -401,4 +414,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
401414
override isAiSdkProvider(): boolean {
402415
return true
403416
}
417+
418+
/**
419+
* Returns the thought signature captured from the last Gemini response.
420+
* Gemini 3 models return thoughtSignature on function call parts,
421+
* which must be round-tripped back for tool use continuations.
422+
*/
423+
getThoughtSignature(): string | undefined {
424+
return this.lastThoughtSignature
425+
}
404426
}

src/api/providers/vertex.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
3535
protected options: ApiHandlerOptions
3636
protected provider: GoogleVertexProvider
3737
private readonly providerName = "Vertex"
38+
private lastThoughtSignature: string | undefined
3839

3940
constructor(options: ApiHandlerOptions) {
4041
super()
@@ -138,11 +139,26 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
138139
}
139140

140141
try {
142+
// Reset thought signature for this request
143+
this.lastThoughtSignature = undefined
144+
141145
// Use streamText for streaming responses
142146
const result = streamText(requestOptions)
143147

144148
// Process the full stream to get all events including reasoning
145149
for await (const part of result.fullStream) {
150+
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
151+
// The AI SDK's tool-call event includes providerMetadata with the signature
152+
// Vertex AI stores it under the "vertex" key in providerMetadata
153+
if (part.type === "tool-call") {
154+
const vertexMeta = (part as any).providerMetadata?.vertex
155+
const googleMeta = (part as any).providerMetadata?.google
156+
const sig = vertexMeta?.thoughtSignature ?? googleMeta?.thoughtSignature
157+
if (sig) {
158+
this.lastThoughtSignature = sig
159+
}
160+
}
161+
146162
for (const chunk of processAiSdkStreamPart(part)) {
147163
yield chunk
148164
}
@@ -406,4 +422,13 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
406422
override isAiSdkProvider(): boolean {
407423
return true
408424
}
425+
426+
/**
427+
* Returns the thought signature captured from the last Vertex AI response.
428+
* Gemini 3 models return thoughtSignature on function call parts,
429+
* which must be round-tripped back for tool use continuations.
430+
*/
431+
getThoughtSignature(): string | undefined {
432+
return this.lastThoughtSignature
433+
}
409434
}

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,101 @@ describe("AI SDK conversion utilities", () => {
399399
],
400400
})
401401
})
402+
403+
it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => {
404+
const messages: Anthropic.Messages.MessageParam[] = [
405+
{
406+
role: "assistant",
407+
content: [
408+
{ type: "text", text: "Let me check that." },
409+
{
410+
type: "tool_use",
411+
id: "tool-1",
412+
name: "read_file",
413+
input: { path: "test.txt" },
414+
},
415+
{ type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any,
416+
],
417+
},
418+
]
419+
420+
const result = convertToAiSdkMessages(messages)
421+
422+
expect(result).toHaveLength(1)
423+
const assistantMsg = result[0]
424+
expect(assistantMsg.role).toBe("assistant")
425+
426+
const content = assistantMsg.content as any[]
427+
expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through)
428+
429+
const toolCallPart = content.find((p: any) => p.type === "tool-call")
430+
expect(toolCallPart).toBeDefined()
431+
expect(toolCallPart.providerOptions).toEqual({
432+
google: { thoughtSignature: "encrypted-sig-abc" },
433+
vertex: { thoughtSignature: "encrypted-sig-abc" },
434+
})
435+
})
436+
437+
it("attaches thoughtSignature only to the first tool-call in parallel calls", () => {
438+
const messages: Anthropic.Messages.MessageParam[] = [
439+
{
440+
role: "assistant",
441+
content: [
442+
{
443+
type: "tool_use",
444+
id: "tool-1",
445+
name: "get_weather",
446+
input: { city: "Paris" },
447+
},
448+
{
449+
type: "tool_use",
450+
id: "tool-2",
451+
name: "get_weather",
452+
input: { city: "London" },
453+
},
454+
{ type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any,
455+
],
456+
},
457+
]
458+
459+
const result = convertToAiSdkMessages(messages)
460+
const content = (result[0] as any).content as any[]
461+
462+
const toolCalls = content.filter((p: any) => p.type === "tool-call")
463+
expect(toolCalls).toHaveLength(2)
464+
465+
// Only the first tool call should have the signature
466+
expect(toolCalls[0].providerOptions).toEqual({
467+
google: { thoughtSignature: "sig-parallel" },
468+
vertex: { thoughtSignature: "sig-parallel" },
469+
})
470+
// Second tool call should NOT have the signature
471+
expect(toolCalls[1].providerOptions).toBeUndefined()
472+
})
473+
474+
it("does not attach providerOptions when no thoughtSignature block is present", () => {
475+
const messages: Anthropic.Messages.MessageParam[] = [
476+
{
477+
role: "assistant",
478+
content: [
479+
{ type: "text", text: "Using tool" },
480+
{
481+
type: "tool_use",
482+
id: "tool-1",
483+
name: "read_file",
484+
input: { path: "test.txt" },
485+
},
486+
],
487+
},
488+
]
489+
490+
const result = convertToAiSdkMessages(messages)
491+
const content = (result[0] as any).content as any[]
492+
const toolCallPart = content.find((p: any) => p.type === "tool-call")
493+
494+
expect(toolCallPart).toBeDefined()
495+
expect(toolCallPart.providerOptions).toBeUndefined()
496+
})
402497
})
403498

404499
describe("convertToolsForAiSdk", () => {

src/api/transform/ai-sdk.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,45 @@ export function convertToAiSdkMessages(
136136
toolCallId: string
137137
toolName: string
138138
input: unknown
139+
providerOptions?: Record<string, Record<string, unknown>>
139140
}> = []
140141

142+
// Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping).
143+
// Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks.
144+
let thoughtSignature: string | undefined
145+
for (const part of message.content) {
146+
const partAny = part as unknown as { type?: string; thoughtSignature?: string }
147+
if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
148+
thoughtSignature = partAny.thoughtSignature
149+
}
150+
}
151+
141152
for (const part of message.content) {
142153
if (part.type === "text") {
143154
textParts.push(part.text)
144155
continue
145156
}
146157

147158
if (part.type === "tool_use") {
148-
toolCalls.push({
159+
const toolCall: (typeof toolCalls)[number] = {
149160
type: "tool-call",
150161
toolCallId: part.id,
151162
toolName: part.name,
152163
input: part.input,
153-
})
164+
}
165+
166+
// Attach thoughtSignature as providerOptions on tool-call parts.
167+
// The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature
168+
// and attaches it to the Gemini functionCall part.
169+
// Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature.
170+
if (thoughtSignature && toolCalls.length === 0) {
171+
toolCall.providerOptions = {
172+
google: { thoughtSignature },
173+
vertex: { thoughtSignature },
174+
}
175+
}
176+
177+
toolCalls.push(toolCall)
154178
continue
155179
}
156180

@@ -183,7 +207,13 @@ export function convertToAiSdkMessages(
183207
const content: Array<
184208
| { type: "reasoning"; text: string }
185209
| { type: "text"; text: string }
186-
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
210+
| {
211+
type: "tool-call"
212+
toolCallId: string
213+
toolName: string
214+
input: unknown
215+
providerOptions?: Record<string, Record<string, unknown>>
216+
}
187217
> = []
188218

189219
if (reasoningContent) {

0 commit comments

Comments
 (0)