diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 1120dfba69..8231814f57 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -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 = {}; + 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 = {}; + 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" diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index c860a1acf0..7073e3b969 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -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" + ); +});