diff --git a/src/llm_core.py b/src/llm_core.py index 3e84c1060..86bf9b7e1 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -2109,7 +2109,17 @@ async def llm_call_async( response = _parse_ollama_response(data) else: msg = data["choices"][0]["message"] - response = msg.get("content") or msg.get("reasoning_content") or "" + content = msg.get("content") + if isinstance(content, list): + # Mistral structured content — extract thinking + text + # (same contract as llm_call / stream_llm; see #5435). + text_part, thinking_part = _normalize_mistral_content(content) + if thinking_part: + response = thinking_part + "\n\n" + (text_part or "") + else: + response = text_part or msg.get("reasoning_content") or "" + else: + response = content or msg.get("reasoning_content") or "" _set_cached_response(cache_key, response) return response except Exception: diff --git a/tests/test_llm_core_async_mistral_content.py b/tests/test_llm_core_async_mistral_content.py new file mode 100644 index 000000000..5d9cbcabf --- /dev/null +++ b/tests/test_llm_core_async_mistral_content.py @@ -0,0 +1,71 @@ +"""Integration regression test for #5435. + +llm_call_async must normalise Mistral structured content to a plain string, +matching llm_call (sync) and stream_llm. Before the fix, the async +non-streaming parser returned the raw list when Mistral reasoning was enabled, +violating its -> str contract, leaking a non-string into callers such as +auto-title generation and memory extraction, and poisoning _response_cache +with a non-string value. +""" +import asyncio + +import src.llm_core as llm_core + + +class _FakeResponse: + is_success = True + status_code = 200 + text = "" + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _payload(content): + return {"choices": [{"message": {"role": "assistant", "content": content}}]} + + +def _call(monkeypatch, content): + async def fake_post(client, url, headers, **kwargs): + return _FakeResponse(_payload(content)) + + monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post) + llm_core._response_cache.clear() + return asyncio.run(llm_core.llm_call_async( + "http://mistral.test/v1/chat/completions", + "mistral-medium", + [{"role": "user", "content": "q"}], + )) + + +def test_llm_call_async_normalizes_mistral_structured_content(monkeypatch): + out = _call(monkeypatch, [ + {"type": "thinking", + "thinking": [{"type": "text", "text": "Let me work through this..."}], + "closed": True}, + {"type": "text", "text": "The answer is 42."}, + ]) + assert isinstance(out, str), f"expected str, got {type(out).__name__}" + assert "The answer is 42." in out + assert "Let me work through this..." in out + # The cache must hold the normalised string, not the raw list, + # otherwise repeat calls serve the poisoned value. + assert all(isinstance(v, str) for v in llm_core._response_cache.values()) + + +def test_llm_call_async_thinking_only_still_returns_str(monkeypatch): + out = _call(monkeypatch, [ + {"type": "thinking", + "thinking": [{"type": "text", "text": "still thinking"}], + "closed": True}, + ]) + assert isinstance(out, str) + assert "still thinking" in out + + +def test_llm_call_async_plain_string_passthrough(monkeypatch): + out = _call(monkeypatch, "plain answer") + assert out == "plain answer"