diff --git a/src/adapters/google-progress-history.ts b/src/adapters/google-progress-history.ts index 68802f9145c..de95dabc12f 100644 --- a/src/adapters/google-progress-history.ts +++ b/src/adapters/google-progress-history.ts @@ -27,6 +27,7 @@ const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F\u /** A boundary that only closes a leading list marker ("1.", "a.") or a common abbreviation ("e.g."). */ const NOT_A_SENTENCE = /^(?:\d{1,3}|[a-z])\.$|(?:^|\s)(?:e\.g|i\.e|etc|vs)\.$/i; +const WHITESPACE = /\s/; /** Whether replayed progress text for this model is trimmed to its lead sentence. */ export function trimsGeminiProgressHistory(modelId: string): boolean { @@ -42,9 +43,26 @@ export function geminiProgressLead(text: string): string | undefined { if (trimmed.length === 0) return undefined; SENTENCE_END.lastIndex = 0; let end = trimmed.length; + let checked = 0; + let contentEnd = 0; for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) { const boundary = match.index + match[0].length; - if (!NOT_A_SENTENCE.test(trimmed.slice(0, boundary).trim())) { end = boundary; break; } + // Only a newline boundary can end in whitespace. Scan its new interval once; repeated + // blank lines retain the previous content end without rescanning the preceding text. + if (match[0] === "\n") { + for (let index = checked; index < boundary; index++) { + if (!WHITESPACE.test(trimmed[index])) contentEnd = index + 1; + } + } else { + contentEnd = boundary; + } + checked = boundary; + // The longest exception is four characters plus its preceding whitespace. The list + // marker alternative is anchored to the whole prefix, so allow it only at the start. + const start = Math.max(0, contentEnd - 5); + const suffix = trimmed.slice(start, contentEnd); + const exception = NOT_A_SENTENCE.exec(suffix); + if (!exception || (exception.index === 0 && start > 0 && !/^\s/.test(suffix))) { end = boundary; break; } } const lead = trimmed.slice(0, end).trim(); // A code-fence opener is not a sentence; replaying it alone would leave an unclosed fence. diff --git a/structure/providers/google.md b/structure/providers/google.md index 050021d8257..2ceb5a5a7b5 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -172,7 +172,8 @@ tool call and turns with Responses `phase: "final_answer"` are replayed whole. whitespace, a script's own sentence terminator (CJK, Devanagari, Arabic, Armenian, Ethiopic, Myanmar), or a line break, with trailing closing quotes, brackets and Markdown emphasis kept. Boundaries that only close a leading list marker or -`e.g.`/`i.e.`/`etc.`/`vs.` are skipped. It never matches wording. +`e.g.`/`i.e.`/`etc.`/`vs.` are skipped. Boundary checks use a fixed-size suffix and scan +intervening whitespace intervals once, keeping the split linear in text length. It never matches wording. Final answers (assistant turns without tool calls), non-Gemini models such as Claude on Antigravity, tool calls, their signatures and client-visible output are unchanged. A leak in the diff --git a/tests/adapters/google/gemini-progress-history.test.ts b/tests/adapters/google/gemini-progress-history.test.ts index afad0fd6614..617b8cb5b6e 100644 --- a/tests/adapters/google/gemini-progress-history.test.ts +++ b/tests/adapters/google/gemini-progress-history.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../../../src/adapters/google"; import { geminiProgressLead } from "../../../src/adapters/google-progress-history"; import { ANTIGRAVITY_MODEL_EFFORTS } from "../../../src/providers/antigravity-models"; +import { parseRequest } from "../../../src/responses/parser"; import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; @@ -45,6 +46,77 @@ async function modelTexts(provider: OcxProviderConfig, modelId: string, phase?: } describe("Gemini progress-text history", () => { + test("repeated abbreviation boundaries inspect only a linear amount of text", () => { + const text = "e.g. E.G. i.e. I.E. etc. ETC. vs. VS.\u2003\n \n".repeat(1024) + "Ready. Tail."; + const slice = String.prototype.slice; + const trim = String.prototype.trim; + const testRegex = RegExp.prototype.test; + let inspected = 0; + let lead: string | undefined; + try { + String.prototype.slice = function (start = 0, end = this.length) { + const result = slice.call(this, start, end); + inspected += result.length; + return result; + }; + String.prototype.trim = function () { + inspected += this.length; + return trim.call(this); + }; + RegExp.prototype.test = function (value) { + inspected += String(value).length; + return testRegex.call(this, value); + }; + lead = geminiProgressLead(text); + } finally { + String.prototype.slice = slice; + String.prototype.trim = trim; + RegExp.prototype.test = testRegex; + } + expect(lead).toBe(text.slice(0, -" Tail.".length)); + expect(inspected).toBeLessThan(text.length * 16); + }); + + test("keeps abbreviation, marker, whitespace and closing-character semantics", () => { + for (const marker of ["1.", "999.", "a.", "Z."]) { + expect(geminiProgressLead(`${marker}\u2003\n\tRead now. Tail.`)).toBe(`${marker}\u2003\n\tRead now.`); + } + for (const abbreviation of ["e.g.", "E.G.", "i.e.", "I.E.", "etc.", "ETC.", "vs.", "VS."]) { + expect(geminiProgressLead(`\uFEFF ${abbreviation}\u00A0\n \nRead now. Tail. \u3000`)) + .toBe(`${abbreviation}\u00A0\n \nRead now.`); + expect(geminiProgressLead(`See ${abbreviation}\" Tail.`)).toBe(`See ${abbreviation}\"`); + } + for (const lead of ["1000.", "worda.", "Xe.g.", "Ready!\"", "Ready?)**", "確認。\u300D", "完了!\u300F"]) { + expect(geminiProgressLead(`${lead} Tail.`)).toBe(lead); + } + expect(geminiProgressLead("e.g.\n\n")).toBe("e.g."); + expect(geminiProgressLead("\u200B e.g.\nRead now. Tail.")).toBe("\u200B e.g.\nRead now."); + expect(geminiProgressLead("```ts\nReady. Tail.\n```")).toBeUndefined(); + expect(geminiProgressLead("e.g.\n" + " \n".repeat(2048) + " ".repeat(8192) + "Ready. Tail.")) + .toBe("e.g.\n" + " \n".repeat(2048) + " ".repeat(8192) + "Ready."); + }); + + test("Responses parser keeps history intact until the Gemini adapter projects progress", async () => { + const progress = "e.g. E.G.\u2003\n \n".repeat(256) + "Ready. Tail."; + for (const phase of ["commentary", "final_answer"] as const) { + const parsed = parseRequest({ model: "gemini-4-argon", input: [ + { role: "user", content: "Continue" }, + { role: "assistant", phase, content: [{ type: "output_text", text: progress }] }, + { type: "function_call", call_id: "call_1", name: "exec", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "ok" }, + { role: "assistant", phase: "final_answer", content: "Final answer. Kept whole." }, + ] }); + const original = JSON.stringify(parsed.context.messages); + expect(original).toContain(JSON.stringify(progress).slice(1, -1)); + const request = await withTestTranslatorBudget(createGoogleAdapterProduction(aiStudio)).buildRequest(parsed); + const contents = JSON.parse(request.body).contents; + expect(contents.filter((turn: { role: string }) => turn.role === "model") + .map((turn: { parts: Array<{ text?: string }> }) => turn.parts.flatMap(part => part.text ?? []))) + .toEqual([[phase === "commentary" ? progress.slice(0, -" Tail.".length) : progress], ["Final answer. Kept whole."]]); + expect(JSON.stringify(parsed.context.messages)).toBe(original); + } + }); + test("Argon tool turns replay only the lead sentence before the call; final answers stay whole", async () => { expect(await modelTexts(antigravity, "gemini-4-argon")).toEqual([ [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."],