From 13cd9b5c606ec61998c5c433cade967ffa0f9367 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 00:14:57 +0900 Subject: [PATCH 1/3] fix(google): stop replayed Gemini progress text from spreading leaked planning --- .../src/content/docs/guides/providers.md | 7 ++ scripts/test-layout/layout.json | 1 + src/adapters/google-progress-history.ts | 50 ++++++++++++++ src/adapters/google.ts | 15 +++- structure/providers/google.md | 17 +++++ .../google/gemini-progress-history.test.ts | 69 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/adapters/google-progress-history.ts create mode 100644 tests/adapters/google/gemini-progress-history.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 45a3340944a..eaebffef159 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -178,6 +178,13 @@ this does not turn off the model's internal reasoning. A thought signature is co metadata and can accompany ordinary text, so its presence alone does not hide that text. OpenCodex preserves unmarked answer text even when its wording sounds like planning or analysis. +Gemini models, Argon in particular, sometimes skip thinking on a tool-calling turn and write their +planning into the visible progress text, for example a progress line followed by "Let's read +google.ts next." Once that text is sent back as history, the model tends to repeat the pattern +on every later turn. To stop it from spreading, OpenCodex sends Gemini only the first sentence of +the text from earlier turns that ended in a tool call. Final answers are sent back in full, and +what your client displays is not changed. + The built-in `google-antigravity` model list is Gemini 3.8 Flash (`gemini-3.8-flash`), Gemini 4 Argon (`gemini-4-argon`), Claude Opus 5.5 (`claude-opus-5-5`), and Claude Sonnet 5.5 (`claude-sonnet-5-5`), each with low, medium, and high efforts. What you actually see is your diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 679a0bb95c7..ef6d6d5c638 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -954,6 +954,7 @@ "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", + "gemini-progress-history.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", diff --git a/src/adapters/google-progress-history.ts b/src/adapters/google-progress-history.ts new file mode 100644 index 00000000000..027daa90fb7 --- /dev/null +++ b/src/adapters/google-progress-history.ts @@ -0,0 +1,50 @@ +/** + * Gemini progress-text history trimming. + * + * Gemini models, observed on Gemini 4 Argon over Cloud Code Assist, sometimes skip their thinking + * channel on a tool-calling turn and plan in the visible text instead. The provider sends that + * planning as an ordinary text part without a thought marker, for example a progress line in the + * user's language followed by "Let's read google.ts 580-1700 first." Once one turn like that is + * replayed as history, the model copies the pattern on the turns that follow, so the leak grows + * through the rest of the session. Replaying the same 300K-token request with the leaked tails + * removed from the history stopped the leak, while stronger system-prompt wording did not. + * + * The fix is on the history side only. A replayed model turn that ends in a tool call is + * intermediate progress, and the Google brevity instruction already asks for a single short line + * there, so its text is replayed as its lead sentence. Visible output is never rewritten, final + * answers (turns without tool calls) are replayed in full, and the split is punctuation-based so + * it works the same in every language without matching any wording. + */ + +/** + * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" is + * not a boundary), full-width CJK terminal punctuation anywhere, or a line break. + */ +const SENTENCE_END = /[.!?](?=\s|$)|[\u3002\uFF01\uFF1F]|\n/g; + +/** Ignore boundaries this early so list markers and short labels ("1.", "Done.") do not become the whole lead. */ +const MIN_LEAD_LENGTH = 8; + +/** Whether replayed progress text for this model is trimmed to its lead sentence. */ +export function trimsGeminiProgressHistory(modelId: string): boolean { + return /gemini/i.test(modelId); +} + +/** + * The lead sentence of a replayed progress text, or undefined when nothing usable remains. + * Text without a sentence boundary is already a single sentence and is returned whole. + */ +export function geminiProgressLead(text: string): string | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) return undefined; + SENTENCE_END.lastIndex = 0; + let end = trimmed.length; + for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) { + const boundary = match.index + match[0].length; + if (boundary >= MIN_LEAD_LENGTH) { 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. + if (lead.length === 0 || lead.startsWith("```")) return undefined; + return lead; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 0bed1927387..6091283cf4c 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -44,6 +44,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { geminiProgressLead, trimsGeminiProgressHistory } from "./google-progress-history"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between @@ -466,9 +467,21 @@ function messagesToGeminiFormat( const aMsg = msg as OcxAssistantMessage; const parts: unknown[] = []; const toolCalls: Array<{ wireCallId: string; wireName: string }> = []; + // A Gemini tool-call turn replays only the lead sentence of its progress text, so planning + // the model leaked into visible text is not copied forward (google-progress-history.ts). + const trimProgress = trimsGeminiProgressHistory(identityModelId) + && aMsg.content.some(part => part.type === "toolCall"); + let progressLeadKept = false; for (const p of aMsg.content) { if (p.type === "text") { - const textPart = geminiTextPart((p as OcxTextContent).text); + let text: unknown = (p as OcxTextContent).text; + if (trimProgress) { + if (progressLeadKept || typeof text !== "string") continue; + text = geminiProgressLead(text); + if (text === undefined) continue; + progressLeadKept = true; + } + const textPart = geminiTextPart(text); if (textPart) parts.push(textPart); } else if (p.type === "toolCall") { const tc = p as OcxToolCall; diff --git a/structure/providers/google.md b/structure/providers/google.md index 074909d87dd..62ac8b7992e 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -131,6 +131,23 @@ valid requests. > Decision record: [ADR-5008](../decisions/ADR-5008-google-opening-functioncall-repair.md) +## Gemini progress-text history + +Gemini sometimes skips its thinking channel on a tool-calling turn and plans in visible text +instead: a progress line followed by unmarked planning such as "Let's read google.ts next." +Replayed whole, that tail is copied by every later turn of the session. When +`messagesToGeminiFormat` serializes an assistant turn that contains a tool call for a model id +containing `gemini`, it keeps only the lead sentence of the turn's first non-empty text part and +drops the turn's other text parts. `src/adapters/google-progress-history.ts` owns the split: ASCII +`.`, `!` or `?` followed by whitespace, full-width CJK terminal punctuation, or a line break, +ignoring boundaries in the first eight characters. 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 +current response is still delivered as ordinary text; the trim keeps it from propagating. +`tests/adapters/google/gemini-progress-history.test.ts` covers CCA and AI Studio Gemini, the +Claude exemption and the split. + ## Structured output on generateContent A caller's Responses `text.format` reaches the Gemini wire as diff --git a/tests/adapters/google/gemini-progress-history.test.ts b/tests/adapters/google/gemini-progress-history.test.ts new file mode 100644 index 00000000000..05389290a0b --- /dev/null +++ b/tests/adapters/google/gemini-progress-history.test.ts @@ -0,0 +1,69 @@ +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 type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +// Shape of a live Gemini 4 Argon tool turn (2026-10-17): a progress line in the user's language +// with the model's planning appended as ordinary, unmarked text. Replayed whole, later turns +// copied the tail; replayed as the lead sentence, they did not. +const LEAKED = "앞선 배치에서 잘린 google.ts를 마저 읽겠습니다. Let's read google.ts 580-1700 first."; +const LEAD = "앞선 배치에서 잘린 google.ts를 마저 읽겠습니다."; + +const antigravity = { + adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", googleMode: "cloud-code-assist", + project: "proj-123", apiKey: "ya29.token", modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, +} as OcxProviderConfig; +const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: "k" } as OcxProviderConfig; + +function history(): OcxParsedRequest["context"]["messages"] { + return [ + { role: "user", content: "google.ts를 읽어줘" }, + { role: "assistant", content: [ + { type: "text", text: LEAKED }, + { type: "text", text: "Maybe the classifier is wrong." }, + { type: "toolCall", id: "call_1", name: "exec", arguments: { cmd: "cat google.ts" } }, + ] }, + { role: "toolResult", toolCallId: "call_1", toolName: "exec", content: [{ type: "text", text: "ok" }] }, + { role: "assistant", content: [{ type: "text", text: "Final answer. It keeps every sentence." }] }, + { role: "user", content: "계속" }, + ] as unknown as OcxParsedRequest["context"]["messages"]; +} + +async function modelTexts(provider: OcxProviderConfig, modelId: string): Promise { + const parsed = { + modelId, stream: false, options: {}, + context: { messages: history(), systemPrompt: [], tools: [] }, + } as unknown as OcxParsedRequest; + const req = await withTestTranslatorBudget(createGoogleAdapterProduction(provider)).buildRequest(parsed); + const body = JSON.parse(req.body); + const contents = (body.request ?? body).contents as Array<{ role: string; parts: Array<{ text?: string }> }>; + return contents.filter(c => c.role === "model") + .map(c => c.parts.filter(p => typeof p.text === "string").map(p => p.text!)); +} + +describe("Gemini progress-text history", () => { + test("Argon tool turns replay only the lead sentence; final answers stay whole", async () => { + expect(await modelTexts(antigravity, "gemini-4-argon")).toEqual([[LEAD], ["Final answer. It keeps every sentence."]]); + }); + + test("AI Studio Gemini gets the same history", async () => { + expect(await modelTexts(aiStudio, "gemini-3.5-flash")).toEqual([[LEAD], ["Final answer. It keeps every sentence."]]); + }); + + test("Claude on Antigravity keeps its full progress text", async () => { + expect(await modelTexts(antigravity, "claude-sonnet-5-5")).toEqual([ + [LEAKED, "Maybe the classifier is wrong."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("the lead split is punctuation-based and language-neutral", () => { + expect(geminiProgressLead("Reading the adapter now! Wait, maybe the encoder too.")).toBe("Reading the adapter now!"); + expect(geminiProgressLead("1. Read google.ts next.\n2. Then the encoder.")).toBe("1. Read google.ts next."); + expect(geminiProgressLead("google.ts와 chat.ts를 읽겠습니다")).toBe("google.ts와 chat.ts를 읽겠습니다"); + expect(geminiProgressLead("次に google.ts を読みます。Let's go.")).toBe("次に google.ts を読みます。"); + expect(geminiProgressLead(" \n ")).toBeUndefined(); + expect(geminiProgressLead("```js\nconst x = 1;\n```")).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e8e81b968a1..9ae6ad80eb6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -686,7 +686,7 @@ "flash-route-image-modalities.test.ts": "providers", "format-result.test.ts": "web-search", "forward-admission-separation.test.ts": "providers", "fresh-connection-optout.test.ts": "responses", "fulfill-video.test.ts": "videos", "gather-routed-models-single-flight.test.ts": "codex-integration", - "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", + "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", "gemini-progress-history.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", "generic-session-affinity.test.ts": "oauth", "server-generic-oauth-affinity.test.ts": "server", From 14adb2751dbb17817b52b98fcedd7510cf979d89 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 00:36:01 +0900 Subject: [PATCH 2/3] fix(google): scope progress trimming to pre-call text and real sentence ends --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/google-progress-history.ts | 18 +++++----- src/adapters/google.ts | 9 +++-- structure/providers/google.md | 15 +++++--- .../google/gemini-progress-history.test.ts | 34 ++++++++++++++----- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index eaebffef159..6345506e0b7 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -182,7 +182,7 @@ Gemini models, Argon in particular, sometimes skip thinking on a tool-calling tu planning into the visible progress text, for example a progress line followed by "Let's read google.ts next." Once that text is sent back as history, the model tends to repeat the pattern on every later turn. To stop it from spreading, OpenCodex sends Gemini only the first sentence of -the text from earlier turns that ended in a tool call. Final answers are sent back in full, and +the text that came before a tool call in earlier turns. Final answers are sent back in full, and what your client displays is not changed. The built-in `google-antigravity` model list is Gemini 3.8 Flash (`gemini-3.8-flash`), Gemini 4 Argon diff --git a/src/adapters/google-progress-history.ts b/src/adapters/google-progress-history.ts index 027daa90fb7..e956a19edb6 100644 --- a/src/adapters/google-progress-history.ts +++ b/src/adapters/google-progress-history.ts @@ -9,21 +9,23 @@ * through the rest of the session. Replaying the same 300K-token request with the leaked tails * removed from the history stopped the leak, while stronger system-prompt wording did not. * - * The fix is on the history side only. A replayed model turn that ends in a tool call is + * The fix is on the history side only. Text that precedes the tool call of a replayed model turn is * intermediate progress, and the Google brevity instruction already asks for a single short line - * there, so its text is replayed as its lead sentence. Visible output is never rewritten, final + * there, so it is replayed as its lead sentence. A turn Codex marks `final_answer` and text after a + * tool call are replayed whole. Visible output is never rewritten, final * answers (turns without tool calls) are replayed in full, and the split is punctuation-based so * it works the same in every language without matching any wording. */ /** - * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" is - * not a boundary), full-width CJK terminal punctuation anywhere, or a line break. + * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" and + * "3.5" are not boundaries), full-width CJK terminal punctuation, or a line break. Closing quotes, + * brackets and Markdown emphasis right after the punctuation belong to the sentence. */ -const SENTENCE_END = /[.!?](?=\s|$)|[\u3002\uFF01\uFF1F]|\n/g; +const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g; -/** Ignore boundaries this early so list markers and short labels ("1.", "Done.") do not become the whole lead. */ -const MIN_LEAD_LENGTH = 8; +/** 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; /** Whether replayed progress text for this model is trimmed to its lead sentence. */ export function trimsGeminiProgressHistory(modelId: string): boolean { @@ -41,7 +43,7 @@ export function geminiProgressLead(text: string): string | undefined { let end = trimmed.length; for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) { const boundary = match.index + match[0].length; - if (boundary >= MIN_LEAD_LENGTH) { end = boundary; break; } + if (!NOT_A_SENTENCE.test(trimmed.slice(0, boundary).trim())) { 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/src/adapters/google.ts b/src/adapters/google.ts index 6091283cf4c..5093fe09179 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -467,15 +467,17 @@ function messagesToGeminiFormat( const aMsg = msg as OcxAssistantMessage; const parts: unknown[] = []; const toolCalls: Array<{ wireCallId: string; wireName: string }> = []; - // A Gemini tool-call turn replays only the lead sentence of its progress text, so planning - // the model leaked into visible text is not copied forward (google-progress-history.ts). + // Progress text before a Gemini tool call replays as its lead sentence, so planning the + // model leaked into visible text is not copied forward (google-progress-history.ts). const trimProgress = trimsGeminiProgressHistory(identityModelId) + && aMsg.phase !== "final_answer" && aMsg.content.some(part => part.type === "toolCall"); let progressLeadKept = false; + let afterToolCall = false; for (const p of aMsg.content) { if (p.type === "text") { let text: unknown = (p as OcxTextContent).text; - if (trimProgress) { + if (trimProgress && !afterToolCall) { if (progressLeadKept || typeof text !== "string") continue; text = geminiProgressLead(text); if (text === undefined) continue; @@ -484,6 +486,7 @@ function messagesToGeminiFormat( const textPart = geminiTextPart(text); if (textPart) parts.push(textPart); } else if (p.type === "toolCall") { + afterToolCall = true; const tc = p as OcxToolCall; // Preserve the thought signature on the function-call part so Antigravity/Gemini-3 // reasoning continuity survives history-driven (stateless) turns, not just same-process diff --git a/structure/providers/google.md b/structure/providers/google.md index 62ac8b7992e..5d8982dae14 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -137,16 +137,21 @@ Gemini sometimes skips its thinking channel on a tool-calling turn and plans in instead: a progress line followed by unmarked planning such as "Let's read google.ts next." Replayed whole, that tail is copied by every later turn of the session. When `messagesToGeminiFormat` serializes an assistant turn that contains a tool call for a model id -containing `gemini`, it keeps only the lead sentence of the turn's first non-empty text part and -drops the turn's other text parts. `src/adapters/google-progress-history.ts` owns the split: ASCII -`.`, `!` or `?` followed by whitespace, full-width CJK terminal punctuation, or a line break, -ignoring boundaries in the first eight characters. It never matches wording. +containing `gemini`, the text before the turn's first tool call is replayed as the lead sentence +of its first non-empty text part; the other text parts before the call are dropped. Text after a +tool call and turns with Responses `phase: "final_answer"` are replayed whole. +`src/adapters/google-progress-history.ts` owns the split: ASCII `.`, `!` or `?` followed by +whitespace, full-width CJK terminal punctuation, 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. 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 current response is still delivered as ordinary text; the trim keeps it from propagating. +The trade-off is that facts a model states only in pre-call text, beyond its first sentence, are +not replayed; the call, its result and any final answer still are. `tests/adapters/google/gemini-progress-history.test.ts` covers CCA and AI Studio Gemini, the -Claude exemption and the split. +`final_answer` and Claude exemptions, text after a call and the split. ## Structured output on generateContent diff --git a/tests/adapters/google/gemini-progress-history.test.ts b/tests/adapters/google/gemini-progress-history.test.ts index 05389290a0b..6a8b75e5669 100644 --- a/tests/adapters/google/gemini-progress-history.test.ts +++ b/tests/adapters/google/gemini-progress-history.test.ts @@ -17,13 +17,14 @@ const antigravity = { } as OcxProviderConfig; const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: "k" } as OcxProviderConfig; -function history(): OcxParsedRequest["context"]["messages"] { +function history(phase?: "commentary" | "final_answer"): OcxParsedRequest["context"]["messages"] { return [ { role: "user", content: "google.ts를 읽어줘" }, - { role: "assistant", content: [ + { role: "assistant", ...(phase ? { phase } : {}), content: [ { type: "text", text: LEAKED }, { type: "text", text: "Maybe the classifier is wrong." }, { type: "toolCall", id: "call_1", name: "exec", arguments: { cmd: "cat google.ts" } }, + { type: "text", text: "After the call. Kept whole." }, ] }, { role: "toolResult", toolCallId: "call_1", toolName: "exec", content: [{ type: "text", text: "ok" }] }, { role: "assistant", content: [{ type: "text", text: "Final answer. It keeps every sentence." }] }, @@ -31,10 +32,10 @@ function history(): OcxParsedRequest["context"]["messages"] { ] as unknown as OcxParsedRequest["context"]["messages"]; } -async function modelTexts(provider: OcxProviderConfig, modelId: string): Promise { +async function modelTexts(provider: OcxProviderConfig, modelId: string, phase?: "commentary" | "final_answer"): Promise { const parsed = { modelId, stream: false, options: {}, - context: { messages: history(), systemPrompt: [], tools: [] }, + context: { messages: history(phase), systemPrompt: [], tools: [] }, } as unknown as OcxParsedRequest; const req = await withTestTranslatorBudget(createGoogleAdapterProduction(provider)).buildRequest(parsed); const body = JSON.parse(req.body); @@ -44,17 +45,30 @@ async function modelTexts(provider: OcxProviderConfig, modelId: string): Promise } describe("Gemini progress-text history", () => { - test("Argon tool turns replay only the lead sentence; final answers stay whole", async () => { - expect(await modelTexts(antigravity, "gemini-4-argon")).toEqual([[LEAD], ["Final answer. It keeps every sentence."]]); + 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."], + ]); + expect(await modelTexts(antigravity, "gemini-4-argon", "commentary")).toEqual([ + [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("a turn Codex marks final_answer is replayed whole", async () => { + expect(await modelTexts(antigravity, "gemini-4-argon", "final_answer")).toEqual([ + [LEAKED, "Maybe the classifier is wrong.", "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); }); test("AI Studio Gemini gets the same history", async () => { - expect(await modelTexts(aiStudio, "gemini-3.5-flash")).toEqual([[LEAD], ["Final answer. It keeps every sentence."]]); + expect(await modelTexts(aiStudio, "gemini-3.5-flash")).toEqual([ + [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); }); test("Claude on Antigravity keeps its full progress text", async () => { expect(await modelTexts(antigravity, "claude-sonnet-5-5")).toEqual([ - [LEAKED, "Maybe the classifier is wrong."], ["Final answer. It keeps every sentence."], + [LEAKED, "Maybe the classifier is wrong.", "After the call. Kept whole."], ["Final answer. It keeps every sentence."], ]); }); @@ -63,6 +77,10 @@ describe("Gemini progress-text history", () => { expect(geminiProgressLead("1. Read google.ts next.\n2. Then the encoder.")).toBe("1. Read google.ts next."); expect(geminiProgressLead("google.ts와 chat.ts를 읽겠습니다")).toBe("google.ts와 chat.ts를 읽겠습니다"); expect(geminiProgressLead("次に google.ts を読みます。Let's go.")).toBe("次に google.ts を読みます。"); + expect(geminiProgressLead("Done. Let's read google.ts next.")).toBe("Done."); + expect(geminiProgressLead("確認します。Let's inspect the encoder next.")).toBe("確認します。"); + expect(geminiProgressLead("Reading **the docs.** Let's inspect the encoder next.")).toBe("Reading **the docs.**"); + expect(geminiProgressLead("Check e.g. the 3.5 encoder first. Then more.")).toBe("Check e.g. the 3.5 encoder first."); expect(geminiProgressLead(" \n ")).toBeUndefined(); expect(geminiProgressLead("```js\nconst x = 1;\n```")).toBeUndefined(); }); From 276c67af851256122b590b6bfceeb6752ff058b7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 00:49:04 +0900 Subject: [PATCH 3/3] fix(google): end the progress lead at other scripts sentence terminators --- src/adapters/google-progress-history.ts | 7 ++++--- structure/providers/google.md | 3 ++- tests/adapters/google/gemini-progress-history.test.ts | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/adapters/google-progress-history.ts b/src/adapters/google-progress-history.ts index e956a19edb6..68802f9145c 100644 --- a/src/adapters/google-progress-history.ts +++ b/src/adapters/google-progress-history.ts @@ -19,10 +19,11 @@ /** * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" and - * "3.5" are not boundaries), full-width CJK terminal punctuation, or a line break. Closing quotes, - * brackets and Markdown emphasis right after the punctuation belong to the sentence. + * "3.5" are not boundaries), a script's own sentence terminator (CJK 。!?, Devanagari + * ।॥, Arabic ۔؟, Armenian ։, Ethiopic ።, Myanmar ။), or a line break. Closing quotes, brackets and + * Markdown emphasis right after the punctuation belong to the sentence. */ -const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g; +const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F\u0964\u0965\u06D4\u061F\u0589\u1362\u104B]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g; /** 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; diff --git a/structure/providers/google.md b/structure/providers/google.md index 5d8982dae14..ca74f843867 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -141,7 +141,8 @@ containing `gemini`, the text before the turn's first tool call is replayed as t of its first non-empty text part; the other text parts before the call are dropped. Text after a tool call and turns with Responses `phase: "final_answer"` are replayed whole. `src/adapters/google-progress-history.ts` owns the split: ASCII `.`, `!` or `?` followed by -whitespace, full-width CJK terminal punctuation, or a line break, with trailing closing quotes, +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. diff --git a/tests/adapters/google/gemini-progress-history.test.ts b/tests/adapters/google/gemini-progress-history.test.ts index 6a8b75e5669..afad0fd6614 100644 --- a/tests/adapters/google/gemini-progress-history.test.ts +++ b/tests/adapters/google/gemini-progress-history.test.ts @@ -79,6 +79,8 @@ describe("Gemini progress-text history", () => { expect(geminiProgressLead("次に google.ts を読みます。Let's go.")).toBe("次に google.ts を読みます。"); expect(geminiProgressLead("Done. Let's read google.ts next.")).toBe("Done."); expect(geminiProgressLead("確認します。Let's inspect the encoder next.")).toBe("確認します。"); + expect(geminiProgressLead("मैं फ़ाइल पढ़ूँगा। Let's read google.ts next.")).toBe("मैं फ़ाइल पढ़ूँगा।"); + expect(geminiProgressLead("سأقرأ الملف۔ Let's read google.ts next.")).toBe("سأقرأ الملف۔"); expect(geminiProgressLead("Reading **the docs.** Let's inspect the encoder next.")).toBe("Reading **the docs.**"); expect(geminiProgressLead("Check e.g. the 3.5 encoder first. Then more.")).toBe("Check e.g. the 3.5 encoder first."); expect(geminiProgressLead(" \n ")).toBeUndefined();