@@ -54,6 +54,215 @@ describe("GeminiHandler", () => {
5454 } )
5555 } )
5656
57+ describe ( "thoughtSignature round-trip (issue #536)" , ( ) => {
58+ const systemPrompt = "You are a helpful assistant"
59+ const toolMetadata = { tools : [ { function : { name : "read_file" , description : "" , parameters : { } } } ] } as any
60+
61+ // Helper: build a mock async-iterable stream from chunks
62+ function makeStream ( chunks : unknown [ ] ) {
63+ return {
64+ [ Symbol . asyncIterator ] : async function * ( ) {
65+ for ( const chunk of chunks ) yield chunk
66+ } ,
67+ }
68+ }
69+
70+ // Simulate a Gemini 3.x response: thoughtSignature arrives on its own part,
71+ // alongside a functionCall part (the way the real Gemini 3 API returns it).
72+ const turn1Response = makeStream ( [
73+ {
74+ candidates : [
75+ {
76+ content : {
77+ parts : [
78+ { thought : true , text : "thinking…" } ,
79+ { functionCall : { name : "read_file" , args : { path : "foo.ts" } } } ,
80+ { thoughtSignature : "sig-abc123" } ,
81+ ] ,
82+ } ,
83+ } ,
84+ ] ,
85+ } ,
86+ { usageMetadata : { promptTokenCount : 10 , candidatesTokenCount : 5 } } ,
87+ ] )
88+
89+ it ( "captures thoughtSignature from the stream after turn 1" , async ( ) => {
90+ ; ( handler [ "client" ] . models . generateContentStream as any ) . mockResolvedValue ( turn1Response )
91+
92+ const messages : Anthropic . Messages . MessageParam [ ] = [ { role : "user" , content : "Read foo.ts" } ]
93+
94+ for await ( const _chunk of handler . createMessage ( systemPrompt , messages , toolMetadata ) ) {
95+ // drain
96+ }
97+
98+ expect ( handler . getThoughtSignature ( ) ) . toBe ( "sig-abc123" )
99+ } )
100+
101+ it ( "sends thoughtSignature from history on turn 2 (core regression)" , async ( ) => {
102+ // This is the bug from issue #536: after turn 1 the thoughtSignature block is
103+ // persisted into apiConversationHistory. On turn 2 the handler must include it
104+ // in the outgoing request, otherwise Gemini 3.x returns an empty response.
105+ const historyAfterTurn1 : Anthropic . Messages . MessageParam [ ] = [
106+ { role : "user" , content : "Read foo.ts" } ,
107+ {
108+ role : "assistant" ,
109+ // assistant turn as stored by prepareApiConversationMessage:
110+ // tool_use block + appended thoughtSignature block
111+ content : [
112+ { type : "tool_use" , id : "call-1" , name : "read_file" , input : { path : "foo.ts" } } ,
113+ { type : "thoughtSignature" , thoughtSignature : "sig-abc123" } as any ,
114+ ] ,
115+ } ,
116+ {
117+ role : "user" ,
118+ content : [ { type : "tool_result" , tool_use_id : "call-1" , content : "file contents here" } ] ,
119+ } ,
120+ ]
121+
122+ ; ( handler [ "client" ] . models . generateContentStream as any ) . mockResolvedValue (
123+ makeStream ( [
124+ { candidates : [ { content : { parts : [ { text : "Done." } ] } } ] } ,
125+ { usageMetadata : { promptTokenCount : 20 , candidatesTokenCount : 5 } } ,
126+ ] ) ,
127+ )
128+
129+ for await ( const _chunk of handler . createMessage ( systemPrompt , historyAfterTurn1 , toolMetadata ) ) {
130+ // drain
131+ }
132+
133+ const callArgs = ( handler [ "client" ] . models . generateContentStream as any ) . mock . calls [ 0 ] [ 0 ]
134+ const contents : any [ ] = callArgs . contents
135+
136+ // The model turn in the outgoing request must carry the thoughtSignature on its functionCall part
137+ const modelTurn = contents . find ( ( c : any ) => c . role === "model" )
138+ expect ( modelTurn ) . toBeDefined ( )
139+ const fnPart = modelTurn . parts . find ( ( p : any ) => p . functionCall )
140+ expect ( fnPart ) . toBeDefined ( )
141+ expect ( fnPart . thoughtSignature ) . toBe ( "sig-abc123" )
142+ } )
143+
144+ it ( "falls back to base64-encoded skip_thought_signature_validator when history has no signature" , async ( ) => {
145+ // Cross-model history scenario: prior session used a non-Gemini model, no signature stored.
146+ // The fallback bypass token must be base64-encoded because Part.thoughtSignature is
147+ // documented as a base64 field. Vertex AI validates this strictly.
148+ const historyNoSig : Anthropic . Messages . MessageParam [ ] = [
149+ { role : "user" , content : "Read foo.ts" } ,
150+ {
151+ role : "assistant" ,
152+ content : [ { type : "tool_use" , id : "call-1" , name : "read_file" , input : { path : "foo.ts" } } ] ,
153+ } ,
154+ {
155+ role : "user" ,
156+ content : [ { type : "tool_result" , tool_use_id : "call-1" , content : "file contents" } ] ,
157+ } ,
158+ ]
159+
160+ ; ( handler [ "client" ] . models . generateContentStream as any ) . mockResolvedValue (
161+ makeStream ( [
162+ { candidates : [ { content : { parts : [ { text : "Done." } ] } } ] } ,
163+ { usageMetadata : { promptTokenCount : 20 , candidatesTokenCount : 5 } } ,
164+ ] ) ,
165+ )
166+
167+ for await ( const _chunk of handler . createMessage ( systemPrompt , historyNoSig , toolMetadata ) ) {
168+ // drain
169+ }
170+
171+ const callArgs = ( handler [ "client" ] . models . generateContentStream as any ) . mock . calls [ 0 ] [ 0 ]
172+ const contents : any [ ] = callArgs . contents
173+ const modelTurn = contents . find ( ( c : any ) => c . role === "model" )
174+ const fnPart = modelTurn ?. parts . find ( ( p : any ) => p . functionCall )
175+ expect ( fnPart ) . toBeDefined ( )
176+ const expectedBypass = Buffer . from ( "skip_thought_signature_validator" ) . toString ( "base64" )
177+ expect ( fnPart . thoughtSignature ) . toBe ( expectedBypass )
178+ } )
179+
180+ it ( "sends thoughtSignature even when reasoningEffort is disabled" , async ( ) => {
181+ // If the user disables reasoning effort, thinkingConfig=undefined.
182+ // The old code: includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length)
183+ // With tools present this is still true — but if called with no tools it would be false.
184+ // Verify the signature is sent regardless when tools are in the metadata.
185+ const handlerNoReasoning = new GeminiHandler ( {
186+ apiKey : "test-key" ,
187+ geminiApiKey : "test-key" ,
188+ apiModelId : GEMINI_MODEL_NAME ,
189+ reasoningEffort : "disable" as any ,
190+ } )
191+ handlerNoReasoning [ "client" ] = handler [ "client" ] as any
192+
193+ const historyWithSig : Anthropic . Messages . MessageParam [ ] = [
194+ { role : "user" , content : "Read foo.ts" } ,
195+ {
196+ role : "assistant" ,
197+ content : [
198+ { type : "tool_use" , id : "call-1" , name : "read_file" , input : { path : "foo.ts" } } ,
199+ { type : "thoughtSignature" , thoughtSignature : "sig-xyz" } as any ,
200+ ] ,
201+ } ,
202+ {
203+ role : "user" ,
204+ content : [ { type : "tool_result" , tool_use_id : "call-1" , content : "file contents" } ] ,
205+ } ,
206+ ]
207+
208+ ; ( handler [ "client" ] . models . generateContentStream as any ) . mockResolvedValue (
209+ makeStream ( [
210+ { candidates : [ { content : { parts : [ { text : "Done." } ] } } ] } ,
211+ { usageMetadata : { promptTokenCount : 20 , candidatesTokenCount : 5 } } ,
212+ ] ) ,
213+ )
214+
215+ for await ( const _chunk of handlerNoReasoning . createMessage ( systemPrompt , historyWithSig , toolMetadata ) ) {
216+ // drain
217+ }
218+
219+ const callArgs = ( handler [ "client" ] . models . generateContentStream as any ) . mock . calls [ 0 ] [ 0 ]
220+ const contents : any [ ] = callArgs . contents
221+ const modelTurn = contents . find ( ( c : any ) => c . role === "model" )
222+ const fnPart = modelTurn ?. parts . find ( ( p : any ) => p . functionCall )
223+ expect ( fnPart ) . toBeDefined ( )
224+ expect ( fnPart . thoughtSignature ) . toBe ( "sig-xyz" )
225+ } )
226+
227+ it ( "does NOT capture thoughtSignature when there are no tools in metadata" , async ( ) => {
228+ // Without tools, includeThoughtSignatures=false when thinkingConfig is also absent.
229+ // This tests the boundary so we don't over-eagerly store signatures for non-tool calls.
230+ const handlerNoReasoning = new GeminiHandler ( {
231+ apiKey : "test-key" ,
232+ geminiApiKey : "test-key" ,
233+ apiModelId : GEMINI_MODEL_NAME ,
234+ reasoningEffort : "disable" as any ,
235+ } )
236+ handlerNoReasoning [ "client" ] = handler [ "client" ] as any
237+ ; ( handler [ "client" ] . models . generateContentStream as any ) . mockResolvedValue (
238+ makeStream ( [
239+ {
240+ candidates : [
241+ {
242+ content : {
243+ parts : [
244+ { functionCall : { name : "read_file" , args : { path : "foo.ts" } } } ,
245+ { thoughtSignature : "sig-should-not-be-captured" } ,
246+ ] ,
247+ } ,
248+ } ,
249+ ] ,
250+ } ,
251+ { usageMetadata : { promptTokenCount : 10 , candidatesTokenCount : 5 } } ,
252+ ] ) ,
253+ )
254+
255+ // No tools in metadata, no thinkingConfig → includeThoughtSignatures=false
256+ for await ( const _chunk of handlerNoReasoning . createMessage ( systemPrompt , [
257+ { role : "user" , content : "hi" } ,
258+ ] ) ) {
259+ // drain
260+ }
261+
262+ expect ( handlerNoReasoning . getThoughtSignature ( ) ) . toBeUndefined ( )
263+ } )
264+ } )
265+
57266 describe ( "createMessage" , ( ) => {
58267 const mockMessages : Anthropic . Messages . MessageParam [ ] = [
59268 {
0 commit comments