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
30 changes: 25 additions & 5 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Comment on lines +318 to +322

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the Google thought-output mapping

When Google returns a thought: true part, this changes the public Responses/Chat contract from ordinary assistant text to reasoning output (and Responses requests with reasoning.summary: "none" suppress it entirely), but docs-site/src/content/docs/reference/adapters.md and its translations still omit this behavior. Add the visibility and output-channel rule to the Google adapter documentation and synchronize the translated pages.

AGENTS.md reference: src/AGENTS.md:L24-L28

Useful? React with 👍 / 👎.

}

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.
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand Down Expand Up @@ -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" }]);
}
Expand All @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/google-antigravity-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
91 changes: 91 additions & 0 deletions tests/google-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,97 @@ 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" });
}
});

// `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: {
Expand Down
Loading