From 99b849463b6748dbb14f4d4cdfa18185e2a860a9 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 31 Jul 2026 11:40:34 +0200 Subject: fix: keep reasoning out of the reply body A delta carrying both reasoning_content and an empty content was classified by truthiness, so the reasoning fell through to the content branch. The tail of the model's thinking was stored and displayed as the answer, splitting mid-sentence across the two fields. Classify on the presence of the field instead, and return nothing for an empty reasoning delta rather than falling through. The visible symptom was a code block swallowing the message: leaked thinking is dense with backticks, and an odd count leaves a run open to the end of the reply. Close an unterminated fence before parsing, with a closer matching the opener's length, and drop a lone dangling inline backtick. Streaming needs this regardless, since a fence is unclosed on nearly every frame. Co-Authored-By: Claude Opus 5 --- test_llamachat.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) (limited to 'test_llamachat.py') diff --git a/test_llamachat.py b/test_llamachat.py index 0986631..f23a576 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -217,6 +217,21 @@ def test_sse_parsing(): ) assert opening is None assert opening != backend.DONE + + # A delta carrying both fields is thinking, not reply. Classifying it as + # content splices the tail of the reasoning onto the front of the reply. + both = backend._parse_sse_line( + 'data: {"choices":[{"delta":' + '{"reasoning_content":"still thinking","content":""}}]}' + ) + assert both == ("reasoning", "still thinking"), both + + # An empty reasoning delta renders nothing and must not fall through to + # the content branch. + empty = backend._parse_sse_line( + 'data: {"choices":[{"delta":{"reasoning_content":""}}]}' + ) + assert empty is None, empty print("ok sse parsing") @@ -365,6 +380,41 @@ def test_markdown_rendering(): print("ok markdown rendering") +def test_unbalanced_backticks(): + """An odd backtick cannot reflow the rest of a reply as code.""" + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from llamachat.ui import _close_fences, _markdown_to_fragment + + app = QApplication.instance() or QApplication([]) + assert app is not None + + # An unclosed fence gets one, so the tail is not swallowed. + assert _close_fences("a\n```py\nx=1").endswith("\n```") + # A closed fence is left exactly as it was. + balanced = "a\n```py\nx=1\n```\ntail" + assert _close_fences(balanced) == balanced + # A longer opener needs a closer at least as long. + assert _close_fences("````\nx").endswith("\n````") + # Tildes are fences too. + assert _close_fences("~~~\nx").endswith("\n~~~") + # A lone inline tick is dropped rather than opening a run. + assert "`" not in _close_fences("use ` here") + # Balanced inline ticks survive untouched. + assert _close_fences("a `b` c") == "a `b` c" + + # The real failure: reasoning text full of ticks must not become one + # block that eats the prose after it. + leaked = "` (wait)\nthen `find / -perm -4000` and more prose here" + rendered = _markdown_to_fragment(leaked) + assert "more prose here" in rendered + assert "