Skip to content

fix(adapters): validate buffered response and ndjson frame shapes (#2531) - #2532

Merged
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/buffered-and-ndjson-shape-guards
Aug 25, 2026
Merged

fix(adapters): validate buffered response and ndjson frame shapes (#2531)#2532
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/buffered-and-ndjson-shape-guards

Conversation

@snowyukitty

@snowyukitty snowyukitty commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #2531.

Problem

JSON.parse("null") returns null without throwing, so a try/catch around a body parse cannot see
it. #1240 closed that at the SSE frame root for the four SSE parsers, and reported: "The other
eight SSE data-frame parsers were audited and are already correct."

Accurate about what it covered. A buffered body is not an SSE data frame, and Command Code is
NDJSON
:

# Site Trigger Result
A command-code.ts ndjson() frame null null is not an object ('event.type')
B google.ts parseResponse body null null is not an object ('raw.error')
C anthropic.ts parseResponse body null null is not an object ('json.content')
D anthropic.ts parseResponse content = true / {} is not iterable
E anthropic.ts parseResponse content = [null] / content[i] = null null is not an object ('block.type')
F anthropic.ts parseResponse content = a string no throw — silent loss
G openai-chat.ts parseResponse message = "txt" / true / [{…}] no throw — silent loss

parseResponse is the live non-streaming path (src/server/responses/core.ts:5065), not dead code.

F and G are the ones worth reading twice. A string is iterable, so
for (const block of "a claimed answer") walks it one character at a time, emits nothing, and reports
a clean done — a successful empty turn for a response that claimed content.

G is the same outcome from a different cause: if (!choice.message) splits this input class on
truthiness, not shape. null and 0 fail closed; "text", true and [{…}] pass, every
property read yields undefined, and the turn completes successfully — stranding any tool call the
choice claimed. The guard is one line below a full record check on the choice container itself.

Fix

Four rules, each precedented, so the diff contains no asymmetry for one input class:

A non-record frame root in a stream is skipped as paddingsrc/adapters/command-code.ts.
#1240's rule: terminating discards an answer whose deltas have already arrived. The parse and guard
move into one decodeEventLine helper so the newline loop and the trailing-buffer branch — which
shared the defect — cannot drift apart again.

A non-record body root in a buffered response fails closedsrc/adapters/google.ts,
src/adapters/anthropic.ts. No next frame to recover into, and this matches the unparseable-body
branch sitting immediately beside it in google.ts.

Malformed nested claimed content fails closedsrc/adapters/anthropic.ts (content must be an
array of records), src/adapters/openai-chat.ts (message must be a record). #1332/#2232's rule.

An empty array stays legal for content, and NOT for message. content is genuinely an array
of blocks, and diagnoseGoogleContent already accepts [] there. message is a record on a
plain-JSON wire that already has {}, so the google carve-out is a protobuf-wire artifact that does
not transfer — importing it would be reasoning from analogy rather than from this wire. Absence
(omitted, and null) is unchanged everywhere.

openai-chat reads choice.message through unknown rather than the declared type: choices is a
cast over wire data, so its message?: Record<string, unknown> is an assertion the upstream never
made — narrowing against it is what let the missing check look type-safe.

Diagnostics name the rung and value type (content_not_array,
content_block_not_object; blockIndex=1; valueType=null), shaped like invalidGoogleShapeEvent.

Tests

tests/buffered-response-shape-guards.test.ts46 pass / 0 fail.

Every assertion is parity against a control that was already correct — an unparseable body, an
unparseable line, or the falsy-message cases that already failed closed — so the test states the
requirement rather than re-encoding each adapter's wording.

Activation: on clean dev@98ed186c, same file and runner: 21 pass / 25 fail. The 22 passing
on both are the controls, including the absence encodings and empty-array carve-outs that must keep
working.

Blast radius: 97 files importing any of the four adapters — 1591 pass / 1 fail. The failure is
tests/translator-budget.test.ts (--ignoreConfig is not a recognised tsc option in this
environment; expects TS2554, gets TS5023). Controlled on a detached upstream/dev@98ed186c
checkout, same runner and file: fails identically, 14 pass / 1 fail. Pre-existing.

Typecheck identical to clean dev (one pre-existing @napi-rs/keyring error). Privacy scan
passed.

Notes

How this was found. Not from a report — from an enumerative sweep driving each adapter's real
parser over every addressable JSON location in a healthy exemplar, with values drawn from what
actually reproduced in #1219/#1325/#2231/#2232 rather than a generic corpus. 2,937 single-location
mutations. Baseline vs. patched, same harness:

cells: 2937 before / 2937 after; changed: 50
    8  anthropic/buffered: CRASH        -> ERROR_EVENT
   19  anthropic/buffered: OK           -> ERROR_EVENT
   11  anthropic/buffered: SILENT_EMPTY -> ERROR_EVENT
    2  anthropic/buffered: TEXT_LOSS    -> ERROR_EVENT
    4  command-code/stream: CRASH       -> OK
    1  google/buffered: CRASH           -> ERROR_EVENT
    5  openai-chat/buffered: SILENT_EMPTY -> ERROR_EVENT
0 CRASH, 0 TIMEOUT remaining

openai-responses and every streaming path are cell-for-cell identical — the change is confined to
the four adapters it claims.

Coverage is a census, not a sample. kiro and cursor cannot be driven by the sweep (binary
event-stream framing; runTurn never receives a Response). Rather than build exemplar builders,
every JSON.parse and response.json() site in src/adapters/ and src/web-search/ was enumerated
and its guard read — for this class that is conclusive where a sweep is only ever a sample. All are
clean; the table is in the issue. Kiro is the positive example this PR is modelled on.

What this does not cover: single mutations only, one healthy exemplar per adapter. Finding F was
found by a hand probe, not the sweep; the oracle was then extended so that class surfaces
automatically, which is what found G.

One behaviour change to flag rather than bury. 19 anthropic cells and 4 openai-chat cells move
from silently-accepted to a structured error: a present-but-malformed content/message used to be
accepted as an empty turn and is now refused. That is the point of D/F/G, but it is a real tightening
on paths that previously accepted anything. Absence and empty-array encodings are unchanged and
covered by their own tests.

A behaviour change to state plainly rather than soften. On dev, a Command Code stream whose
only frame is null (or any non-record JSON) throws TypeError: null is not an object out of
switch (event.type). After this change it yields [done]. A crash becomes an empty success for
that input.
The full picture:

Command Code stream dev after
empty body [done] [done]
blank-line-only [done] [done]
unparseable-only ({not json}) [done] [done]
non-record-only (null) throws [done]
null between real deltas throws [text_delta, …, done]

The last row is the observed #1219 shape and the reason for the change. The fourth row joins an
existing class: a stream that produced no valid event, whose other three members already end in a
single terminal done on unmodified dev. Making only the fourth fail closed would create the
asymmetry, not remove it; making all four fail closed would rewrite pre-existing behaviour this diff
does not otherwise touch, and would make this the only parser in the family that fails closed at EOF.

This does not contradict findings F and G above. Those are about reporting success while
delivering nothing when the payload claimed content. A junk-only stream claims nothing. Where
content is claimed, this diff reports it — that is the last row.

The residual question is real, and is deliberately left open. if (!sawFinish) yield done means
this adapter reports any no-valid-event stream as an empty success, which for a client that is
itself a coding agent is arguably worse than a 502. That is pre-existing, applies to all four inputs
equally, and is a product decision about this adapter's terminal contract — not something a crash
fix should decide. Happy to file it separately if you want it addressed.

Review record. This diff was put through an independent three-reviewer panel before submission
(gpt-5.6-sol xhigh, grok-4.6, gemini-3.7-flash). Two approved; one requested changes with three
findings. Two were accepted and fixed here — a cargo-culted empty-array carve-out on
choice.message, and a test whose comment claimed trailing-buffer coverage it did not have because
the helper appended a newline to every line. The third is the behaviour change disclosed above,
declined on the reasoning given. A fourth finding from a different reviewer (an orphaned JSDoc) was
also accepted. Details available if useful.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of malformed responses from Anthropic, Google, and OpenAI Chat services.
    • Invalid response shapes now produce structured errors instead of crashes or misleading successful results.
    • Malformed Command Code stream frames are safely ignored, allowing valid subsequent data to continue processing.
    • Resource accounting remains consistent when validation fails early.
  • Tests

    • Added comprehensive coverage for malformed responses, recovery behavior, valid responses, and error handling across supported adapters.

`JSON.parse("null")` returns null without throwing, so a try/catch around a
body parse cannot see it. lidge-jun#1240 closed that at the SSE frame root for the four
SSE parsers; the buffered bodies and the NDJSON transport were never swept.

- google/anthropic parseResponse: a valid-JSON non-record body reached
  `raw.error` and `json.content` and threw out of the adapter. A buffered body
  has no next frame to recover into, so both now fail closed with a structured
  error, matching the unparseable-body branch beside them.
- anthropic parseResponse: `content` was consumed unchecked. A present
  non-array was silently accepted, and a string is iterable, so a claimed
  answer was walked one character at a time and reported as a successful empty
  turn. Absence (omitted or null) stays legal.
- openai-chat parseResponse: `if (!choice.message)` split this input class on
  truthiness, not shape - null and 0 failed closed while "text", true and
  [{...}] passed and completed as a successful empty turn, stranding any tool
  call the choice claimed. Empty array stays legal, as in the google adapter.
- command-code ndjson: a non-record line crashed on `event.type`. A stream
  frame does have a next frame, so it is skipped as padding, per lidge-jun#1240.
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed; the review readiness checklist is complete.

Review readiness checklist

  • ✅ All CI tests are green on my local testing.
  • ✅ I pushed my PR to the latest dev commit.
  • ✅ I resolved all correct Codex and CodeRabbit findings.
  • ✅ My PR is ready for review.

4/4 boxes ticked.

This pull request has been marked Ready for Review.
The review-ready label marks this PR as ready; review automation runs independently.
Maintainers notified: @lidge-jun @Ingwannu

@github-actions
github-actions Bot marked this pull request as draft August 25, 2026 04:58
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a6c0cc7f-ef68-4875-9f3e-74306583b3e0

📥 Commits

Reviewing files that changed from the base of the PR and between 98ed186 and 5d35684.

📒 Files selected for processing (5)
  • src/adapters/anthropic.ts
  • src/adapters/command-code.ts
  • src/adapters/google.ts
  • src/adapters/openai-chat.ts
  • tests/buffered-response-shape-guards.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The adapters now validate buffered JSON response shapes before reading nested fields. Anthropic reports structured 502 errors for malformed content. Command Code skips malformed NDJSON frames. OpenAI Chat rejects non-object messages. Tests cover failures, recovery, valid responses, and budget cleanup.

Changes

Response shape guards

Layer / File(s) Summary
Buffered adapter validation
src/adapters/anthropic.ts, src/adapters/google.ts, src/adapters/openai-chat.ts
Anthropic validates the response root, content, and content blocks at lines 268–300 and 1315–1363. Google rejects non-object roots at lines 1161–1172. OpenAI Chat rejects non-record choice.message values at lines 1935–1956.
Command Code frame decoding
src/adapters/command-code.ts
Lines 10 and 369–422 add debug logging and route complete and residual NDJSON lines through shape validation. Invalid JSON and non-object frames are skipped.
Shape guard regression coverage
tests/buffered-response-shape-guards.test.ts
Lines 1–340 test malformed roots, Anthropic content shapes, Command Code recovery, OpenAI Chat message shapes, valid responses, event behavior, and translator-budget release.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 5d356

The PR adds localized validation for malformed buffered and NDJSON responses, converting crashes or silent empty results into the intended error or skip behavior. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #2531. src/adapters/command-code.ts skips non-record NDJSON roots, src/adapters/google.ts and src/adapters/anthropic.ts reject non-record buffered roots, `src/adapters/…
Out of Scope Changes check ✅ Passed The changes remain within issue #2531. The four adapter changes directly implement the requested guards, diagnostics, padding behavior, and budget handling. `tests/buffered-response-shape-guards.test.…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: shape validation for buffered responses and NDJSON frames across the adapters.
Full details: Linked Issues check

Explanation

The changes satisfy issue #2531. src/adapters/command-code.ts skips non-record NDJSON roots, src/adapters/google.ts and src/adapters/anthropic.ts reject non-record buffered roots, src/adapters/anthropic.ts validates content arrays and blocks, and src/adapters/openai-chat.ts validates choice.message records. The added tests cover malformed shapes, valid absence cases, event behavior, and budget cleanup.

Full details: Out of Scope Changes check

Explanation

The changes remain within issue #2531. The four adapter changes directly implement the requested guards, diagnostics, padding behavior, and budget handling. tests/buffered-response-shape-guards.test.ts provides in-scope regression coverage. No unrelated production changes are identified.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@snowyukitty

Copy link
Copy Markdown
Contributor Author

Verification record for this head (5d35684d, rebased onto dev@98ed186c).

Local test evidence

Check Result
tests/buffered-response-shape-guards.test.ts 46 pass / 0 fail
Activation control — same file on detached upstream/dev@98ed186c 21 pass / 25 fail
Blast radius — 97 files importing any of the four adapters 1606 pass / 1 fail
bun x tsc --noEmit identical to clean dev
bun scripts/privacy-scan.ts passed

Disclosing the one failure rather than silently ticking the box. It is
tests/translator-budget.test.ts"production adapter contract rejects omitted translator budgets
at typecheck"
. It shells out to tsc with --ignoreConfig, which this environment's TypeScript
does not recognise, so it expects TS2554 and gets TS5023. Controlled on a detached
upstream/dev@98ed186c checkout with the same runner and the same file: fails identically, 14 pass /
1 fail.
Pre-existing and environmental, not from this PR.

The typecheck line above is parity, not a clean exit: clean dev also reports the known
Cannot find module '@napi-rs/keyring' error, and the output is byte-identical with and without this
change.

How the findings were produced. Not from a field report — from an enumerative sweep that drives
each adapter's real parseStream/parseResponse over every addressable JSON location in a healthy
exemplar, with hostile values drawn from the shapes that actually reproduced in #1219 / #1325 / #2231
/ #2232 rather than a generic corpus. 2,937 single-location mutations across five adapters in both
modes. Baseline vs. this head, same harness:

cells: 2937 before / 2937 after / 2937 compared; changed: 50
    8  anthropic/buffered:   CRASH        -> ERROR_EVENT
   19  anthropic/buffered:   OK           -> ERROR_EVENT
   11  anthropic/buffered:   SILENT_EMPTY -> ERROR_EVENT
    2  anthropic/buffered:   TEXT_LOSS    -> ERROR_EVENT
    4  command-code/stream:  CRASH        -> OK
    1  google/buffered:      CRASH        -> ERROR_EVENT
    5  openai-chat/buffered: SILENT_EMPTY -> ERROR_EVENT
remaining CRASH/TIMEOUT: none

openai-responses and every streaming path are cell-for-cell identical, so the change is confined to
the four adapters it claims. All 13 crashes and 27 silent-empty cells were re-confirmed as still
reproducing on dev@98ed186c immediately before this was filed.

Pre-submission review. The diff went through an independent three-reviewer panel before
submission (gpt-5.6-sol xhigh, grok-4.6, gemini-3.7-flash). Two approved; one requested changes with
three findings. Two were accepted and fixed in this head:

  • an empty-array carve-out on choice.message copied by analogy from diagnoseGoogleContent — that
    rationale is specific to a protobuf-derived wire where content is genuinely an array, so it does
    not transfer to a record on a plain-JSON wire. message now requires a record; every array is
    rejected.
  • a test whose comment claimed trailing-buffer coverage it did not have, because the helper appended
    a newline to every line so const final = buffer.trim() was always empty. Rewritten to build a
    body with no trailing newline.

The third is the command-code behaviour change disclosed in the PR body, declined on the reasoning
given there and stated plainly rather than softened. Happy to take the other disposition if you
prefer it — it is a small change either way.

@github-actions
github-actions Bot marked this pull request as ready for review August 25, 2026 05:03
@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 68 / 80

설명: 이 풀은 버퍼로 받은 응답과 커맨드코드 NDJSON 한 줄이 레코드가 아닐 때 턴이 터지거나 빈 성공으로 끝나는 구멍을 막는다. 지금 CURRENT dev HEAD 는 98ed186 이다. 이번 시간에 SHA 는 안 움직였다. 작성자는 snowyukitty 다. 라벨은 bug 와 review-ready 다. 드래프트가 아니다. mergeable 은 true 다. 베이스는 지금 origin/dev 다. 라벨은 바꾸지 말 것.

HEAD 의 구멍은 네 군데다. command-code.ts ndjson 368줄과 379줄은 JSON.parse 를 try/catch 로만 감싼다. JSON.parse 에 null 을 넣으면 던지지 않고 null 을 준다. 그 null 이 parseStream 527줄 switch (event.type) 에서 TypeError 가 된다. google.ts parseResponse 1161줄은 같은 이유로 raw.error 에서 터진다. anthropic.ts 1282줄은 json.content 에서 터진다. openai-chat.ts 1934줄은 if (!choice.message) 가 참거짓만 본다. 문자열이나 true 나 배열은 통과하고 속성 읽기가 전부 undefined 가 되어 빈 done 으로 끝난다. anthropic 의 content 가 문자열이면 for-of 가 글자 하나씩 돌고 역시 빈 성공이다. 이 길은 죽어 있지 않다. responses/core.ts 가 비스트림 턴에서 parseResponse 를 부른다.

이 풀은 네 규칙을 맞춘다. 스트림 프레임 루트가 레코드가 아니면 건너뛴다. 버퍼 본문 루트가 레코드가 아니면 에러로 닫는다. 안에 있다고 주장한 content 와 message 가 모양이 아니면 역시 닫는다. content 빈 배열은 살리고 message 빈 배열은 거절한다. 구글의 isGoogleRecord 와 googleStructuralValueType 은 이미 HEAD 483-490줄에 있다. 새로 만들지 않았다. 커맨드코드는 decodeEventLine 한 곳으로 줄 루프와 끝 버퍼를 모았다. debugDroppedFrame 은 이미 src/lib/debug.ts 18줄에 있다.

시험은 tests/buffered-response-shape-guards.test.ts 하나다. 구글 본문 루트, 클로드 본문 루트, content 칸과 블록, 커맨드코드 중간 줄과 끝 버퍼, 챗 메시지, 예산 누수를 본다. 끝 버퍼 시험은 마지막 줄바꿈을 빼서 final 분기를 탄다. 예산 시험은 dispose 전에 재고를 본다. 구글 비레코드 본문은 예산 목록에 없다. 그 길은 charge 전에 돌아가서 새 누수는 아니다. 위쪽이 준 숫자는 46 통과다. 내가 여기서 다시 돌리지는 않는다.

합치기 전에 남는 제품 결정은 커맨드코드다. 쓰레기만 있는 스트림은 예전엔 TypeError 였고 이 풀 뒤에는 done 하나다. 빈 본문, 빈 줄, 파싱 실패 스트림이 이미 HEAD 592줄 if (!sawFinish) 에서 같은 끝이다. 비대칭을 만들지 않으려면 네 번째도 같다. 이건 고치기 전에 이미 있던 계약이다. 이 풀이 새로 정할 일이 아니다. types.ts 와 config.ts 가르기와는 무관하다. 프리뷰 배포가 아니다. 2531 은 이 풀이 머지되기 전에 닫지 말 것. 1240 을 다시 열지 말 것. 2423 2472 2210 도 이 풀로 닫지 말 것. 그 구멍은 다른 길이다.

src/adapters/command-code.ts 368줄 HEAD - JSON.parse 가 null 을 던지지 않아 event.type 에서 턴이 죽는다
src/adapters/command-code.ts 379줄 HEAD - 끝 버퍼 분기가 같은 구멍을 따로 가지고 있다. 이 풀이 decodeEventLine 로 모은다
src/adapters/google.ts 1161줄 HEAD - 본문 null 이 raw.error 에서 TypeError 가 된다
src/adapters/anthropic.ts 1282줄 HEAD - 본문 null 이 json.content 에서 TypeError 가 된다. content 문자열이 빈 성공이 된다
src/adapters/openai-chat.ts 1934줄 HEAD - if (!choice.message) 가 참거짓만 본다. 문자열 true 배열이 빈 성공이 된다
src/adapters/command-code.ts 592줄 HEAD - 유효 이벤트가 없는 스트림은 이미 done 이다. 쓰레기만 있는 입력도 이 계약에 합류한다
tests/buffered-response-shape-guards.test.ts 예산 목록 - 구글 비레코드 본문이 없다. charge 전 반환이라 새 누수는 아니지만 목록이 비대칭이다

메인테이너의 판단이 필요한 지점

  • 이 풀을 지금 합칠지. 내가 머지하지 않는다. 레디이고 베이스는 지금 HEAD 다. 시험이 구멍과 건강한 본문을 같이 고정한다
  • 쓰레기만 있는 커맨드코드 스트림을 빈 성공으로 둘지. 두는 편이 맞다. 빈 본문과 파싱 실패가 이미 그 끝이다. 이 풀에서 바꾸지 말 것
  • 2531 을 지금 닫을지. 닫지 말 것. 이 풀이 머지된 뒤에 GitHub 가 Closes 로 닫는다
  • 구글 비레코드 본문을 예산 시험에 넣을지. 넣어도 된다. 막지는 않는다

너의 추천
기다린다. 레디로 둔다. 내가 머지하지 않는다. 메인테이너가 합친다. 합친 뒤에 2531 을 닫는다. 지금은 2531 을 연다. 쓰레기만 있는 스트림의 done 은 그대로 둔다. 1240 2423 2472 2210 은 연다. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

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

@lidge-jun
lidge-jun merged commit fea4538 into lidge-jun:dev Aug 25, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants