From 219e7f365a7cf4ce8334dc9216880d0921a04a53 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 12 Aug 2026 02:50:13 +0000 Subject: [PATCH 1/2] fix(google): keep thought parts out of visible text --- src/adapters/google.ts | 30 ++++++++++-- structure/04_transports-and-sidecars.md | 17 +++++++ tests/google-hardening.test.ts | 64 +++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 15e418a625..974fe4858b 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -304,6 +304,24 @@ function artifactMarkdownUrl(filePath: string): string { return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1"); } +interface GoogleResponsePart { + text?: string; + thought?: boolean; + functionCall?: { name: string; args: unknown }; +} + +/** + * Google marks model-internal reasoning as a normal text-bearing part plus `thought: true`. + * Keep that provider visibility bit authoritative here so the streaming and buffered parsers + * cannot accidentally expose the same hidden reasoning through different event types. + */ +function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined { + if (!part.text) return undefined; + return part.thought === true + ? { type: "reasoning_raw_delta", text: part.text } + : { type: "text_delta", text: part.text }; +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -602,7 +620,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte sawTerminalSignal = true; } - const parts = candidate.content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined; + const parts = candidate.content?.parts as GoogleResponsePart[] | undefined; // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; @@ -613,9 +631,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (parts) { for (const part of parts) { - if (part.text) { + const textEvent = googlePartTextEvent(part); + if (textEvent) { emittedContentEvent = true; - yield { type: "text_delta", text: part.text }; + yield textEvent; } const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { @@ -817,7 +836,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const events: AdapterEvent[] = []; - const candidates = json.candidates as { content?: { parts?: { text?: string; functionCall?: { name: string; args: unknown } }[] }; finishReason?: string }[] | undefined; + const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined; if (!candidates?.length) { return finish([{ type: "error", message: "google response contained no candidates" }]); } @@ -833,7 +852,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); } for (const part of candidates[0].content.parts) { - if (part.text) events.push({ type: "text_delta", text: part.text }); + const textEvent = googlePartTextEvent(part); + if (textEvent) events.push(textEvent); const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index cbf057a044..1b09e8fdde 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -437,6 +437,23 @@ pre-compaction checkpoint is not persisted for later carry-forward. - 장점, 단점 및 영향: Active-context reporting stays monotonic within an uncompacted Cursor conversation; no-checkpoint turns remain estimated; a process restart loses the numeric cache, and when neither a checkpoint nor a carry-forward is available the turn reports a request-local estimate derived from the same pruned payload sent to Cursor (#373 — reporting output-only usage made Codex read the context as nearly empty). Estimates are never persisted or promoted into checkpoint carry-forward; only live checkpoint frames update the cache. ``` +## Google thought-text visibility boundary + +Google-family responses may represent model-internal reasoning as a text-bearing part with +`thought: true`. The Google adapter maps that text to the internal `reasoning_raw_delta` event; +only text without the marker becomes visible `text_delta`. Streaming SSE and buffered JSON share +one classifier so transport selection cannot change whether provider-declared reasoning is shown +as assistant output. Thought-signature observation still runs on the original parts before text +classification, preserving the opaque continuation state independently of display semantics. + +[Decision Log] +- 목적과 의도: Prevent provider-marked internal reasoning from appearing as ordinary assistant text while preserving reasoning and tool-call continuation. +- 기존 구현 및 제약 조건: Both Google response paths emitted every non-empty `Part.text` as visible text; function calls, inline images, and Antigravity/Vertex thought-signature replay already depended on the original part ordering. +- 검토한 주요 대안: Drop thought text; classify it separately in each parser; remove the marker and keep visible text; use one shared classifier without mutating the provider parts. +- 선택한 방식: Map `thought: true` text to `reasoning_raw_delta` through one helper used by streaming and buffered parsing, leaving part order and signature observation unchanged. +- 다른 대안 대신 이 방식을 선택한 이유: Dropping the text loses reasoning replay/display policy input, while duplicated parser rules can drift and exposing marked thoughts violates the provider's visibility boundary. +- 장점, 단점 및 영향: Internal reasoning no longer leaks into normal answers and both transports stay consistent; downstream reasoning policy still decides whether raw reasoning is rendered or only preserved, and malformed non-boolean markers remain ordinary text rather than broadening hidden-content inference. + ## Google tool-call thought-signature replay Gemini may attach an opaque `thoughtSignature` to a `functionCall` and requires that exact value on diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index b9bc5c0204..37dc104262 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -293,6 +293,70 @@ describe("google provider hardening", () => { expect(events.some(e => e.type === "error")).toBe(false); }); + test("thought text stays hidden reasoning in streaming and non-streaming responses", async () => { + const body = { + candidates: [{ + content: { parts: [{ thought: true, text: "private analysis" }] }, + finishReason: "STOP", + }], + }; + + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([body]))); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(body), { status: 200 }), + ); + + for (const events of [streamEvents, responseEvents]) { + expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "private analysis" }); + expect(events).not.toContainEqual({ type: "text_delta", text: "private analysis" }); + } + }); + + test("thought text preserves ordering before function calls in both response modes", async () => { + const body = { + candidates: [{ + content: { + parts: [ + { thought: true, text: "choose the tool" }, + { functionCall: { name: "lookup", args: { id: 7 } } }, + ], + }, + finishReason: "STOP", + }], + }; + + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([body]))); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(body), { status: 200 }), + ); + + for (const events of [streamEvents, responseEvents]) { + expect(events.slice(0, 4)).toEqual([ + { type: "reasoning_raw_delta", text: "choose the tool" }, + { type: "tool_call_start", id: expect.stringMatching(/^call_/), name: "lookup" }, + { type: "tool_call_delta", arguments: '{"id":7}' }, + { type: "tool_call_end" }, + ]); + expect(events).not.toContainEqual({ type: "text_delta", text: "choose the tool" }); + } + }); + + test("ordinary Google text remains visible in both response modes", async () => { + const body = { + candidates: [{ content: { parts: [{ text: "visible answer" }] }, finishReason: "STOP" }], + }; + + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([body]))); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(body), { status: 200 }), + ); + + for (const events of [streamEvents, responseEvents]) { + expect(events).toContainEqual({ type: "text_delta", text: "visible answer" }); + expect(events).not.toContainEqual({ type: "reasoning_raw_delta", text: "visible answer" }); + } + }); + test("sends Gemini Flash thinkingLevel only for direct AI Studio requests", async () => { const direct = createGoogleAdapter(provider({ modelReasoningEfforts: { From e0d2df3b35734a793fcdea03e655d485206797da Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 12 Aug 2026 20:12:01 +0900 Subject: [PATCH 2/2] test(google): pin heartbeat classification and signature replay for thought parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contracts the thought-visibility change touches implicitly, made explicit so neither can drift. emittedContentEvent decides content vs continue, and its only consumer is the synthetic-heartbeat suppression in the read loop. A thought delta is real upstream activity, so it must count as content — emitting a heartbeat alongside it would claim the stream was idle while the model was working. The visible-text case is asserted next to it as the control. Gemini 3 rejects a follow-up turn whose first function-call part lost its thoughtSignature, so a classification change that also dropped replay would trade a visible-text bug for a hard 400. The new case observes a payload mixing a thought part with a signed function call and asserts the signature is still replayed, rather than inferring safety from unrelated fixtures that happen to still pass. --- tests/google-antigravity-wire.test.ts | 33 +++++++++++++++++++++++++++ tests/google-hardening.test.ts | 27 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 148b6be502..b68cf7a0f4 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -505,6 +505,39 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { applyAntigravityReplay("gemini-3-pro", antigravitySessionId(followup), contents); expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-nonstream0000000"); }); + + // Guard for #1503: routing `thought: true` text to the reasoning channel must not disturb + // signature observation. Gemini 3 rejects a follow-up turn whose first function-call part + // lost its signature, so a classification change that also dropped replay would trade a + // visible-text bug for a hard 400. Asserting the signature survives a payload that mixes a + // thought part with a signed function call is the direct proof, rather than inferring it + // from unrelated fixtures that happen to still pass. + test("a thought part alongside a signed function call does not disturb replay", async () => { + const { __resetAntigravityReplayCache, applyAntigravityReplay } = await import("../src/adapters/google-antigravity-replay"); + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(parsed("hello world")); + const body = JSON.stringify({ + response: { + candidates: [{ + content: { + parts: [ + { thought: true, text: "deciding which tool to call" }, + { functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-withthought00000" }, + ], + }, + }], + }, + }); + const events = await adapter.parseResponse!(new Response(body, { status: 200 })); + + expect(events).not.toContainEqual({ type: "text_delta", text: "deciding which tool to call" }); + + const followup = parsed("hello world"); + const contents = [{ role: "model", parts: [{ functionCall: { name: "do_x", args: { a: 1 } } }] }]; + applyAntigravityReplay("gemini-3-pro", antigravitySessionId(followup), contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-withthought00000"); + }); }); describe("antigravity history preserves tool-call thoughtSignature", () => { diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 37dc104262..f92b3a3bc0 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -357,6 +357,33 @@ describe("google provider hardening", () => { } }); + // `emittedContentEvent` decides `"content"` vs `"continue"`, and its only consumer is the + // synthetic-heartbeat suppression in the read loop. A thought delta is real upstream + // activity, so it must count as content: emitting a heartbeat alongside it would claim the + // stream was idle while the model was demonstrably working. Pinning that here keeps the + // classification a decision rather than a side effect of routing thought text elsewhere. + test("a thought-only frame counts as content, so no synthetic heartbeat is emitted", async () => { + const thoughtOnly = { + candidates: [{ content: { parts: [{ thought: true, text: "private analysis" }] } }], + }; + const events = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([thoughtOnly]))); + + expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "private analysis" }); + expect(events.some(e => e.type === "heartbeat")).toBe(false); + }); + + // The visible-text control for the assertion above: an ordinary text frame has always + // suppressed the heartbeat, so a divergence here would mean thought parts are classified + // differently from the text they replaced. + test("a visible-text frame also suppresses the synthetic heartbeat", async () => { + const textOnly = { + candidates: [{ content: { parts: [{ text: "visible answer" }] } }], + }; + const events = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([textOnly]))); + + expect(events).toContainEqual({ type: "text_delta", text: "visible answer" }); + expect(events.some(e => e.type === "heartbeat")).toBe(false); + }); test("sends Gemini Flash thinkingLevel only for direct AI Studio requests", async () => { const direct = createGoogleAdapter(provider({ modelReasoningEfforts: {