diff options
| author | Danilo M. <danix@danix.xyz> | 2026-07-31 11:40:34 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-07-31 11:40:34 +0200 |
| commit | 99b849463b6748dbb14f4d4cdfa18185e2a860a9 (patch) | |
| tree | 1d02ded50190c67acfa86eb96e320d8f54fb1d00 | |
| parent | 879c1bf3af959234ef608ca90e96ded4c8a81498 (diff) | |
| download | llamachat-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.py | 7 | ||||
| -rw-r--r-- | llamachat/ui.py | 29 | ||||
| -rwxr-xr-x | test_llamachat.py | 51 |
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() |
