Prefer JSON-bearing envelope fields, skip metadata, in Claude CLI unwrap
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled

The first CUST-WP-0045 canary retry after 9de0f49 still failed schema
validation with `Expecting value: line 1 column 1 (char 0)`. The original
allowlist returned envelope.result verbatim, which on longer prompts
carries the model's conversational preamble ("Triage report generated
and returned via structured output. Key signals: ..."), not the
schema-enforced JSON. The actual structured payload lives in a different
envelope field whose name varies across CLI versions.

Make the unwrap order-aware:
  1. Scan envelope fields and return the first one whose value parses as
     JSON (dict, list, or a string that loads cleanly). Skip well-known
     metadata keys (type, usage, total_cost_usd, etc.) so telemetry can
     never be mistaken for the model payload.
  2. Fall back to the original text-field allowlist only when no field
     carries JSON, so non-schema callers via this same code path still
     see the model's prose.
  3. Surface the raw envelope as last resort.

This is robust against unknown envelope shapes — as long as the schema-
enforced JSON appears somewhere in a non-metadata field, the adapter
will find it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:44:25 +02:00
parent 9de0f495db
commit 435da49263
2 changed files with 129 additions and 37 deletions

View File

@@ -16,11 +16,13 @@ def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
calls["capture_output"] = capture_output
calls["text"] = text
calls["timeout"] = timeout
return SimpleNamespace(
returncode=0,
stdout='{"summary":"ok","recommendations":[]}',
stderr="",
)
# With --output-format json the CLI returns an envelope.
envelope = {
"type": "result",
"result": '{"summary":"ok","recommendations":[]}',
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
@@ -33,22 +35,18 @@ def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
),
)
assert calls == {
"cmd": [
"/custom/claude",
"--print",
"--json-schema",
'{"type":"object"}',
"--output-format",
"json",
],
"input": "Produce a report.",
"capture_output": True,
"text": True,
"timeout": 42,
}
# Stdout shape that does not match any known envelope field is returned
# verbatim so the caller can introspect and we can extend the field list.
assert calls["cmd"] == [
"/custom/claude",
"--print",
"--json-schema",
'{"type":"object"}',
"--output-format",
"json",
]
assert calls["input"] == "Produce a report."
assert calls["timeout"] == 42
# Envelope's result field carries the schema-enforced JSON; the adapter
# unwraps it before returning to the caller.
assert response.content == '{"summary":"ok","recommendations":[]}'
@@ -80,6 +78,53 @@ def test_execute_prompt_unwraps_cli_json_envelope_result_field(monkeypatch):
assert response.content == '{"summary":"ok","recommendations":[]}'
def test_execute_prompt_prefers_json_field_over_prose_preamble(monkeypatch):
"""When the model adds a prose preamble in the envelope's primary text
field but the schema-enforced JSON is in a different field, the adapter
must find and return the JSON, not the preamble."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": "result",
"result": "Triage report generated and returned via structured output. Key signals: healthy.",
"structured_result": '{"summary":"healthy","recommendations":[]}',
"total_cost_usd": 0.002,
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Long triage prompt.",
RunConfig(model_params={"json_schema": {"type": "object"}}),
)
assert response.content == '{"summary":"healthy","recommendations":[]}'
def test_execute_prompt_skips_envelope_metadata_keys(monkeypatch):
"""Metadata keys like `type`, `model`, `usage` must never be returned as
the model payload, even if their values look JSON-like."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": '{"this":"is_metadata"}', # decoy
"usage": {"input_tokens": 5}, # decoy dict
"result": '{"summary":"ok"}',
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Prompt.", RunConfig(model_params={"json_schema": {"type": "object"}})
)
assert response.content == '{"summary":"ok"}'
def test_execute_prompt_no_unwrap_without_json_schema(monkeypatch):
"""Without --json-schema we do not pass --output-format json, so the
envelope unwrap path stays inert and raw stdout passes through."""