feat(providers): flatten multi-turn history for gemini-web (#8371) (#8607)

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
This commit is contained in:
Prudhvi Vuda
2026-07-27 18:07:37 -04:00
committed by GitHub
parent ea678daad6
commit bca309457b
2 changed files with 145 additions and 3 deletions

View File

@@ -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:
* <system text>
*
* Previous conversation:
* User: ...
* Assistant: ...
*
* Current user message:
* <last 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 {

View File

@@ -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/);
});