diff --git a/comfy/text_encoders/gemma4.py b/comfy/text_encoders/gemma4.py
index 5b0b968c9..2d1e45a75 100644
--- a/comfy/text_encoders/gemma4.py
+++ b/comfy/text_encoders/gemma4.py
@@ -6,6 +6,7 @@ import numpy as np
from tokenizers import Tokenizer
from dataclasses import dataclass
import math
+import re
from comfy import sd1_clip
import comfy.model_management
@@ -1401,9 +1402,11 @@ class Gemma4SDTokenizer(Gemma4_Tokenizer, sd1_clip.SDTokenizer):
def decode(self, token_ids, **kwargs):
text = super().decode(token_ids, skip_special_tokens=False)
- # Translate thinking channel markers to standard / tags
+ # Translate thinking channel markers to standard / tags. Only a close
+ # that ends a thought channel becomes ; generation primed with another channel
+ # leaves its opening marker in the prompt, so its close is not reasoning.
+ text = re.sub(r"<\|channel>thought\n(.*?)", r"\n\1", text, flags=re.DOTALL)
text = text.replace("<|channel>thought\n", "\n")
- text = text.replace("", "")
# Strip remaining special tokens
text = text.replace("", "").replace("", "").strip()
return text
diff --git a/tests-unit/comfy_test/test_gemma4_tokenizer.py b/tests-unit/comfy_test/test_gemma4_tokenizer.py
new file mode 100644
index 000000000..c4d86f867
--- /dev/null
+++ b/tests-unit/comfy_test/test_gemma4_tokenizer.py
@@ -0,0 +1,45 @@
+import torch
+
+from comfy.cli_args import args as cli_args
+
+if not torch.cuda.is_available():
+ cli_args.cpu = True
+
+from comfy.text_encoders.gemma4 import Gemma4SDTokenizer # noqa: E402
+
+
+class _StubTokenizer:
+ """Returns a canned decode so the marker translation can be tested without model files."""
+ def __init__(self, text):
+ self.text = text
+
+ def decode(self, token_ids, skip_special_tokens=False):
+ return self.text
+
+
+def decode(text):
+ tokenizer = Gemma4SDTokenizer.__new__(Gemma4SDTokenizer)
+ tokenizer.tokenizer = _StubTokenizer(text)
+ return tokenizer.decode([])
+
+
+class TestGemma4Decode:
+ def test_thought_channel_becomes_think_tags(self):
+ assert decode("<|channel>thought\nreasoningthe answer") == "\nreasoningthe answer"
+
+ def test_primed_empty_thought_channel(self):
+ assert decode("<|channel>thought\nthe answer") == "\nthe answer"
+
+ def test_unclosed_thought_channel(self):
+ assert decode("<|channel>thought\nreasoning") == "\nreasoning"
+
+ def test_other_channel_close_is_not_reasoning(self):
+ # Non-thinking LTX2 prompt enhancement primes a "final" channel, so only its close is
+ # generated. Turning that into made the whole answer look like reasoning.
+ assert decode("the answer") == "the answer"
+
+ def test_other_channel_kept_after_a_thought_channel(self):
+ assert decode("<|channel>thought\nreasoning<|channel>final\nthe answer") == "\nreasoning<|channel>final\nthe answer"
+
+ def test_turn_and_eos_are_stripped(self):
+ assert decode("the answer") == "the answer"