diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index dec3d7a445..6b9e388c2b 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -398,11 +398,6 @@ def _replay_reasoning_items( return replayed -def _turn_has_encrypted_reasoning(msg: Dict[str, Any]) -> bool: - """True when the stored assistant turn produced an encrypted ``reasoning`` item alongside its message.""" - return any(isinstance(ri, dict) and ri.get("encrypted_content") for ri in _as_list(msg.get("codex_reasoning_items"))) - - def _replay_message_items( msg: Dict[str, Any], *, is_github_responses: bool, current_issuer_kind: Optional[str] = None, ) -> List[Dict[str, Any]]: @@ -411,11 +406,14 @@ def _replay_message_items( A ``msg_*`` id minted in the same response as a ``reasoning`` item is bound to that item's ``rs_*`` id, which ``_replay_reasoning_items`` always strips (store=False). Replaying the message id alone is a deterministic HTTP 400 ("provided without its required 'reasoning' item", #97427/#97442), so the message - id is dropped whenever its turn carried encrypted reasoning — replayed, suppressed or foreign-issuer — - and the message goes out as content/status/phase only. Reasoning-free turns keep their id. + id is dropped whenever its turn carried encrypted reasoning — replayed, suppressed, foreign-issuer or + trimmed by the transport (``codex_reasoning_trimmed``) — and the message goes out as content/status/phase + only. Reasoning-free turns keep their id. """ replayed: List[Dict[str, Any]] = [] - linked_to_reasoning = _turn_has_encrypted_reasoning(msg) + linked_to_reasoning = bool(msg.get("codex_reasoning_trimmed")) or any( + isinstance(ri, dict) and ri.get("encrypted_content") for ri in _as_list(msg.get("codex_reasoning_items")) + ) for raw_item in _as_list(msg.get("codex_message_items")): if not (isinstance(raw_item, dict) and raw_item.get("type") == "message" and raw_item.get("role") == "assistant"): continue diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 985c498d09..98112f7580 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -450,7 +450,8 @@ def _is_azure_responses(params: dict[str, Any]) -> bool: def _newest_reasoning_only(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Copy of ``messages`` keeping ``codex_reasoning_items`` only on the newest assistant row that has any. Foundry rejects a request that replays encrypted reasoning from more than one prior response (HTTP 400 - "Conflicting authenticated continuation identities", #105369). ``compaction`` checkpoints stay everywhere.""" + "Conflicting authenticated continuation identities", #105369). ``compaction`` checkpoints stay everywhere. + A trimmed row is marked ``codex_reasoning_trimmed`` so the converter still drops its ``msg_*`` id (#97427).""" out: list[dict[str, Any]] = [] newest_kept = False for msg in reversed(messages): @@ -458,7 +459,7 @@ def _newest_reasoning_only(messages: list[dict[str, Any]]) -> list[dict[str, Any if isinstance(items, list) and any(isinstance(i, dict) and i.get("type") != "compaction" for i in items): if newest_kept: checkpoints = [i for i in items if isinstance(i, dict) and i.get("type") == "compaction"] - msg = dict(msg) + msg = dict(msg, codex_reasoning_trimmed=True) if checkpoints: msg["codex_reasoning_items"] = checkpoints else: diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6b90b7b415..3ac83698e6 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -481,6 +481,39 @@ class TestCodexBuildKwargs: reasoning = [item for item in kw["input"] if item.get("type") == "reasoning"] assert [item["encrypted_content"] for item in reasoning] == ["sealed-2"] + def test_azure_trimmed_reasoning_turn_still_drops_its_message_id(self, transport): + """The older turn's reasoning is trimmed for Azure (#105369) but its ``msg_*`` id is still bound to a + ``rs_*`` id that is no longer on the wire; the id must go with it (#97427). The newest turn's id is + dropped too (its reasoning replays without id); a reasoning-free turn keeps its id.""" + def _turn(text, *, reasoning): + msg = { + "role": "assistant", "content": text, + "codex_message_items": [{ + "type": "message", "role": "assistant", "status": "completed", "id": f"msg_{text}", + "content": [{"type": "output_text", "text": text}], + }], + } + if reasoning: + msg["codex_reasoning_items"] = [{"type": "reasoning", "id": f"rs_{text}", "encrypted_content": f"sealed-{text}", "summary": []}] + return msg + + messages = [ + {"role": "user", "content": "first"}, _turn("old", reasoning=True), + {"role": "user", "content": "second"}, _turn("plain", reasoning=False), + {"role": "user", "content": "third"}, _turn("new", reasoning=True), + {"role": "user", "content": "fourth"}, + ] + kw = transport.build_kwargs( + model="gpt-6-astra", messages=messages, tools=[], + base_url="https://placeholder.openai.azure.com/openai/v1", replay_encrypted_reasoning=True, + ) + reasoning = [i for i in kw["input"] if i.get("type") == "reasoning"] + assert [i["encrypted_content"] for i in reasoning] == ["sealed-new"] + by_text = {i["content"][0]["text"]: i for i in kw["input"] if i.get("type") == "message" and i.get("role") == "assistant"} + assert "id" not in by_text["old"] and "id" not in by_text["new"] + assert by_text["plain"]["id"] == "msg_plain" + assert "codex_reasoning_items" in messages[1] # canonical history untouched + def test_default_responses_new_turn_replays_all_reasoning(self, transport): """Non-Azure Responses endpoints keep cross-turn reasoning replay.""" kw = transport.build_kwargs(