aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--llamachat/backend.py7
-rw-r--r--llamachat/ui.py29
-rwxr-xr-xtest_llamachat.py51
3 files changed, 84 insertions, 3 deletions
diff --git a/llamachat/backend.py b/llamachat/backend.py
index 9e0556f..ec3b7d6 100644
--- a/llamachat/backend.py
+++ b/llamachat/backend.py
@@ -267,9 +267,12 @@ def _parse_sse_line(line: str) -> tuple[str, str] | None:
return None
delta = choices[0].get("delta") or {}
+ # Both fields can arrive in one delta, and a reasoning delta can be an
+ # empty string. Test for presence, not truthiness, so a chunk carrying
+ # reasoning is never mistaken for a content chunk.
reasoning = delta.get("reasoning_content")
- if reasoning:
- return ("reasoning", reasoning)
+ if reasoning is not None:
+ return ("reasoning", reasoning) if reasoning else None
content = delta.get("content")
if content:
return ("content", content)
diff --git a/llamachat/ui.py b/llamachat/ui.py
index fd2380c..f599bdd 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -1291,6 +1291,33 @@ _PRE_STYLE = (
)
+_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})", re.MULTILINE)
+
+
+def _close_fences(text: str) -> str:
+ """Close an unterminated code fence and neuter a lone inline backtick.
+
+ A reply is streamed, so a fence is unclosed on nearly every frame; a
+ model can also emit an odd backtick by accident. Either way the rest of
+ the reply would reflow as code, which is how a whole message turns into
+ one grey block. Closing the fence here bounds the damage to the text
+ that was really meant as code.
+ """
+ fences = _FENCE_RE.findall(text)
+ if len(fences) % 2:
+ # Match the opener's own length; a longer run is legal and only a
+ # fence at least as long as the opener actually closes it.
+ closer = fences[-1][0] * max(3, len(fences[-1]))
+ nl = "" if text.endswith("\n") else "\n"
+ return f"{text}{nl}{closer}"
+ if not fences and text.count("`") % 2:
+ # No fences at all, one dangling inline tick: drop it rather than
+ # let it open a run to the end of the message.
+ head, _, tail = text.rpartition("`")
+ return head + tail
+ return text
+
+
def _markdown_to_fragment(text: str) -> str:
"""Render markdown to an HTML fragment safe to splice into the page.
@@ -1300,7 +1327,7 @@ def _markdown_to_fragment(text: str) -> str:
if not text:
return ""
doc = QTextDocument()
- doc.setMarkdown(text, MARKDOWN_FLAGS)
+ doc.setMarkdown(_close_fences(text), MARKDOWN_FLAGS)
html_text = doc.toHtml()
start = html_text.find("<body")
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()