# NousResearch/hermes-agent · reasoning_content handling bugs and fixes across providers

## Open now

- 4 issues, 1 PR open on GitHub.
- #17992: DeepSeek V4 /anthropic: signed thinking blocks stripped, causing HTTP 400
- #24443: MiMo reasoning models may fail in Hermes because reasoning_content is not preserved in chat history
- #29607: Third-party relay (PackyAPI) + DeepSeek: thinking blocks stripped causing HTTP 400
- #58117: Thinking blocks cause blank text responses in CLI mode + infinite heartbeat loops
- PR #43947: Fix/deepsfix(deepseek): omitting tool_choice auto/any and preserving signed thinking blocks for /anthropic endpointeek anthropic compat

This cluster concerns bugs and proposed fixes around preserving, sanitizing, and correctly replaying reasoning_content (thinking blocks) in multi-turn sessions for DeepSeek and Kimi models. Issues span HTTP 400 errors from missing or malformed reasoning_content on tool-call and assistant turns, with fixes targeting extraction, persistence, and provider-specific sanitization.

## TL;DR

- 61 member(s): 4 open issue(s), 22 closed issue(s), 1 open PR(s), 8 merged PR(s), 26 PR(s) closed without merging.
- A fix was merged to main in PR #14018. Verify it addresses your case.
- **Recommended:** read PR #14018 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-14018,pr-15407,pr-15478,pr-16500,pr-17510,pr-18045,pr-22798,pr-26648`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.04`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #17992: DeepSeek V4 /anthropic: signed thinking blocks stripped, causing HTTP 400
- #24443: MiMo reasoning models may fail in Hermes because reasoning_content is not preserved in chat history
- #29607: Third-party relay (PackyAPI) + DeepSeek: thinking blocks stripped causing HTTP 400
- #58117: Thinking blocks cause blank text responses in CLI mode + infinite heartbeat loops

## Closed / duplicate issues

- #13848: \[Bug\]: 400 error for kimi-for-coding
- #14933: \[BUG\] DeepSeek V4 thinking mode fails with reasoning_content error
- #14938: DeepSeek V4 Flash Toolcall Fails with reasoning_content Error
- #15213: \[Bug\]: HTTP 400 "reasoning_content must be passed back" with deepseek-v4-pro in cron/auxiliary path (thinking mode works in main loop, breaks elsewhere)
- #15250: \[Bug\]: DeepSeek V4 Flash Discord session poisoned when tool-call assistant lacks reasoning_content
- #15353: DeepSeek V4 thinking mode: missing reasoning_content on tool-call messages causes 400
- #15679: \[Bug\]: DeepSeek reasoning models not supported (reasoning_content missing)
- #15717: \[Bug\]: DeepSeek API 400 error: "reasoning_content" in thinking mode must be passed back to the API
- #15741: \[Bug\]: cron path still emits HTTP 400 reasoning_content error on deepseek-v4-flash after #15213 closure
- #15748: \[Bug\] _copy_reasoning_content_for_api: cross-provider reasoning promotion leaks stale content to DeepSeek/Kimi
- #16021: \[Bug report\] DeepSeek V4: reasoning_content missing on non-tool-call turns causes HTTP 400
- #16135: \[BUG\] DeepSeek v4-pro reasoning mode breaks with "reasoning_content must be passed back" error
- #16137: \[Bug\]: DeepSeek thinking mode requires reasoning_content in API replay
- #16388: DeepSeek tool-calling turns missing reasoning_content cause HTTP 400 — Kimi-only compensation should generalise
- #16748: DeepSeek /anthropic (V4 thinking): stripped thinking blocks cause HTTP 400 on replay
- #17212: DeepSeek direct API 400 "reasoning_content must be passed back" on multi-turn tool calls
- #17400: reasoning_content dropped in multi-turn tool calls with DeepSeek v4 (causes HTTP 400)
- #17825: \[Bug\]: DeepSeek reasoning models crash with HTTP 400 on multi-turn sessions — reasoning_content dropped during session reload
- #21944: Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)
- #21946: Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)
- #22313: bug: DeepSeek Anthropic-compatible API — HTTP 400: thinking blocks stripped from message history
- #34519: \[Bug\]: Fallback provider activation reuses stale api_messages — reasoning_content not injected for DeepSeek/Kimi thinking mode

## Prior & competing PRs

- **#14018: fix(kimi): preserve reasoning_content in Anthropic adapter for /coding endpoint (salvage #13897)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `scripts/release.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -1083,6 +1083,31 @@ def convert_messages_to_anthropic(
                         "name": fn.get("name", ""),
                         "input": parsed_args,
                     })
    +            # Kimi's /coding endpoint (Anthropic protocol) requires assistant
    +            # tool-call messages to carry reasoning_content when thinking is
    +            # enabled server-side.  Preserve it as a thinking block so Kimi
    +            # can validate the message history.  See hermes-agent#13848.
    +            #
    +            # Accept empty string "" — _copy_reasoning_content_for_api()
    +            # injects "" as a tier-3 fallback for Kimi tool-call messages
    +            # that had no reasoning.  Kimi requires the field to exist, even
    +            # if empty.
    +            #
    +            # Prepend (not append): Anthropic protocol requires thinking
    +            # blocks before text and tool_use blocks.
    +            #
    +            # Guard: only add when reasoning_details didn't already contribute
    +            # thinking blocks.  On native Anthropic, reasoning_details produces
    +            # signed thinking blocks — adding another unsigned one from
    +            # reasoning_content would create a duplicate (same text) that gets
    +            # downgraded to a spurious text block on the last assistant message.
    +            reasoning_content = m.get("reasoning_content")
    +            _already_has_thinking = any(
    +                isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking")
    +                for b in blocks
    +            )
    +            if isinstance(reasoning_content, str) and not _already_has_thinking:
    +                blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
                 # Anthropic rejects empty assistant content
                 effective = blocks or content
                 if not effective or effective == "":
    ```

    ```diff
    @@ -1238,6 +1263,7 @@ def convert_messages_to_anthropic(
         #    cache markers can interfere with signature validation.
         _THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
         _is_third_party = _is_third_party_anthropic_endpoint(base_url)
    +    _is_kimi = _is_kimi_coding_endpoint(base_url)
     
         last_assistant_idx = None
         for i in range(len(result) - 1, -1, -1):
    ```

    ```diff
    @@ -1249,7 +1275,25 @@ def convert_messages_to_anthropic(
             if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
                 continue
     
    -        if _is_third_party or idx != last_assistant_idx:
    +        if _is_kimi:
    +            # Kimi's /coding endpoint enables thinking server-side and
    +            # requires unsigned thinking blocks on replayed assistant
    +            # tool-call messages.  Strip signed Anthropic blocks (Kimi
    +            # can't validate signatures) but preserve the unsigned ones
    +            # we synthesised from reasoning_content above.
    +            new_content = []
    +            for b in m["content"]:
    +                if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
    +                    new_content.append(b)
    +                    continue
    +                if b.get("signature") or b.get("data"):
    +                    # Anthropic-signed block — Kimi can't validate, strip
    +                    continue
    +                # Unsigned thinking (synthesised from reasoning_content) —
    +                # keep it: Kimi needs it for message-history validation.
    +                new_content.append(b)
    +            m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
    +        elif _is_third_party or idx != last_assistant_idx:
                 # Third-party endpoint: strip ALL thinking blocks from every
                 # assistant message — signatures are Anthropic-proprietary.
                 # Direct Anthropic: strip from non-latest assistant messages only.
    ```
  - `scripts/release.py`

    ```diff
    @@ -95,6 +95,8 @@
         "[redacted:email]": "sgaofen",
         "[redacted:email]": "nocoo",
         "[redacted:email]": "n-WN",
    +    "[redacted:email]": "hiddenpuppy",
    +    "[redacted:email]": "HiddenPuppy",
         "[redacted:email]": "keyuyuan",
         "[redacted:email]": "Only-Code-A",
         "[redacted:email]": "TroyMitchell911",
    ```
- **#15407: fix: DeepSeek V4 thinking mode reasoning_content echo on tool-call messages**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `scripts/release.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -7625,6 +7625,12 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
                 raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
                 if raw_reasoning_content is not None:
                     msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
    +            elif msg.get("tool_calls") and self._needs_deepseek_tool_reasoning():
    +                # DeepSeek thinking mode requires reasoning_content on every
    +                # assistant tool-call message. Without it, replaying the
    +                # persisted message causes HTTP 400. Include empty string
    +                # as a defensive compatibility fallback (refs #15250).
    +                msg["reasoning_content"] = ""
     
             if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
                 # Pass reasoning_details back unmodified so providers (OpenRouter,
    ```

    ```diff
    @@ -7700,6 +7706,35 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
     
             return msg
     
    +    def _needs_kimi_tool_reasoning(self) -> bool:
    +        """Return True when the current provider is Kimi / Moonshot thinking mode.
    +
    +        Kimi ``/coding`` and Moonshot thinking mode both require
    +        ``reasoning_content`` on every assistant tool-call message; omitting
    +        it causes the next replay to fail with HTTP 400.
    +        """
    +        return (
    +            self.provider in {"kimi-coding", "kimi-coding-cn"}
    +            or base_url_host_matches(self.base_url, "api.kimi.com")
    +            or base_url_host_matches(self.base_url, "moonshot.ai")
    +            or base_url_host_matches(self.base_url, "moonshot.cn")
    +        )
    +
    +    def _needs_deepseek_tool_reasoning(self) -> bool:
    +        """Return True when the current provider is DeepSeek thinking mode.
    +
    +        DeepSeek V4 thinking mode requires ``reasoning_content`` on every
    +        assistant tool-call turn; omitting it causes HTTP 400 when the
    +        message is replayed in a subsequent API request (#15250).
    +        """
    +        provider = (self.provider or "").lower()
    +        model = (self.model or "").lower()
    +        return (
    +            provider == "deepseek"
    +            or "deepseek" in model
    +            or base_url_host_matches(self.base_url, "api.deepseek.com")
    +        )
    +
         def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None:
             """Copy provider-facing reasoning fields onto an API replay message."""
             if source_msg.get("role") != "assistant":
    ```

    ```diff
    @@ -7715,13 +7750,14 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                 api_msg["reasoning_content"] = normalized_reasoning
                 return
     
    -        kimi_requires_reasoning = (
    -            self.provider in {"kimi-coding", "kimi-coding-cn"}
    -            or base_url_host_matches(self.base_url, "api.kimi.com")
    -            or base_url_host_matches(self.base_url, "moonshot.ai")
    -            or base_url_host_matches(self.base_url, "moonshot.cn")
    -        )
    -        if kimi_requires_reasoning and source_msg.get("tool_calls"):
    +        # Providers that require an echoed reasoning_content on every
    +        # assistant tool-call turn. Detection logic lives in the per-provider
    +        # helpers so both the creation path (_build_assistant_message) and
    +        # this replay path stay in sync.
    +        if source_msg.get("tool_calls") and (
    +            self._needs_kimi_tool_reasoning()
    +            or self._needs_deepseek_tool_reasoning()
    +        ):
                 api_msg["reasoning_content"] = ""
     
         @staticmethod
    ```

    ```diff
    @@ -9095,6 +9131,7 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
                 api_messages = []
                 for msg in messages:
                     api_msg = msg.copy()
    +                self._copy_reasoning_content_for_api(msg, api_msg)
                     for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
                         api_msg.pop(internal_field, None)
                     if _needs_sanitize:
    ```
  - `scripts/release.py`

    ```diff
    @@ -499,6 +499,7 @@
         "[redacted:email]": "zhangxicen",
         "[redacted:email]": "teknium1",
         "[redacted:email]": "teknium1",
    +    "[redacted:email]": "chen1749144759",
     }
     
     
    ```
  - `tests/run_agent/test_deepseek_reasoning_content_echo.py`

    ```diff
    @@ -0,0 +1,213 @@
    +"""Regression test: DeepSeek V4 thinking mode reasoning_content echo.
    +
    +DeepSeek V4-flash / V4-pro thinking mode requires ``reasoning_content`` on
    +every assistant message that carries ``tool_calls``. When a persisted
    +session replays an assistant tool-call turn that was recorded without the
    +field, DeepSeek rejects the next request with HTTP 400::
    +
    +    The reasoning_content in the thinking mode must be passed back to the API.
    +
    +Fix covers three paths:
    +
    +1. ``_build_assistant_message`` — new tool-call messages without raw
    +   reasoning_content get ``""`` pinned at creation time so nothing gets
    +   persisted poisoned.
    +2. ``_copy_reasoning_content_for_api`` — already-poisoned history replays
    +   with ``reasoning_content=""`` injected defensively.
    +3. Detection covers three signals: ``provider == "deepseek"``,
    +   ``"deepseek" in model``, and ``api.deepseek.com`` host match. The third
    +   catches custom-provider setups pointing at DeepSeek.
    +
    +Refs #15250 / #15353.
    +"""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +from run_agent import AIAgent
    +
    +
    +def _make_agent(provider: str = "", model: str = "", base_url: str = "") -> AIAgent:
    +    agent = object.__new__(AIAgent)
    +    agent.provider = provider
    +    agent.model = model
    +    agent.base_url = base_url
    +    return agent
    +
    +
    +class TestNeedsDeepSeekToolReasoning:
    +    """_needs_deepseek_tool_reasoning() recognises all three detection signals."""
    +
    +    def test_provider_deepseek(self) -> None:
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        assert agent._needs_deepseek_tool_reasoning() is True
    +
    +    def test_model_substring(self) -> None:
    +        # Custom provider pointing at DeepSeek with provider='custom'
    +        agent = _make_agent(provider="custom", model="deepseek-v4-pro")
    +        assert agent._needs_deepseek_tool_reasoning() is True
    +
    +    def test_base_url_host(self) -> None:
    +        agent = _make_agent(
    +            provider="custom",
    +            model="some-aliased-name",
    +            base_url="https://api.deepseek.com/v1",
    +        )
    +        assert agent._needs_deepseek_tool_reasoning() is True
    +
    +    def test_provider_case_insensitive(self) -> None:
    +        agent = _make_agent(provider="DeepSeek", model="")
    +        assert agent._needs_deepseek_tool_reasoning() is True
    +
    +    def test_non_deepseek_provider(self) -> None:
    +        agent = _make_agent(
    +            provider="openrouter",
    +            model="anthropic/claude-sonnet-4.6",
    +            base_url="https://openrouter.ai/api/v1",
    +        )
    +        assert agent._needs_deepseek_tool_reasoning() is False
    +
    +    def test_empty_everything(self) -> None:
    +        agent = _make_agent()
    +        assert agent._needs_deepseek_tool_reasoning() is False
    +
    +
    +class TestCopyReasoningContentForApi:
    +    """_copy_reasoning_content_for_api pads reasoning_content for DeepSeek tool-calls."""
    +
    +    def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:
    +        """Already-poisoned history (no reasoning_content, no reasoning) gets ''."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        source = {
    +            "role": "assistant",
    +            "content": "",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg.get("reasoning_content") == ""
    +
    +    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
    +        """Plain assistant turns without tool_calls don't get padded."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        source = {"role": "assistant", "content": "hello"}
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert "reasoning_content" not in api_msg
    +
    +    def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
    +        """When reasoning_content is already set, it's copied verbatim."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        source = {
    +            "role": "assistant",
    +            "reasoning_content": "<think>real chain of thought</think>",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg["reasoning_content"] == "<think>real chain of thought</think>"
    +
    +    def test_deepseek_reasoning_field_promoted(self) -> None:
    +        """When only 'reasoning' is set, it gets promoted to reasoning_content."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        source = {
    +            "role": "assistant",
    +            "reasoning": "thought trace",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg["reasoning_content"] == "thought trace"
    +
    +    def test_kimi_path_still_works(self) -> None:
    +        """Existing Kimi detection still pads reasoning_content."""
    +        agent = _make_agent(provider="kimi-coding", model="kimi-k2.5")
    +        source = {
    +            "role": "assistant",
    +            "content": "",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg.get("reasoning_content") == ""
    +
    +    def test_kimi_moonshot_base_url(self) -> None:
    +        agent = _make_agent(
    +            provider="custom", model="kimi-k2", base_url="https://api.moonshot.ai/v1"
    +        )
    +        source = {
    +            "role": "assistant",
    +            "content": "",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg.get("reasoning_content") == ""
    +
    +    def test_non_thinking_provider_not_padded(self) -> None:
    +        """Providers that don't require the echo are untouched."""
    +        agent = _make_agent(
    +            provider="openrouter",
    +            model="anthropic/claude-sonnet-4.6",
    +            base_url="https://openrouter.ai/api/v1",
    +        )
    +        source = {
    +            "role": "assistant",
    +            "content": "",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert "reasoning_content" not in api_msg
    +
    +    def test_deepseek_custom_base_url(self) -> None:
    +        """Custom provider pointing at api.deepseek.com is detected via host."""
    +        agent = _make_agent(
    +            provider="custom",
    +            model="whatever",
    +            base_url="https://api.deepseek.com/v1",
    +        )
    +        source = {
    +            "role": "assistant",
    +            "content": "",
    +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
    +        }
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert api_msg.get("reasoning_content") == ""
    +
    +    def test_non_assistant_role_ignored(self) -> None:
    +        """User/tool messages are left alone."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        source = {"role": "user", "content": "hi"}
    +        api_msg: dict = {}
    +        agent._copy_reasoning_content_for_api(source, api_msg)
    +        assert "reasoning_content" not in api_msg
    +
    +
    +class TestNeedsKimiToolReasoning:
    +    """The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact."""
    +
    +    @pytest.mark.parametrize(
    +        "provider,base_url",
    +        [
    +            ("kimi-coding", ""),
    +            ("kimi-coding-cn", ""),
    +            ("custom", "https://api.kimi.com/v1"),
    +            ("custom", "https://api.moonshot.ai/v1"),
    +            ("custom", "https://api.moonshot.cn/v1"),
    +        ],
    +    )
    +    def test_kimi_signals(self, provider: str, base_url: str) -> None:
    +        agent = _make_agent(provider=provider, model="kimi-k2", base_url=base_url)
    +        assert agent._needs_kimi_tool_reasoning() is True
    +
    +    def test_non_kimi_provider(self) -> None:
    +        agent = _make_agent(
    +            provider="openrouter",
    +            model="moonshotai/kimi-k2",
    +            base_url="https://openrouter.ai/api/v1",
    +        )
    +        # model name contains 'moonshot' but host is openrouter — should be False
    +        assert agent._needs_kimi_tool_reasoning() is False
    ```
- **#15478: fix: DeepSeek/Kimi thinking mode requires reasoning_content on ALL assistant messages**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -7785,7 +7785,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                 api_msg["reasoning_content"] = normalized_reasoning
                 return
     
    -        # 4. reasoning_content was present but not a string (e.g. None after
    +        # 4. DeepSeek / Kimi thinking mode: all assistant messages need
    +        # reasoning_content. Inject "" to satisfy the provider's requirement
    +        # when no explicit reasoning content is present.
    +        if (
    +            self._needs_kimi_tool_reasoning()
    +            or self._needs_deepseek_tool_reasoning()
    +        ):
    +            api_msg["reasoning_content"] = ""
    +            return
    +
    +        # 5. reasoning_content was present but not a string (e.g. None after
             # context compaction).  Don't pass null to the API.
             api_msg.pop("reasoning_content", None)
     
    ```
  - `tests/run_agent/test_deepseek_reasoning_content_echo.py`

    ```diff
    @@ -88,13 +88,13 @@ def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:
             agent._copy_reasoning_content_for_api(source, api_msg)
             assert api_msg.get("reasoning_content") == ""
     
    -    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
    -        """Plain assistant turns without tool_calls don't get padded."""
    +    def test_deepseek_assistant_no_tool_call_gets_padded(self) -> None:
    +        """DeepSeek thinking mode pads ALL assistant turns, even without tool_calls."""
             agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
             source = {"role": "assistant", "content": "hello"}
             api_msg: dict = {}
             agent._copy_reasoning_content_for_api(source, api_msg)
    -        assert "reasoning_content" not in api_msg
    +        assert api_msg.get("reasoning_content") == ""
     
         def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
             """When reasoning_content is already set, it's copied verbatim."""
    ```
- **#15688: fix(auxiliary): sanitize reasoning_content for DeepSeek/Kimi tool-call replay**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15792: fix(deepseek): pad reasoning_content on plain assistant turns in thinking mode (#15741)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `5ae608152`, `ad0ac8947`, `63bf7a29b`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#16500: fix(agent): block cross-provider reasoning leak to DeepSeek/Kimi (#15748)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`
- **#17223: fix(anthropic_adapter): preserve unsigned thinking blocks for DeepSeek /anthropic (#16748)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a7e7921db`, `#16748`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#17510: fix(anthropic): preserve DeepSeek /anthropic thinking blocks on replay (#16748)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `tests/agent/test_deepseek_anthropic_thinking.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -420,6 +420,33 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->
         return False
     
     
    +def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
    +    """Return True for DeepSeek's Anthropic-compatible endpoint.
    +
    +    DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol
    +    but, when thinking mode is enabled, requires the ``thinking`` blocks
    +    from prior assistant turns to round-trip on subsequent requests — the
    +    generic third-party path strips them and triggers HTTP 400::
    +
    +        The content[].thinking in the thinking mode must be passed back
    +        to the API.
    +
    +    Per DeepSeek's published compatibility matrix the blocks are unsigned
    +    (no Anthropic-proprietary signature, no ``redacted_thinking`` support),
    +    so this endpoint is handled with the same strip-signed / keep-unsigned
    +    policy used for Kimi's ``/coding`` endpoint.  The match is pinned to
    +    the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com``
    +    base URL (which never reaches this adapter) is not misclassified.
    +    See hermes-agent#16748.
    +    """
    +    if not base_url_host_matches(base_url or "", "api.deepseek.com"):
    +        return False
    +    normalized = _normalize_base_url_text(base_url)
    +    if not normalized:
    +        return False
    +    return "/anthropic" in normalized.rstrip("/").lower()
    +
    +
     def _requires_bearer_auth(base_url: str | None) -> bool:
         """Return True for Anthropic-compatible providers that require Bearer auth.
     
    ```

    ```diff
    @@ -1569,7 +1596,16 @@ def convert_messages_to_anthropic(
         #    cache markers can interfere with signature validation.
         _THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
         _is_third_party = _is_third_party_anthropic_endpoint(base_url)
    -    _is_kimi = _is_kimi_family_endpoint(base_url, model)
    +    # Kimi /coding and DeepSeek /anthropic share a contract: both speak the
    +    # Anthropic Messages protocol upstream but require that thinking blocks
    +    # synthesised from reasoning_content round-trip on subsequent turns when
    +    # thinking is enabled.  Signed Anthropic blocks still have to be stripped
    +    # (neither endpoint can validate Anthropic's signatures); unsigned blocks
    +    # are preserved.  See hermes-agent#13848 (Kimi) and #16748 (DeepSeek).
    +    _preserve_unsigned_thinking = (
    +        _is_kimi_family_endpoint(base_url, model)
    +        or _is_deepseek_anthropic_endpoint(base_url)
    +    )
     
         last_assistant_idx = None
         for i in range(len(result) - 1, -1, -1):
    ```

    ```diff
    @@ -1581,22 +1617,22 @@ def convert_messages_to_anthropic(
             if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
                 continue
     
    -        if _is_kimi:
    -            # Kimi's /coding endpoint enables thinking server-side and
    -            # requires unsigned thinking blocks on replayed assistant
    -            # tool-call messages.  Strip signed Anthropic blocks (Kimi
    -            # can't validate signatures) but preserve the unsigned ones
    -            # we synthesised from reasoning_content above.
    +        if _preserve_unsigned_thinking:
    +            # Kimi's /coding and DeepSeek's /anthropic endpoints both enable
    +            # thinking server-side and require unsigned thinking blocks on
    +            # replayed assistant tool-call messages.  Strip signed Anthropic
    +            # blocks (neither upstream can validate Anthropic signatures) but
    +            # preserve the unsigned ones we synthesised from reasoning_content.
                 new_content = []
                 for b in m["content"]:
                     if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
                         new_content.append(b)
                         continue
                     if b.get("signature") or b.get("data"):
    -                    # Anthropic-signed block — Kimi can't validate, strip
    +                    # Anthropic-signed block — upstream can't validate, strip
                         continue
                     # Unsigned thinking (synthesised from reasoning_content) —
    -                # keep it: Kimi needs it for message-history validation.
    +                # keep it: the upstream needs it for message-history validation.
                     new_content.append(b)
                 m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
             elif _is_third_party or idx != last_assistant_idx:
    ```
  - `tests/agent/test_deepseek_anthropic_thinking.py`

    ```diff
    @@ -0,0 +1,242 @@
    +"""Regression guard: preserve thinking blocks on DeepSeek's /anthropic endpoint.
    +
    +DeepSeek's ``api.deepseek.com/anthropic`` route speaks the Anthropic Messages
    +protocol but, when thinking mode is enabled, requires ``thinking`` blocks from
    +prior assistant turns to round-trip on subsequent requests.  The generic
    +third-party path strips them (signatures are Anthropic-proprietary and other
    +proxies cannot validate them), so without a DeepSeek-specific carve-out the
    +next tool-call turn fails with HTTP 400::
    +
    +    The content[].thinking in the thinking mode must be passed back to the
    +    API.
    +
    +DeepSeek's compatibility matrix lists ``thinking`` as supported but
    +``redacted_thinking`` and ``cache_control`` on thinking blocks as not
    +supported.  Handling is the same as Kimi's ``/coding`` endpoint: strip
    +Anthropic-signed blocks (DeepSeek can't validate them) but preserve unsigned
    +blocks that Hermes synthesises from ``reasoning_content``.
    +
    +See hermes-agent#16748.
    +"""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +
    +class TestDeepSeekAnthropicPreservesThinking:
    +    """convert_messages_to_anthropic must replay DeepSeek thinking blocks."""
    +
    +    @pytest.mark.parametrize(
    +        "base_url",
    +        [
    +            "https://api.deepseek.com/anthropic",
    +            "https://api.deepseek.com/anthropic/",
    +            "https://api.deepseek.com/anthropic/v1",
    +            "https://API.DeepSeek.com/anthropic",
    +        ],
    +    )
    +    def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
    +        """Unsigned thinking (synthesised from reasoning_content) must be preserved."""
    +        from agent.anthropic_adapter import convert_messages_to_anthropic
    +
    +        messages = [
    +            {"role": "user", "content": "hi"},
    +            {
    +                "role": "assistant",
    +                "reasoning_content": "planning the tool call",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_1",
    +                        "type": "function",
    +                        "function": {"name": "skill_view", "arguments": "{}"},
    +                    }
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
    +        ]
    +        _system, converted = convert_messages_to_anthropic(
    +            messages, base_url=base_url
    +        )
    +
    +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
    +        thinking_blocks = [
    +            b for b in assistant_msg["content"]
    +            if isinstance(b, dict) and b.get("type") == "thinking"
    +        ]
    +        assert len(thinking_blocks) == 1, (
    +            f"DeepSeek /anthropic ({base_url}) must preserve unsigned thinking "
    +            "blocks synthesised from reasoning_content — upstream rejects "
    +            "replayed tool-call messages without them."
    +        )
    +        assert thinking_blocks[0]["thinking"] == "planning the tool call"
    +        # Synthesised block — never has a signature
    +        assert "signature" not in thinking_blocks[0]
    +
    +    def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:
    +        """DeepSeek validates history across every prior assistant turn, not just last."""
    +        from agent.anthropic_adapter import convert_messages_to_anthropic
    +
    +        messages = [
    +            {"role": "user", "content": "q1"},
    +            {
    +                "role": "assistant",
    +                "reasoning_content": "r1",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_1",
    +                        "type": "function",
    +                        "function": {"name": "f", "arguments": "{}"},
    +                    }
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
    +            {"role": "user", "content": "q2"},
    +            {
    +                "role": "assistant",
    +                "reasoning_content": "r2",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_2",
    +                        "type": "function",
    +                        "function": {"name": "f", "arguments": "{}"},
    +                    }
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_2", "content": "ok"},
    +        ]
    +        _system, converted = convert_messages_to_anthropic(
    +            messages, base_url="https://api.deepseek.com/anthropic"
    +        )
    +
    +        assistants = [m for m in converted if m["role"] == "assistant"]
    +        assert len(assistants) == 2
    +        for assistant, expected in zip(assistants, ("r1", "r2")):
    +            thinking = [
    +                b for b in assistant["content"]
    +                if isinstance(b, dict) and b.get("type") == "thinking"
    +            ]
    +            assert len(thinking) == 1
    +            assert thinking[0]["thinking"] == expected
    +
    +    def test_signed_anthropic_thinking_block_is_stripped(self) -> None:
    +        """Anthropic-signed blocks (that leaked through) must still be stripped.
    +
    +        DeepSeek issues its own signatures and cannot validate Anthropic's —
    +        the strip-signed / keep-unsigned split matches the Kimi policy.
    +        """
    +        from agent.anthropic_adapter import convert_messages_to_anthropic
    +
    +        messages = [
    +            {"role": "user", "content": "hi"},
    +            {
    +                "role": "assistant",
    +                "content": [
    +                    {
    +                        "type": "thinking",
    +                        "thinking": "anthropic-signed payload",
    +                        "signature": "anthropic-sig-xyz",
    +                    },
    +                    {"type": "text", "text": "hello"},
    +                ],
    +            },
    +            {"role": "user", "content": "again"},
    +        ]
    +        _system, converted = convert_messages_to_anthropic(
    +            messages, base_url="https://api.deepseek.com/anthropic"
    +        )
    +
    +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
    +        thinking_blocks = [
    +            b for b in assistant_msg["content"]
    +            if isinstance(b, dict) and b.get("type") == "thinking"
    +        ]
    +        assert thinking_blocks == [], (
    +            "Signed Anthropic thinking blocks must be stripped on DeepSeek — "
    +            "DeepSeek cannot validate Anthropic-proprietary signatures."
    +        )
    +
    +    def test_cache_control_stripped_from_thinking_block(self) -> None:
    +        """cache_control must still be stripped even when the block is preserved.
    +
    +        DeepSeek's compatibility matrix lists cache_control on thinking blocks
    +        as ignored — cache markers interfere with signature validation on
    +        upstreams that do check them, so Hermes strips them everywhere.
    +        """
    +        from agent.anthropic_adapter import convert_messages_to_anthropic
    +
    +        messages = [
    +            {"role": "user", "content": "hi"},
    +            {
    +                "role": "assistant",
    +                "reasoning_content": "r1",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_1",
    +                        "type": "function",
    +                        "function": {"name": "f", "arguments": "{}"},
    +                    }
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
    +        ]
    +        # Inject cache_control on the synthesised thinking block after-the-fact
    +        # by running conversion once, mutating, then re-running would be
    +        # indirect.  Instead check the simpler invariant: no thinking block in
    +        # the converted output carries cache_control.
    +        _system, converted = convert_messages_to_anthropic(
    +            messages, base_url="https://api.deepseek.com/anthropic"
    +        )
    +        for m in converted:
    +            if not isinstance(m.get("content"), list):
    +                continue
    +            for b in m["content"]:
    +                if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"):
    +                    assert "cache_control" not in b
    +
    +    def test_openai_compat_deepseek_base_is_not_matched(self) -> None:
    +        """The OpenAI-compatible ``api.deepseek.com`` base must NOT trigger the
    +        DeepSeek /anthropic branch — it never reaches this adapter, but the
    +        detector should still fail closed so an accidental misuse doesn't
    +        quietly send signed Anthropic blocks to an OpenAI endpoint.
    +        """
    +        from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint
    +
    +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False
    +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False
    +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/anthropic") is True
    +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/anthropic/v1") is True
    +
    +    def test_non_deepseek_third_party_still_strips_all_thinking(self) -> None:
    +        """MiniMax and other third-party Anthropic endpoints must keep the
    +        generic strip-all behaviour (they reject unsigned blocks outright).
    +        """
    +        from agent.anthropic_adapter import convert_messages_to_anthropic
    +
    +        messages = [
    +            {"role": "user", "content": "hi"},
    +            {
    +                "role": "assistant",
    +                "reasoning_content": "r1",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_1",
    +                        "type": "function",
    +                        "function": {"name": "f", "arguments": "{}"},
    +                    }
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
    +        ]
    +        _system, converted = convert_messages_to_anthropic(
    +            messages, base_url="https://api.minimax.io/anthropic"
    +        )
    +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
    +        thinking_blocks = [
    +            b for b in assistant_msg["content"]
    +            if isinstance(b, dict) and b.get("type") == "thinking"
    +        ]
    +        assert thinking_blocks == [], (
    +            "Non-DeepSeek third-party endpoints must keep the generic "
    +            "strip-all-thinking behaviour — unsigned blocks get rejected."
    +        )
    ```
- **#18045: fix(agent): preserve reasoning_content replay on DeepSeek v4 + Kimi/Moonshot thinking**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/transports/chat_completions.py`, `run_agent.py`, `scripts/release.py`, `tests/agent/transports/test_chat_completions.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`, `tests/run_agent/test_deepseek_v4_thinking_live.py`
  - fix hunks:
  - `agent/transports/chat_completions.py`

    ```diff
    @@ -477,9 +477,13 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
             # so keep them apart in provider_data rather than merging.
             reasoning = getattr(msg, "reasoning", None)
             reasoning_content = getattr(msg, "reasoning_content", None)
    +        if reasoning_content is None and hasattr(msg, "model_extra"):
    +            model_extra = getattr(msg, "model_extra", None) or {}
    +            if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
    +                reasoning_content = model_extra["reasoning_content"]
     
             provider_data: Dict[str, Any] = {}
    -        if reasoning_content:
    +        if reasoning_content is not None:
                 provider_data["reasoning_content"] = reasoning_content
             rd = getattr(msg, "reasoning_details", None)
             if rd:
    ```
  - `run_agent.py`

    ```diff
    @@ -8501,6 +8501,7 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
             Handles reasoning extraction, reasoning_details, and optional tool_calls
             so both the tool-call path and the final-response path share one builder.
             """
    +        assistant_tool_calls = getattr(assistant_message, "tool_calls", None)
             reasoning_text = self._extract_reasoning(assistant_message)
             _from_structured = bool(reasoning_text)
     
    ```

    ```diff
    @@ -8560,16 +8561,22 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
                 "finish_reason": finish_reason,
             }
     
    -        if hasattr(assistant_message, "reasoning_content"):
    -            raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
    -            if raw_reasoning_content is not None:
    -                msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
    -            elif msg.get("tool_calls") and self._needs_deepseek_tool_reasoning():
    -                # DeepSeek thinking mode requires reasoning_content on every
    -                # assistant tool-call message. Without it, replaying the
    -                # persisted message causes HTTP 400. Include empty string
    -                # as a defensive compatibility fallback (refs #15250).
    -                msg["reasoning_content"] = ""
    +        raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
    +        if raw_reasoning_content is None and hasattr(assistant_message, "model_extra"):
    +            model_extra = getattr(assistant_message, "model_extra", None) or {}
    +            if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
    +                raw_reasoning_content = model_extra["reasoning_content"]
    +        if raw_reasoning_content is not None:
    +            msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
    +        elif assistant_tool_calls and self._needs_thinking_reasoning_pad():
    +            # DeepSeek v4 thinking mode and Kimi / Moonshot thinking mode
    +            # both require reasoning_content on every assistant tool-call
    +            # message. Without it, replaying the persisted message causes
    +            # HTTP 400 ("The reasoning_content in the thinking mode must
    +            # be passed back to the API"). Include streamed reasoning
    +            # text when captured; otherwise pad with empty string.
    +            # Refs #15250, #17400.
    +            msg["reasoning_content"] = reasoning_text or ""
     
             # Additive fallback (refs #16844, #16884). Streaming-only providers
             # (glm, MiniMax, gpt-5.x via aigw, Anthropic via openai-compat shims)
    ```

    ```diff
    @@ -8626,9 +8633,9 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
             if codex_message_items:
                 msg["codex_message_items"] = codex_message_items
     
    -        if assistant_message.tool_calls:
    +        if assistant_tool_calls:
                 tool_calls = []
    -            for tool_call in assistant_message.tool_calls:
    +            for tool_call in assistant_tool_calls:
                     raw_id = getattr(tool_call, "id", None)
                     call_id = getattr(tool_call, "call_id", None)
                     if not isinstance(call_id, str) or not call_id.strip():
    ```

    ```diff
    @@ -8677,6 +8684,18 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
     
             return msg
     
    +    def _needs_thinking_reasoning_pad(self) -> bool:
    +        """Return True when the active provider enforces reasoning_content echo-back.
    +
    +        DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays
    +        of assistant tool-call messages that omit ``reasoning_content`` (refs
    +        #15250, #17400).
    +        """
    +        return (
    +            self._needs_deepseek_tool_reasoning()
    +            or self._needs_kimi_tool_reasoning()
    +        )
    +
         def _needs_kimi_tool_reasoning(self) -> bool:
             """Return True when the current provider is Kimi / Moonshot thinking mode.
     
    ```

    ```diff
    @@ -8719,20 +8738,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                 api_msg["reasoning_content"] = existing
                 return
     
    -        needs_thinking_pad = (
    -            self._needs_kimi_tool_reasoning()
    -            or self._needs_deepseek_tool_reasoning()
    -        )
    +        needs_thinking_pad = self._needs_thinking_reasoning_pad()
     
             # 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
             # if the source turn has tool_calls AND a 'reasoning' field but no
             # 'reasoning_content' key, the 'reasoning' text was written by a
             # prior provider (e.g. MiniMax) — DeepSeek's own _build_assistant_message
    -        # always pins reasoning_content="" at creation time for tool-call turns,
    -        # so the shape (reasoning set, reasoning_content absent, tool_calls
    -        # present) is unreachable from same-provider DeepSeek history. Inject
    -        # "" to satisfy the API without leaking another provider's chain of
    -        # thought to DeepSeek/Kimi.
    +        # pins reasoning_content at creation time for tool-call turns, so the
    +        # shape (reasoning set, reasoning_content absent, tool_calls present)
    +        # is unreachable from same-provider DeepSeek history after this fix.
    +        # Inject "" to satisfy the API without leaking another provider's
    +        # chain of thought to DeepSeek/Kimi.
             normalized_reasoning = source_msg.get("reasoning")
             if (
                 needs_thinking_pad
    ```

    ```diff
    @@ -8745,9 +8761,9 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
     
             # 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
             # for providers that use the internal 'reasoning' key.
    -        # This must happen BEFORE the DeepSeek/Kimi tool-call check so that
    -        # genuine reasoning content is not overwritten by the empty-string
    -        # fallback (#15812 regression in PR #15478).
    +        # This must happen before the unconditional empty-string fallback so
    +        # genuine reasoning content is not overwritten (#15812 regression in
    +        # PR #15478).
             if isinstance(normalized_reasoning, str) and normalized_reasoning:
                 api_msg["reasoning_content"] = normalized_reasoning
                 return
    ```
  - `scripts/release.py`

    ```diff
    @@ -53,6 +53,9 @@
         "sr@samirusani": "samrusani",
         "[redacted:email]": "angel12",
         "[redacted:email]": "charles-brooks",
    +    # DeepSeek v4 + Kimi thinking-mode reasoning_content salvage (April 2026)
    +    "[redacted:email]": "lsdsjy",
    +    "[redacted:email]": "season179",
         "[redacted:email]": "heathley",
         "[redacted:email]": "dandaka",
         "[redacted:email]": "cyclingwithelephants",
    ```
  - `tests/agent/transports/test_chat_completions.py`

    ```diff
    @@ -620,6 +620,41 @@ def test_reasoning_content_preserved_separately(self, transport):
             assert nr.reasoning == "summary text"
             assert nr.provider_data == {"reasoning_content": "detailed scratchpad"}
     
    +    def test_empty_reasoning_content_preserved(self, transport):
    +        """DeepSeek can require an explicit empty reasoning_content replay field."""
    +        r = SimpleNamespace(
    +            choices=[SimpleNamespace(
    +                message=SimpleNamespace(
    +                    content=None,
    +                    tool_calls=None,
    +                    reasoning=None,
    +                    reasoning_content="",
    +                ),
    +                finish_reason="stop",
    +            )],
    +            usage=None,
    +        )
    +        nr = transport.normalize_response(r)
    +        assert nr.provider_data == {"reasoning_content": ""}
    +        assert nr.reasoning_content == ""
    +
    +    def test_reasoning_content_preserved_from_model_extra(self, transport):
    +        """OpenAI SDK can expose provider-specific DeepSeek fields via model_extra."""
    +        r = SimpleNamespace(
    +            choices=[SimpleNamespace(
    +                message=SimpleNamespace(
    +                    content=None,
    +                    tool_calls=None,
    +                    reasoning=None,
    +                    model_extra={"reasoning_content": "model-extra scratchpad"},
    +                ),
    +                finish_reason="stop",
    +            )],
    +            usage=None,
    +        )
    +        nr = transport.normalize_response(r)
    +        assert nr.provider_data == {"reasoning_content": "model-extra scratchpad"}
    +
     
     class TestChatCompletionsCacheStats:
     
    ```
  - `tests/run_agent/test_deepseek_reasoning_content_echo.py`

    ```diff
    @@ -23,6 +23,8 @@
     
     from __future__ import annotations
     
    +from types import SimpleNamespace
    +
     import pytest
     
     from run_agent import AIAgent
    ```

    ```diff
    @@ -33,9 +35,36 @@ def _make_agent(provider: str = "", model: str = "", base_url: str = "") -> AIAg
         agent.provider = provider
         agent.model = model
         agent.base_url = base_url
    +    agent.verbose_logging = False
    +    agent.reasoning_callback = None
    +    agent.stream_delta_callback = None
    +    agent._stream_callback = None
         return agent
     
     
    +_ATTR_ABSENT = object()
    +_EXPECT_NOT_PRESENT = object()
    +
    +
    +def _sdk_tool_call(call_id: str = "c1", name: str = "terminal", arguments: str = "{}"):
    +    """Minimal SDK-shaped tool_call object that satisfies the builder's iteration."""
    +    return SimpleNamespace(
    +        id=call_id,
    +        call_id=call_id,
    +        type="function",
    +        function=SimpleNamespace(name=name, arguments=arguments),
    +        extra_content=None,
    +    )
    +
    +
    +def _build_sdk_message(reasoning_content=_ATTR_ABSENT, **extra):
    +    """SDK-shaped assistant message; ``reasoning_content`` defaults to absent."""
    +    kwargs = {"content": "", **extra}
    +    if reasoning_content is not _ATTR_ABSENT:
    +        kwargs["reasoning_content"] = reasoning_content
    +    return SimpleNamespace(**kwargs)
    +
    +
     class TestNeedsDeepSeekToolReasoning:
         """_needs_deepseek_tool_reasoning() recognises all three detection signals."""
     
    ```

    ```diff
    @@ -109,16 +138,7 @@ def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
             assert api_msg["reasoning_content"] == "<think>real chain of thought</think>"
     
         def test_deepseek_reasoning_field_promoted(self) -> None:
    -        """When only 'reasoning' is set (no tool_calls), it gets promoted to reasoning_content.
    -
    -        On DeepSeek/Kimi, tool-call turns with 'reasoning' but no
    -        'reasoning_content' are treated as cross-provider poisoned history
    -        (#15748) and padded with "" instead of promoted. Same-provider
    -        DeepSeek tool-call turns always have reasoning_content pinned at
    -        creation time by _build_assistant_message, so the (reasoning-set,
    -        reasoning_content-absent, tool_calls-present) shape is unreachable
    -        from same-provider history.
    -        """
    +        """When only 'reasoning' is set, it gets promoted to reasoning_content."""
             agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
             source = {
                 "role": "assistant",
    ```

    ```diff
    @@ -135,8 +155,8 @@ def test_deepseek_poisoned_cross_provider_history_padded(self) -> None:
     
             If the source turn has tool_calls AND a 'reasoning' field but NO
             'reasoning_content' key, it's from a prior provider (the DeepSeek
    -        build path always pins reasoning_content="" at creation). Inject
    -        "" instead of forwarding the prior provider's chain of thought.
    +        build path pins reasoning_content at creation). Inject "" instead
    +        of forwarding the prior provider's chain of thought.
             """
             agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
             source = {
    ```

    ```diff
    @@ -228,6 +248,172 @@ def test_non_assistant_role_ignored(self) -> None:
             assert "reasoning_content" not in api_msg
     
     
    +class TestBuildAssistantMessageDeepSeekReasoningContent:
    +    """_build_assistant_message pins replay-safe DeepSeek tool-call state."""
    +
    +    def test_deepseek_tool_call_reasoning_is_backfilled_into_reasoning_content(self) -> None:
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        assistant_message = SimpleNamespace(
    +            content=None,
    +            reasoning="DeepSeek tool-call reasoning",
    +            reasoning_content=None,
    +            reasoning_details=None,
    +            codex_reasoning_items=None,
    +            codex_message_items=None,
    +            tool_calls=[
    +                SimpleNamespace(
    +                    id="call_1",
    +                    call_id=None,
    +                    response_item_id=None,
    +                    type="function",
    +                    function=SimpleNamespace(name="terminal", arguments="{}"),
    +                )
    +            ],
    +        )
    +
    +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
    +
    +        assert msg["reasoning_content"] == "DeepSeek tool-call reasoning"
    +        assert msg["tool_calls"][0]["id"] == "call_1"
    +
    +    def test_deepseek_model_extra_reasoning_content_is_preserved(self) -> None:
    +        """OpenAI SDK stores unknown provider fields in model_extra."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        assistant_message = SimpleNamespace(
    +            content=None,
    +            reasoning=None,
    +            reasoning_content=None,
    +            model_extra={"reasoning_content": "DeepSeek model_extra reasoning"},
    +            reasoning_details=None,
    +            codex_reasoning_items=None,
    +            codex_message_items=None,
    +            tool_calls=[
    +                SimpleNamespace(
    +                    id="call_1",
    +                    call_id=None,
    +                    response_item_id=None,
    +                    type="function",
    +                    function=SimpleNamespace(name="terminal", arguments="{}"),
    +                )
    +            ],
    +        )
    +
    +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
    +
    +        assert msg["reasoning_content"] == "DeepSeek model_extra reasoning"
    +
    +    def test_deepseek_tool_call_without_raw_reasoning_content_gets_empty_string(self) -> None:
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
    +        assistant_message = SimpleNamespace(
    +            content=None,
    +            reasoning=None,
    +            reasoning_content=None,
    +            reasoning_details=None,
    +            codex_reasoning_items=None,
    +            codex_message_items=None,
    +            tool_calls=[
    +                SimpleNamespace(
    +                    id="call_1",
    +                    call_id=None,
    +                    response_item_id=None,
    +                    type="function",
    +                    function=SimpleNamespace(name="terminal", arguments="{}"),
    +                )
    +            ],
    +        )
    +
    +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
    +
    +        assert msg["reasoning_content"] == ""
    +        assert msg["tool_calls"][0]["id"] == "call_1"
    +
    +
    +class TestBuildAssistantMessagePadsStrictProviders:
    +    """Regression for #17400: _build_assistant_message must pin reasoning_content
    +    on tool-call turns when the active provider enforces echo-back, regardless
    +    of whether the SDK exposed reasoning_content as None, omitted it entirely,
    +    or returned an empty thinking block.
    +
    +    Prior to the fix, the pad branch was guarded by ``msg.get("tool_calls")``,
    +    which was always falsy because tool_calls were assigned later in the same
    +    method. Persisted history accumulated assistant tool-call turns with no
    +    reasoning_content; the next replay 400'd on DeepSeek/Kimi.
    +    """
    +
    +    @pytest.mark.parametrize(
    +        "provider,model,base_url,sdk_reasoning_content,expected",
    +        [
    +            pytest.param(
    +                "deepseek", "deepseek-v4-pro", "",
    +                None, "",
    +                id="deepseek-attr-none",
    +            ),
    +            pytest.param(
    +                "deepseek", "deepseek-v4-pro", "",
    +                _ATTR_ABSENT, "",
    +                id="deepseek-attr-absent",
    +            ),
    +            pytest.param(
    +                "kimi-coding", "kimi-k2.6", "",
    +                None, "",
    +                id="kimi-attr-none",
    +            ),
    +            pytest.param(
    +                "custom", "kimi-k2", "https://api.moonshot.ai/v1",
    +                _ATTR_ABSENT, "",
    +                id="moonshot-base-url",
    +            ),
    +            pytest.param(
    +                "openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1",
    +                _ATTR_ABSENT, _EXPECT_NOT_PRESENT,
    +                id="openrouter-no-pad",
    +            ),
    +        ],
    +    )
    +    def test_tool_call_reasoning_content_pad(
    +        self, provider, model, base_url, sdk_reasoning_content, expected,
    +    ) -> None:
    +        agent = _make_agent(provider=provider, model=model, base_url=base_url)
    +        msg_in = _build_sdk_message(
    +            reasoning_content=sdk_reasoning_content,
    +            tool_calls=[_sdk_tool_call()],
    +        )
    +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
    +        if expected is _EXPECT_NOT_PRESENT:
    +            assert "reasoning_content" not in msg
    +        else:
    +            assert msg["reasoning_content"] == expected
    +
    +    def test_tool_call_preserves_real_reasoning_content(self) -> None:
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
    +        msg_in = _build_sdk_message(
    +            reasoning_content="actual chain of thought",
    +            tool_calls=[_sdk_tool_call()],
    +        )
    +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
    +        assert msg["reasoning_content"] == "actual chain of thought"
    +
    +    def test_text_only_turn_not_padded_by_tool_call_branch(self) -> None:
    +        """Plain-text turns rely on _copy_reasoning_content_for_api at replay
    +        time, not on this builder's tool-call pad."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
    +        msg_in = SimpleNamespace(content="hello", tool_calls=None)
    +        msg = agent._build_assistant_message(msg_in, finish_reason="stop")
    +        assert "tool_calls" not in msg
    +        assert "reasoning_content" not in msg
    +
    +    def test_streamed_reasoning_text_promoted_over_pad(self) -> None:
    +        """When ``.reasoning`` carries streamed thinking, it must be promoted
    +        to reasoning_content rather than overwritten with the empty pad."""
    +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
    +        msg_in = _build_sdk_message(
    +            reasoning="streamed thoughts",
    +            tool_calls=[_sdk_tool_call()],
    +        )
    +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
    +        assert msg["reasoning_content"] == "streamed thoughts"
    +
    +
     class TestNeedsKimiToolReasoning:
         """The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact."""
     
    ```
  - `tests/run_agent/test_deepseek_v4_thinking_live.py`

    ```diff
    @@ -0,0 +1,245 @@
    +"""Live DeepSeek V4 thinking-mode tool-call replay smoke test.
    +
    +Opt-in only:
    +    HERMES_LIVE_TESTS=1 pytest tests/run_agent/test_deepseek_v4_thinking_live.py -q
    +
    +Requires DEEPSEEK_API_KEY in the process environment. The key is captured at
    +module import time because tests/conftest.py intentionally removes credential
    +environment variables before each test body runs.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +import os
    +import sys
    +from typing import Any
    +
    +import pytest
    +
    +
    +LIVE = os.environ.get("HERMES_LIVE_TESTS") == "1"
    +DEEPSEEK_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
    +LIVE_MODELS = ("deepseek-v4-flash", "deepseek-v4-pro")
    +LIVE_BASE_URL = "https://api.deepseek.com"
    +
    +pytestmark = [
    +    pytest.mark.skipif(not LIVE, reason="live-only: set HERMES_LIVE_TESTS=1"),
    +    pytest.mark.skipif(not DEEPSEEK_KEY, reason="DEEPSEEK_API_KEY not configured"),
    +]
    +
    +TOOL_NAME = "lookup_ticket_status"
    +TOOLS = [
    +    {
    +        "type": "function",
    +        "function": {
    +            "name": TOOL_NAME,
    +            "description": "Return the status for a test ticket id.",
    +            "parameters": {
    +                "type": "object",
    +                "properties": {
    +                    "ticket_id": {
    +                        "type": "string",
    +                        "description": "The ticket id to look up.",
    +                    },
    +                },
    +                "required": ["ticket_id"],
    +                "additionalProperties": False,
    +            },
    +        },
    +    }
    +]
    +
    +
    +def _thinking_kwargs() -> dict:
    +    return {
    +        "reasoning_effort": "high",
    +        "extra_body": {"thinking": {"type": "enabled"}},
    +    }
    +
    +
    +def _jsonable(value: Any) -> Any:
    +    if hasattr(value, "model_dump"):
    +        return value.model_dump(mode="json")
    +    if isinstance(value, dict):
    +        return {k: _jsonable(v) for k, v in value.items()}
    +    if isinstance(value, list):
    +        return [_jsonable(v) for v in value]
    +    return value
    +
    +
    +def _print_trace(label: str, value: Any) -> None:
    +    sys.__stdout__.write(f"\n--- {label} ---\n")
    +    sys.__stdout__.write(
    +        json.dumps(_jsonable(value), ensure_ascii=False, indent=2, sort_keys=True)
    +    )
    +    sys.__stdout__.write("\n")
    +    sys.__stdout__.flush()
    +
    +
    +def _message_snapshot(message) -> dict:
    +    return {
    +        "content": getattr(message, "content", None),
    +        "reasoning": getattr(message, "reasoning", None),
    +        "reasoning_content": _raw_reasoning_content(message),
    +        "model_extra": getattr(message, "model_extra", None),
    +        "tool_calls": _jsonable(getattr(message, "tool_calls", None)),
    +    }
    +
    +
    +def _make_live_client():
    +    from openai import OpenAI
    +
    +    return OpenAI(api_key=[redacted:secret-assignment] base_url=LIVE_BASE_URL)
    +
    +
    +def _make_agent_for_message_building(model: str):
    +    from run_agent import AIAgent
    +
    +    agent = object.__new__(AIAgent)
    +    agent.provider = "deepseek"
    +    agent.model = model
    +    agent.base_url = LIVE_BASE_URL
    +    agent.verbose_logging = False
    +    agent.reasoning_callback = None
    +    agent.stream_delta_callback = None
    +    agent._stream_callback = None
    +    return agent
    +
    +
    +def _raw_reasoning_content(message):
    +    direct = getattr(message, "reasoning_content", None)
    +    if direct is not None:
    +        return direct
    +    model_extra = getattr(message, "model_extra", None) or {}
    +    if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
    +        return model_extra["reasoning_content"]
    +    return None
    +
    +
    [redacted:email]("live_model", LIVE_MODELS)
    +def test_deepseek_v4_thinking_tool_call_replay_round_trip(live_model: str):
    +    """Hit DeepSeek twice and replay the assistant tool-call turn.
    +
    +    The first request forces a tool call with thinking enabled. The second
    +    request replays that assistant message with content, reasoning_content,
    +    and tool_calls, then appends the tool result. DeepSeek accepting the
    +    second request is the live guardrail for the V4 thinking replay contract.
    +    """
    +
    +    client = _make_live_client()
    +    agent = _make_agent_for_message_building(live_model)
    +
    +    first_request = {
    +        "model": live_model,
    +        "messages": [
    +            {
    +                "role": "user",
    +                "content": (
    +                    "You must use the provided lookup_ticket_status tool "
    +                    "exactly once with ticket_id 'DS-4242'. Do not answer "
    +                    "directly."
    +                ),
    +            }
    +        ],
    +        "tools": TOOLS,
    +        "max_tokens": 1024,
    +        "timeout": 90,
    +        **_thinking_kwargs(),
    +    }
    +    _print_trace(f"{live_model} first request", first_request)
    +    first = client.chat.completions.create(**first_request)
    +    _print_trace(f"{live_model} first raw response", first)
    +
    +    first_choice = first.choices[0]
    +    first_message = first_choice.message
    +    _print_trace(
    +        f"{live_model} first assistant message",
    +        {
    +            "finish_reason": first_choice.finish_reason,
    +            **_message_snapshot(first_message),
    +        },
    +    )
    +    assert first_message.tool_calls, "DeepSeek did not return a tool call"
    +    first_tool_call = first_message.tool_calls[0]
    +    assert first_tool_call.function.name == TOOL_NAME
    +    assert isinstance(json.loads(first_tool_call.function.arguments or "{}"), dict)
    +
    +    raw_reasoning_content = _raw_reasoning_content(first_message)
    +    assert raw_reasoning_content is not None, (
    +        "DeepSeek did not return reasoning_content; the thinking payload may "
    +        "not have been honored"
    +    )
    +
    +    stored_assistant = agent._build_assistant_message(
    +        first_message,
    +        first_choice.finish_reason or "tool_calls",
    +    )
    +    _print_trace(f"{live_model} stored assistant message", stored_assistant)
    +    assert stored_assistant["reasoning_content"] == raw_reasoning_content
    +
    +    replay_assistant = {
    +        "role": "assistant",
    +        "content": stored_assistant.get("content") or "",
    +        "tool_calls": stored_assistant["tool_calls"],
    +    }
    +    agent._copy_reasoning_content_for_api(stored_assistant, replay_assistant)
    +    _print_trace(f"{live_model} replay assistant message", replay_assistant)
    +
    +    tool_call_id = stored_assistant["tool_calls"][0]["id"]
    +    messages = [
    +        {
    +            "role": "user",
    +            "content": (
    +                "You must use the provided lookup_ticket_status tool "
    +                "exactly once with ticket_id 'DS-4242'. Do not answer "
    +                "directly."
    +            ),
    +        },
    +        replay_assistant,
    +        {
    +            "role": "tool",
    +            "tool_call_id": tool_call_id,
    +            "content": json.dumps(
    +                {"ticket_id": "DS-4242", "status": "green", "source": "live-test"},
    +                separators=(",", ":"),
    +            ),
    +        },
    +    ]
    +
    +    from agent.transports.chat_completions import ChatCompletionsTransport
    +
    +    api_messages = ChatCompletionsTransport().convert_messages(messages)
    +    _print_trace(
    +        f"{live_model} second request messages after transport conversion",
    +        api_messages,
    +    )
    +    assert api_messages[1]["reasoning_content"] == raw_reasoning_content
    +    assert "call_id" not in api_messages[1]["tool_calls"][0]
    +    assert "response_item_id" not in api_messages[1]["tool_calls"][0]
    +
    +    second_request = {
    +        "model": live_model,
    +        "messages": api_messages,
    +        "max_tokens": 1024,
    +        "timeout": 90,
    +        **_thinking_kwargs(),
    +    }
    +    _print_trace(f"{live_model} second request", second_request)
    +    second = client.chat.completions.create(**second_request)
    +    _print_trace(f"{live_model} second raw response", second)
    +    _print_trace(
    +        f"{live_model} second assistant message",
    +        {
    +            "finish_reason": second.choices[0].finish_reason,
    +            **_message_snapshot(second.choices[0].message),
    +        },
    +    )
    +
    +    second_message = second.choices[0].message
    +    final_content = second_message.content or ""
    +    final_reasoning = _raw_reasoning_content(second_message) or ""
    +    assert second.choices[0].finish_reason == "stop"
    +    assert final_content.strip() or final_reasoning.strip(), (
    +        "DeepSeek returned neither visible content nor reasoning_content"
    +    )
    ```
- **#21033: fix(agent): rebuild api_messages after provider fallback to apply reasoning requirements**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b5495db70117b45320066f4e2768e66382062fce`
- **#21668: fix(deepseek): send explicit thinking:disabled when reasoning is off**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `cd9470f41638bd515db096cd934c463205790110`, `068c24f8a4203e86de32b0d84ccaf047e8cd6ef7`, `#17225`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#22798: fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro (salvage #22643)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -3529,6 +3529,19 @@ def _extract_reasoning(self, assistant_message) -> Optional[str]:
             # instead of returning structured reasoning fields.  Only fall back
             # to inline extraction when no structured reasoning was found.
             content = getattr(assistant_message, "content", None)
    +        if not reasoning_parts and isinstance(content, list):
    +            # DeepSeek V4 Pro (and compatible providers) return content as a
    +            # list of typed blocks, e.g.:
    +            #   [{"type": "thinking", "thinking": "..."}, {"type": "output", ...}]
    +            # Without this branch the thinking text is silently dropped and the
    +            # next turn fails with HTTP 400 ("thinking must be passed back").
    +            # Refs #21944.
    +            for block in content:
    +                if isinstance(block, dict) and block.get("type") == "thinking":
    +                    thinking_text = block.get("thinking") or block.get("text") or ""
    +                    thinking_text = thinking_text.strip()
    +                    if thinking_text and thinking_text not in reasoning_parts:
    +                        reasoning_parts.append(thinking_text)
             if not reasoning_parts and isinstance(content, str) and content:
                 inline_patterns = (
                     r"<think>(.*?)</think>",
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -517,6 +517,42 @@ def test_inline_reasoning_blocks_fallback(self, agent, content, expected):
             msg = _mock_assistant_msg(content=content)
             assert agent._extract_reasoning(msg) == expected
     
    +    def test_content_list_thinking_blocks_extracted(self, agent):
    +        """DeepSeek V4 Pro returns content as a typed-block list (issue #21944).
    +
    +        Without this branch thinking text is silently dropped → HTTP 400 on
    +        the next turn ("thinking must be passed back to the API").
    +        """
    +        msg = _mock_assistant_msg(
    +            content=[
    +                {"type": "thinking", "thinking": "deep analysis here"},
    +                {"type": "output", "text": "final answer"},
    +            ]
    +        )
    +        result = agent._extract_reasoning(msg)
    +        assert result == "deep analysis here"
    +
    +    def test_content_list_non_thinking_blocks_ignored(self, agent):
    +        """Non-thinking blocks in a content list must not be treated as reasoning."""
    +        msg = _mock_assistant_msg(
    +            content=[
    +                {"type": "text", "text": "just a regular response"},
    +            ]
    +        )
    +        assert agent._extract_reasoning(msg) is None
    +
    +    def test_content_list_thinking_prefers_structured_field(self, agent):
    +        """Structured ``reasoning`` field wins over content-list thinking blocks."""
    +        msg = _mock_assistant_msg(
    +            reasoning="from structured field",
    +            content=[
    +                {"type": "thinking", "thinking": "from content list"},
    +            ],
    +        )
    +        result = agent._extract_reasoning(msg)
    +        # structured field was found first → content-list branch skipped
    +        assert result == "from structured field"
    +
     
     class TestCleanSessionContent:
         def test_none_passthrough(self):
    ```
- **#24605: fix(run_agent): enroll Xiaomi MiMo in reasoning_content echo-back enforcement**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24662`
- **#26648: fix(deepseek): wire thinking-mode via DeepSeekProfile (closes #15700, #17212, #17825)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/model-providers/deepseek/__init__.py`, `scripts/release.py`, `tests/plugins/model_providers/test_deepseek_profile.py`
- **#13897: fix(anthropic_adapter): preserve reasoning_content for Kimi /coding tool-call messages**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#14018`
- **#14941: fix: set empty reasoning_content for DeepSeek when tool_calls present**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `93a2d6b307674fc9975538cd7a2f903068211c95`, `d58b305ad`, `d58b305adfdb5697c0ff268696040b0505d40892`
- **#14973: fix: extract DeepSeek reasoning_content from model_extra**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `93a2d6b30`, `d58b305ad`, `ad0ac8947`, `5ae608152`, `ad0ac8947`, `9daa0620a`, `63bf7a29b`, `#16500`
- **#15354: fix: add DeepSeek reasoning_content echo for tool-call messages (fixes #15353)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `93a2d6b30`, `#15407`, `#15250`, `#15353`
- **#15446: fix(agent): comprehensive DeepSeek V4 support — context windows, thinking mode, reasoning replay**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `93a2d6b30`, `d58b305ad`, `ad0ac8947`, `5ae608152`, `ad0ac8947`, `9daa0620a`, `63bf7a29b`, `#16500`
- **#15760: Fix DeepSeek reasoning_content propagation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `93a2d6b30`, `d58b305ad`, `ad0ac8947`, `5ae608152`, `ad0ac8947`, `9daa0620a`, `63bf7a29b`, `#16500`
- **#15804: fix: extract reasoning_content from model_extra for DeepSeek/Moonshot**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `93a2d6b30`, `d58b305ad`, `ad0ac8947`, `5ae608152`, `ad0ac8947`, `9daa0620a`, `63bf7a29b`, `#16500`
- **#16614: fix(transport): emit deepseek-v4 thinking.type and reasoning_effort on non-OpenRouter routes**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#14958`, `#15577`, `#16448`, `#15251`
- **#16781: fix: preserve DeepSeek thinking blocks on Anthropic replay (#16748)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `fd5479a4f`, `#17510`
- **#17225: fix(agent): send thinking control to DeepSeek direct API to prevent 400 on tool-call replay**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#17489: fix(agent): pad reasoning_content on DeepSeek/Kimi tool-call turns**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `76edc40ab`, `#18045`, `#16855`
- **#18218: fix: preserve DeepSeek V4 signed thinking blocks on /anthropic endpoint**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#21986: Fix DeepSeek thinking content array replay**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#21989`
- **#21989: Fix DeepSeek thinking content array replay**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#22643: fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#22798`, `#21946`
- **#24603: fix(agent): preserve reasoning_content for Xiaomi MiMo thinking mode (#24443)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#24465`, `#24465`, `#24603`, `#24605`, `#24465`, `#24443`
- **#24638: fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24662`
- **#24662: fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24465`, `#24443`
- **#24887: feat: add Xiaomi MiMo Anthropic-compatible endpoint support for thinking mode**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#24465`, `#24603`, `#24784`, `#24810`, `#24884`, `#24443`
- **#43947: Fix/deepsfix(deepseek): omitting tool_choice auto/any and preserving signed thinking blocks for /anthropic endpointeek anthropic compat**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/anthropic_adapter.py`, `tests/agent/test_deepseek_anthropic_thinking.py`
- **#58148: fix: surface reasoning/thinking content as response instead of '(empty)'**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#50397`

## Fix status

- Merged to main in PR #14018 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-16`.
- Subsystem: `agent`
