Repository navigation
[Bug] Google adapter: candidate, content and parts containers escape the #1332 nested-shape rule #2231
Description
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;
thenfor (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 atcontentitself.src/adapters/google.ts— buffered:const candidates = json.candidates as {...}[] | undefined;
→if (!candidates?.length)→candidates[0].content.parts. Neitherjson.candidatesbeing a
non-array norcandidates[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: {}andcandidates: 5already return a structured error
(google response contained no candidates), because.lengthisundefined. 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: nullandcandidates: []
all behave sensibly today.content: []is worth calling out — it is how a JSON writer with no
distinct empty-object form spells an emptycontent, 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: nullbetween 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
github-actions commentedon Aug 20, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
snowyukitty commentedon Aug 20, 2026
One more case, and it points the other way. Found from a CodeRabbit finding on #2232 and
confirmed against clean
dev@03735ecabefore acting on it.Everything above is the parser being too permissive. The streaming parser is also too strict in
one place: a
candidatescontainer that isnullis treated as corruption and terminates the turn,even though an absent key and an empty array are both skipped.
Reproduce with three frames in order — a content delta,
{"candidates":null}, then{"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.
nullis anabsence encoding, and the buffered parser already reads it that way (
candidates: nulltherereturns
google response contained no candidates, an absence result, not a corruption one).#2232 now treats
nullas absence inparseStreamtoo, keeps a non-null non-array containerterminal, and adds a regression proving a stream made only of such frames still ends on
upstream stream ended without a terminal signal — possible truncationrather than becoming a falsesuccessful empty turn.
lidge-jun commentedon Aug 20, 2026
리뷰 · 우선순위 64 / 80
재현이 지금 HEAD랑 같음.
dev03735eca6src/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 사이드카, #2190x_search, #2217/#2227 와이어 기본이랑 무관. 2.28 블로커 아님. 그래도 구글 패밀리 기본 파서라 점수는 있음.해결방안: #2232 머지. 이 이슈는 그 PR이 닫음. 두 번째 구현 금지. 안쪽 필드는 #2233에서 정책 고르고 후속.
이 댓글은 grok-bot이 작성했습니다
Merge pull request #2232 from snowyukitty/fix/google-response-shape-v…
Ingwannu commentedon Aug 21, 2026
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.
Merge pull request lidge-jun#2232 from snowyukitty/fix/google-respons…