diff --git a/changelog.d/fixes/7163-gemini-web-duplicated-text.md b/changelog.d/fixes/7163-gemini-web-duplicated-text.md new file mode 100644 index 0000000000..6bac17d7e8 --- /dev/null +++ b/changelog.d/fixes/7163-gemini-web-duplicated-text.md @@ -0,0 +1 @@ +- fix(sse): stop duplicating text in Gemini Web streamed responses (#7163) diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 48a4cbbfdb..0286cc8202 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -112,12 +112,15 @@ function parseCookies(raw: string): Array<{ name: string; value: string }> { * [["wrb.fr", null, ""]] * * The JSON string contains nested array: inner[4][0][1] = ["text chunks"]. - * We concatenate text from every wrb.fr line because Gemini can split one - * assistant answer across multiple StreamGenerate chunks. + * Each wrb.fr line is a CUMULATIVE snapshot of the whole answer generated so + * far (not an independent delta), so we keep only the text from the LAST + * frame that yields non-empty text instead of concatenating every frame — + * concatenating would reproduce the same growing text with each snapshot + * (see #7163). */ export function parseStreamResponse(raw: string): string { const lines = raw.split("\n"); - const textChunks: string[] = []; + let lastText = ""; for (const rawLine of lines) { const line = rawLine.trim(); @@ -133,12 +136,12 @@ export function parseStreamResponse(raw: string): string { const responseArray = inner?.[4]?.[0]?.[1]; if (!Array.isArray(responseArray)) continue; const text = responseArray.filter((c: unknown) => typeof c === "string").join(""); - if (text) textChunks.push(text); + if (text) lastText = text; } catch { // Skip unparseable lines } } - return textChunks.join(""); + return lastText; } function readCredentialString(value: unknown): string { diff --git a/tests/unit/gemini-web.test.ts b/tests/unit/gemini-web.test.ts index 5e4a25d0cf..8ae1fe9675 100644 --- a/tests/unit/gemini-web.test.ts +++ b/tests/unit/gemini-web.test.ts @@ -266,15 +266,20 @@ test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (i // ─── StreamGenerate parsing ───────────────────────────────────────────────── -test("parseStreamResponse concatenates Gemini Web text from multiple wrb.fr chunks", () => { +test("parseStreamResponse keeps only the final cumulative StreamGenerate snapshot (no duplication) — regression for #7163", () => { const makeChunk = (text: string) => { const inner = new Array(80).fill(null); inner[4] = [[null, [text]]]; return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`; }; - const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`; - assert.equal(parseStreamResponse(raw), "First chunk"); + // Gemini's StreamGenerate frames are CUMULATIVE snapshots: each later frame + // repeats the full answer generated so far, not just the new characters. + const frame1 = "Hello!"; + const frame2 = "Hello! How can I"; + const frame3 = "Hello! How can I help you out today?"; + const raw = `)]}'\n10\n${makeChunk(frame1)}\n5\n${makeChunk(frame2)}\n5\n${makeChunk(frame3)}`; + assert.equal(parseStreamResponse(raw), frame3); }); test("parseStreamResponse ignores wrb.fr lines whose first entry is not an array", () => {