diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 15e418a62..974fe4858 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 cbf057a04..1b09e8fdd 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 b9bc5c020..37dc10426 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: {