aboutsummaryrefslogtreecommitdiffstats
path: root/test_llamachat.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-31 11:40:34 +0200
committerDanilo M. <danix@danix.xyz>2026-07-31 11:40:34 +0200
commit99b849463b6748dbb14f4d4cdfa18185e2a860a9 (patch)
tree1d02ded50190c67acfa86eb96e320d8f54fb1d00 /test_llamachat.py
parent879c1bf3af959234ef608ca90e96ded4c8a81498 (diff)
downloadllamachat-99b849463b6748dbb14f4d4cdfa18185e2a860a9.tar.gz
llamachat-99b849463b6748dbb14f4d4cdfa18185e2a860a9.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'test_llamachat.py')
-rwxr-xr-xtest_llamachat.py51
1 files changed, 51 insertions, 0 deletions
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 "<pre" not in rendered, rendered
+ print("ok unbalanced backticks")
+
+
def test_system_qt_theme_guard():
"""The plugin path is only borrowed when the Qt versions agree."""
import os
@@ -864,6 +914,7 @@ if __name__ == "__main__":
test_session_prompt_storage()
test_prompt_column_migration()
test_markdown_rendering()
+ test_unbalanced_backticks()
test_sidebar_toggle()
test_shortcuts()
test_system_qt_theme_guard()