From bca309457b875288a607a32fd01ce88e5b61ada4 Mon Sep 17 00:00:00 2001 From: Prudhvi Vuda <53619858+Prudhvivuda@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:07:37 -0400 Subject: [PATCH] feat(providers): flatten multi-turn history for gemini-web (#8371) (#8607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini-web is a stateless Web Cookie provider: it drives a real browser page and captures only the first StreamGenerate response, so it has no upstream conversation id to thread across turns. The no-tools path forwarded only the last user message (`messages.filter(m => m.role === "user").pop()`), so follow-up questions lost all prior context — e.g. "I am in Berlin" then "What should I wear today?" was answered without Berlin. Implement the issue's accepted fallback (b): flatten the full messages history into the single prompt typed into the web UI, emitting a labeled System / Previous conversation / Current user message transcript. Single-turn requests are preserved byte-for-byte (only the final user message is returned), keeping the #7286 no-tools regression guard intact. Applies uniformly to streaming and non-streaming since both derive from the same `prompt`. claude-web already threads context via its conversation cache (session.ts, #8230) and needs no change. Closes #8371 --- open-sse/executors/gemini-web.ts | 73 +++++++++++++++++- .../gemini-web-multiturn-context-8371.test.ts | 75 +++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/unit/gemini-web-multiturn-context-8371.test.ts diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index de018e77b0..4befb78c6e 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -73,6 +73,70 @@ function formatStreamChunk(content: string, model: string, finishReason: string }; } +/** + * Flatten the OpenAI-style multi-turn `messages[]` into the single plain-text + * prompt typed into the Gemini web UI (#8371). + * + * gemini-web drives a real browser page and captures only the FIRST + * `StreamGenerate` response, so — unlike claude-web — it has no upstream + * conversation id to thread across turns. It is therefore a stateless, + * single-turn provider: the previous code forwarded only the last user message + * (`messages.filter(m => m.role === "user").pop()`), so follow-up questions + * lost all prior context ("I am in Berlin" → "What should I wear today?" was + * answered without Berlin). This implements the issue's accepted fallback (b): + * flatten the full history into one prompt so the web UI still sees the + * conversation. + * + * Single-turn requests are preserved byte-for-byte (only the final user message + * is returned) — the regression guard for the pre-existing no-tools path. + * Multi-turn requests emit a labeled transcript: + * + * System: + * + * + * Previous conversation: + * User: ... + * Assistant: ... + * + * Current user message: + * + */ +export function buildGeminiPrompt(messages: Array<{ role: string; content: unknown }>): string { + const textMessages = messages.filter( + (m) => typeof m.content === "string" && (m.content as string).trim().length > 0 + ) as Array<{ role: string; content: string }>; + + const userMessages = textMessages.filter((m) => m.role === "user"); + const lastUser = userMessages[userMessages.length - 1]; + const lastUserContent = lastUser?.content ?? ""; + const lastUserIdx = lastUser ? textMessages.lastIndexOf(lastUser) : -1; + + // Prior conversation = every user/assistant turn before the final user turn. + const priorTurns = textMessages.filter( + (m, i) => i < lastUserIdx && (m.role === "user" || m.role === "assistant") + ); + + // Single-turn (no earlier user/assistant turns): byte-for-byte the original + // single-message derivation. Do NOT prepend system text here — the old + // no-tools path ignored a system-only prefix on the first turn. + if (priorTurns.length === 0) return lastUserContent; + + const systemText = textMessages + .filter((m) => m.role === "system") + .map((m) => m.content) + .join("\n\n"); + + const historyLines = priorTurns.map( + (m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}` + ); + + const parts: string[] = []; + if (systemText) parts.push(`System:\n${systemText}`); + parts.push(`Previous conversation:\n${historyLines.join("\n\n")}`); + parts.push(`Current user message:\n${lastUserContent}`); + return parts.join("\n\n"); +} + /** * Build the plain-text prompt typed into the Gemini web UI when a tool * contract is active — the synthetic system message injected by @@ -337,11 +401,14 @@ export class GeminiWebExecutor extends BaseExecutor { messages ); - // hasTools === false: byte-for-byte the original prompt derivation - // (regression guard — #7286 must not touch the no-tools path). + // hasTools === false: flatten the full multi-turn history into the single + // prompt so gemini-web (a stateless web-cookie provider that captures only + // the first StreamGenerate response) preserves prior context across turns + // (#8371). Single-turn requests stay byte-for-byte identical to the original + // derivation, keeping the #7286 no-tools regression guard intact. const prompt = hasTools ? buildGeminiToolPrompt(effectiveMessages) - : messages.filter((m) => m.role === "user").pop()?.content || ""; + : buildGeminiPrompt(messages); if (!prompt) { return { diff --git a/tests/unit/gemini-web-multiturn-context-8371.test.ts b/tests/unit/gemini-web-multiturn-context-8371.test.ts new file mode 100644 index 0000000000..fca4965b9b --- /dev/null +++ b/tests/unit/gemini-web-multiturn-context-8371.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #8371 — gemini-web is a stateless, single-turn Web Cookie provider: the +// no-tools path historically forwarded only the LAST user message +// (`messages.filter(m => m.role === "user").pop()`), dropping all prior turns +// so follow-up questions lost context ("I am in Berlin" → "What should I wear?" +// answered without Berlin). The issue's accepted fallback (b) is to flatten the +// full messages history into the single prompt sent to the web UI. These tests +// pin that flatten behavior while guaranteeing single-turn requests stay +// byte-for-byte identical (regression guard for the existing no-tools path). + +const { buildGeminiPrompt } = await import("../../open-sse/executors/gemini-web.ts"); + +// ─── Single-turn: byte-for-byte identical to the original derivation ───────── + +test("#8371: single user message returns that message verbatim (single-turn unchanged)", () => { + const prompt = buildGeminiPrompt([{ role: "user", content: "What about Paris?" }]); + assert.equal(prompt, "What about Paris?"); +}); + +test("#8371: single user turn with a system message still returns only the user text", () => { + // Preserves the pre-existing no-tools derivation, which ignored system-only + // context on the first turn. + const prompt = buildGeminiPrompt([ + { role: "system", content: "You are helpful" }, + { role: "user", content: "Hello" }, + ]); + assert.equal(prompt, "Hello"); +}); + +// ─── Multi-turn: full history is flattened into the prompt ─────────────────── + +test("#8371: multi-turn history is flattened so prior turns are preserved as context", () => { + const prompt = buildGeminiPrompt([ + { role: "user", content: "I am in Berlin." }, + { role: "assistant", content: "Nice, Berlin is great." }, + { role: "user", content: "What should I wear today?" }, + ]); + + // The prior turns must survive into the prompt (this is what fails today — + // the old code only forwarded the final user message). + assert.match(prompt, /Berlin/); + assert.match(prompt, /I am in Berlin\./); + assert.match(prompt, /Nice, Berlin is great\./); + // The current question must still be present and clearly marked. + assert.match(prompt, /What should I wear today\?/); + // Prior turns are labeled by role. + assert.match(prompt, /User: I am in Berlin\./); + assert.match(prompt, /Assistant: Nice, Berlin is great\./); +}); + +test("#8371: system instructions are carried into the flattened multi-turn prompt", () => { + const prompt = buildGeminiPrompt([ + { role: "system", content: "Always answer in French." }, + { role: "user", content: "What about Paris?" }, + { role: "assistant", content: "Paris is the capital of France." }, + { role: "user", content: "How is the weather?" }, + ]); + assert.match(prompt, /Always answer in French\./); + assert.match(prompt, /What about Paris\?/); + assert.match(prompt, /Paris is the capital of France\./); + assert.match(prompt, /How is the weather\?/); +}); + +test("#8371: non-string / empty message contents are ignored without throwing", () => { + const prompt = buildGeminiPrompt([ + { role: "user", content: "First question" }, + { role: "assistant", content: "" }, + { role: "user", content: "Second question" }, + ] as Array<{ role: string; content: string }>); + // Empty assistant turn drops out; the earlier user turn is still preserved. + assert.match(prompt, /First question/); + assert.match(prompt, /Second question/); +});