fix(translator): pair Gemini tool responses per turn to prevent cross-turn ID collision (#13848)

This commit is contained in:
Nguyễn Viết Tuấn
2026-09-19 10:04:31 +07:00
committed by GitHub
parent f6bafe4b88
commit c299e6c180
2 changed files with 194 additions and 10 deletions

View File

@@ -346,7 +346,8 @@ function openaiToGeminiBase(
// Convert messages
if (messages && Array.isArray(messages)) {
for (const msg of messages) {
for (let msgIndex = 0; msgIndex < messages.length; msgIndex++) {
const msg = messages[msgIndex];
const role = msg.role;
const content = msg.content;
@@ -482,20 +483,47 @@ function openaiToGeminiBase(
result.contents.push({ role: "model", parts });
}
// Collect turn-specific tool responses: in standard OpenAI chat format, tool responses
// immediately follow the assistant message that requested them.
const turnToolResponses: Record<string, unknown> = {};
for (let j = msgIndex + 1; j < messages.length; j++) {
const later = messages[j];
if (later.role === "assistant" || later.role === "user") break;
if (later.role === "tool" && later.tool_call_id) {
turnToolResponses[later.tool_call_id as string] = later.content;
}
}
// Build a turn-specific map of tool call IDs to function names from this assistant message's toolCalls.
// This prevents cross-turn ID collisions where an identical tool_call_id reused in a later turn
// would otherwise overwrite the function name and content of an earlier turn (#e59118).
const turnTcID2Name: Record<string, string> = {};
for (const tc of toolCalls) {
const fn = tc.function as { name?: string } | undefined;
if (tc.type === "function" && tc.id && fn?.name) {
turnTcID2Name[tc.id as string] = fn.name;
}
}
const resolveToolResponse = (id: string): unknown =>
turnToolResponses[id] !== undefined ? turnToolResponses[id] : toolResponses[id];
const hasToolResponse = (id: string): boolean => resolveToolResponse(id) !== undefined;
// Check if there are actual tool responses in the next messages
const hasSignaturelessTextResponses =
contextualizeSignaturelessToolResponses &&
toolCalls.some((tc) => {
const id = tc.id as string;
return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id];
return tc.type === "function" && !resolvedSignatures.has(id) && hasToolResponse(id);
});
const hasActualResponses =
toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses;
toolCallIds.some((fid) => hasToolResponse(fid)) || hasSignaturelessTextResponses;
if (hasActualResponses) {
const toolParts: GeminiPart[] = [];
for (const fid of toolCallIds) {
if (!toolResponses[fid]) continue;
const resp = resolveToolResponse(fid);
if (resp === undefined) continue;
if (
!toolNameOptions.supportsSignatureBypass &&
contextualizeSignaturelessToolResponses &&
@@ -503,7 +531,7 @@ function openaiToGeminiBase(
)
continue;
let name = tcID2Name[fid];
let name = turnTcID2Name[fid] || tcID2Name[fid];
if (!name) {
const idParts = fid.split("-");
if (idParts.length > 2) {
@@ -514,8 +542,6 @@ function openaiToGeminiBase(
}
name = sanitizeToolName(name);
const resp = toolResponses[fid];
toolParts.push({
functionResponse: {
...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
@@ -538,10 +564,10 @@ function openaiToGeminiBase(
for (const tc of toolCalls) {
const id = tc.id as string;
if (tc.type !== "function" || !id) continue;
if (!resolvedSignatures.has(id) && toolResponses[id]) {
const resp = resolveToolResponse(id);
if (!resolvedSignatures.has(id) && resp !== undefined) {
const fn = tc.function as { name?: string } | undefined;
const name = tcID2Name[id] || fn?.name || "unknown";
const resp = toolResponses[id];
const name = turnTcID2Name[id] || tcID2Name[id] || fn?.name || "unknown";
toolParts.push({
text:
signaturelessToolCallMode === "text"

View File

@@ -1648,3 +1648,161 @@ test("OpenAI -> Gemini allows thinkingConfig for unknown model (no spec)", () =>
assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 5000);
assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true);
});
test("OpenAI -> Gemini pairs tool calls and responses per turn without cross-turn ID collision mismatch", () => {
const result = openaiToCloudCodeGeminiRequest(
"gemini-3.8-flash-high",
{
messages: [
{ role: "user", content: "read file" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_collision_123",
type: "function",
function: { name: "read_file", arguments: '{"path":"a.txt"}' },
},
],
},
{
role: "tool",
tool_call_id: "call_collision_123",
content: "file content from turn 1",
},
{ role: "user", content: "now run terminal command" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_collision_123",
type: "function",
function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
},
],
},
{
role: "tool",
tool_call_id: "call_collision_123",
content: "terminal output from turn 2",
},
{ role: "user", content: "done" },
],
},
false
) as any;
// Verify Turn 1 functionCall and functionResponse
const turn1Model = result.contents.find((c: any) =>
c.parts?.some((p: any) => p.functionCall?.name === "read_file")
);
assert.ok(turn1Model, "Turn 1 model functionCall must be read_file");
const turn1User = result.contents.find((c: any) =>
c.parts?.some(
(p: any) =>
p.functionResponse?.response?.result === "file content from turn 1" ||
p.functionResponse?.name === "read_file"
)
);
assert.ok(turn1User, "Turn 1 user functionResponse must exist");
const turn1Resp = turn1User.parts.find((p: any) => p.functionResponse);
assert.equal(
turn1Resp.functionResponse.name,
"read_file",
"Turn 1 functionResponse name must match functionCall name, not be overwritten by turn 2"
);
assert.equal(
turn1Resp.functionResponse.response.result,
"file content from turn 1",
"Turn 1 functionResponse must contain turn 1 output, not turn 2 output"
);
// Verify Turn 2 functionCall and functionResponse
const turn2User = result.contents.find((c: any) =>
c.parts?.some(
(p: any) =>
p.functionResponse?.response?.result === "terminal output from turn 2" ||
p.functionResponse?.name === "run_terminal_command"
)
);
assert.ok(turn2User, "Turn 2 user functionResponse must exist");
const turn2Resp = turn2User.parts.find((p: any) => p.functionResponse);
assert.equal(
turn2Resp.functionResponse.name,
"run_terminal_command",
"Turn 2 functionResponse name must match functionCall name"
);
assert.equal(
turn2Resp.functionResponse.response.result,
"terminal output from turn 2",
"Turn 2 functionResponse must contain turn 2 output"
);
});
test("OpenAI -> Gemini pairs tool calls and responses in context mode without ID collision mismatch", () => {
const result = openaiToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "read file" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_collision_999",
type: "function",
function: { name: "read_file", arguments: '{"path":"a.txt"}' },
},
],
},
{
role: "tool",
tool_call_id: "call_collision_999",
content: "file content from turn 1",
},
{ role: "user", content: "now run terminal command" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_collision_999",
type: "function",
function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
},
],
},
{
role: "tool",
tool_call_id: "call_collision_999",
content: "terminal output from turn 2",
},
{ role: "user", content: "done" },
],
},
false,
null,
{ signaturelessToolCallMode: "context" }
) as any;
// In context mode without thought signatures, tool responses are emitted as context text
const textParts = result.contents.flatMap((c: any) =>
(c.parts || []).filter((p: any) => typeof p.text === "string").map((p: any) => p.text)
);
assert.ok(
textParts.some(
(t: string) => t.includes("read_file") && t.includes("file content from turn 1")
),
"Turn 1 context text must pair read_file with its own turn 1 output"
);
assert.ok(
textParts.some(
(t: string) => t.includes("run_terminal_command") && t.includes("terminal output from turn 2")
),
"Turn 2 context text must pair run_terminal_command with its own turn 2 output"
);
});