Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/adapters/google-progress-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion structure/providers/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/adapters/google/gemini-progress-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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."],
Expand Down
Loading