mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
@@ -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 {
|
||||
|
||||
75
tests/unit/gemini-web-multiturn-context-8371.test.ts
Normal file
75
tests/unit/gemini-web-multiturn-context-8371.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user