Skip to content

[Bug] Google adapter: candidate, content and parts containers escape the #1332 nested-shape rule #2231

Closed

Description

@snowyukitty

Client or integration

Not client-specific — reproduced by driving the adapter directly with a synthetic Response.

Area

Provider adapter · Streaming / SSE parsing · Non-streaming response parsing

Summary

#1332 established the rule that a malformed nested payload inside a well-formed frame is a
claimed response, not padding, and must fail closed through the adapter's structured error channel.
It applied that rule to the streaming Google candidate and to OpenAI Chat tool calls on both
paths. Three containers in src/adapters/google.ts were left behind, and one of them is a site my
own report in #1325 explicitly cleared.

1. content.parts is consumed unchecked on both paths. candidate.content?.parts is cast to
GoogleResponsePart[] and iterated directly, so a container that is not an array of objects either
throws a raw TypeError out of the adapter or is consumed as nothing. parts: "txt" is the
nastiest of these: a string is iterable, so it is walked character by character and the turn
completes empty.

2. candidate.content is never inspected at all. content?.parts reads undefined from any
non-record, so a candidate shaped content: [{ parts: [{ text: "…" }] }] — a plausible shape from
anything that mirrors the request's repeated contents — silently drops its own text and reports a
successful empty turn.

3. The buffered path never got #1332's candidate guard. parseResponse checks
!candidates?.length and then reads candidates[0] without a record check, so a
claimed-but-malformed candidate becomes a bare done. The streaming path returns a terminal
google response contained invalid candidates for the same input.

Correction to my own earlier report. #1325 listed google.ts:809 as "checked and clean —
Google's non-stream path needs no change", on the grounds that it returns a normal done for
{"candidates":[null]} instead of throwing. That was true about the throw and wrong about the
conclusion: it returns a normal done because it silently drops the candidate. That is the exact
outcome #1332's rationale names — "silently skipping it could strand a tool turn" — so it is the
same defect, not an exemption from it.

These are found by audit, not from a live capture; same bar as #1325, which was accepted on shape
reasoning plus reproductions. Verified on dev at 03735eca, with #1240, #1249, #1266 and #1332
all present.

# Path Trigger (inside a well-formed frame) Observed on dev@03735eca
A stream candidates[0].content.parts is {} TypeError: {} is not iterable
B stream parts is [null] TypeError: null is not an object (evaluating 'part.thoughtSignature')
C stream parts is 5 TypeError: number is not iterable
D stream parts is "txt" no error — the string is iterated by character, turn completes empty
E stream parts is [5] no error — the element is skipped, turn completes empty
F buffered parts is {} TypeError: {} is not iterable
G buffered parts is [null] TypeError: null is not an object (evaluating 'part.thoughtSignature')
H buffered parts is 5 TypeError: number is not iterable
I buffered parts is "txt" no error — turn completes empty
J buffered parts is [5] no error — turn completes empty
K buffered candidates is [null] [done] — streaming returns invalid candidates for the same input
L buffered candidates is [5] [done]
M buffered candidates is "abc" [done] — "abc".length is 3, so the emptiness check passes and candidates[0] is the character "a"
N stream content is [{ parts: [{ text: "lost" }] }] [heartbeat,done] — the claimed text is dropped and the turn succeeds
O buffered same as N [done]
P buffered content is "txt" [done]

A–C and F–H throw out of the adapter. The buffered call site (src/server/responses/core.ts, the
parseResponse branch) wraps the call in try { … } finally with no catch, so the
TypeError leaves the turn unstructured rather than becoming an adapter error event.

D, E, I, J and K–P are the quieter half: the request is reported as a successful, empty turn. N and
O are the ones where a complete answer actually arrived and was discarded.

Where

  • src/adapters/google.ts — streaming: const parts = candidate.content?.parts as GoogleResponsePart[] | undefined;
    then for (const part of parts). The candidate guard above it (fix(adapters): reject malformed nested response shapes #1332) stops one level short of
    this, and nothing looks at content itself.
  • src/adapters/google.ts — buffered: const candidates = json.candidates as {...}[] | undefined;
    → if (!candidates?.length) → candidates[0].content.parts. Neither json.candidates being a
    non-array nor candidates[0] being a non-record is checked.

Checked and clean — not every site is affected

  • observeAntigravityReplay (src/adapters/google-antigravity-replay.ts) does not crash on any of
    these: it early-returns on !Array.isArray(parts) and skips elements failing
    !raw || typeof raw !== "object". Note that this predicate does not exclude arrays, so it is
    crash-safe for the shapes above rather than a strict record check — and its disposition is skip,
    which is the fix(sse): treat a data frame that parses to a non-record as malformed (#1219) #1240 padding rule, not the fix(adapters): reject malformed nested response shapes #1332 claimed-content rule. It is the right guard shape
    to copy and the wrong disposition to copy.
  • Buffered candidates: {} and candidates: 5 already return a structured error
    (google response contained no candidates), because .length is undefined. The message
    misclassifies them as absent rather than malformed, but nothing crashes.
  • Absence is genuinely fine everywhere and must stay that way: absent content, content: null,
    content: [], content: {}, parts: null, parts: [], candidates: null and candidates: []
    all behave sensibly today. content: [] is worth calling out — it is how a JSON writer with no
    distinct empty-object form spells an empty content, and it already means "no parts".
  • The root-frame padding behaviour from fix(sse): treat a data frame that parses to a non-record as malformed (#1219) #1240 (data: null between deltas) is a different rung and
    is not involved in any of the above.

Reproduction

Save as tests/zz-google-shape-probe.test.ts on dev, run
bun scripts/test.ts tests/zz-google-shape-probe.test.ts, then delete it.

import { test } from "bun:test";
import { createGoogleAdapter } from "../src/adapters/google";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const g = { adapter: "google", baseUrl: "https://x.test", apiKey: "k", authMode: "key" } as any;
const adapter = () => withTestTranslatorBudget(createGoogleAdapter(g));

async function stream(label: string, chunk: unknown) {
  try {
    const out: string[] = [];
    const body = `data: ${JSON.stringify(chunk)}\n\ndata: {"candidates":[{"finishReason":"STOP"}]}\n\n`;
    for await (const e of adapter().parseStream(new Response(body, { headers: { "content-type": "text/event-stream" } }))) {
      out.push(e.type === "error" ? `error(${(e as any).message})` : e.type);
    }
    console.log(`  ${label}: no throw -> [${out.join(",")}]`);
  } catch (e) { console.log(`  ${label}: THREW -> ${(e as Error).message}`); }
}

async function buffered(label: string, body: unknown) {
  try {
    const ev = await adapter().parseResponse!(new Response(JSON.stringify(body)));
    console.log(`  ${label}: no throw -> [${ev.map((e: any) => e.type === "error" ? `error(${e.message})` : e.type).join(",")}]`);
  } catch (e) { console.log(`  ${label}: THREW -> ${(e as Error).message}`); }
}

test("google response shape probe", async () => {
  console.log("-- content.parts, streaming --");
  await stream("A parts:{}          ", { candidates: [{ content: { parts: {} } }] });
  await stream("B parts:[null]      ", { candidates: [{ content: { parts: [null] } }] });
  await stream("C parts:5           ", { candidates: [{ content: { parts: 5 } }] });
  await stream("D parts:\"txt\"       ", { candidates: [{ content: { parts: "txt" } }] });
  await stream("E parts:[5]         ", { candidates: [{ content: { parts: [5] } }] });
  console.log("-- content.parts, buffered --");
  await buffered("F parts:{}          ", { candidates: [{ content: { parts: {} } }] });
  await buffered("G parts:[null]      ", { candidates: [{ content: { parts: [null] } }] });
  await buffered("H parts:5           ", { candidates: [{ content: { parts: 5 } }] });
  await buffered("I parts:\"txt\"       ", { candidates: [{ content: { parts: "txt" } }] });
  await buffered("J parts:[5]         ", { candidates: [{ content: { parts: [5] } }] });
  console.log("-- candidates, buffered vs streaming --");
  await stream("K candidates:[null] ", { candidates: [null] });
  await buffered("L candidates:[null] ", { candidates: [null] });
  await buffered("M candidates:[5]    ", { candidates: [5] });
  await buffered("N candidates:\"abc\"  ", { candidates: "abc" });
  await buffered("O candidates:{}     ", { candidates: {} });
  console.log("-- candidate.content container --");
  await stream("P content:[{parts}] ", { candidates: [{ content: [{ parts: [{ text: "lost" }] }] }] });
  await buffered("Q content:[{parts}] ", { candidates: [{ content: [{ parts: [{ text: "lost" }] }], finishReason: "STOP" }] });
  await buffered("R content:\"txt\"     ", { candidates: [{ content: "txt", finishReason: "STOP" }] });
  console.log("-- shapes that must stay legal --");
  await buffered("S parts:[] empty    ", { candidates: [{ content: { parts: [] }, finishReason: "STOP" }] });
  await buffered("T content:null      ", { candidates: [{ content: null, finishReason: "STOP" }] });
  await buffered("U candidates:[]     ", { candidates: [] });
});

Logs or error output

-- content.parts, streaming --
  A parts:{}          : THREW -> {} is not iterable
  B parts:[null]      : THREW -> null is not an object (evaluating 'part.thoughtSignature')
  C parts:5           : THREW -> number is not iterable
  D parts:"txt"       : no throw -> [heartbeat,done]
  E parts:[5]         : no throw -> [heartbeat,done]
-- content.parts, buffered --
  F parts:{}          : THREW -> {} is not iterable
  G parts:[null]      : THREW -> null is not an object (evaluating 'part.thoughtSignature')
  H parts:5           : THREW -> number is not iterable
  I parts:"txt"       : no throw -> [done]
  J parts:[5]         : no throw -> [done]
-- candidates, buffered vs streaming --
  K candidates:[null] : no throw -> [error(google response contained invalid candidates)]
  L candidates:[null] : no throw -> [done]
  M candidates:[5]    : no throw -> [done]
  N candidates:"abc"  : no throw -> [done]
  O candidates:{}     : no throw -> [error(google response contained no candidates)]
-- candidate.content container --
  P content:[{parts}] : no throw -> [heartbeat,done]
  Q content:[{parts}] : no throw -> [done]
  R content:"txt"     : no throw -> [done]
-- shapes that must stay legal --
  S parts:[] empty    : no throw -> [done]
  T content:null      : no throw -> [done]
  U candidates:[]     : no throw -> [error(google response contained no candidates)]

(The probe's letters run A–U; the table above uses its own A–P for the defective cases only.)

Version

dev at 03735eca (verified), Bun 1.3.14, Windows 11.

Operating system

Windows 11 Pro 24H2 (10.0.26200). Reproduced through the repository's own isolated test runner
(bun scripts/test.ts) against the adapter directly, so the defect is platform-independent — the
parser control flow is the same on every platform.

Notes

Published Gemini/Vertex/Antigravity responses type Content.parts as Part[], so none of these
shapes should come from a first-party backend. The exposure is a Gemini-compatible third-party
baseUrl, or a CCA response envelope produced by something other than Google.

A PR follows that applies the #1332 rule to all three containers. There is a separate question
I am deliberately not folding into it: fields inside a well-formed part are still untyped on the
way out — a functionCall that is not a record, or one with a missing, blank or non-string name,
currently produces a tool_call_start with name: undefined, and a non-string part.text reaches
text_delta as a number or object. OpenAI Chat now rejects the equivalent shapes via
diagnoseInvalidToolCalls / unnamedToolCallEvent, but Google assembles tool calls per-part rather
than incrementally, so whether the same disposition is correct here is a policy call rather than a
parity fix. I will file that separately with its own reproductions unless you would rather it rode
along.

Activity

  1. added
    bugSomething isn't working
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 20, 2026
  2. github-actions commented on Aug 20, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. snowyukitty commented on Aug 20, 2026

    @snowyukitty
    ContributorAuthor

    One more case, and it points the other way. Found from a CodeRabbit finding on #2232 and
    confirmed against clean dev@03735eca before acting on it.

    Everything above is the parser being too permissive. The streaming parser is also too strict in
    one place: a candidates container that is null is treated as corruption and terminates the turn,
    even though an absent key and an empty array are both skipped.

    text, candidates:null, STOP  : [text(PONG),error(google response contained invalid candidates)]
    text, absent-candidates, STOP: [text(PONG),done]
    text, candidates:[], STOP    : [text(PONG),done]
    

    Reproduce with three frames in order — a content delta, {"candidates":null}, then
    {"candidates":[{"finishReason":"STOP"}]}:

    await run("text, candidates:null, STOP ", [
      { candidates: [{ content: { parts: [{ text: "PONG" }] } }] },
      { candidates: null },
      { candidates: [{ finishReason: "STOP" }] },
    ]);

    That is the #1219 shape at a different rung: the answer had already fully arrived, and a
    padding-shaped frame between the content delta and the finish chunk failed the turn anyway — the
    same outcome the #1219 reporter measured before #1240 fixed it at the frame root. null is an
    absence encoding, and the buffered parser already reads it that way (candidates: null there
    returns google response contained no candidates, an absence result, not a corruption one).

    #2232 now treats null as absence in parseStream too, keeps a non-null non-array container
    terminal, and adds a regression proving a stream made only of such frames still ends on
    upstream stream ended without a terminal signal — possible truncation rather than becoming a false
    successful empty turn.

  4. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 64 / 80

    재현이 지금 HEAD랑 같음. dev 03735eca6 src/adapters/google.ts 스트림은 후보만 #1332 가드 (:671-685). candidate.content?.parts는 캐스팅하고 바로 이터레이션 (:696-712). parts: {} / 5는 TypeError. "txt"는 문자 단위로 돌고 빈 턴 성공. [null]은 part.thoughtSignature에서 터짐. 버퍼는 !candidates?.length만 봄 (:927-929). "abc".length가 3이라 통과하고 candidates[0]이 "a". [null]은 length 1이라 no candidates도 아니고 그냥 done. #1325가 버퍼를 깨끗하다고 한 건 안 던진다는 뜻이지, 침묵 드롭이 맞다는 뜻은 아님.

    호출 쪽이 구멍을 키움. 비스트림 src/server/responses/core.ts :4660-4692가 parseResponse를 try/finally만 감쌈. catch 없음. 어댑터 throw가 구조화 에러가 안 되고 턴이 깨짐. 컨티뉴 :4552-4574는 catch가 있음. 초기 버퍼만 비대칭임.

    이슈 품질봇이 한 번 닫았다가 다시 열림. 재현이 합성 Response로 어댑터를 직접 때린 거라 클라이언트 캡처는 없음. #1325 / #1332랑 같은 바임. 그걸로 다시 닫지 말 것. 구멍은 코드에 있음. 저자 후속이 스트림 candidates: null을 집어넣음. 콘텐츠 델타랑 STOP 사이에 그 프레임이 오면 답이 이미 왔는데 턴이 죽음. #1219 한 단 안쪽임.

    고치는 PR이 이미 #2232. 컨테이너 가드 + null 후보 부재 처리. 체크리스트 4/4, draft 아님. 이 이슈에 두 번째 패치 올리지 말 것. 중복으로 닫지도 말고 #2232 머지까지 열어 둬라. Closes #2231이 붙어 있음.

    #2233이랑 단이 다름. 이건 컨테이너. 저건 파트 안쪽 필드(functionCall.name / 비문자열 text). 접지 말 것. types.ts/config.ts 스플릿, #2188 사이드카, #2190 x_search, #2217/#2227 와이어 기본이랑 무관. 2.28 블로커 아님. 그래도 구글 패밀리 기본 파서라 점수는 있음.

    해결방안: #2232 머지. 이 이슈는 그 PR이 닫음. 두 번째 구현 금지. 안쪽 필드는 #2233에서 정책 고르고 후속.

    이 댓글은 grok-bot이 작성했습니다

  5. added a commit that references this issue on Aug 21, 2026
  6. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    Fixed by #2232 and merged into dev as 38278e7. The adapter now validates candidate, content, and parts containers consistently across buffered and streaming paths, preserves null keepalive frames, and fails closed on malformed or unterminated responses.

  7. added a commit that references this issue on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions