{"repo":"NousResearch/hermes-agent","public_id":"17861","display_label":"Anthropic thinking block signature management in multi-turn","summary":"These issues and fixes center on preserving, reordering, or demoting Anthropic extended-thinking block signatures across multi-turn and tool-use conversations. The recurring problem is that stale, reordered, or dropped thinking blocks in conversation history trigger non-retryable HTTP 400 errors from the Anthropic API.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35859},"resolution":{"state":"partially_resolved","by":"pr-35859"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T03:45:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12879120879120878},"fix_edges":[{"issue_number":17861,"pr_number":43943,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35847,"pr_number":35859,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17861,"title":"[Bug] Multi-turn history loses thinking/redacted_thinking blocks -- raw Anthropic content array not preserved as source of truth","url":null,"state":"closed"},{"kind":"issue","number":35847,"title":"[Bug]: extended-thinking + interrupted parallel tool batch → non-retryable HTTP 400 crash-loop (stale thinking signature)","url":null,"state":"closed"},{"kind":"issue","number":35975,"title":"[Bug]: extended-thinking + interleaved multi-thinking tool turn → non-retryable HTTP 400 crash-loop (reordered thinking signature)","url":null,"state":"closed"},{"kind":"pr","number":17884,"title":"fix(anthropic): strip thinking blocks from all assistant turns to prevent stale signature HTTP 400 (#17861)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17890","number":17890},{"kind":"pr","value":"#17861","number":17861}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":17981,"title":"fix(anthropic): preserve raw content blocks as source of truth (#17861)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17861","number":17861},{"kind":"pr","value":"#17890","number":17890},{"kind":"pr","value":"#17884","number":17884}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":20997,"title":"fix(anthropic): preserve raw response content history","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43943","number":43943},{"kind":"pr","value":"#17861","number":17861}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35846,"title":"fix(anthropic): demote dead thinking signature when orphan-strip mutates the latest turn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"64628ea89","number":0},{"kind":"pr","value":"#35859","number":35859}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35855,"title":"fix(anthropic): drop thinking blocks when orphan-strip mutates an assistant turn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"64628ea89b1d5624f47b402edd54b13afd335123","number":0},{"kind":"pr","value":"#35859","number":35859},{"kind":"pr","value":"#35859","number":35859},{"kind":"pr","value":"#35847","number":35847}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35859,"title":"fix(anthropic): demote dead thinking signature when orphan-strip mutates the latest turn","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","scripts/release.py","tests/agent/test_anthropic_adapter.py"]},{"kind":"pr","number":35976,"title":"fix(anthropic): demote reordered thinking signatures on interleaved multi-thinking turns","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35975","number":35975}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40373,"title":"fix(agent): preserve interleaved thinking+tool_use order to prevent HTTP 400 crash-loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40370","number":40370}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40517,"title":"fix: demote reordered Anthropic thinking signatures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35975","number":35975}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41266,"title":"fix(provider): preserve interleaved thinking+tool_use block order for Anthropic multi-turn (#35975)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35975","number":35975}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43943,"title":"fix(anthropic): preserve interleaved thinking/tool_use block order on replay (#17861)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/chat_completion_helpers.py","agent/transports/anthropic.py","agent/transports/types.py","scripts/release.py","tests/agent/test_anthropic_output_field_leak.py","tests/agent/test_anthropic_thinking_block_order.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1571,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:\n \n     if ptype == \"input_text\":\n         block: Dict[str, Any] = {\"type\": \"text\", \"text\": part.get(\"text\", \"\")}\n+    elif ptype == \"text\":\n+        # A stored Anthropic text block. Rebuild from whitelisted fields only —\n+        # SDK response text blocks carry output-only siblings (parsed_output,\n+        # citations=None) that the Messages INPUT schema rejects with HTTP 400\n+        # \"Extra inputs are not permitted\". Do NOT dict(part) it verbatim.\n+        block = {\"type\": \"text\", \"text\": part.get(\"text\", \"\")}\n+        cits = part.get(\"citations\")\n+        if isinstance(cits, list) and cits:\n+            block[\"citations\"] = cits\n     elif ptype in {\"image_url\", \"input_image\"}:\n         image_value = part.get(\"image_url\", {})\n         url = image_value.get(\"url\", \"\") if isinstance(image_value, dict) else str(image_value or \"\")","@@ -1685,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:\n     return out\n \n \n+def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:\n+    \"\"\"Strip output-only fields from a stored Anthropic content block so it is\n+    valid as REQUEST input on replay.\n+\n+    The SDK response objects carry output-only attributes that the Messages\n+    *input* schema forbids (\"Extra inputs are not permitted\"): text blocks get\n+    ``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,\n+    etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,\n+    so these leak back as input on the next turn → HTTP 400.\n+\n+    Whitelist per type (NOT a blacklist) so future SDK output-only fields can't\n+    reintroduce the bug. Returns a clean block, or None to drop it.\n+    \"\"\"\n+    if not isinstance(b, dict):\n+        return None\n+    btype = b.get(\"type\")\n+    if btype == \"text\":\n+        out: Dict[str, Any] = {\"type\": \"text\", \"text\": b.get(\"text\", \"\")}\n+        # citations is input-valid ONLY when it's a non-empty list; the SDK\n+        # emits citations=None on responses, which the input schema rejects.\n+        cits = b.get(\"citations\")\n+        if isinstance(cits, list) and cits:\n+            out[\"citations\"] = cits\n+        if isinstance(b.get(\"cache_control\"), dict):\n+            out[\"cache_control\"] = b[\"cache_control\"]\n+        return out\n+    if btype == \"thinking\":\n+        out = {\"type\": \"thinking\", \"thinking\": b.get(\"thinking\", \"\")}\n+        if b.get(\"signature\"):\n+            out[\"signature\"] = b[\"signature\"]\n+        return out\n+    if btype == \"redacted_thinking\":\n+        # Only valid with its data payload; drop if missing.\n+        return {\"type\": \"redacted_thinking\", \"data\": b[\"data\"]} if b.get(\"data\") else None\n+    if btype == \"tool_use\":\n+        out = {\n+            \"type\": \"tool_use\",\n+            \"id\": _sanitize_tool_id(b.get(\"id\", \"\")),\n+            \"name\": b.get(\"name\", \"\"),\n+            \"input\": b.get(\"input\", {}),\n+        }\n+        if isinstance(b.get(\"cache_control\"), dict):\n+            out[\"cache_control\"] = b[\"cache_control\"]\n+        return out\n+    if btype == \"image\":\n+        src = b.get(\"source\")\n+        return {\"type\": \"image\", \"source\": src} if isinstance(src, dict) else None\n+    # Unknown/unsupported block type on the input path — drop rather than risk\n+    # another \"Extra inputs are not permitted\".\n+    return None\n+\n+\n def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:\n     \"\"\"Convert an assistant message to Anthropic content blocks.\n ","@@ -1692,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:\n     reasoning_content injection for Kimi/DeepSeek endpoints.\n     \"\"\"\n     content = m.get(\"content\", \"\")\n+    # Anthropic interleaved-thinking fast path: when this turn carries a\n+    # verbatim, order-preserving block list (set by normalize_response only\n+    # for turns that interleave SIGNED thinking with tool_use), replay it.\n+    # Each block is run through _sanitize_replay_block to strip output-only\n+    # SDK fields (parsed_output, caller, citations=None, …) that the Messages\n+    # INPUT schema forbids — replaying them verbatim caused HTTP 400 \"Extra\n+    # inputs are not permitted\" (text.parsed_output). Block ORDER is preserved\n+    # (the reason this channel exists); only forbidden sibling fields are\n+    # dropped, leaving thinking signatures and tool_use id/name/input intact.\n+    ordered_blocks = m.get(\"anthropic_content_blocks\")\n+    if isinstance(ordered_blocks, list) and ordered_blocks:\n+        # Re-source each tool_use input from the stored tool_calls map rather\n+        # than the captured block. The ordered-blocks list captures tool_use\n+        # input from the RAW API response (normalize_response), which is NOT\n+        # credential-redacted; tool_calls[].function.arguments IS redacted at\n+        # storage time (build_assistant_message, #19798). Replaying the raw\n+        # block input would resurrect a secret the model inlined into a tool\n+        # call (e.g. terminal(command=\"curl -H 'Authorization: Bearer sk-...'\")\n+        # onto the wire, even though the same value is redacted everywhere else\n+        # in history. Keying by sanitized tool id preserves interleave order\n+        # (the reason this channel exists) while swapping in the redacted\n+        # input. Adapted from #36071 (replay-time tool-input re-sourcing).\n+        redacted_input_by_id: Dict[str, Any] = {}\n+        for tc in m.get(\"tool_calls\", []) or []:\n+            if not isinstance(tc, dict):\n+                continue\n+            fn = tc.get(\"function\", {}) or {}\n+            raw_args = fn.get(\"arguments\", \"{}\")\n+            try:\n+                parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args\n+            except (json.JSONDecodeError, ValueError):\n+                parsed_args = {}\n+            redacted_input_by_id[_sanitize_tool_id(tc.get(\"id\", \"\"))] = parsed_args\n+        replayed: List[Dict[str, Any]] = []\n+        for b in ordered_blocks:\n+            clean = _sanitize_replay_block(b)\n+            if clean is None:\n+                continue\n+            if clean.get(\"type\") == \"tool_use\":\n+                # Override raw (un-redacted) input with the redacted copy when\n+                # we have one for this id; fall back to the sanitized block\n+                # input only if the tool_call is missing (shape mismatch).\n+                redacted = redacted_input_by_id.get(clean.get(\"id\", \"\"))\n+                if redacted is not None:\n+                    clean[\"input\"] = redacted\n+            replayed.append(clean)\n+        if replayed:\n+            return {\"role\": \"assistant\", \"content\": replayed}\n+\n     blocks = _extract_preserved_thinking_blocks(m)\n     if content:\n         if isinstance(content, list):"]},{"file":"agent/chat_completion_helpers.py","hunks":["@@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic\n         if preserved:\n             msg[\"reasoning_details\"] = preserved\n \n+    # Anthropic interleaved-thinking replay: when a turn interleaves signed\n+    # thinking blocks with tool_use, the parallel reasoning_details +\n+    # tool_calls fields lose the cross-type ordering, and reconstruction\n+    # front-loads thinking — reordering signed blocks and triggering HTTP 400\n+    # (\"thinking ... blocks in the latest assistant message cannot be\n+    # modified\"). Carry the verbatim ordered block list so the adapter can\n+    # replay the latest assistant message unchanged. See\n+    # agent/transports/anthropic.py and agent/anthropic_adapter.py.\n+    ordered_blocks = getattr(assistant_message, \"anthropic_content_blocks\", None)\n+    if ordered_blocks:\n+        msg[\"anthropic_content_blocks\"] = ordered_blocks\n+\n     # Codex Responses API: preserve encrypted reasoning items for\n     # multi-turn continuity. These get replayed as input on the next turn.\n     codex_items = getattr(assistant_message, \"codex_reasoning_items\", None)"]},{"file":"agent/transports/anthropic.py","hunks":["@@ -84,7 +84,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         to OpenAI finish_reason, and collects reasoning_details in provider_data.\n         \"\"\"\n         import json\n-        from agent.anthropic_adapter import _to_plain_data\n+        from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block\n         from agent.transports.types import ToolCall\n \n         strip_tool_prefix = kwargs.get(\"strip_tool_prefix\", False)","@@ -94,14 +94,40 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         reasoning_parts = []\n         reasoning_details = []\n         tool_calls = []\n+        # Verbatim, order-preserving copy of every content block in the turn.\n+        # Anthropic signs each thinking block against the turn content that\n+        # PRECEDES it at its position; when a turn interleaves thinking and\n+        # tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel\n+        # reasoning_details + tool_calls lists below lose that cross-type\n+        # ordering. Replaying the latest assistant message in the wrong order\n+        # invalidates the signatures -> HTTP 400 \"thinking ... blocks in the\n+        # latest assistant message cannot be modified\". Preserve the exact\n+        # block sequence here so the adapter can replay it unchanged. See\n+        # tests/agent/test_anthropic_thinking_block_order.py.\n+        ordered_blocks = []\n \n         for block in response.content:\n+            block_dict = _to_plain_data(block)\n+            clean_block = None\n+            if isinstance(block_dict, dict):\n+                # Sanitize at capture so output-only SDK fields (parsed_output,\n+                # caller, citations=None, …) never persist to state.db and leak\n+                # back as request input on replay → HTTP 400 \"Extra inputs are\n+                # not permitted\". Defence-in-depth with the replay-side sanitize.\n+                clean_block = _sanitize_replay_block(block_dict)\n+                if clean_block is not None:\n+                    ordered_blocks.append(clean_block)\n             if block.type == \"text\":\n                 text_parts.append(block.text)\n-            elif block.type == \"thinking\":\n-                reasoning_parts.append(block.thinking)\n-                block_dict = _to_plain_data(block)\n-                if isinstance(block_dict, dict):\n+            elif block.type in (\"thinking\", \"redacted_thinking\"):\n+                if block.type == \"thinking\":\n+                    reasoning_parts.append(block.thinking)\n+                # Use the sanitized block (clean_block) for reasoning_details too,\n+                # since _extract_preserved_thinking_blocks replays these on the\n+                # non-ordered path. Falls back to raw only if sanitize dropped it.\n+                if isinstance(clean_block, dict):\n+                    reasoning_details.append(clean_block)\n+                elif isinstance(block_dict, dict):\n                     reasoning_details.append(block_dict)\n             elif block.type == \"tool_use\":\n                 name = block.name","@@ -130,6 +156,23 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         provider_data = {}\n         if reasoning_details:\n             provider_data[\"reasoning_details\"] = reasoning_details\n+        # Only worth carrying the ordered-blocks channel when the turn\n+        # actually interleaves signed thinking with tool_use — that's the\n+        # only shape the parallel lists reconstruct incorrectly. A turn that\n+        # is purely text, or thinking-then-tools with a single leading\n+        # thinking block, replays correctly without it.\n+        _has_signed_thinking = any(\n+            isinstance(b, dict)\n+            and b.get(\"type\") in (\"thinking\", \"redacted_thinking\")\n+            and (b.get(\"signature\") or b.get(\"data\"))\n+            for b in ordered_blocks\n+        )\n+        _has_tool_use = any(\n+            isinstance(b, dict) and b.get(\"type\") == \"tool_use\"\n+            for b in ordered_blocks\n+        )\n+        if _has_signed_thinking and _has_tool_use:\n+            provider_data[\"anthropic_content_blocks\"] = ordered_blocks\n \n         return NormalizedResponse(\n             content=\"\\n\".join(text_parts) if text_parts else None,"]},{"file":"agent/transports/types.py","hunks":["@@ -121,6 +121,18 @@ def reasoning_details(self):\n         pd = self.provider_data or {}\n         return pd.get(\"reasoning_details\")\n \n+    @property\n+    def anthropic_content_blocks(self):\n+        \"\"\"Verbatim, order-preserving Anthropic content blocks for a turn.\n+\n+        Present only when an Anthropic turn interleaves signed thinking with\n+        tool_use — the one shape the parallel reasoning_details + tool_calls\n+        lists reconstruct in the wrong order, invalidating thinking-block\n+        signatures on replay. See agent/transports/anthropic.py.\n+        \"\"\"\n+        pd = self.provider_data or {}\n+        return pd.get(\"anthropic_content_blocks\")\n+\n     @property\n     def codex_reasoning_items(self):\n         pd = self.provider_data or {}"]},{"file":"scripts/release.py","hunks":["@@ -944,6 +944,7 @@\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"gnanam1990\",\n     \"[redacted:email]\": \"0xyg3n\",\n+    \"[redacted:email]\": \"Spaceman-Spiffy\",  # PR #35586 (renamed account)\n     \"[redacted:email]\": \"ianculling\",  # PR #36087\n     \"[redacted:email]\": \"0xyg3n\",\n     \"[redacted:email]\": \"nftpoetrist\",  # PR #18982"]},{"file":"tests/agent/test_anthropic_output_field_leak.py","hunks":["@@ -0,0 +1,96 @@\n+\"\"\"Regression: output-only SDK fields must not leak into Anthropic request input.\n+\n+Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are\n+not permitted`. Anthropic SDK response blocks carry output-only attributes\n+(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)\n+that the Messages *input* schema forbids. normalize_response captured blocks\n+verbatim via _to_plain_data and replayed them as input → 400.\n+\n+Fix: whitelist input-permitted fields per block type at three points —\n+normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and\n+_convert_content_part_to_anthropic (content-list replay).\n+\"\"\"\n+import sys, os\n+sys.path.insert(0, os.path.expanduser(\"~/.hermes/hermes-agent\"))\n+\n+import pytest\n+from agent.anthropic_adapter import (\n+    _sanitize_replay_block,\n+    _convert_content_part_to_anthropic,\n+    _convert_assistant_message,\n+)\n+\n+FORBIDDEN = {\"parsed_output\", \"caller\"}\n+\n+\n+def _assert_clean(block):\n+    \"\"\"No forbidden output-only key, and no null citations, anywhere.\"\"\"\n+    assert isinstance(block, dict)\n+    for k in FORBIDDEN:\n+        assert k not in block, f\"forbidden field {k!r} survived: {block}\"\n+    if \"citations\" in block:\n+        assert isinstance(block[\"citations\"], list) and block[\"citations\"], \\\n+            \"citations must be a non-empty list if present (None/[] is input-invalid)\"\n+\n+\n+class TestSanitizeReplayBlock:\n+    def test_text_block_strips_parsed_output_and_null_citations(self):\n+        poisoned = {\"type\": \"text\", \"text\": \"hi\", \"parsed_output\": None, \"citations\": None}\n+        out = _sanitize_replay_block(poisoned)\n+        _assert_clean(out)\n+        assert out == {\"type\": \"text\", \"text\": \"hi\"}\n+\n+    def test_tool_use_strips_caller(self):\n+        poisoned = {\"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"read_file\",\n+                    \"input\": {\"path\": \"a\"}, \"caller\": {\"type\": \"agent\"}}\n+        out = _sanitize_replay_block(poisoned)\n+        _assert_clean(out)\n+        assert out[\"name\"] == \"read_file\" and out[\"input\"] == {\"path\": \"a\"}\n+\n+    def test_thinking_preserves_signature(self):\n+        b = {\"type\": \"thinking\", \"thinking\": \"x\", \"signature\": \"sig-AAA\"}\n+        out = _sanitize_replay_block(b)\n+        assert out == {\"type\": \"thinking\", \"thinking\": \"x\", \"signature\": \"sig-AAA\"}\n+\n+    def test_text_keeps_real_citations(self):\n+        real = [{\"type\": \"char_location\", \"cited_text\": \"q\"}]\n+        out = _sanitize_replay_block({\"type\": \"text\", \"text\": \"t\", \"citations\": real})\n+        assert out[\"citations\"] == real\n+\n+    def test_unknown_type_dropped(self):\n+        assert _sanitize_replay_block({\"type\": \"server_tool_use\", \"foo\": 1}) is None\n+\n+\n+class TestContentPartConversion:\n+    def test_stored_text_block_with_parsed_output_cleaned(self):\n+        # The exact content.N.text.parsed_output failure shape.\n+        part = {\"type\": \"text\", \"text\": \"hello\", \"parsed_output\": None, \"citations\": None}\n+        out = _convert_content_part_to_anthropic(part)\n+        _assert_clean(out)\n+\n+\n+class TestAssistantReplay:\n+    def test_interleaved_blocks_replayed_clean_and_ordered(self):\n+        m = {\n+            \"role\": \"assistant\",\n+            \"anthropic_content_blocks\": [\n+                {\"type\": \"thinking\", \"thinking\": \"plan\", \"signature\": \"s1\"},\n+                {\"type\": \"text\", \"text\": \"doing it\", \"parsed_output\": None, \"citations\": None},\n+                {\"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"read_file\",\n+                 \"input\": {\"path\": \"a\"}, \"caller\": {\"type\": \"agent\"}},\n+            ],\n+        }\n+        out = _convert_assistant_message(m)\n+        blocks = out[\"content\"]\n+        # order preserved\n+        assert [b[\"type\"] for b in blocks] == [\"thinking\", \"text\", \"tool_use\"]\n+        # every block clean\n+        for b in blocks:\n+            _assert_clean(b)\n+        # signature + tool fields intact\n+        assert blocks[0][\"signature\"] == \"s1\"\n+        assert blocks[2][\"name\"] == \"read_file\"\n+\n+\n+if __name__ == \"__main__\":\n+    raise SystemExit(pytest.main([__file__, \"-v\"]))"]},{"file":"tests/agent/test_anthropic_thinking_block_order.py","hunks":["@@ -0,0 +1,314 @@\n+\"\"\"Regression test for the Anthropic interleaved thinking-block 400.\n+\n+Reproduces: HTTP 400 ``messages.N.content.M: thinking or redacted_thinking\n+blocks in the latest assistant message cannot be modified. These blocks must\n+remain as they were in the original response.``\n+\n+Root cause under test\n+----------------------\n+With adaptive / interleaved thinking (Claude 4.6+, e.g. Opus 4.8), a single\n+assistant turn can emit content blocks in an interleaved order::\n+\n+    thinking_1 (signed) · tool_use_1 · thinking_2 (signed) · tool_use_2\n+\n+Anthropic signs each thinking block against the turn content that precedes it\n+at its position.  ``thinking_2`` is signed with ``tool_use_1`` before it.\n+\n+``AnthropicTransport.normalize_response`` (agent/transports/anthropic.py)\n+splits the turn into two *parallel* lists — ``reasoning_details`` (thinking\n+blocks) and ``tool_calls`` (tool_use blocks) — discarding the cross-type\n+ordering.  ``run_agent`` stores those as separate fields on the assistant\n+message.  On replay, ``_convert_assistant_message`` (agent/anthropic_adapter.py)\n+rebuilds the content as ``[all thinking][text][all tool_use]``, which reorders\n+``thinking_2`` ahead of ``tool_use_1``.  The signature no longer matches its\n+original position, so Anthropic rejects the latest assistant message with the\n+400 above.\n+\n+This test asserts that an interleaved turn round-trips through\n+normalize_response -> stored message -> convert_messages_to_anthropic with its\n+block order preserved.  It FAILS on the current code (documenting the bug) and\n+should PASS once block ordering is preserved on replay.\n+\"\"\"\n+\n+import json\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+from agent.transports import get_transport\n+from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+\n+def _thinking_block(text: str, signature: str) -> SimpleNamespace:\n+    \"\"\"A signed Anthropic thinking block, shaped like the SDK object.\"\"\"\n+    return SimpleNamespace(type=\"thinking\", thinking=text, signature=signature)\n+\n+\n+def _tool_use_block(block_id: str, name: str, payload: dict) -> SimpleNamespace:\n+    return SimpleNamespace(type=\"tool_use\", id=block_id, name=name, input=payload)\n+\n+\n+def _interleaved_response() -> SimpleNamespace:\n+    \"\"\"An assistant turn with thinking interleaved between two tool_use blocks.\"\"\"\n+    return SimpleNamespace(\n+        content=[\n+            _thinking_block(\"Plan: inspect file A first.\", \"sig-AAA\"),\n+            _tool_use_block(\"toolu_1\", \"read_file\", {\"path\": \"a.py\"}),\n+            _thinking_block(\"A looked fine; now inspect B.\", \"sig-BBB\"),\n+            _tool_use_block(\"toolu_2\", \"read_file\", {\"path\": \"b.py\"}),\n+        ],\n+        stop_reason=\"tool_use\",\n+        usage=None,\n+    )\n+\n+\n+def _stored_assistant_message(normalized) -> dict:\n+    \"\"\"Reconstruct the OpenAI-style assistant message the way run_agent stores it.\n+\n+    run_agent.py persists assistant turns as separate fields: content,\n+    reasoning_details (from provider_data), and tool_calls.  See\n+    run_agent.py L1513-1516 and hermes_state.py.\n+    \"\"\"\n+    provider_data = normalized.provider_data or {}\n+    tool_calls = []\n+    for tc in (normalized.tool_calls or []):\n+        tool_calls.append({\n+            \"id\": tc.id,\n+            \"type\": \"function\",\n+            \"function\": {\"name\": tc.name, \"arguments\": tc.arguments},\n+        })\n+    msg = {\n+        \"role\": \"assistant\",\n+        \"content\": normalized.content or \"\",\n+        \"reasoning_details\": provider_data.get(\"reasoning_details\"),\n+        \"tool_calls\": tool_calls,\n+    }\n+    # build_assistant_message lifts the verbatim ordered-block channel onto\n+    # the stored message; mirror that here.\n+    blocks = provider_data.get(\"anthropic_content_blocks\")\n+    if blocks:\n+        msg[\"anthropic_content_blocks\"] = blocks\n+    return msg\n+\n+\n+def _original_block_order(response) -> list:\n+    \"\"\"The (type, key) sequence of the original interleaved response.\"\"\"\n+    order = []\n+    for b in response.content:\n+        if b.type == \"thinking\":\n+            order.append((\"thinking\", b.signature))\n+        elif b.type == \"tool_use\":\n+            order.append((\"tool_use\", b.id))\n+    return order\n+\n+\n+def _replayed_block_order(assistant_content) -> list:\n+    order = []\n+    for b in assistant_content:\n+        if not isinstance(b, dict):\n+            continue\n+        if b.get(\"type\") in (\"thinking\", \"redacted_thinking\"):\n+            order.append((\"thinking\", b.get(\"signature\")))\n+        elif b.get(\"type\") == \"tool_use\":\n+            order.append((\"tool_use\", b.get(\"id\")))\n+    return order\n+\n+\n+class TestInterleavedThinkingBlockOrder:\n+    def test_normalize_response_loses_interleaving(self):\n+        \"\"\"Confirm the lossy split: normalize_response stores thinking and\n+        tool_use in independent fields with no positional linkage.\"\"\"\n+        transport = get_transport(\"anthropic_messages\")\n+        normalized = transport.normalize_response(_interleaved_response())\n+\n+        # Both thinking blocks are captured...\n+        details = (normalized.provider_data or {}).get(\"reasoning_details\")\n+        assert details is not None and len(details) == 2\n+        # ...and both tool calls...\n+        assert normalized.tool_calls is not None and len(normalized.tool_calls) == 2\n+        # ...but they live in separate fields. There is no single ordered\n+        # structure recording that thinking_2 sat between the two tool calls.\n+        # (This is the structural precondition for the reorder bug.)\n+\n+    def test_interleaved_order_preserved_on_replay(self):\n+        \"\"\"The latest assistant message must replay blocks in their ORIGINAL\n+        order, or Anthropic rejects the signed thinking blocks with a 400.\n+\n+        FAILS on current code: _convert_assistant_message front-loads all\n+        thinking blocks, producing\n+            thinking_1 · thinking_2 · tool_use_1 · tool_use_2\n+        instead of the original\n+            thinking_1 · tool_use_1 · thinking_2 · tool_use_2\n+        \"\"\"\n+        response = _interleaved_response()\n+        original_order = _original_block_order(response)\n+\n+        transport = get_transport(\"anthropic_messages\")\n+        normalized = transport.normalize_response(response)\n+        assistant_msg = _stored_assistant_message(normalized)\n+\n+        # Build a minimal conversation where this assistant turn is the LATEST\n+        # assistant message (the one whose signed blocks are sent verbatim).\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Inspect a.py and b.py.\"},\n+            assistant_msg,\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_1\", \"content\": \"a.py: ok\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_2\", \"content\": \"b.py: ok\"},\n+        ]\n+\n+        _system, anthropic_messages = convert_messages_to_anthropic(\n+            messages,\n+            base_url=None,             # direct Anthropic\n+            model=\"claude-opus-4-8\",   # adaptive thinking family\n+        )\n+\n+        # Find the (latest) assistant message in the converted output.\n+        assistant_out = [m for m in anthropic_messages if m.get(\"role\") == \"assistant\"]\n+        assert assistant_out, \"no assistant message in converted output\"\n+        replayed_order = _replayed_block_order(assistant_out[-1][\"content\"])\n+\n+        assert replayed_order == original_order, (\n+            \"Interleaved thinking/tool_use order was not preserved on replay.\\n\"\n+            f\"  original: {original_order}\\n\"\n+            f\"  replayed: {replayed_order}\\n\"\n+            \"Anthropic signs thinking blocks against their original position; \"\n+            \"reordering invalidates the signature -> HTTP 400 'thinking blocks \"\n+            \"in the latest assistant message cannot be modified'.\"\n+        )\n+\n+    def test_replay_falls_back_gracefully_without_ordered_blocks(self):\n+        \"\"\"Without the ordered-block channel, conversion must not crash.\n+\n+        The channel is intentionally NOT persisted to state.db (in-memory\n+        only): a session reloaded from disk after a crash loses the field\n+        and falls back to reconstruction. That replay may take one HTTP 400,\n+        which the thinking-signature recovery (#43667) absorbs by stripping\n+        reasoning_details and retrying. This test pins the fallback shape:\n+        conversion still produces a valid assistant message from the\n+        parallel reasoning_details + tool_calls fields.\n+        \"\"\"\n+        response = _interleaved_response()\n+        transport = get_transport(\"anthropic_messages\")\n+        normalized = transport.normalize_response(response)\n+        assistant_msg = _stored_assistant_message(normalized)\n+        # Simulate a disk reload: the in-memory-only channel is gone.\n+        assistant_msg.pop(\"anthropic_content_blocks\", None)\n+\n+        messages = [\n+            assistant_msg,\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_1\", \"content\": \"a ok\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_2\", \"content\": \"b ok\"},\n+        ]\n+        _system, anthropic_messages = convert_messages_to_anthropic(\n+            messages, base_url=None, model=\"claude-opus-4-8\",\n+        )\n+        assistant_out = [m for m in anthropic_messages if m.get(\"role\") == \"assistant\"]\n+        assert assistant_out, \"no assistant message in converted output\"\n+        content = assistant_out[-1][\"content\"]\n+        assert isinstance(content, list) and content, \"fallback produced empty content\"\n+        # Reconstruction keeps both tool_use blocks (answered by results).\n+        tool_ids = [b.get(\"id\") for b in content if isinstance(b, dict) and b.get(\"type\") == \"tool_use\"]\n+        assert set(tool_ids) == {\"toolu_1\", \"toolu_2\"}\n+\n+\n+class TestInterleavedReplayCredentialRedaction:\n+    \"\"\"The verbatim-replay fast path must not leak un-redacted secrets.\n+\n+    anthropic_content_blocks captures each tool_use ``input`` from the RAW API\n+    response (normalize_response), which is NOT credential-redacted. The\n+    parallel tool_calls[].function.arguments IS redacted at storage time\n+    (build_assistant_message, #19798). If the fast path replays the block's raw\n+    input verbatim, a secret the model inlined into a tool call rides back onto\n+    the wire — even though it is redacted everywhere else in history. The fix\n+    re-sources tool_use input from the redacted tool_calls map by id.\n+    \"\"\"\n+\n+    def test_tool_use_input_resourced_from_redacted_tool_calls(self):\n+        REDACTED = \"[REDACTED_SECRET]\"\n+        # Ordered channel: raw input carries the live secret (as captured from\n+        # the unredacted API response).\n+        ordered = [\n+            {\"type\": \"thinking\", \"thinking\": \"Call the API.\", \"signature\": \"sig-AAA\"},\n+            {\n+                \"type\": \"tool_use\",\n+                \"id\": \"toolu_1\",\n+                \"name\": \"terminal\",\n+                \"input\": {\"command\": \"curl -H 'Authorization: Bearer sk-LIVE-SECRET-123'\"},\n+            },\n+            {\"type\": \"thinking\", \"thinking\": \"Now the second call.\", \"signature\": \"sig-BBB\"},\n+            {\n+                \"type\": \"tool_use\",\n+                \"id\": \"toolu_2\",\n+                \"name\": \"terminal\",\n+                \"input\": {\"command\": \"echo done\"},\n+            },\n+        ]\n+        # Stored tool_calls: arguments already redacted (the #19798 path).\n+        assistant_msg = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"reasoning_details\": [b for b in ordered if b[\"type\"] == \"thinking\"],\n+            \"tool_calls\": [\n+                {\n+                    \"id\": \"toolu_1\",\n+                    \"type\": \"function\",\n+                    \"function\": {\n+                        \"name\": \"terminal\",\n+                        \"arguments\": json.dumps(\n+                            {\"command\": f\"curl -H 'Authorization: Bearer {REDACTED}'\"}\n+                        ),\n+                    },\n+                },\n+                {\n+                    \"id\": \"toolu_2\",\n+                    \"type\": \"function\",\n+                    \"function\": {\n+                        \"name\": \"terminal\",\n+                        \"arguments\": json.dumps({\"command\": \"echo done\"}),\n+                    },\n+                },\n+            ],\n+            \"anthropic_content_blocks\": ordered,\n+        }\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Hit the API twice.\"},\n+            assistant_msg,\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_1\", \"content\": \"200 OK\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"toolu_2\", \"content\": \"done\"},\n+        ]\n+\n+        _system, anthropic_messages = convert_messages_to_anthropic(\n+            messages, base_url=None, model=\"claude-opus-4-8\",\n+        )\n+        assistant_out = [m for m in anthropic_messages if m.get(\"role\") == \"assistant\"]\n+        assert assistant_out, \"no assistant message in converted output\"\n+        blocks = assistant_out[-1][\"content\"]\n+\n+        tool_uses = {b[\"id\"]: b for b in blocks if b.get(\"type\") == \"tool_use\"}\n+        assert set(tool_uses) == {\"toolu_1\", \"toolu_2\"}, \"tool_use blocks missing/renamed\"\n+\n+        # The replayed input must be the REDACTED value, not the live secret.\n+        replayed_cmd = tool_uses[\"toolu_1\"][\"input\"][\"command\"]\n+        assert \"sk-LIVE-SECRET-123\" not in replayed_cmd, (\n+            \"Un-redacted secret leaked onto the wire via the verbatim-replay \"\n+            \"fast path. tool_use input must be re-sourced from the redacted \"\n+            \"tool_calls map, not the raw captured block.\"\n+        )\n+        assert REDACTED in replayed_cmd\n+\n+        # Interleave order is still preserved (the reason the channel exists).\n+        order = [\n+            (\"thinking\", b.get(\"signature\")) if b.get(\"type\") == \"thinking\"\n+            else (\"tool_use\", b.get(\"id\"))\n+            for b in blocks if b.get(\"type\") in (\"thinking\", \"tool_use\")\n+        ]\n+        assert order == [\n+            (\"thinking\", \"sig-AAA\"),\n+            (\"tool_use\", \"toolu_1\"),\n+            (\"thinking\", \"sig-BBB\"),\n+            (\"tool_use\", \"toolu_2\"),\n+        ]\n+\n+\n+if __name__ == \"__main__\":\n+    raise SystemExit(pytest.main([__file__, \"-v\"]))\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}