Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
(`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
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions src/adapters/google-progress-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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. 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 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" and
* "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\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;

/** 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 (!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.
if (lead.length === 0 || lead.startsWith("```")) return undefined;
return lead;
}
18 changes: 17 additions & 1 deletion src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -466,11 +467,26 @@ function messagesToGeminiFormat(
const aMsg = msg as OcxAssistantMessage;
const parts: unknown[] = [];
const toolCalls: Array<{ wireCallId: string; wireName: string }> = [];
// 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") {
const textPart = geminiTextPart((p as OcxTextContent).text);
let text: unknown = (p as OcxTextContent).text;
if (trimProgress && !afterToolCall) {
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") {
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
Expand Down
23 changes: 23 additions & 0 deletions structure/providers/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ 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`, 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, 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.

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
`final_answer` and Claude exemptions, text after a call and the split.

## Structured output on generateContent

A caller's Responses `text.format` reaches the Gemini wire as
Expand Down
89 changes: 89 additions & 0 deletions tests/adapters/google/gemini-progress-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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(phase?: "commentary" | "final_answer"): OcxParsedRequest["context"]["messages"] {
return [
{ role: "user", content: "google.ts를 읽어줘" },
{ 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." }] },
{ role: "user", content: "계속" },
] as unknown as OcxParsedRequest["context"]["messages"];
}

async function modelTexts(provider: OcxProviderConfig, modelId: string, phase?: "commentary" | "final_answer"): Promise<string[][]> {
const parsed = {
modelId, stream: false, options: {},
context: { messages: history(phase), 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 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, "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.", "After the call. Kept whole."], ["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("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();
expect(geminiProgressLead("```js\nconst x = 1;\n```")).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading