You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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 donebecause 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".
-- 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.
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.tswere left behind, and one of them is a site myown report in #1325 explicitly cleared.
1.
content.partsis consumed unchecked on both paths.candidate.content?.partsis cast toGoogleResponsePart[]and iterated directly, so a container that is not an array of objects eitherthrows a raw
TypeErrorout of the adapter or is consumed as nothing.parts: "txt"is thenastiest of these: a string is iterable, so it is walked character by character and the turn
completes empty.
2.
candidate.contentis never inspected at all.content?.partsreadsundefinedfrom anynon-record, so a candidate shaped
content: [{ parts: [{ text: "…" }] }]— a plausible shape fromanything that mirrors the request's repeated
contents— silently drops its own text and reports asuccessful empty turn.
3. The buffered path never got #1332's candidate guard.
parseResponsechecks!candidates?.lengthand then readscandidates[0]without a record check, so aclaimed-but-malformed candidate becomes a bare
done. The streaming path returns a terminalgoogle response contained invalid candidatesfor the same input.Correction to my own earlier report. #1325 listed
google.ts:809as "checked and clean —Google's non-stream path needs no change", on the grounds that it returns a normal
donefor{"candidates":[null]}instead of throwing. That was true about the throw and wrong about theconclusion: it returns a normal
donebecause it silently drops the candidate. That is the exactoutcome #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
devat03735eca, with #1240, #1249, #1266 and #1332all present.
dev@03735ecacandidates[0].content.partsis{}TypeError: {} is not iterablepartsis[null]TypeError: null is not an object (evaluating 'part.thoughtSignature')partsis5TypeError: number is not iterablepartsis"txt"partsis[5]partsis{}TypeError: {} is not iterablepartsis[null]TypeError: null is not an object (evaluating 'part.thoughtSignature')partsis5TypeError: number is not iterablepartsis"txt"partsis[5]candidatesis[null][done]— streaming returnsinvalid candidatesfor the same inputcandidatesis[5][done]candidatesis"abc"[done]—"abc".lengthis 3, so the emptiness check passes andcandidates[0]is the character"a"contentis[{ parts: [{ text: "lost" }] }][heartbeat,done]— the claimed text is dropped and the turn succeeds[done]contentis"txt"[done]A–C and F–H throw out of the adapter. The buffered call site (
src/server/responses/core.ts, theparseResponsebranch) wraps the call intry { … } finallywith nocatch, so theTypeErrorleaves 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 ofthis, and nothing looks at
contentitself.src/adapters/google.ts— buffered:const candidates = json.candidates as {...}[] | undefined;→
if (!candidates?.length)→candidates[0].content.parts. Neitherjson.candidatesbeing anon-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 ofthese: 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 iscrash-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.
candidates: {}andcandidates: 5already return a structured error(
google response contained no candidates), because.lengthisundefined. The messagemisclassifies them as absent rather than malformed, but nothing crashes.
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 nodistinct empty-object form spells an empty
content, and it already means "no parts".data: nullbetween deltas) is a different rung andis not involved in any of the above.
Reproduction
Save as
tests/zz-google-shape-probe.test.tsondev, runbun scripts/test.ts tests/zz-google-shape-probe.test.ts, then delete it.Logs or error output
(The probe's letters run A–U; the table above uses its own A–P for the defective cases only.)
Version
devat03735eca(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 — theparser control flow is the same on every platform.
Notes
Published Gemini/Vertex/Antigravity responses type
Content.partsasPart[], so none of theseshapes should come from a first-party backend. The exposure is a Gemini-compatible third-party
baseUrl, or a CCAresponseenvelope 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
functionCallthat is not a record, or one with a missing, blank or non-stringname,currently produces a
tool_call_startwithname: undefined, and a non-stringpart.textreachestext_deltaas a number or object. OpenAI Chat now rejects the equivalent shapes viadiagnoseInvalidToolCalls/unnamedToolCallEvent, but Google assembles tool calls per-part ratherthan 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.