Files
diegosouzapw--omniroute/tests/unit/translator-gemini-consecutive-role-2191.test.ts
2026-07-13 13:39:12 +08:00

123 lines
4.6 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
// Regression for 9router#2191: the OpenAI->Gemini request translator must not
// emit two adjacent `contents[]` entries with the same role. Gemini-family APIs
// (incl. Antigravity / Vertex) reject those with
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// The translator had no consecutive-same-role merge pass (unlike the Kiro and
// Claude paths), so consecutive `user` turns — or a tool-result turn (role:user)
// immediately followed by a plain user turn — produced an invalid alternation.
const { openaiToGeminiRequest, mergeConsecutiveSameRoleContents } = await import(
"../../open-sse/translator/request/openai-to-gemini.ts"
);
type GeminiContent = { role: string; parts: Array<Record<string, unknown>> };
type GeminiReq = { contents: GeminiContent[] };
function assertNoConsecutiveSameRole(contents: GeminiContent[], label: string) {
for (let i = 1; i < contents.length; i++) {
assert.notStrictEqual(
contents[i].role,
contents[i - 1].role,
`${label}: contents[${i - 1}] and contents[${i}] both have role "${contents[i].role}" ` +
`(Gemini rejects consecutive same-role messages)`
);
}
}
test("OpenAI -> Gemini merges two consecutive user messages into one content block", () => {
const body = {
messages: [
{ role: "user", content: "Hello" },
{ role: "user", content: "Additional context" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "two-user");
// The two user turns collapse into a single user content carrying both parts.
assert.equal(result.contents.length, 1, "expected the two user turns to merge into one");
assert.equal(result.contents[0].role, "user");
const texts = result.contents[0].parts.map((p) => p.text);
assert.deepEqual(texts, ["Hello", "Additional context"]);
});
test("OpenAI -> Gemini does not emit a tool-result(user) turn adjacent to a user turn", () => {
// Agentic history: user -> assistant(tool_call) -> tool(result) -> user.
// The assistant block pushes model + user(toolResponse); the trailing plain
// user turn would otherwise produce two adjacent role:"user" contents.
const body = {
messages: [
{ role: "user", content: "List files" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "ls", arguments: '{"path":"."}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: "a.ts\nb.ts" },
{ role: "user", content: "Now read a.ts" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "tool-result-then-user");
// Roles must strictly alternate: user, model, user (toolResp + "Now read a.ts" merged).
assert.deepEqual(
result.contents.map((c) => c.role),
["user", "model", "user"]
);
});
test("OpenAI -> Gemini keeps a normally alternating conversation unchanged", () => {
const body = {
messages: [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello there" },
{ role: "user", content: "How are you?" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "alternating");
assert.deepEqual(
result.contents.map((c) => c.role),
["user", "model", "user"]
);
});
test("mergeConsecutiveSameRoleContents merges adjacent same-role entries without mutating the input", () => {
const userPartsA = [{ text: "Hello" }];
const userPartsB = [{ text: "Additional context" }];
const input: GeminiContent[] = [
{ role: "user", parts: userPartsA },
{ role: "user", parts: userPartsB },
{ role: "model", parts: [{ text: "Hi" }] },
];
const merged = mergeConsecutiveSameRoleContents(input) as GeminiContent[];
// Merged output: one user block carrying both parts, then the model block.
assert.deepEqual(
merged.map((c) => c.role),
["user", "model"]
);
assert.deepEqual(
merged[0].parts.map((p) => p.text),
["Hello", "Additional context"]
);
// The caller's input objects and their parts arrays must be untouched.
assert.equal(input.length, 3, "input array must not be mutated");
assert.equal(userPartsA.length, 1, "first input parts array must not be mutated");
assert.equal(userPartsB.length, 1, "second input parts array must not be mutated");
assert.notStrictEqual(merged[0].parts, userPartsA, "merged parts must be a fresh array");
});