Skip to content

fix(responses): validate message content blocks before use (#435)#436

Closed
snowyukitty wants to merge 2 commits into
lidge-jun:devfrom
snowyukitty:fix/responses-parser-content-validation
Closed

fix(responses): validate message content blocks before use (#435)#436
snowyukitty wants to merge 2 commits into
lidge-jun:devfrom
snowyukitty:fix/responses-parser-content-validation

Conversation

@snowyukitty

@snowyukitty snowyukitty commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes #435.

Problem

The strict schemas do require well-formed content blocks — inputTextSchema is
{ type: "input_text", text: z.string() }. But inputItemSchema ends with a permissive catch-all:

export const inputItemSchema = z.union([
  userMessageItemSchema,
  // …
  z.object({ type: z.string() }).loose(),   // <-- malformed message items land here
]);

A message item whose content blocks fail their strict schema is therefore not rejected — it falls
through to that catch-all, which passes the raw content through untouched. inputContentParts()
and outputTextOf() then cast it without checking:

parts.push({ type: "text", text: (block as { text: string }).text });   // text may be absent

Five outcomes, all confirmed against dev at 6f4cd1d6:

Input block Result
user + {"type":"input_text"} (no text) message content becomes undefined → adapter throws TypeError
system + {"type":"input_text"} parseRequest throws undefined is not an object (evaluating 'text.map')
user + [null] parseRequest throws null is not an object (evaluating 'block.type')
user + {"type":"input_text","text":{"a":1}} content becomes the object {"a":1} — neither string nor array
assistant + {"type":"output_text"} (no text) emits a bare {"type":"text"} part with no text

The undefined content comes from the single-part collapse, which returns the unvalidated field
directly:

if (parts.length === 1 && parts[0].type === "text") return parts[0].text;   // undefined

That last table row is the same malformed shape behind the Anthropic 400 in #420
(content.N.text.text: Field required), reached from a different direction.

outputToToolResultContent() in this same file already validates correctly — if (!isObj(raw)) continue;, if (typeof raw.text === "string"). This PR brings the other two readers in line with it.

Fix

src/responses/parser.ts, in inputContentParts() and outputTextOf():

  • Skip non-object blocksisObj(raw) guard, exactly as outputToToolResultContent does.
  • Require a string text / refusal before emitting a part, so a malformed block is dropped
    instead of becoming {"type":"text"} with no text.
  • Require a non-empty string image_url before treating a block as an image. A non-string
    previously reached parseDataUrl(), which calls .match() on it; it now falls back to the
    existing [image: <file_id>] marker.
  • Resolve input_file / file_id refs only from strings, so a non-string cannot be interpolated
    into the marker.

With every emitted part well-formed, the single-part collapse can no longer return undefined, and
message content is always a string or an array — which is what every adapter already assumes.

Malformed blocks are dropped rather than coerced: coercing an object to text yields
"[object Object]", which is worse than omitting it, and dropping matches the precedent in
outputToToolResultContent.

Tests

  • tests/responses-parser-malformed-content.test.ts (new, 10 cases): one per table row, plus
    valid-blocks-survive-alongside-malformed, a valid-image structural control, and an end-to-end case
    asserting the Google and Anthropic adapters build a request instead of throwing. 9 of 10 fail
    without the source change
    ; the valid-image control passes either way.
  • Parser-adjacent suites green — responses, bridge, chat-completions, claude-messages, vision,
    compaction and friends (21 files): 272 passed.
  • bun run typecheck and bun run privacy:scan pass.
  • Full suite: 4,094 passed, 4 failed, 4 skipped across 323 files. The 4 failures are
    pre-existing on this Windows machine and were each reproduced on a clean dev checkout while
    verifying fix(google): never emit empty or malformed content parts (#420) #430ci-workflows (2, Bun.spawnSync(["bun", …]) needs bun on PATH; passes 10/10
    with it), codex-catalog routed-normalization (1), and openai-provider-option-e2e (1, passes
    alone). None touch the Responses parser.

A note on how the full suite was run: a single bun test process hangs on this machine partway
through the anthropic e2e files, and that hang reproduces on a clean dev checkout with no changes
applied, so it is environmental. I ran the suite in 27 batches instead; the one batch that hit the
hang was then re-run file-by-file (120 passed, 0 failed). CI remains the authority.

Notes

  • Well-formed requests are unaffected. Every guard added is satisfied by input that already
    matched the strict schema, so valid requests produce identical output. The one place I had to be
    careful was detail: b.detail ? … and typeof b.detail === "string" differ for detail: "", so
    the check is typeof b.detail === "string" && b.detail.length > 0 to preserve the existing
    omit-on-empty behavior.
  • Scope is deliberately the two unvalidated readers. Tightening inputItemSchema's catch-all
    would be the deeper fix, but that catch-all is load-bearing — it is how unknown and newly added
    item types pass through the proxy — so narrowing it risks rejecting items that are currently
    relayed fine. Happy to look at that separately if you want it.
  • No authentication, credential, logging, or workflow path is touched.

Summary by CodeRabbit

  • Bug Fixes
    • Hardened parsing of response and request content blocks with runtime validation.
    • Prevented malformed text, image, file, and refusal fields from producing undefined output or crashing.
    • Ensured malformed blocks safely degrade while preserving any valid content.
    • Continued successful processing for requests with malformed content across supported adapters.
  • Tests
    • Added a new test suite covering malformed content blocks, null/non-array inputs, invalid images, and refusal handling.

A message item whose content blocks fail their strict schema falls through
to inputItemSchema's permissive catch-all, which passes the raw content
through untouched. inputContentParts and outputTextOf then cast without
checking, so a malformed block could crash parseRequest itself (system
role, null block), produce undefined or object message content that throws
in the provider adapter, or emit a text part with no text field.

Validate each block and field before use, matching the pattern
outputToToolResultContent in the same file already applies.
@github-actions github-actions Bot added the bug Something isn't working label Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The response parser now validates malformed content blocks before reading fields, safely handles invalid images and files, and updates call sites to pass raw content. New tests cover user, system, assistant, Google, and Anthropic parsing paths.

Changes

Malformed content handling

Layer / File(s) Summary
Parser validation and call-site updates
src/responses/parser.ts
inputContentParts and outputTextOf now validate runtime block objects and string fields, safely omit malformed output, represent invalid images/files with markers, and receive raw message content without casts.
Malformed content and adapter coverage
tests/responses-parser-malformed-content.test.ts
Tests verify malformed blocks are safely omitted or represented, valid image and neighboring blocks remain intact, and Google and Anthropic adapters build requests without throwing.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: ingwannu, lidge-j

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: validating Responses message content blocks before use.
Linked Issues check ✅ Passed The parser guards and malformed-content tests address #435's crashes, unchecked casts, and adapter failures.
Out of Scope Changes check ✅ Passed The changes stay focused on parser validation and related tests, with no clear unrelated scope added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/responses/parser.ts`:
- Line 38: Guard the content container before iteration: update
inputContentParts and outputTextOf to accept unknown values and return [] unless
the value is a string or an array, adding an Array.isArray(blocks) check before
each loop. In tests/responses-parser-malformed-content.test.ts lines 33-43, add
regressions for malformed user, system, and assistant content such as content:
{}, asserting parsing does not throw.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c6a6a6f2-fe7d-48f9-bca3-20b1eccb9cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 6f4cd1d and d3f0904.

📒 Files selected for processing (2)
  • src/responses/parser.ts
  • tests/responses-parser-malformed-content.test.ts

Comment thread src/responses/parser.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3f090494f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/responses/parser.ts
// A malformed message item fails its strict schema and falls through to inputItemSchema's
// permissive catch-all, so blocks reaching here are NOT guaranteed to match the declared
// shape. Validate each field before use, as outputToToolResultContent already does.
if (!isObj(raw)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard the content container before iterating

When the permissive catch-all receives a message whose content is a plain object or scalar—for example, {type:"message", role:"user", content:{type:"input_text", text:"x"}}—that outer value also passes through unvalidated. The for (const raw of blocks) loop then throws before this new per-block guard executes, so malformed user, system, or assistant content can still cause a server exception. Check Array.isArray(blocks) before iterating, apply the same guard in outputTextOf, and add focused object/scalar regression cases.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in acfe5c1 — thank you, this was a real gap in the fix.

I had guarded each block but not the container. Verified against the previous commit:

user content = object          -> THREW: {} is not iterable
user content = number          -> THREW: number is not iterable
user content = true            -> THREW: true is not iterable
system content = object        -> THREW: {} is not iterable
assistant content = object     -> THREW: {} is not iterable

Both readers now take unknown and return [] unless the value is a string or an array, so the container is validated before the loop. That also let me drop the now-redundant as unknown[] | string | undefined casts at all four call sites.

Added two cases covering object/number/boolean containers across the user, system, and assistant roles; both fail against the previous commit. Suite is now 12 passed, 22 parser-adjacent suites 310 passed, typecheck and privacy scan green.

The catch-all can also retain a non-array `content` (an object, a number,
a boolean). Both readers threw at `for (const raw of blocks)` before any
per-block guard ran, so a malformed container still crashed parseRequest.

Accept unknown and return [] unless the value is a string or an array,
which also removes the now-redundant call-site casts. Addresses
CodeRabbit review feedback on lidge-jun#436.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/responses/parser.ts (1)

44-56: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Drop reference-less malformed image/file blocks.

When image_url is invalid and no non-empty string file_id exists, this emits [image: ?]; { type: "input_file" } emits [file: ?]. Those fabricated parts can be forwarded to providers as user-visible content, contrary to the PR objective to drop malformed blocks. Emit a marker only when a non-empty string reference exists; otherwise skip the block. Add regressions for both cases in tests/responses-parser-malformed-content.test.ts.

Proposed fix
       } else if (block.type === "input_image") {
         const b = block as { image_url?: string; file_id?: string; detail?: string };
         if (typeof b.image_url === "string" && b.image_url.length > 0) {
           parts.push({ type: "image", imageUrl: b.image_url, ...(typeof b.detail === "string" && b.detail.length > 0 ? { detail: normalizeImageDetail(b.detail) } : {}) });
-        } else {
-          parts.push({ type: "text", text: `[image: ${typeof b.file_id === "string" ? b.file_id : "?"}]` });
+        } else if (typeof b.file_id === "string" && b.file_id.length > 0) {
+          parts.push({ type: "text", text: `[image: ${b.file_id}]` });
         }
       } else if (block.type === "input_file") {
         const b = block as { file_id?: string; filename?: string };
-        const ref = typeof b.file_id === "string" ? b.file_id : typeof b.filename === "string" ? b.filename : "?";
-        parts.push({ type: "text", text: `[file: ${ref}]` });
+        const ref = typeof b.file_id === "string" && b.file_id.length > 0
+          ? b.file_id
+          : typeof b.filename === "string" && b.filename.length > 0
+            ? b.filename
+            : undefined;
+        if (ref !== undefined) parts.push({ type: "text", text: `[file: ${ref}]` });
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/responses/parser.ts` around lines 44 - 56, Update the input_image and
input_file branches in the response parser to emit fallback text markers only
when a non-empty string file_id or filename reference exists; otherwise skip the
malformed block without adding a part. Preserve structured image handling for
valid non-empty image_url values, and add regressions covering reference-less
malformed images and files in tests/responses-parser-malformed-content.test.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/responses/parser.ts`:
- Around line 44-56: Update the input_image and input_file branches in the
response parser to emit fallback text markers only when a non-empty string
file_id or filename reference exists; otherwise skip the malformed block without
adding a part. Preserve structured image handling for valid non-empty image_url
values, and add regressions covering reference-less malformed images and files
in tests/responses-parser-malformed-content.test.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 85fb0661-2587-40dd-be98-ef8b855370f5

📥 Commits

Reviewing files that changed from the base of the PR and between d3f0904 and acfe5c1.

📒 Files selected for processing (2)
  • src/responses/parser.ts
  • tests/responses-parser-malformed-content.test.ts

@lidge-jun

Copy link
Copy Markdown
Owner

🔒 Maintainer integration in progress — please hold off on merging

@lidge-jun (maintainer) is actively integrating this PR into dev as part of a coordinated
integration pass. Please do not merge, rebase, force-push, or close this PR until this
marker is removed.

What is happening

This PR is being integrated on the maintainer branch codex/260725-pr-rework, which is
based on dev. The pass applies the contributed change, adds any missing regression
coverage, and repairs review-identified defects before a single verified merge into dev.
Every integrated change goes through bun run typecheck, the full bun run test suite,
bun run privacy:scan, and bun run lint:gui, followed by exact-SHA hosted
Cross-platform CI and Service lifecycle runs.

What this means for you

  • Your authorship and commits are preserved. Nothing is being rewritten under your name.
  • No action is needed from you right now. If a defect repair changes your intended
    behavior, it will be described explicitly in a follow-up comment before the merge.
  • If you have work in flight on this branch, please comment here instead of pushing, so we
    do not race each other.

This PR will be closed with a merge receipt (integration commit SHA, verification output,
and hosted CI links) once the pass lands on dev. Thanks for the contribution.

Integration tracker: devlog/_plan/260725_pr_issue_rework · marker posted by the maintainer integration pass

lidge-jun added a commit that referenced this pull request Jul 25, 2026
PR #436 (head acfe5c1, author snowyukitty)을 통합하고, 유효한 참조가 없는
malformed image/file 블록이 가짜 첨부 마커가 되던 결함을 함께 고친다.

inputItemSchema의 catch-all 때문에 여기 도달하는 블록은 선언된 shape를 보장하지
않는다. 무검증 cast는 content가 [null]이면 block.type 접근에서 throw했다.

추가 repair: 기존 fallback은 참조가 없을 때 '[image: ?]' / '[file: ?]'를 만들어
모델에게 실제 첨부가 있었다고 믿게 했다. 이제 참조 우선순위는 image가
image_url > file_id > 생략, file이 file_id > file_data(+filename) > 생략이다.
빈 문자열과 non-string은 참조로 보지 않고, filename 단독은 파일 resource가 아니며,
file_data 바이트는 어떤 경로에서도 사용자 content로 새지 않는다.

Co-authored-by: snowyukitty <snowyukitty@users.noreply.github.com>
@lidge-jun

Copy link
Copy Markdown
Owner

✅ Integrated into dev

Thank you for this fix. It has been integrated into dev as part of a verified integration pass.

Integration commit: 4cc7f692fix(responses): validate message content blocks before use (#435)
Merged into dev at: ebc62d1f
Your authorship is preserved via Co-authored-by.

What was changed on top

Your validation is what closes the crash, and it is in as written. One follow-on defect was repaired in the same commit: the existing fallback turned a reference-less input_image / input_file into [image: ?] / [file: ?], which tells the model an attachment existed when the request never carried one. The reference precedence is now explicit:

  • image: image_url > file_id > omit
  • file: file_id > file_data (named by filename when present) > omit

A bare filename is not a file resource in the Responses schema, so it is omitted rather than turned into a marker, and file_data bytes never reach user content on any path.

Verification

  • bun test tests/responses-parser-malformed-content.test.ts: 16 pass / 0 fail
  • Activation proof: on unpatched dev, parseRequest({input:[{type:"message",role:"user",content:[null]}]}) throws TypeError: null is not an object (evaluating 'block.type'); with this change it does not
  • Reverting the change fails 15 of the 16 tests
  • Full suite at merge: 4222 pass / 0 fail

Issue #435 is closed by this change. Thanks for the precise write-up of the catch-all cast — it made the failure modes easy to enumerate.

@lidge-jun

Copy link
Copy Markdown
Owner

Closing: integrated into dev at ebc62d1f with authorship preserved. See the receipt comment above for the integration commit, the changes made on top, and the verification evidence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants