Skip to content

fix(json): unwrap single-element array wrapper for object schemas - #1152

Merged
murdore merged 1 commit into
releasefrom
fix/audit-structured-coerce
Jul 15, 2026
Merged

murdore merged 1 commit into
releasefrom
fix/audit-structured-coerce

Conversation

@murdore

@murdore murdore commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

What

On the native-Anthropic structured-output path under escaping stress, models sometimes return [{...}] instead of {...} for an object schema. Because a JS array is typeof "object", coerceJsonToSchema accepted it as firstValid and returned an array as structuredData, failing the caller's object-shaped schema. Caught adversarially during the audit's live test:json-e2e run (#635).

Fix

When a candidate array does not itself satisfy the schema, and it holds exactly one non-array object element that does satisfy it, unwrap to that element. Gated by safeParse, mirroring the existing nested-string unwrap:

  • array-typed schemas keep their array (no over-unwrap)
  • multi-element arrays are left untouched (never silently reduced to their first element)

Proof

coerce-nested-unwrap 9/9 (3 new tests), structured-coerce 7/7, json 21/21 — no regressions.

Refs #635 (feature already shipped; this hardens the fallback edge case).

Summary by CodeRabbit

  • Bug Fixes

    • Improved structured data handling when valid object data is wrapped in a single-element array.
    • Preserved array results when the schema expects an array or when multiple elements are present.
  • Tests

    • Added coverage for single-element arrays, array schemas, and multi-element arrays.

Copilot AI review requested due to automatic review settings July 12, 2026 05:28
@vercel

vercel Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Ready Ready Preview, Comment Jul 15, 2026 9:25pm

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4981e5d2-6382-467b-b03e-0c1a1a03e751

📥 Commits

Reviewing files that changed from the base of the PR and between 3881530 and fbcb9fb.

📒 Files selected for processing (2)
  • src/lib/utils/json/coerce.ts
  • test/continuous-test-suite-coerce-nested-unwrap.ts

📝 Walkthrough

Walkthrough

coerceJsonToSchema now unwraps single-element arrays containing schema-valid objects after initial validation fails. Tests verify object unwrapping, preservation of array schemas, and non-unwrapping of multi-element arrays.

Changes

JSON wrapper repair

Layer / File(s) Summary
Wrapper repair and validation
src/lib/utils/json/coerce.ts, test/continuous-test-suite-coerce-nested-unwrap.ts
Single-element arrays containing non-null objects are unwrapped and revalidated as repaired candidates, while array schemas and multi-element arrays retain array results.

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

Possibly related PRs

  • juspay/neurolink#1088: Updates coerceJsonToSchema with related schema-recovery and candidate-selection behavior.
🚥 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 Title clearly summarizes the main change: unwrapping single-element array wrappers for object schemas in JSON coercion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audit-structured-coerce

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@github-actions

github-actions Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: fbcb9fba11b8a5bfa237c2474d16d34a2dabfb22
  • Message: fix(json): unwrap single-element array wrapper for object schemas
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

Copilot AI 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.

Pull request overview

Hardens coerceJsonToSchema against a native-Anthropic structured-output edge case where an object schema response may arrive as a single-element array ([{...}]), causing callers to receive an array-shaped structuredData even when the schema expects an object.

Changes:

  • Add a schema-gated unwrap for single-element array wrappers where the lone element validates against the provided schema.
  • Add continuous tests covering the unwrap behavior and ensuring array-typed schemas aren’t over-unwrapped.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/lib/utils/json/coerce.ts Adds single-element array-wrapper unwrapping logic gated by safeParse.
test/continuous-test-suite-coerce-nested-unwrap.ts Adds tests for the array-wrapper unwrap behavior and related invariants.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +263 to 281
} else if (
// Single-element array wrapper: for an OBJECT schema, models sometimes
// return `[{...}]` instead of `{...}` (seen on the native Anthropic
// path under escaping stress). Unwrap a lone object element and
// re-validate — the safeParse gate rejects an incorrect unwrap, so an
// array schema (which validates the array directly above) is untouched.
Array.isArray(outcome.value) &&
outcome.value.length === 1 &&
outcome.value[0] !== null &&
typeof outcome.value[0] === "object" &&
!Array.isArray(outcome.value[0]) &&
safeParseable.safeParse(outcome.value[0]).success
) {
schemaValid.push({
value: outcome.value[0],
repaired: true,
truncated: candidate.truncated,
});
}
Comment on lines +149 to +163
const inner = {
summary: "wrapped in an array",
attachment: null,
};
const r = coerceJsonToSchema(JSON.stringify([inner]), schema);
assertEqual(
Array.isArray(r?.structuredData),
false,
"result is the object, not an array",
);
assertEqual(
obj(r).summary,
"wrapped in an array",
"object content recovered",
);

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review Summary

Files reviewed: 2

  • src/lib/utils/json/coerce.ts
  • test/continuous-test-suite-coerce-nested-unwrap.ts

New issues raised this run: 0

Decision: Approve

The fix is narrow, well-gated by safeParse, and correctly avoids over-unwrapping array schemas and multi-element arrays. The new tests cover the stated native-Anthropic [{...}] regression case and the anti-regression cases.

I did not raise any new inline comments because the two substantive points I would have flagged are already covered by the existing Copilot review threads:

  • The array-wrapper branch validates outcome.value[0] before deepUnwrapJsonStrings is applied, so a wrapped object that only becomes schema-valid after nested-string unwrapping is still returned as an array.
  • The test suite lacks coverage for that combined wrapper + nested-string case.

Those are worth addressing, but per review protocol I am not re-raising already-commented points as blocking criteria. No new security, architectural, or backward-compatibility concerns were introduced.

On the native Anthropic path under escaping stress, models sometimes
return `[{...}]` instead of `{...}` for an object schema. Because a
JS array is `typeof 'object'`, coerceJsonToSchema accepted it as
firstValid and returned an array as structuredData, failing the caller's
object-shaped schema.

When a candidate array does not itself satisfy the schema, and it holds
exactly one non-array object element that DOES satisfy it, unwrap to that
element. Gated by safeParse, mirroring the existing nested-string unwrap:
array-typed schemas keep their array, and multi-element arrays are left
untouched (never silently reduced to their first element).

Adds 3 regression tests (coerce-nested-unwrap 9/9). Refs #635.
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore
murdore merged commit 344ee70 into release Jul 15, 2026
17 checks passed
@murdore
murdore deleted the fix/audit-structured-coerce branch July 15, 2026 21:30

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review Summary

Files reviewed: 2

  • src/lib/utils/json/coerce.ts
  • test/continuous-test-suite-coerce-nested-unwrap.ts

New issues raised this run: 0

I analyzed the single-element array unwrap logic added to coerceJsonToSchema. The change is narrowly scoped, gated by safeParse, and correctly preserves array schemas and multi-element arrays. The regression tests cover the motivating native-Anthropic [{...}] case, the no-over-unwrap array-schema case, and the multi-element rejection case.

I noted the two existing unresolved review comments from the automated reviewer regarding the combined wrapper + nested-string-unwrap scenario. Those are already raised and should be addressed by the author; I am not duplicating them here.

No blocking issues (security, backward compatibility, CLAUDE.md critical-rule violations, or 3+ MAJOR issues) were found in this run. Approving.

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.88.9 🎉

The release is available on:

Your semantic-release bot 📦🚀

mansiverma897993 added a commit to mansiverma897993/neurolink that referenced this pull request Aug 8, 2026
On the native Anthropic-direct path a max_tokens cut left `structuredData`
as a string instead of the schema object. When output is truncated the root
brace never closes, so the balanced-span scan walks past it and matches a
bracket pair living INSIDE a string value — `[step 1]` in a shell script
becomes `["step 1"]` — reported with `truncated: false`. At other cut points
nothing parsed at all, coerceJsonToSchema returned null, and the caller kept
the raw text. A prefix sweep over a realistic huge-output payload hit the
first case at ~22% of cut points and the second at a handful more.

coerce: mark candidates that start at the document's first opening bracket
and order those first, so the partial real root beats a span scraped from
inside a string; flag every candidate truncated when the root never closes;
and when an unclosed root yields nothing trustworthy, back off to the last
completed structural boundary and repair from there, returning a PARTIAL
object rather than degrading to raw text.

consumers: add schemaAccepts and gate structuredData on it — a scalar root,
or an experimental_output string that is not an exact raw-text echo, is only
published when the caller's schema accepts it. neurolink also re-runs
recovery when a provider already produced a schema-rejected string.
String-root schemas are unaffected.

anthropic: forced-json mode drops text blocks because the payload rides in
the synthetic tool's input; when the response is cut short that tool call can
be missing entirely, leaving an empty completion. Keep the text as a fallback
when no synthetic tool call arrived, so a partial object can still be
salvaged. final_result still supersedes it.

Adds test:coerce-truncation (8 tests) sweeping every truncation point of a
huge-output payload. Fixes juspay#1156. Refs juspay#635, juspay#1152.
murdore pushed a commit to mansiverma897993/neurolink that referenced this pull request Aug 14, 2026
On the native Anthropic-direct path a max_tokens cut left `structuredData`
as a string instead of the schema object. When output is truncated the root
brace never closes, so the balanced-span scan walks past it and matches a
bracket pair living INSIDE a string value — `[step 1]` in a shell script
becomes `["step 1"]` — reported with `truncated: false`. At other cut points
nothing parsed at all, coerceJsonToSchema returned null, and the caller kept
the raw text. A prefix sweep over a realistic huge-output payload hit the
first case at ~22% of cut points and the second at a handful more.

coerce: mark candidates that start at the document's first opening bracket
and order those first, so the partial real root beats a span scraped from
inside a string; flag every candidate truncated when the root never closes;
and when an unclosed root yields nothing trustworthy, back off to the last
completed structural boundary and repair from there, returning a PARTIAL
object rather than degrading to raw text.

consumers: add schemaAccepts and gate structuredData on it — a scalar root,
or an experimental_output string that is not an exact raw-text echo, is only
published when the caller's schema accepts it. neurolink also re-runs
recovery when a provider already produced a schema-rejected string.
String-root schemas are unaffected.

anthropic: forced-json mode drops text blocks because the payload rides in
the synthetic tool's input; when the response is cut short that tool call can
be missing entirely, leaving an empty completion. Keep the text as a fallback
when no synthetic tool call arrived, so a partial object can still be
salvaged. final_result still supersedes it.

Adds test:coerce-truncation (8 tests) sweeping every truncation point of a
huge-output payload. Fixes juspay#1156. Refs juspay#635, juspay#1152.
murdore pushed a commit to mansiverma897993/neurolink that referenced this pull request Aug 14, 2026
On the native Anthropic-direct path a max_tokens cut left `structuredData`
as a string instead of the schema object. When output is truncated the root
brace never closes, so the balanced-span scan walks past it and matches a
bracket pair living INSIDE a string value — `[step 1]` in a shell script
becomes `["step 1"]` — reported with `truncated: false`. At other cut points
nothing parsed at all, coerceJsonToSchema returned null, and the caller kept
the raw text. A prefix sweep over a realistic huge-output payload hit the
first case at ~22% of cut points and the second at a handful more.

coerce: mark candidates that start at the document's first opening bracket
and order those first, so the partial real root beats a span scraped from
inside a string; flag every candidate truncated when the root never closes;
and when an unclosed root yields nothing trustworthy, back off to the last
completed structural boundary and repair from there, returning a PARTIAL
object rather than degrading to raw text.

consumers: add schemaAccepts and gate structuredData on it — a scalar root,
or an experimental_output string that is not an exact raw-text echo, is only
published when the caller's schema accepts it. neurolink also re-runs
recovery when a provider already produced a schema-rejected string.
String-root schemas are unaffected.

anthropic: forced-json mode drops text blocks because the payload rides in
the synthetic tool's input; when the response is cut short that tool call can
be missing entirely, leaving an empty completion. Keep the text as a fallback
when no synthetic tool call arrived, so a partial object can still be
salvaged. final_result still supersedes it.

Adds test:coerce-truncation (8 tests) sweeping every truncation point of a
huge-output payload. Fixes juspay#1156. Refs juspay#635, juspay#1152.
mansiverma897993 added a commit to mansiverma897993/neurolink that referenced this pull request Aug 15, 2026
On the native Anthropic-direct path a max_tokens cut left `structuredData`
as a string instead of the schema object. When output is truncated the root
brace never closes, so the balanced-span scan walks past it and matches a
bracket pair living INSIDE a string value - `[step 1]` in a shell script
becomes `["step 1"]` - reported with `truncated: false`. At other cut points
nothing parsed at all, coerceJsonToSchema returned null, and the caller kept
the raw text. A prefix sweep over a realistic huge-output payload hit the
first case at ~22% of cut points and the second at a handful more.

coerce: mark candidates that start at the document's first opening bracket
and order those first, so the partial real root beats a span scraped from
inside a string; flag every candidate truncated when the root never closes;
and when an unclosed root yields nothing trustworthy, back off to the last
completed structural boundary and repair from there, returning a PARTIAL
object rather than degrading to raw text.

consumers: add schemaAccepts and gate structuredData on it - a scalar root,
or an experimental_output string that is not an exact raw-text echo, is only
published when the caller's schema accepts it. neurolink also re-runs
recovery when a provider already produced a schema-rejected string.
String-root schemas are unaffected. The shared scalar-recovery policy lives
in recoverScalarRoot (with ScalarRecoveryDecision in src/lib/types), used by
both neurolink.recoverStructuredData and GenerationHandler.coerceTextMode so
it cannot drift.

anthropic: forced-json mode drops text blocks because the payload rides in
the synthetic tool's input; when the response is cut short that tool call can
be missing entirely, leaving an empty completion. Keep the text as a fallback
when no synthetic tool call arrived, so a partial object can still be
salvaged. final_result still supersedes it.

Adds test:coerce-truncation (8 tests) sweeping every truncation point of a
huge-output payload, with assertion messages restricted to structural cut
positions (never recovered payload values) so a genuine failure reports as
FAIL, not SKIP. Adds a test:json-e2e cell on the direct Anthropic path that
forces a maxTokens cut and asserts structuredData is a plain object with
jsonTruncated === true. Fixes juspay#1156. Refs juspay#635, juspay#1152.
murdore pushed a commit that referenced this pull request Aug 15, 2026
On the native Anthropic-direct path a max_tokens cut left `structuredData`
as a string instead of the schema object. When output is truncated the root
brace never closes, so the balanced-span scan walks past it and matches a
bracket pair living INSIDE a string value - `[step 1]` in a shell script
becomes `["step 1"]` - reported with `truncated: false`. At other cut points
nothing parsed at all, coerceJsonToSchema returned null, and the caller kept
the raw text. A prefix sweep over a realistic huge-output payload hit the
first case at ~22% of cut points and the second at a handful more.

coerce: mark candidates that start at the document's first opening bracket
and order those first, so the partial real root beats a span scraped from
inside a string; flag every candidate truncated when the root never closes;
and when an unclosed root yields nothing trustworthy, back off to the last
completed structural boundary and repair from there, returning a PARTIAL
object rather than degrading to raw text.

consumers: add schemaAccepts and gate structuredData on it - a scalar root,
or an experimental_output string that is not an exact raw-text echo, is only
published when the caller's schema accepts it. neurolink also re-runs
recovery when a provider already produced a schema-rejected string.
String-root schemas are unaffected. The shared scalar-recovery policy lives
in recoverScalarRoot (with ScalarRecoveryDecision in src/lib/types), used by
both neurolink.recoverStructuredData and GenerationHandler.coerceTextMode so
it cannot drift.

anthropic: forced-json mode drops text blocks because the payload rides in
the synthetic tool's input; when the response is cut short that tool call can
be missing entirely, leaving an empty completion. Keep the text as a fallback
when no synthetic tool call arrived, so a partial object can still be
salvaged. final_result still supersedes it.

Adds test:coerce-truncation (8 tests) sweeping every truncation point of a
huge-output payload, with assertion messages restricted to structural cut
positions (never recovered payload values) so a genuine failure reports as
FAIL, not SKIP. Adds a test:json-e2e cell on the direct Anthropic path that
forces a maxTokens cut and asserts structuredData is a plain object with
jsonTruncated === true. Fixes #1156. Refs #635, #1152.

This branch was successfully deployed

1 active deployment
Preview — fbcb9fba Deployed Jul 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants