Skip to content

Commit d7d0bc7

Browse files
fix(genai): round-trip thinking content blocks in multi-turn convos (#10415)
1 parent 08657f2 commit d7d0bc7

File tree

4 files changed

+138
-13
lines changed

4 files changed

+138
-13
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@langchain/google-genai": patch
3+
"@langchain/google": patch
4+
---
5+
6+
fix(genai): round-trip thinking content blocks in multi-turn convos

libs/providers/langchain-google-genai/src/tests/common.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, test, expect } from "vitest";
22
import {
33
convertResponseContentToChatGenerationChunk,
4+
convertMessageContentToParts,
45
mapGenerateContentResultToChatResult,
56
} from "../utils/common.js";
7+
import { AIMessage } from "@langchain/core/messages";
68
import type {
79
EnhancedGenerateContentResponse,
810
FinishReason,
@@ -269,3 +271,108 @@ describe("Streaming thinking content handling", () => {
269271
expect(Array.isArray(content)).toBe(true);
270272
});
271273
});
274+
275+
// https://github.com/langchain-ai/langchainjs/issues/10103
276+
describe("Round-trip thinking content handling", () => {
277+
test("thinking block with signature converts back to Gemini part", () => {
278+
const message = new AIMessage({
279+
content: [
280+
{
281+
type: "thinking",
282+
thinking: "Let me reason about this...",
283+
signature: "sig123",
284+
},
285+
{ type: "text", text: "The answer is 42." },
286+
],
287+
});
288+
289+
const parts = convertMessageContentToParts(message, true, []);
290+
291+
expect(parts).toHaveLength(2);
292+
expect(parts[0]).toEqual({
293+
text: "Let me reason about this...",
294+
thought: true,
295+
thoughtSignature: "sig123",
296+
});
297+
expect(parts[1]).toEqual({ text: "The answer is 42." });
298+
});
299+
300+
test("thinking block without signature converts back without thoughtSignature", () => {
301+
const message = new AIMessage({
302+
content: [
303+
{ type: "thinking", thinking: "Some thinking" },
304+
{ type: "text", text: "Some answer" },
305+
],
306+
});
307+
308+
const parts = convertMessageContentToParts(message, true, []);
309+
310+
expect(parts).toHaveLength(2);
311+
expect(parts[0]).toEqual({
312+
text: "Some thinking",
313+
thought: true,
314+
});
315+
expect(parts[0]).not.toHaveProperty("thoughtSignature");
316+
expect(parts[1]).toEqual({ text: "Some answer" });
317+
});
318+
319+
test("thinking-only content (no text block) works", () => {
320+
const message = new AIMessage({
321+
content: [
322+
{
323+
type: "thinking",
324+
thinking: "Only thinking, no answer",
325+
signature: "sigABC",
326+
},
327+
],
328+
});
329+
330+
const parts = convertMessageContentToParts(message, true, []);
331+
332+
expect(parts).toHaveLength(1);
333+
expect(parts[0]).toEqual({
334+
text: "Only thinking, no answer",
335+
thought: true,
336+
thoughtSignature: "sigABC",
337+
});
338+
});
339+
340+
test("full round-trip: Gemini response -> LangChain -> Gemini parts", () => {
341+
const originalParts = [
342+
{
343+
text: "Let me think step by step...",
344+
thought: true,
345+
thoughtSignature: "roundtrip-sig",
346+
},
347+
{
348+
text: "The final answer is 7.",
349+
},
350+
] as GoogleGenerativeAIPart[];
351+
352+
// Gemini response -> LangChain AIMessage
353+
const mockResponse = createMockResponse([
354+
{
355+
content: { role: "model", parts: originalParts },
356+
finishReason: "STOP" as FinishReason,
357+
index: 0,
358+
safetyRatings: [],
359+
},
360+
]);
361+
362+
const chatResult = mapGenerateContentResultToChatResult(mockResponse);
363+
const aiMessage = chatResult.generations[0].message;
364+
365+
// LangChain AIMessage -> Gemini parts (outgoing direction)
366+
const roundTrippedParts = convertMessageContentToParts(aiMessage, true, []);
367+
368+
expect(roundTrippedParts).toHaveLength(2);
369+
expect(roundTrippedParts[0]).toEqual({
370+
text: "Let me think step by step...",
371+
thought: true,
372+
thoughtSignature: "roundtrip-sig",
373+
});
374+
expect(roundTrippedParts[1]).toEqual({
375+
text: "The final answer is 7.",
376+
});
377+
});
378+
});

libs/providers/langchain-google-genai/src/utils/common.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,19 @@ function _convertLangChainContentToPart(
429429
data: content.data,
430430
},
431431
};
432+
} else if (content.type === "thinking") {
433+
const thinkingContent = content as {
434+
type: "thinking";
435+
thinking: string;
436+
signature?: string;
437+
};
438+
return {
439+
text: thinkingContent.thinking,
440+
thought: true,
441+
...(thinkingContent.signature
442+
? { thoughtSignature: thinkingContent.signature }
443+
: {}),
444+
} as Part;
432445
} else if ("functionCall" in content) {
433446
// No action needed here — function calls will be added later from message.tool_calls
434447
return undefined;

libs/providers/langchain-google/src/converters/tests/messages.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,12 @@ describe("convertMessagesToGeminiContents", () => {
258258

259259
const contents = convertMessagesToGeminiContents(messages);
260260

261-
// Should produce: user, function (single merged turn)
262-
// The AIMessage with empty content and tool_calls produces no model content block
263-
expect(contents).toHaveLength(2);
261+
// Should produce: user, model (functionCall parts), function (single merged turn)
262+
expect(contents).toHaveLength(3);
264263

265-
const functionTurn = contents[1];
264+
expect(contents[1].role).toBe("model");
265+
266+
const functionTurn = contents[2];
266267
expect(functionTurn.role).toBe("function");
267268
expect(functionTurn.parts).toHaveLength(2);
268269

@@ -445,18 +446,16 @@ describe("convertMessagesToGeminiContents", () => {
445446

446447
const contents = convertMessagesToGeminiContents(messages);
447448

449+
// Consecutive ToolMessages with the same "function" role are merged into one content
448450
const toolResponseContents = contents.filter((c) => c.role === "function");
449-
expect(toolResponseContents).toHaveLength(2);
450-
451-
const firstResponse = toolResponseContents[0].parts.find(
452-
(p) => "functionResponse" in p && p.functionResponse
453-
) as Gemini.Part.FunctionResponse;
454-
expect(firstResponse.functionResponse!.name).toBe("get_weather");
451+
expect(toolResponseContents).toHaveLength(1);
455452

456-
const secondResponse = toolResponseContents[1].parts.find(
453+
const mergedParts = toolResponseContents[0].parts.filter(
457454
(p) => "functionResponse" in p && p.functionResponse
458-
) as Gemini.Part.FunctionResponse;
459-
expect(secondResponse.functionResponse!.name).toBe("get_time");
455+
) as Gemini.Part.FunctionResponse[];
456+
expect(mergedParts).toHaveLength(2);
457+
expect(mergedParts[0].functionResponse!.name).toBe("get_weather");
458+
expect(mergedParts[1].functionResponse!.name).toBe("get_time");
460459
});
461460

462461
test("passes tool_call_id through as functionResponse.id (v1 standard path)", () => {

0 commit comments

Comments
 (0)