From 43a0a39efac8366dffcdec82b0b990dd307446f0 Mon Sep 17 00:00:00 2001 From: Stacey2911 Date: Sat, 27 Jun 2026 09:01:58 +1000 Subject: [PATCH] fix: gate reasoning_content replay by provider policy Prevent historical assistant reasoning_content from being replayed to generic/local providers by default while preserving current behavior for known reasoning-capable provider protocols. Adds a webui.reasoning_content_replay policy read by the sanitizer: auto, strip, and preserve. Auto strips for local/generic backends such as LM Studio/Ollama/llama.cpp and preserves for explicitly known provider protocols that may require reasoning replay. This only affects provider-facing sanitized history. It does not modify stored session/display reasoning metadata. --- api/routes.py | 8 +- api/streaming.py | 167 ++++++++- tests/test_native_image_attachments.py | 7 +- tests/test_reasoning_content_replay.py | 474 +++++++++++++++++++++++++ 4 files changed, 649 insertions(+), 7 deletions(-) create mode 100644 tests/test_reasoning_content_replay.py diff --git a/api/routes.py b/api/routes.py index 3a6561fc4..834fa547f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18263,7 +18263,13 @@ def _handle_chat_sync(handler, body): result = agent.run_conversation( user_message=workspace_ctx + msg, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=get_config()), + conversation_history=_sanitize_messages_for_api( + _previous_context_messages, + cfg=get_config(), + effective_model=_model, + effective_provider=_provider, + effective_base_url=_base_url, + ), task_id=s.session_id, persist_user_message=msg, ) diff --git a/api/streaming.py b/api/streaming.py index e18c6aeca..dcdbad88a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3738,7 +3738,136 @@ def _is_reasoning_only_assistant_message(msg) -> bool: return _content_has_reasoning_only_parts(content) -def _sanitize_messages_for_api(messages, *, cfg: dict = None): +def _is_local_reasoning_replay_base_url(base_url: str | None) -> bool: + """Return True when a custom provider base URL confidently points at localhost.""" + if not base_url: + return False + try: + from urllib.parse import urlsplit + + raw = str(base_url or '').strip() + if not raw: + return False + parsed = urlsplit(raw) + if not parsed.hostname and '://' not in raw: + parsed = urlsplit(f"http://{raw}") + host = (parsed.hostname or '').strip().lower() + except Exception: + return False + return host in {'localhost', '127.0.0.1', '::1', 'localhost.localdomain'} + + +def _should_strip_reasoning_content( + cfg: dict | None, + *, + mode: str | None = None, + effective_model: str | None = None, + effective_provider: str | None = None, + effective_base_url: str | None = None, +) -> bool: + """Decide whether historical assistant reasoning_content should be stripped from model-facing history. + + This is a provider/protocol decision, not a model-capability heuristic. + Local/generic backends (LM Studio, llama.cpp, Ollama, and custom localhost + OpenAI-compatible endpoints) do not require historical reasoning replay and + receive stale content when it's preserved. Unknown providers preserve by + default so replay does not break providers that require reasoning/tool-call + continuity. + + Args: + cfg: Config dict from get_config(), expected to contain webui.reasoning_content_replay. + mode: Explicit override mode ("strip", "preserve", "auto"). If provided, bypasses config lookup. + effective_model: Runtime-resolved model for the current session/request. + effective_provider: Runtime-resolved provider for the current session/request. + effective_base_url: Runtime-resolved base URL for custom providers. + + Returns: + True if reasoning_content should be stripped from sanitized output. + False if it should be preserved in sanitized output. + """ + # Explicit mode override takes priority + if mode is not None: + return mode == "strip" + + # Config lookup. Missing/invalid config preserves shipped behavior: do not + # strip reasoning_content unless config explicitly requests it or auto can + # identify a local/generic effective backend. + if cfg is None: + return False + + webui_cfg = cfg.get("webui", {}) or {} + if not isinstance(webui_cfg, dict): + return False + + replay_mode = webui_cfg.get("reasoning_content_replay") + if not isinstance(replay_mode, str): + return False + + normalized = replay_mode.strip().lower() + + if normalized == "preserve": + return False + if normalized == "strip": + return True + if normalized == "auto": + # Auto mode: prefer runtime-resolved provider/model because profile + # defaults may differ from a per-session/request override. + model_cfg = cfg.get("model", {}) or {} + if not isinstance(model_cfg, dict): + model_cfg = {} + + provider_id = str(effective_provider or model_cfg.get("provider", "") or "").strip().lower() + model_id = str( + effective_model or model_cfg.get("default") or model_cfg.get("name") or "" + ).strip().lower() + base_url = effective_base_url or model_cfg.get("base_url") + + if not provider_id: + return False + + # Known providers that require historical reasoning_content replay: + # - DeepSeek thinking mode distinguishes normal history from tool-call reasoning chains + # - Anthropic Claude 4+/3.7+ uses structured reasoning in tool-use contexts + # - OpenAI GPT-5+/o-series requires reasoning replay for tool-use continuity + if provider_id == "deepseek": + return False # preserve for DeepSeek + + if provider_id == "anthropic" and model_id.startswith("claude"): + return False # preserve for Claude models + + if provider_id == "openai": + # Preserve for GPT-5+ and o-series (not GPT-4o, etc.) + # Use exact match + dash-prefixed suffix to avoid broad substring matches. + _is_reasoning_model = ( + model_id == "gpt-5" + or model_id.startswith("gpt-5-") + or model_id in {"o1", "o3", "o4"} + or model_id.startswith(("o1-", "o3-", "o4-")) + ) + if _is_reasoning_model: + return False # preserve for reasoning-capable OpenAI models + + if provider_id in {"lmstudio", "ollama", "llamacpp", "llama.cpp"}: + return True + + if provider_id == "custom" or provider_id.startswith("custom:"): + return _is_local_reasoning_replay_base_url(base_url) + + # Unknown/cloud providers preserve by default. + return False + + # Unknown mode -- preserve default behavior. + return False + + +def _sanitize_messages_for_api( + messages, + *, + cfg: dict = None, + effective_model: str | None = None, + effective_provider: str | None = None, + effective_base_url: str | None = None, +): """Return a deep copy of messages with only API-safe fields. The webui stores extra metadata on messages (attachments, timestamp, _ts) @@ -3804,6 +3933,18 @@ def _sanitize_messages_for_api(messages, *, cfg: dict = None): # Orphaned tool result — skip to avoid 400 from strict providers. continue sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS} + # Provider-aware reasoning_content stripping from model-facing history. + # Historical assistant reasoning_content is stripped only when the user + # explicitly requests strip mode or auto mode identifies a local/generic + # effective backend. + if msg.get('role') == 'assistant' and 'reasoning_content' in sanitized: + if _should_strip_reasoning_content( + cfg, + effective_model=effective_model, + effective_provider=effective_provider, + effective_base_url=effective_base_url, + ): + del sanitized['reasoning_content'] if is_recovered: sanitized['_recovered'] = True # temporary marker — stripped before return if 'content' in sanitized: @@ -7800,7 +7941,13 @@ def _run_agent_streaming( _run_conversation_kwargs = dict( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), + conversation_history=_sanitize_messages_for_api( + _previous_context_messages, + cfg=_cfg, + effective_model=resolved_model, + effective_provider=resolved_provider, + effective_base_url=resolved_base_url, + ), task_id=session_id, persist_user_message=msg_text, ) @@ -8195,7 +8342,13 @@ def _run_agent_streaming( _heal_kwargs = dict( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), + conversation_history=_sanitize_messages_for_api( + _previous_context_messages, + cfg=_cfg, + effective_model=resolved_model, + effective_provider=resolved_provider, + effective_base_url=resolved_base_url, + ), task_id=session_id, persist_user_message=msg_text, ) @@ -9254,7 +9407,13 @@ def _run_agent_streaming( _heal_kwargs2 = dict( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), + conversation_history=_sanitize_messages_for_api( + _previous_context_messages, + cfg=_cfg, + effective_model=resolved_model, + effective_provider=resolved_provider, + effective_base_url=resolved_base_url, + ), task_id=session_id, persist_user_message=msg_text, ) diff --git a/tests/test_native_image_attachments.py b/tests/test_native_image_attachments.py index b8b0c78da..b3118d328 100644 --- a/tests/test_native_image_attachments.py +++ b/tests/test_native_image_attachments.py @@ -357,8 +357,11 @@ class TestBuildNativeMultimodalMessage: def test_sync_chat_history_sanitizer_receives_config(self): """#2398: fallback POST /api/chat must use the text-mode history sanitizer too.""" src = Path('api/routes.py').read_text() - call = 'conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=get_config())' - assert call in src, ( + assert 'conversation_history=_sanitize_messages_for_api(' in src, ( + 'The legacy synchronous /api/chat endpoint must sanitize history through ' + '_sanitize_messages_for_api.' + ) + assert 'cfg=get_config(),' in src, ( 'The legacy synchronous /api/chat endpoint must pass current config into ' '_sanitize_messages_for_api so historical image_url parts are stripped ' 'for text-mode providers just like the streaming endpoint.' diff --git a/tests/test_reasoning_content_replay.py b/tests/test_reasoning_content_replay.py new file mode 100644 index 000000000..4ea316bbb --- /dev/null +++ b/tests/test_reasoning_content_replay.py @@ -0,0 +1,474 @@ +"""Tests for provider-aware reasoning_content history replay stripping. + +Historical assistant reasoning_content is preserved by default. It is stripped +only when the user explicitly chooses strip mode, or when auto mode can identify +a local/generic effective backend for the current session/request. +""" +import copy +import pathlib +import sys + +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(REPO_ROOT)) + +from api.streaming import _sanitize_messages_for_api + + +def _asst_with_reasoning(content="hello", reasoning="I think about this..."): + """Create an assistant message with both content and reasoning_content.""" + return { + "role": "assistant", + "content": content, + "reasoning_content": reasoning, + "_ts": 12345, + } + + +def _asst_with_tool_calls_and_reasoning(reasoning="Let me call a tool..."): + """Create an assistant message with tool_calls AND reasoning_content.""" + return { + "role": "assistant", + "content": None, + "reasoning_content": reasoning, + "tool_calls": [ + {"type": "function", "id": "call-1", "function": {"name": "search", "arguments": "{}"}} + ], + } + + +def _tool_result(call_id="call-1"): + return {"role": "tool", "tool_call_id": call_id, "content": "ok"} + + +def _user(text="hello"): + return {"role": "user", "content": text} + + +def _assistant(result): + return [m for m in result if m["role"] == "assistant"][0] + + +def _auto_cfg(provider="openai", default="gpt-4o", **model_overrides): + model = {"provider": provider, "default": default} + model.update(model_overrides) + return {"model": model, "webui": {"reasoning_content_replay": "auto"}} + + +def _assert_reasoning_preserved(result, expected): + assert _assistant(result).get("reasoning_content") == expected + + +def _assert_reasoning_stripped(result): + assert "reasoning_content" not in _assistant(result) + + +def test_cfg_none_preserves_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + + result = _sanitize_messages_for_api(msgs, cfg=None) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_empty_cfg_preserves_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + + result = _sanitize_messages_for_api(msgs, cfg={}) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_empty_webui_cfg_preserves_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + cfg = {"webui": {}, "model": {"provider": "ollama", "default": "qwen3:latest"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_non_dict_webui_cfg_preserves_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + cfg = {"webui": "not-a-dict", "model": {"provider": "ollama", "default": "qwen3:latest"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_unknown_mode_preserves_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + cfg = {"webui": {"reasoning_content_replay": "foobar"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_strip_mode_removes_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + assert len(result) == 2 + assistant_msg = _assistant(result) + assert assistant_msg["content"] == "A1" + assert "reasoning_content" not in assistant_msg + + +def test_preserve_mode_keeps_reasoning_content(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + cfg = {"webui": {"reasoning_content_replay": "preserve"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "Internal thought") + + +def test_auto_with_profile_openai_effective_lmstudio_strips(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "LM Studio stale thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="qwen3-235b-a22b", + effective_provider="lmstudio", + ) + + _assert_reasoning_stripped(result) + + +def test_auto_with_profile_openai_effective_ollama_strips(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Ollama stale thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="qwen3:latest", + effective_provider="ollama", + ) + + _assert_reasoning_stripped(result) + + +def test_auto_with_profile_openai_effective_llamacpp_strips(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "llama.cpp stale thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="qwen3-235b-a22b", + effective_provider="llamacpp", + ) + + _assert_reasoning_stripped(result) + + +def test_auto_with_custom_without_local_base_url_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Custom cloud thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="custom-reasoner", + effective_provider="custom", + ) + + _assert_reasoning_preserved(result, "Custom cloud thought") + + +def test_auto_with_custom_local_base_url_strips(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Custom local stale thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="qwen3-235b-a22b", + effective_provider="custom", + effective_base_url="http://127.0.0.1:1234/v1", + ) + + _assert_reasoning_stripped(result) + + +def test_auto_with_anthropic_claude_effective_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Claude thinking")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="claude-sonnet-4.6", + effective_provider="anthropic", + ) + + _assert_reasoning_preserved(result, "Claude thinking") + + +def test_auto_with_deepseek_effective_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "DeepSeek thinking")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="deepseek-v4-flash", + effective_provider="deepseek", + ) + + _assert_reasoning_preserved(result, "DeepSeek thinking") + + +def test_auto_with_openai_gpt5_effective_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "GPT-5 reasoning")] + cfg = _auto_cfg(provider="ollama", default="qwen3:latest") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="gpt-5-mini", + effective_provider="openai", + ) + + _assert_reasoning_preserved(result, "GPT-5 reasoning") + + +def test_auto_with_openai_o_series_effective_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "o1 reasoning")] + cfg = _auto_cfg(provider="ollama", default="qwen3:latest") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="o1-preview", + effective_provider="openai", + ) + + _assert_reasoning_preserved(result, "o1 reasoning") + + +def test_auto_with_openai_gpt4o_effective_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "gpt-4o thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="gpt-4o", + effective_provider="openai", + ) + + _assert_reasoning_preserved(result, "gpt-4o thought") + + +def test_auto_with_unknown_effective_provider_preserves(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Unknown provider thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api( + msgs, + cfg=cfg, + effective_model="mystery-model", + effective_provider="unknown-cloud", + ) + + _assert_reasoning_preserved(result, "Unknown provider thought") + + +def test_auto_without_effective_provider_uses_production_profile_keys_for_local_fallback(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Profile local stale thought")] + cfg = _auto_cfg(provider="ollama", default="qwen3:latest") + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_stripped(result) + + +def test_auto_without_effective_provider_preserves_openai_gpt4o_profile(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "OpenAI non-reasoning thought")] + cfg = _auto_cfg(provider="openai", default="gpt-4o") + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "OpenAI non-reasoning thought") + + +def test_auto_profile_model_name_fallback_preserves_anthropic_claude(): + msgs = [_user("Q"), _asst_with_reasoning("A1", "Claude profile thinking")] + cfg = { + "model": {"provider": "anthropic", "name": "claude-sonnet-4"}, + "webui": {"reasoning_content_replay": "auto"}, + } + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + _assert_reasoning_preserved(result, "Claude profile thinking") + + +def test_input_messages_not_mutated(): + original_messages = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")] + msgs = copy.deepcopy(original_messages) + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + _sanitize_messages_for_api(msgs, cfg=cfg) + + assert msgs == original_messages + + +def test_multiple_assistant_reasoning_all_stripped(): + msgs = [ + _user("Q1"), + _asst_with_reasoning("A1", "Thought 1"), + _user("Q2"), + _asst_with_reasoning("A2", "Thought 2"), + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + for msg in result: + if msg["role"] == "assistant": + assert "reasoning_content" not in msg + + +def test_tool_call_chain_preserved_with_reasoning_stripped(): + msgs = [ + _user("Q"), + _asst_with_tool_calls_and_reasoning("Let me search..."), + _tool_result("call-1"), + _user("A2"), + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + roles = [m["role"] for m in result] + assert roles == ["user", "assistant", "tool", "user"] + asst_msg = _assistant(result) + assert asst_msg.get("tool_calls") is not None + assert len(asst_msg["tool_calls"]) == 1 + assert asst_msg["tool_calls"][0]["id"] == "call-1" + assert "reasoning_content" not in asst_msg + tool_ids = {m["tool_call_id"] for m in result if m["role"] == "tool"} + assistant_tool_ids = {tc["id"] for tc in asst_msg["tool_calls"]} + assert tool_ids == assistant_tool_ids + + +def test_orphaned_tool_messages_still_dropped_with_reasoning_strip(): + msgs = [ + _user("Q"), + _asst_with_tool_calls_and_reasoning(), + {"role": "tool", "tool_call_id": "call-orphan", "content": "orphan"}, + _user("A2"), + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + roles = [m["role"] for m in result] + assert "tool" not in roles + assert "assistant" not in roles + + +def test_oob_terminal_marker_behavior_unchanged_with_reasoning_strip(): + msgs = [ + _user("Q"), + { + "role": "assistant", + "content": "Here is the answer. [[hermes:agent:terminal]]\n\nDone.", + "reasoning_content": "I should not leak this.", + }, + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + asst_msg = _assistant(result) + assert asst_msg.get("content") == "Here is the answer. [[hermes:agent:terminal]]\n\nDone." + assert "reasoning_content" not in asst_msg + + +def test_empty_messages_list(): + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + assert _sanitize_messages_for_api([], cfg=cfg) == [] + + +def test_assistant_without_reasoning_unchanged(): + msgs = [_user("Q"), {"role": "assistant", "content": "A1"}] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + assert len(result) == 2 + assert _assistant(result)["content"] == "A1" + + +def test_reasoning_only_assistant_message_not_duplicated(): + msgs = [ + _user("Q"), + {"role": "assistant", "content": "", "reasoning_content": "Thinking..."}, + _user("A2"), + ] + cfg = {"webui": {"reasoning_content_replay": "preserve"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + roles = [m["role"] for m in result] + assert roles == ["user", "user"] + + +def test_system_messages_unaffected(): + msgs = [ + {"role": "system", "content": "You are helpful."}, + _user("Q"), + _asst_with_reasoning("A1", "Internal thought"), + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + assert len(result) == 3 + assert result[0]["role"] == "system" + assert result[0]["content"] == "You are helpful." + + +def test_multiple_turns_with_mixed_reasoning(): + msgs = [ + _user("Q1"), + {"role": "assistant", "content": "A1", "reasoning_content": "Thought 1"}, + _user("Q2"), + {"role": "assistant", "content": "A2"}, + _user("Q3"), + {"role": "assistant", "content": "A3", "reasoning_content": "Thought 3"}, + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + assert len(result) == 6 + for msg in result: + if msg["role"] == "assistant": + assert "reasoning_content" not in msg + if msg.get("content") and msg["role"] != "system": + assert msg["content"] is not None + + +def test_reasoning_content_on_user_message_untouched(): + msgs = [ + _user("Q"), + {"role": "assistant", "content": "A1"}, + {"role": "user", "content": "Q2", "reasoning_content": "unexpected field"}, + ] + cfg = {"webui": {"reasoning_content_replay": "strip"}} + + result = _sanitize_messages_for_api(msgs, cfg=cfg) + + user_msg = [m for m in result if m["role"] == "user" and m.get("reasoning_content")][0] + assert user_msg.get("reasoning_content") == "unexpected field"