fix(llm): parse claude-cli structured_output, not the prose result field (follow-up to #2095)

This commit is contained in:
safishamsi
2026-07-22 15:57:54 +01:00
parent 7116a6f5f5
commit 16315f1956
2 changed files with 43 additions and 1 deletions
+11 -1
View File
@@ -1509,7 +1509,17 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
envelope = _claude_cli_envelope(proc.stdout)
raw_content = envelope.get("result", "")
# When --json-schema is in effect the CLI puts the CONSTRAINED object in the
# `structured_output` envelope field; `result` stays the model's discretionary
# text, which on a "reporting" turn is prose even with the flag set (verified
# live on Claude Code 2.1.185). Prefer the structured channel and route it
# through the same _parse_llm_json normalizer; fall back to parsing `result`
# for older CLIs that don't emit structured_output (#2076 review).
structured = envelope.get("structured_output")
if isinstance(structured, dict):
raw_content = json.dumps(structured)
else:
raw_content = envelope.get("result", "")
result = _parse_llm_json(raw_content or "{}")
usage = envelope.get("usage") or {}
result["input_tokens"] = (
+32
View File
@@ -349,3 +349,35 @@ def test_simple_completion_resolves_cmd_shim_on_windows(monkeypatch):
assert out == "ok"
assert captured["argv0"] == r"C:\npm\claude.cmd"
def test_prefers_structured_output_over_prose_result(monkeypatch):
"""#2076 review: with --json-schema the CLI puts the constrained object in
`structured_output` while `result` may be prose (a 'reporting' turn). The
backend must parse the structured object; parsing the prose would read as an
empty/hollow extraction and bisect forever."""
envelope = {
"type": "result", "subtype": "success", "is_error": False,
"result": "Knowledge graph extracted successfully: 2 nodes, 1 edge.", # prose only
"structured_output": {
"nodes": [
{"id": "foo_module", "label": "Foo", "file_type": "document", "source_file": "foo.md"},
{"id": "foo_greet", "label": "greet", "file_type": "code", "source_file": "foo.md"},
],
"edges": [
{"source": "foo_module", "target": "foo_greet",
"relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0},
],
"hyperedges": [],
},
"stop_reason": "end_turn",
"usage": {"input_tokens": 6, "output_tokens": 11},
"modelUsage": {"claude-opus-4-7[1m]": {}},
}
completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="")
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
result = llm._call_claude_cli("dummy", max_tokens=8192)
assert len(result["nodes"]) == 2, "must parse structured_output, not the prose result"
assert len(result["edges"]) == 1
assert result["finish_reason"] == "stop"