diff --git a/run_agent.py b/run_agent.py
index 3f10f36a0..f7a929118 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -7706,13 +7706,26 @@ class AIAgent:
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.
- Used to decide whether to store reasoning_content on tool-call
- assistant messages. DeepSeek V4 thinking mode requires this field
- on every assistant tool-call turn; omitting it causes HTTP 400
- when the message is replayed in a subsequent API request (#15250).
+ 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()
@@ -7737,17 +7750,14 @@ class AIAgent:
api_msg["reasoning_content"] = normalized_reasoning
return
- provider = (self.provider or "").lower()
- model = (self.model or "").lower()
- needs_tool_reasoning_echo = (
- provider in {"kimi-coding", "kimi-coding-cn", "deepseek"}
- or "deepseek" in model
- 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")
- or base_url_host_matches(self.base_url, "api.deepseek.com")
- )
- if needs_tool_reasoning_echo 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 --git a/tests/run_agent/test_deepseek_reasoning_content_echo.py b/tests/run_agent/test_deepseek_reasoning_content_echo.py
new file mode 100644
index 000000000..98feea859
--- /dev/null
+++ b/tests/run_agent/test_deepseek_reasoning_content_echo.py
@@ -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": "real chain of thought",
+ "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
+ }
+ api_msg: dict = {}
+ agent._copy_reasoning_content_for_api(source, api_msg)
+ assert api_msg["reasoning_content"] == "real chain of thought"
+
+ 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