diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 9f5953da..292ffa36 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -9,6 +9,7 @@ import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js"; import { captureSessionId } from "../utils/sessionManager.js"; import { AntigravityExecutor } from "../executors/antigravity.js"; import { PROVIDERS } from "../providers/index.js"; +import { ROLE, GEMINI_ROLE } from "./schema/roles.js"; // Registry for translators. Lazy-init guards against circular-import order: // translator modules call register() (side-effect) before this module's body runs. @@ -49,12 +50,26 @@ function stripContentTypes(body, stripList = []) { } } +// Role the client's conversation actually ended on, in the source format's own +// shape — not every source uses messages[] (Gemini/Antigravity: contents[], +// Responses/Codex: input[]). Only an explicit trailing model/assistant turn is +// real prefill and must reach ensureTrailingUserTurn as ROLE.ASSISTANT; every +// other tail (including no role, e.g. a function output) stays undefined so +// the emptied-turn fix still applies. +function detectClientLastRole(body) { + if (Array.isArray(body?.messages)) return body.messages[body.messages.length - 1]?.role; + const items = Array.isArray(body?.contents) ? body.contents : Array.isArray(body?.input) ? body.input : null; + if (!items) return undefined; + const role = items[items.length - 1]?.role; + return role === ROLE.ASSISTANT || role === GEMINI_ROLE.MODEL ? ROLE.ASSISTANT : undefined; +} + // Translate request: source -> openai -> target export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null, clientTool = null) { ensureInitialized(); let result = body; // Role the client actually ended on, before any translator drops an emptied turn. - const clientLastRole = Array.isArray(body?.messages) ? body.messages[body.messages.length - 1]?.role : undefined; + const clientLastRole = detectClientLastRole(body); // Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry) stripContentTypes(result, stripList); diff --git a/tests/unit/claude-trailing-user-turn-source-formats.test.js b/tests/unit/claude-trailing-user-turn-source-formats.test.js new file mode 100644 index 00000000..84d8a31a --- /dev/null +++ b/tests/unit/claude-trailing-user-turn-source-formats.test.js @@ -0,0 +1,42 @@ +// Non-messages[] sources (Gemini contents[], Responses input[]) carry the client's +// terminal role in their own shape. An explicit trailing model/assistant turn is real +// prefill and must survive translation to Claude; an emptied trailing user turn must +// still get the "Continue." restoration. +import { describe, it, expect } from "vitest"; +import { translateRequest } from "../../open-sse/translator/index.js"; + +const roles = (body) => body.messages.map((m) => m.role); + +describe("trailing user turn: non-messages[] source formats", () => { + it("keeps a Gemini trailing model turn (real prefill)", () => { + const out = translateRequest("gemini", "claude", "claude-sonnet-4-5", { + contents: [ + { role: "user", parts: [{ text: "hi" }] }, + { role: "model", parts: [{ text: "The answer is" }] }, + ], + }, false); + expect(roles(out)).toEqual(["user", "assistant"]); + }); + + it("keeps a Responses trailing assistant message (real prefill)", () => { + const out = translateRequest("openai-responses", "claude", "claude-sonnet-4-5", { + model: "claude-sonnet-4-5", + input: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "The answer is" }, + ], + }, false); + expect(roles(out)).toEqual(["user", "assistant"]); + }); + + it("still restores a user turn when a Gemini trailing user turn is emptied", () => { + const out = translateRequest("gemini", "claude", "claude-sonnet-4-5", { + contents: [ + { role: "user", parts: [{ text: "hi" }] }, + { role: "model", parts: [{ text: "hello" }] }, + { role: "user", parts: [] }, + ], + }, false); + expect(roles(out)).toEqual(["user", "assistant", "user"]); + }); +});