Reasoning items are replayed with an explicit "encrypted_content": null, inconsistently with the sibling ContextCompaction variant (schema-legal; see correction below)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
Summary
When a provider returns a reasoning item that has no encrypted_content field, Codex stores it and replays it on the following turn with "encrypted_content": null explicitly present. The Responses schema does not permit a null there, so a provider that validates strictly against the schema will reject the turn. The item is otherwise well-formed — a real id, a populated summary[].summary_text — so the request is rejected purely on a field Codex added itself.
The cause looks like a missing serde attribute rather than logic: ResponseItem::Reasoning's encrypted_content is the only optional field in that variant without skip_serializing_if, and the outbound hygiene pass that fixes up replayed items never looks at it.
Environment
codex-cli 0.152.1(Homebrew cask)- macOS 15.8.0, arm64
- Custom
model_providerwithwire_api = "responses",requires_openai_auth = false,supports_websockets = false
Steps to reproduce
Two files. No network access needed; the mock provider is 90 lines of stdlib Python.
1. mock_responses.py — a /v1/responses endpoint that, on turn 1, emits a reasoning item without any encrypted_content key, plus one function_call so there is a turn 2. It logs the input array of every request it receives.
#!/usr/bin/env python3
"""Minimal mock /v1/responses reproducing the null-encrypted_content echo-back."""
import json
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = "received.jsonl"
STATE = {"turn": 0}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self): # /v1/models probe
body = b'{"object":"list","data":[]}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n))
STATE["turn"] += 1
turn = STATE["turn"]
with open(LOG, "a") as fh:
fh.write(json.dumps({"turn": turn, "input": body.get("input", [])}) + "\n")
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
def emit(obj):
self.wfile.write(f"event: {obj['type']}\ndata: {json.dumps(obj)}\n\n".encode())
self.wfile.flush()
response = {"id": f"resp_mock_{turn}", "object": "response",
"status": "in_progress", "model": body.get("model"), "output": []}
emit({"type": "response.created", "response": response})
items = []
if turn == 1:
# NOTE: no `encrypted_content` key at all.
reasoning = {"type": "reasoning", "id": "rs_mock_1",
"summary": [{"type": "summary_text", "text": "thinking about it"}],
"content": []}
items.append(reasoning)
emit({"type": "response.output_item.done", "output_index": 0, "item": reasoning})
call = {"type": "function_call", "id": "fc_0", "call_id": "call_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": ["/bin/echo", "hello"]}),
"status": "completed"}
items.append(call)
emit({"type": "response.output_item.done", "output_index": 1, "item": call})
else:
message = {"type": "message", "id": f"msg_{turn}", "role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "DONE", "annotations": []}]}
items.append(message)
emit({"type": "response.output_item.done", "output_index": 0, "item": message})
done = dict(response)
done["status"] = "completed"
done["output"] = items
done["usage"] = {"input_tokens": 100, "input_tokens_details": {"cached_tokens": 0},
"output_tokens": 10,
"output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 110}
emit({"type": "response.completed", "response": done})
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()
2. home/config.toml — an isolated CODEX_HOME pointing at the mock:
model = "gpt-5.6"
model_provider = "mock"
approval_policy = "never"
sandbox_mode = "danger-full-access"
[model_providers.mock]
name = "mock"
base_url = "http://127.0.0.1:8899/v1"
env_key = "MOCK_API_KEY"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false
3. Run:
mkdir -p home && cp config.toml home/config.toml
python3 mock_responses.py 8899 &
echo "run /bin/echo hello then say DONE" \
| CODEX_HOME="$PWD/home" MOCK_API_KEY=dummy codex exec \
--skip-git-repo-check --dangerously-bypass-approvals-and-sandbox
python3 - <<'PY'
import json
for line in open("received.jsonl"):
rec = json.loads(line)
for item in rec["input"]:
if item.get("type") == "reasoning":
print("turn", rec["turn"], json.dumps(item))
PY
Actual result
Turn 1's response carried a reasoning item with no encrypted_content key. Turn 2's request carries it back with an explicit null:
{
"type": "reasoning",
"id": "rs_mock_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": null
}
Against a provider that validates strictly against the schema this turn would fail; against the mock it simply demonstrates the shape. Stated plainly: I have not captured such a rejection first-hand, so the impact claim rests on the schema itself and on the independent observation quoted in #36704 below, not on an error I reproduced. The in-repo inconsistency in the next section stands regardless.
Expected result
Either omit the field when there is nothing to send:
{
"type": "reasoning",
"id": "rs_mock_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }]
}
…or strip a null encrypted_content in the outbound pass before the request is serialized. Omission seems clearly preferable — it matches what the provider originally sent, and it is what the rest of the enum already does.
Where it comes from
Two places, both at tag rust-v0.152.1 (commit 5adb68a49933ae446bf11935662c83dba55a0804).
1. ResponseItem::Reasoning.encrypted_content has no skip_serializing_if — codex-rs/protocol/src/models.rs:1011-1023. It is the only optional field in the variant without one, so None serializes as null:
Reasoning {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
#[ts(optional)]
content: Option<Vec<ReasoningItemContent>>,
encrypted_content: Option<String>, // <-- no skip_serializing_if
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
The same crate already does the right thing for the same field name on a sibling variant — ResponseItem::ContextCompaction, models.rs:1202-1212:
ContextCompaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
encrypted_content: Option<String>, // <-- has it
...
},
So this reads as an oversight rather than an intentional difference, and the fix is one attribute.
2. The outbound hygiene pass does not compensate — ModelClient::prepare_response_items_for_request(), codex-rs/core/src/client.rs:1035-1044. It is the function that normalizes replayed items before a request, and it only touches id and content-item kinds:
fn prepare_response_items_for_request(&self, input: &mut [ResponseItem]) {
for item in input {
if item.id().is_some_and(|id| !id.is_prefixed()) {
item.set_id(/*new_id*/ None);
}
if !self.state.content_item_kinds_enabled {
item.clear_content_item_kinds();
}
}
}
It is called from all four request paths (client.rs:663, :1637, :1850, :1859), so it is the natural place for a belt-and-braces strip if you would rather not change the serde attribute.
Reproduce these two quotes:
SHA=5adb68a49933ae446bf11935662c83dba55a0804
curl -sL "https://raw.githubusercontent.com/openai/codex/$SHA/codex-rs/protocol/src/models.rs" | sed -n '1011,1023p;1202,1212p'
curl -sL "https://raw.githubusercontent.com/openai/codex/$SHA/codex-rs/core/src/client.rs" | sed -n '1035,1044p'
Suggested fix
Add #[serde(default, skip_serializing_if = "Option::is_none")] to ResponseItem::Reasoning.encrypted_content in codex-rs/protocol/src/models.rs, matching ContextCompaction twelve variants below. If a defensive strip is also wanted, prepare_response_items_for_request is the place.
Related but distinct
- #42249 — DeepSeek +
wire_api = "responses", intermittent 400The `reasoning_text` in the thinking mode must be passed back to the API.There,encrypted_contentis present as a 38-byte stub with an emptysummaryand the payload incontent[], and the rejection is DeepSeek's plaintext-replay contract. Here it is explicitly null, with a populatedsummaryand nocontent, and the rejection is a schema violation. Same serialization layer, different malformed shape, different rejection reason. - #36704 — key-rotation recovery; contains the only other mention of this shape in the tracker, as a side observation: "The 4 earliest
reasoningitems […] hadencrypted_content: nullexplicitly present alongsidecontent: [reasoning_text](length 1) — also a schema problem, since the Responses API enforcescontent: [](maxItems=0) when theencrypted_contentfield is present, even if null." That issue's subject and ask are stale ciphertext after key rotation, not echo-back, but the quote independently supports the claim that the null shape is schema-invalid. - #38855 — type-invalid
item_reasoning ids surviving replay validation. Same function,prepare_response_items_for_request, same layer; validatesidbut notencrypted_content. - #24500 — the chat-completions-path sibling (
reasoning_contentnot serialized back for DeepSeek thinking mode).
Evidence against
Searched the openai/codex tracker (issues and PRs, open and closed) for encrypted_content, encrypted_content null, prepare_response_items_for_request, reasoning_text must be passed back, reasoning item, and several phrasings of the gateway-rejection symptom; pulled and grepped the full bodies of 31 candidate issues. Found no source contradicting the claim, and no prior report of this specific behavior. The nearest counter-consideration is #36704: it shows a maintainer-visible mention of encrypted_content: null has existed since 2026-08-03 without being fixed, so it is possible the shape is considered acceptable by the first-party endpoint and the problem is deemed to be the strict gateway's. That is why this report leads with the in-repo inconsistency (Reasoning vs ContextCompaction) rather than with "our gateway rejects it" — the inconsistency stands on its own regardless of how tolerant the first-party endpoint is.
Verification
prepare_response_items_for_request has no test asserting anything about encrypted_content; a regression test would serialize a ResponseItem::Reasoning with encrypted_content: None and assert the key is absent from the JSON.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in codex-rs/protocol/src/models.rs around ResponseItem::Reasoning.encrypted_content and compare it with the ContextCompaction variant. Check the related prepare_response_items_for_request entry point in codex-rs/core/src/client.rs, then verify serialized reasoning items omit encrypted_content when it is absent; done means the replayed request matches the provider's schema.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- api
- Issue type
- Bug
- Difficulty
- 1/5
- Estimated time
- Under an hour
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100