diff --git a/open-sse/services/requestDedup.ts b/open-sse/services/requestDedup.ts index 6bf780bffc..1a39216197 100644 --- a/open-sse/services/requestDedup.ts +++ b/open-sse/services/requestDedup.ts @@ -32,23 +32,110 @@ export interface DedupResult { const inflight = new Map>(); +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Extract the prompt-bearing content from a (possibly translated) request body. + * + * The prompt content lives under different keys depending on the target + * provider format the body has already been translated to: + * - OpenAI-style bodies (`open-sse/translator/request/*-to-openai.ts`, + * `openai-to-cursor.ts`): `messages` + * - Gemini-translated bodies (`openai-to-gemini.ts`, + * `claude-to-gemini.ts`): `contents` + * - Responses-API-translated bodies (`openai-responses/toResponses.ts`): + * `input` + * - Antigravity-translated bodies (`openai-to-gemini.ts` + * `openaiToAntigravityRequest` / `wrapInCloudCodeEnvelope`): nested under + * `request.contents` (a Cloud Code envelope wrapper) + * - Kiro-translated bodies (`openai-to-kiro.ts` `buildKiroPayload`): nested + * under `conversationState.currentMessage.userInputMessage.content` (the + * current turn) plus `conversationState.history` (prior turns) + * + * Falling back to only `messages` made every non-OpenAI-format body hash the + * prompt as `null`, colliding different prompts onto the same dedup hash + * (#10249). The Antigravity/Kiro nesting was still missed by the flat + * `messages ?? contents ?? input` fallback chain, so different prompts + * targeting those two providers still collided (#10438). + */ +function extractPromptContent(body: Record): unknown { + if (body.messages !== undefined) return body.messages; + if (body.contents !== undefined) return body.contents; + if (body.input !== undefined) return body.input; + + // Antigravity Cloud Code envelope: { request: { contents, ... } } + const request = asRecord(body.request); + if (request && request.contents !== undefined) { + return request.contents; + } + + // Kiro conversationState envelope: + // { conversationState: { currentMessage: { userInputMessage: { content } }, history } } + const conversationState = asRecord(body.conversationState); + if (conversationState) { + const currentMessage = asRecord(conversationState.currentMessage); + const userInputMessage = asRecord(currentMessage?.userInputMessage); + if (userInputMessage || conversationState.history !== undefined) { + return { + content: userInputMessage?.content ?? null, + history: conversationState.history ?? null, + }; + } + } + + return null; +} + +/** + * Extract the system/instruction content that shapes generation but is not + * carried in the message list itself. Two requests with the same user + * message but a different system prompt must hash differently — omitting + * this field let them collide. + * + * - Claude-translated bodies (`openai-to-claude.ts`): `system` + * - Responses-API-translated bodies (`openai-responses/toResponses.ts`): + * `instructions` + * - Gemini-translated bodies (`openai-to-gemini.ts`, `claude-to-gemini.ts`): + * `systemInstruction` + * - Antigravity-translated bodies: nested under `request.systemInstruction` + * (note: the client system prompt is folded into `request.contents[0]` + * instead per #9030, so this is usually the constant Antigravity + * default — it is still included for completeness/future-proofing) + */ +function extractSystemContent(body: Record): unknown { + if (body.system !== undefined) return body.system; + if (body.instructions !== undefined) return body.instructions; + if (body.systemInstruction !== undefined) return body.systemInstruction; + + const request = asRecord(body.request); + if (request && request.systemInstruction !== undefined) { + return request.systemInstruction; + } + + return null; +} + /** * Compute a deterministic hash for a request body. - * Includes: model, messages, temperature, tools, tool_choice, max_tokens, response_format + * Includes: model, messages/prompt content, system/instructions, temperature, + * tools, tool_choice, max_tokens, response_format * Excludes: stream, user, metadata (don't affect LLM output) * - * The prompt content can live under different keys depending on the target - * provider format the body has already been translated to: OpenAI-style - * bodies use `messages`, Gemini-translated bodies use `contents`, and - * Responses-API-translated bodies use `input`. Falling back to only - * `messages` made every non-OpenAI-format body hash the prompt as `null`, - * colliding different prompts onto the same dedup hash (#10249). + * `computeRequestHash` is called post-translation (`chatCore.ts`, on + * `translatedBody`), so the body shape here is whatever the target provider + * format produced — see `extractPromptContent`/`extractSystemContent` for the + * full list of shapes this must cover (#10249, #10438). */ export function computeRequestHash(requestBody: unknown): string { const body = requestBody as Record; const canonical = { model: body.model ?? null, - messages: body.messages ?? body.contents ?? body.input ?? null, + messages: extractPromptContent(body), + system: extractSystemContent(body), temperature: typeof body.temperature === "number" ? body.temperature : 1.0, tools: body.tools ?? null, tool_choice: body.tool_choice ?? null, diff --git a/tests/unit/request-dedup-10249.test.ts b/tests/unit/request-dedup-10249.test.ts index 7926723b51..3ce236dbd3 100644 --- a/tests/unit/request-dedup-10249.test.ts +++ b/tests/unit/request-dedup-10249.test.ts @@ -63,6 +63,122 @@ test("Sanity: OpenAI-format bodies with different prompts DO get distinct hashes assert.notEqual(hashA, hashB); }); +// Regression tests for #10438: the flat `messages ?? contents ?? input` +// fallback chain from #10249 still missed the NESTED prompt shapes that +// `openai-to-gemini.ts::wrapInCloudCodeEnvelope` (Antigravity) and +// `openai-to-kiro.ts::buildKiroPayload` (Kiro) actually produce, and never +// looked at the system/instruction fields (`system` for Claude, `instructions` +// for the Responses API, `systemInstruction` for Gemini) at all — two +// requests with the same user message but a different system prompt hashed +// identically. + +test("Antigravity Cloud Code envelope bodies with different prompts must NOT collide on dedup hash", async () => { + clearInflight(); + const buildEnvelope = (text: string) => ({ + project: "proj-123", + requestId: "req-abc", + request: { + sessionId: "sess-1", + contents: [{ role: "user", parts: [{ text }] }], + systemInstruction: { role: "system", parts: [{ text: "You are Antigravity." }] }, + generationConfig: { maxOutputTokens: 8192 }, + }, + model: "gemini-3-pro", + userAgent: "antigravity/1.0", + requestType: "agent", + }); + const bodyA = buildEnvelope("Summarize the Q3 financial report attached."); + const bodyB = buildEnvelope("Extract every invoice number from the attached PDF."); + const hashA = computeRequestHash({ ...bodyA, model: "antigravity/gemini-3-pro", stream: false }); + const hashB = computeRequestHash({ ...bodyB, model: "antigravity/gemini-3-pro", stream: false }); + assert.notEqual(hashA, hashB, "Different prompts must have different dedup hashes"); + + const [resA, resB] = await Promise.all([ + deduplicate(hashA, async () => "RESPONSE_A"), + deduplicate(hashB, async () => "RESPONSE_B"), + ]); + assert.equal(resA.result, "RESPONSE_A"); + assert.equal(resB.result, "RESPONSE_B"); + assert.equal(resB.wasDeduplicated, false); +}); + +test("Kiro conversationState bodies with different prompts must NOT collide on dedup hash", async () => { + clearInflight(); + const buildPayload = (content: string) => ({ + conversationState: { + chatTriggerType: "MANUAL", + conversationId: "conv-1", + currentMessage: { + userInputMessage: { + content, + modelId: "kiro-claude-sonnet", + origin: "AI_EDITOR", + }, + }, + history: [], + }, + }); + const bodyA = buildPayload("[Context: Current time is 2026-08-17]\n\nWhat is the capital of France?"); + const bodyB = buildPayload("[Context: Current time is 2026-08-17]\n\nExplain quantum entanglement."); + const hashA = computeRequestHash({ ...bodyA, model: "kiro/claude-sonnet-4.5", stream: false }); + const hashB = computeRequestHash({ ...bodyB, model: "kiro/claude-sonnet-4.5", stream: false }); + assert.notEqual(hashA, hashB, "Different prompts must have different dedup hashes"); + + const [resA, resB] = await Promise.all([ + deduplicate(hashA, async () => "RESPONSE_A"), + deduplicate(hashB, async () => "RESPONSE_B"), + ]); + assert.equal(resA.result, "RESPONSE_A"); + assert.equal(resB.result, "RESPONSE_B"); + assert.equal(resB.wasDeduplicated, false); +}); + +test("Claude-translated bodies with the same messages but different `system` prompts must NOT collide", () => { + const bodyA = { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + system: [{ type: "text", text: "You are a pirate. Speak like one." }], + temperature: 0, + }; + const bodyB = { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + system: [{ type: "text", text: "You are a formal legal assistant." }], + temperature: 0, + }; + const hashA = computeRequestHash({ ...bodyA, model: "anthropic/claude-sonnet-4.5", stream: false }); + const hashB = computeRequestHash({ ...bodyB, model: "anthropic/claude-sonnet-4.5", stream: false }); + assert.notEqual(hashA, hashB, "Same messages with a different system prompt must hash differently"); +}); + +test("Responses-API-translated bodies with the same input but different `instructions` must NOT collide", () => { + const bodyA = { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] }], + instructions: "You are a pirate. Speak like one.", + }; + const bodyB = { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] }], + instructions: "You are a formal legal assistant.", + }; + const hashA = computeRequestHash({ ...bodyA, model: "openai/gpt-5", stream: false }); + const hashB = computeRequestHash({ ...bodyB, model: "openai/gpt-5", stream: false }); + assert.notEqual(hashA, hashB, "Same input with different instructions must hash differently"); +}); + +test("Gemini-translated bodies with the same contents but different `systemInstruction` must NOT collide", () => { + const bodyA = { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + systemInstruction: { role: "system", parts: [{ text: "You are a pirate. Speak like one." }] }, + temperature: 0, + }; + const bodyB = { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + systemInstruction: { role: "system", parts: [{ text: "You are a formal legal assistant." }] }, + temperature: 0, + }; + const hashA = computeRequestHash({ ...bodyA, model: "gemini/gemini-2.5-flash", stream: false }); + const hashB = computeRequestHash({ ...bodyB, model: "gemini/gemini-2.5-flash", stream: false }); + assert.notEqual(hashA, hashB, "Same contents with different systemInstruction must hash differently"); +}); + test("Genuinely identical requests still hash identically and get deduplicated (perf feature preserved)", async () => { clearInflight(); const body = {