Skip to content

[Bug] Buffered parseResponse and the NDJSON frame root escape the #1240 non-record guard #2531

Closed

Description

@snowyukitty

Client or integration

Codex CLI via the OpenCodex proxy (non-streaming turns), plus the Command Code provider.

Area

Adapters — anthropic, google, openai-chat, command-code

Summary

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. Two rungs were never swept,
because neither is an SSE frame parser:

  • the buffered body root, on the parseResponse path reached from
    src/server/responses/core.ts:5065 for non-streaming turns — google and anthropic both throw a
    raw TypeError out of the adapter;
  • the NDJSON frame root in command-code, which crashes on event.type.

Two more sites accept a malformed claimed response and report success with no content:
anthropic's content and openai-chat's choice.message.

#1240's own audit statement names the gap precisely: "The other eight SSE data-frame parsers were
audited and are already correct."
A buffered body is not an SSE data frame, and Command Code is
NDJSON rather than SSE, so neither was ever in scope.

Where

# Site Trigger Result
A src/adapters/command-code.ts ndjson() (newline loop and trailing-buffer branch) any frame is null TypeError: null is not an object (evaluating 'event.type')
B src/adapters/google.ts parseResponse body is null TypeError: null is not an object (evaluating 'raw.error')
C src/adapters/anthropic.ts parseResponse body is null TypeError: null is not an object (evaluating 'json.content')
D src/adapters/anthropic.ts parseResponse content is true or {} TypeError: … is not iterable
E src/adapters/anthropic.ts parseResponse content is [null], or content[i] is null TypeError: null is not an object (evaluating 'block.type')
F src/adapters/anthropic.ts parseResponse content is a string no throw — silent data loss. A string is iterable, so the loop walks a claimed answer one character at a time, reads undefined from every block.type, emits nothing, and reports a clean done.
G src/adapters/openai-chat.ts parseResponse message is "txt", true, or [{…}] no throw — silent data loss. See below.

G is the sharpest. The guard is if (!choice.message) — a truthiness test, so the same input
class splits on truthiness rather than shape:

choice.message Result
null error — fails closed ✅
0 error — fails closed ✅
"ANSWER" successful empty turn ❌
true successful empty turn ❌
[{"role":"assistant","content":"ANSWER"}] successful empty turn, answer discarded ❌

That last row is #2232's content: [{ parts: [...] }] one adapter over. The guard sits one line
below
a full record check on the choice container itself — the message, which holds the assistant's
actual output, was left on a truthiness test. A choice with finish_reason: "stop" and a malformed
message reports success while silently stranding any tool call it claimed.

Checked and clean — not every site is affected

Wire JSON enters through exactly two doors, JSON.parse and response.json(), so this is a census
rather than a sample. Every response-path site in src/adapters/ and src/web-search/ was
enumerated and its guard read:

Site Guard Verdict
kiro-events.ts:36, kiro.ts:1095, kiro-truncation.ts:23 unknown → catch → record predicate → malformed(), plus typed per-field accessors clean
cursor/live-transport.ts:200, 1640 optional chaining clean
cursor/protobuf-events.ts (6 sites) discarded probe, or unknown + record predicate clean
web-search/parse.ts:236 explicit non-record guard clean (#1240)
web-search/{anthropic,xai,gemini,exa}-executor.ts unknown → isRec at every level clean
openai-responses.ts:1838, 1891 unknown → isPlainObject clean
anthropic.ts:258, openai-chat.ts:193 unknown → guarded extractor clean
anthropic.ts:1112, google.ts:846, openai-chat.ts:1623 non-record frame guard clean (#1240)

Kiro is worth naming as the positive example: the largest adapter here, and the only one that
validates every field at the boundary (optionalString, optionalBoolean, tokenCount, each
failing closed with a named reason). This report is not asking for new invention — it is asking the
other adapters to do what kiro already does.

One honest non-finding: mimo-free.ts:151 casts a bootstrap body to { jwt?: string }, so null
throws a TypeError where the next line intends Error("MiMo bootstrap returned no JWT"). Both
fail closed
— error-message quality on a credential path, not a defect of this class.

Reproduction

Against dev@98ed186c:

import { createAnthropicAdapter } from "./src/adapters/anthropic";
import { createOpenAIChatAdapter } from "./src/adapters/openai-chat";
import { createTranslatorBudget } from "./src/lib/translator-budget";

const json = (b: string) => new Response(b, { headers: { "content-type": "application/json" } });
const anthropic = { adapter: "anthropic", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" };
const chat = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" };

// C — throws TypeError: null is not an object (evaluating 'json.content')
await createAnthropicAdapter(anthropic).parseResponse!(json("null"), createTranslatorBudget());

// F — no throw; resolves to [{ type: "done", stopReason: "end_turn" }], the answer discarded
await createAnthropicAdapter(anthropic).parseResponse!(
  json('{"type":"message","role":"assistant","content":"a claimed answer","stop_reason":"end_turn"}'),
  createTranslatorBudget(),
);

// G — no throw; resolves to [{ type: "done" }], text and tool call both discarded
await createOpenAIChatAdapter(chat).parseResponse!(
  json('{"choices":[{"message":[{"role":"assistant","content":"ANSWER"}],"finish_reason":"stop","index":0}]}'),
  createTranslatorBudget(),
);

google (B) reproduces with a body of null through createGoogleAdapter(...).parseResponse.
command-code (A) reproduces with a bare null line anywhere in an NDJSON stream, including between
two text-delta frames whose text has already arrived.

Logs or error output

TypeError: null is not an object (evaluating 'event.type')   // command-code ndjson
TypeError: null is not an object (evaluating 'raw.error')    // google parseResponse
TypeError: null is not an object (evaluating 'json.content') // anthropic parseResponse
TypeError: true is not iterable                              // anthropic content
TypeError: null is not an object (evaluating 'block.type')   // anthropic content[i]
(no error)                                                   // F and G: success, no content

Version

dev@98ed186c (package version 2.27.0).

Operating system

Windows 11 Pro 26200.

Notes

No field evidence that a provider emits these today. #1219 established that a real upstream does emit
data: null padding between content deltas, which is why the frame-root case is not hypothetical;
the rest is hardening by parity with sibling adapters that already refuse the same shapes.

Found by an enumerative sweep rather than by a report — 2,937 single-location mutations across five
adapters in both modes, plus the census above. The method and its limits are described in the PR.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

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

    @lidge-jun
    Owner

    리뷰 · 우선순위 63 / 80

    설명: 이 이슈는 버퍼로 받은 응답 루트와 커맨드코드 NDJSON 한 줄이 레코드가 아닐 때 턴이 터지거나 빈 성공이 된다고 적는다. 작성자는 snowyukitty 다. 라벨은 bug, streaming, tools 다. 지금 CURRENT dev HEAD 는 98ed186 이다. 이번 시간에 SHA 는 안 움직였다. 구현 풀은 2532 다. 이슈를 연다. 닫지 말 것. 라벨은 바꾸지 말 것.

    HEAD 에서 재현 길을 읽었다. command-code.ts 368줄과 379줄은 JSON.parse 를 try/catch 로만 감싼다. null 은 던지지 않는다. parseStream 527줄 event.type 이 TypeError 가 된다. google.ts 1161줄 raw.error, anthropic.ts 1282줄 json.content 가 같은 모양이다. anthropic 의 content 가 문자열이면 1288줄 for-of 가 글자 하나씩 돌고 빈 done 이 된다. openai-chat.ts 1934줄 if (!choice.message) 는 null 과 0 만 막는다. 문자열 true 배열은 통과한다. 이슈가 적은 G 표와 맞다.

    이 구멍은 1240 이 닫은 SSE 프레임 루트와 다른 단이다. 1240 은 SSE 파서 네 개를 막았고, 나머지 여덟은 이미 맞다고 적었다. 버퍼 본문은 SSE 프레임이 아니다. 커맨드코드는 NDJSON 이다. 그래서 그 감사 범위 밖이다. kiro 쪽은 unknown 다음에 레코드 검사로 이미 닫혀 있다. 이슈 표의 clean 목록과 맞다. 호출 쪽 responses/core.ts 비스트림 경로는 어댑터 TypeError 를 구조화 에러로 안 감싼다. 가드가 throw 를 없애는 쪽이 맞다. core 에 catch 를 새로 넣지 말 것.

    구현은 2532 다. 그 풀은 레디이고 베이스는 지금 HEAD 다. 이 이슈를 2532 가 머지되기 전에 닫지 말 것. 2532 본문의 Closes 2531 이 머지 뒤에 닫는다. 2423 빈 완료, 2472 빈 도구 결과, 2210 침묵 끊김은 이 이슈로 닫지 말 것. 그 구멍은 다른 길이다. types.ts 와 config.ts 가르기와는 무관하다. 프리뷰 배포가 아니다. 1240 을 다시 열지 말 것.

    현장 증거가 오늘 위쪽이 이 본문을 낸다는 뜻은 아니다. 이슈도 그렇게 적는다. 1219 가 실제 위쪽이 data: null 패딩을 낸다는 것은 이미 있다. 커맨드코드 중간 null 은 그 모양의 NDJSON 판이다. 나머지는 같은 단을 맞추는 경화다. 내가 머지하지 않는다. 구현이 착지하기 전에 이슈를 닫지 말 것.

    src/adapters/command-code.ts 368줄 - 줄 루프가 null 프레임을 이벤트로 넘겨 event.type 에서 턴이 죽는다
    src/adapters/command-code.ts 379줄 - 끝 버퍼 분기가 같은 구멍을 따로 가지고 있다
    src/adapters/google.ts 1161줄 - 본문 null 이 raw.error 에서 TypeError 가 된다
    src/adapters/anthropic.ts 1282줄과 1288줄 - 본문 null 은 터지고 content 문자열은 빈 성공이 된다
    src/adapters/openai-chat.ts 1934줄 - 참거짓 가드가 문자열 true 배열을 빈 성공으로 보낸다
    2532 - 구현 풀이다. 이 이슈보다 먼저 닫지 말 것

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

    • 이 이슈를 지금 닫을지. 닫지 말 것. 2532 가 아직 열려 있다
    • 1240 을 다시 열지. 열지 말 것. 그 풀은 SSE 프레임만 닫았다
    • core.ts 에 catch 를 넣을지. 넣지 말 것. 어댑터 가드가 throw 를 없애는 쪽이 맞다
    • 2423 2472 2210 을 같이 닫을지. 닫지 말 것

    너의 추천
    이 이슈는 연다. 2532 를 기다린다. 2532 가 머지된 뒤에 닫는다. 지금은 닫지 말 것. 1240 을 다시 열지 말 것. 내가 머지하지 않는다. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

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

  3. lidge-jun commented on Aug 25, 2026

    @lidge-jun
    Owner

    Landed via #2532 at fea4538

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 workinglanded-via-maintainerOriginal PR closed after landing via a maintainer merge trainstreamingSSE, 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