Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/lib/utils/json/coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ export function coerceJsonToSchema(
repaired: true,
truncated: candidate.truncated,
});
} 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 +263 to 281
}
}
Expand Down
48 changes: 48 additions & 0 deletions test/continuous-test-suite-coerce-nested-unwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,52 @@ await test("no schema → nested string is left untouched (first parseable wins)
);
});

await test("unwraps a single-element array wrapping the object (native-Anthropic [{...}] case)", () => {
// Models on the native Anthropic path sometimes return `[{...}]` for an
// object schema under escaping stress. The lone object element must be
// unwrapped and validated, not returned as an array. Refs #635.
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",
);
Comment on lines +149 to +163
});

await test("array schema still receives the array directly (no over-unwrap)", () => {
const arrSchema = z.array(z.object({ id: z.number() }));
const r = coerceJsonToSchema('[{"id":1}]', arrSchema);
assertEqual(
Array.isArray(r?.structuredData),
true,
"array-typed schema keeps the array",
);
});

await test("multi-element array is NOT force-unwrapped for an object schema", () => {
// Only a lone element is a safe unwrap; a 2+ element array is genuinely not
// the object the schema wants, so it must fall through unchanged.
const r = coerceJsonToSchema(
JSON.stringify([
{ summary: "a", attachment: null },
{ summary: "b", attachment: null },
]),
schema,
);
assertEqual(
Array.isArray(r?.structuredData),
true,
"multi-element array not silently reduced to its first element",
);
});

await runSuite();
Loading