[BUG] --json-schema: Opus 5.5 ends long StructuredOutput inputs one closing brace short, and strict enforcement doesn't catch it
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
With --json-schema, Opus 5.5 often ends a long, nested StructuredOutput call one closing bracket short. The CLI rejects the call as unparseable JSON, and the model rewrites the whole output from scratch. In our production use (headless code review, outputs of 10 to 17 KB), one review took five attempts and spent about 2.5 minutes of a 4 minute run on rewrites.
What the raw stream shows (reassembled from input_json_delta with --include-partial-messages):
- Every failed input is complete except for the final
}. The output closes the last nested object (..."}) and the block ends withstop_reason: tool_use. Appending one}makes every failed input parse, with every field present. - A related variant: the model closes a nested object one brace early, so its last field lands at the root (
"handoff"beside"tour"instead of inside it). That input parses but fails schema validation, which costs another rewrite. - The schema below uses only keywords the CLI's strict derivation accepts (
type,properties,required,additionalProperties: false,items,enum,const). Even so, both shapes come back, including the misplaced-field one, which strict decoding withadditionalProperties: falseshould make impossible. So strict enforcement does not appear to be applied to these runs (related: #92584). - The retry message points the model at the wrong cause ("unescaped backslashes in file paths ... unescaped control characters, or truncated output"), and the model rewrote everything each time rather than adding a brace. In our worst case it failed four times in a row.
- The failed input is cut to 2,048 characters (
__unparsedToolInput.raw) in both the session transcript andstream-json. A caller can't see what was wrong without--include-partial-messages.
Rates, same prompt and schema:
| Model | Runs | Invalid attempts |
|---|---|---|
| claude-opus-5-5 | 8 | 4 of 12 (3 missing the final brace, 1 field at the wrong level) |
| claude-opus-5 | 6 | 0 of 6 (outputs were larger, 27 to 59 KB) |
On our production prompt, replayed from the same conversation state: Opus 5.5 produced an invalid attempt in 14 of 27 attempts, while Opus 5 and Fable 5.1 had 0 of 6. Across our review history, every parse failure came from Opus 5.5.
What Should Happen?
Any of these would fix it, best first:
- Apply strict (constrained) decoding to
StructuredOutputwhen the schema derives a strict form, so a malformed or misplaced input can't be emitted. - When the only parse error is end-of-input with open brackets, close them and validate the result against the schema before asking for a rewrite. That repair is unambiguous, and schema validation still guards the result.
- Make the retry message name the real defect and position (for example, "input ended with 1 unclosed object at byte 19617"), so the model can fix it instead of rewriting.
Error Messages/Logs
<tool_use_error>InputValidationError: StructuredOutput was called with input that could not be parsed as JSON.
You sent (first 200 of 19617 bytes): {"schema_version": "1", ...
Common causes: unescaped backslashes in file paths (use / or \\), unescaped control characters, or truncated output. Retry with valid JSON.</tool_use_error>
The last two streamed partial_json deltas of each attempt in one run:
attempt 1 (19,617 bytes, rejected): [..., " run.", "\"}"] <- block stops here
attempt 2 (21,277 bytes, rejected): [..., " is fixed.", "\"}"] <- block stops here
attempt 3 (22,137 bytes, accepted): [..., "}", "}"]
Steps to Reproduce
Three files in an empty directory.
work/scan.mjs (anything reviewable works; this is what we used):
// Finds import specifiers in JavaScript source without a parser.
const SPECIFIER = /(['"`])(react-dom\/(?:server|static)(?:\.[a-z.]+)?)\1/g;
export function blankComments(src) {
let out = '', i = 0, inStr = null;
while (i < src.length) {
const c = src[i], n = src[i + 1];
if (inStr) {
out += c;
if (c === '\\') { out += n ?? ''; i += 2; continue; }
if (c === inStr) inStr = null;
i++; continue;
}
if (c === '"' || c === "'" || c === '`') { inStr = c; out += c; i++; continue; }
if (c === '/' && n === '/') { while (i < src.length && src[i] !== '\n') { out += ' '; i++; } continue; }
if (c === '/' && n === '*') {
out += ' '; i += 2;
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) { out += src[i] === '\n' ? '\n' : ' '; i++; }
out += ' '; i += 2; continue;
}
out += c; i++;
}
return out;
}
export function blankStrings(src) {
return src.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
}
export function importsOf(src) {
const code = blankComments(src);
const bare = blankStrings(code);
const hits = [];
for (const m of code.matchAll(SPECIFIER)) {
const before = bare.slice(Math.max(0, m.index - 12), m.index);
if (/(from\s*|import\s*\(\s*|require\s*\(\s*)$/.test(before)) {
hits.push({ specifier: m[2], line: code.slice(0, m.index).split('\n').length });
}
}
return hits;
}
export function typeOnlyImport(line) {
return /^\s*(import|export)\s+type\b/.test(line);
}
schema.json:
{"type":"object","additionalProperties":false,"required":["schema_version","meta","verdict","summary","comments","tour"],
"properties":{
"schema_version":{"type":"string","const":"1"},
"meta":{"type":"object","additionalProperties":false,"required":["file","reviewed_at","reviewer"],"properties":{"file":{"type":"string"},"reviewed_at":{"type":"string"},"reviewer":{"type":"string"}}},
"verdict":{"type":"string","enum":["APPROVE","COMMENT","REQUEST_CHANGES"]},
"summary":{"type":"string"},
"comments":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["id","category","line","body"],"properties":{"id":{"type":"string"},"category":{"type":"string","enum":["issue","suggestion","praise","question"]},"line":{"type":"integer"},"body":{"type":"string"}}}},
"tour":{"type":"object","additionalProperties":false,"required":["title","intro","stops","handoff"],"properties":{
"title":{"type":"string"},"intro":{"type":"string"},
"stops":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["kind","title","body"],"properties":{"kind":{"type":"string","enum":["overview","context","change","architecture"]},"title":{"type":"string"},"file":{"type":"string"},"body":{"type":"string"}}}},
"handoff":{"type":"string"}}}
}}
prompt.txt:
Review the file scan.mjs in the current directory as a careful senior engineer would. Read it first.
Then return your review through the structured output. Be thorough: write a multi-paragraph summary, at least six comments (each body two or three paragraphs, quoting the relevant code in backticks), and a reading tour of six or seven stops (each body a full paragraph), ending with a handoff paragraph.
Run it eight times in parallel:
cd work
for n in 1 2 3 4 5 6 7 8; do
claude -p --output-format stream-json --verbose --include-partial-messages \
--setting-sources '' --tools Read --model claude-opus-5-5 \
--json-schema "$(cat ../schema.json)" < ../prompt.txt > ../r$n.jsonl &
done; wait
Count StructuredOutput tool results with is_error: true in the output files. To see the defect, concatenate each tool block's input_json_delta.partial_json and JSON.parse it: the failures end one } short or carry handoff at the root. Swap in --model claude-opus-5 for the control.
Claude Model
Opus
Is this a regression?
I don't know (it appears with Opus 5.5; the same prompts on Opus 5 and Fable 5.1 don't show it)
Claude Code Version
2.1.287 (Claude Code)
Platform
Anthropic API (signed in with a claude.ai Max plan)
Operating System
macOS
Additional Information
Related: #92584 (no way to tell whether strict enforcement was applied) and #74800 (how an unparseable tool input is replayed to the model).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗