[Bugfix][Parser] Emit buffered post-reasoning text when no tool parser is configured (#58911)

Signed-off-by: Dhairyashil R G <18738735+dhairyashilRG@users.noreply.github.com>
Signed-off-by: sfeng33 <4florafeng@gmail.com>
Co-authored-by: sfeng33 <4florafeng@gmail.com>
This commit is contained in:
Dhairyashil R G
2026-10-07 23:21:51 +00:00
committed by GitHub
co-authored by sfeng33
parent 4ac5a36a8e
commit d6fe5dca68
2 changed files with 83 additions and 2 deletions
+72
View File
@@ -663,6 +663,78 @@ def test_engine_reasoning_no_tool_batched_content_passthrough(tokenizer, request
assert len(tool_calls) == 0
@pytest.mark.parametrize(
"text_chunks",
[
# A trailing "<" that the engine holds back (it may start a tag).
["<think>let me think</think>a <", "x> b"],
# Whitespace after "</think>" in the same delta as the marker.
["<think>let me think</think>\n\n", "<b>bold</b> done"],
# The delta that ends reasoning carries only held-back text.
["<think>let me think", "</think><", "b>bold</b> done"],
],
)
def test_engine_reasoning_no_tool_batched_holdback_not_dropped(
tokenizer, request_obj, text_chunks
):
"""Text the engine buffers when reasoning ends inside a batched delta is
returned by finish_streaming(); with no tool parser it must still be
emitted as content, not dropped."""
parser = Qwen3ReasoningNoToolParser(tokenizer)
chunks = [tokenizer.encode(t, add_special_tokens=False) for t in text_chunks]
results = stream_chunks(parser, tokenizer, chunks, request_obj)
reasoning, content, tool_calls = collect_fields(results)
assert reasoning == "let me think"
assert content == "".join(text_chunks).split("</think>", 1)[1]
assert len(tool_calls) == 0
@pytest.mark.parametrize(
("text_chunks", "include_reasoning", "expected_content"),
[
# The delta that ends reasoning is also the last one: the held-back
# "<" must be emitted exactly once (the end-of-stream flush skips the
# reasoning parser once reasoning has ended).
(["<think>let me think</think>a <"], True, "a <"),
# Whitespace-only content after the marker at the end of the stream.
(["<think>let me think</think>\n\n"], True, "\n\n"),
# Hiding reasoning must not hide the flushed content.
(["<think>let me think</think>\n\n", "<b>bold</b>"], False, "\n\n<b>bold</b>"),
],
)
def test_engine_reasoning_no_tool_holdback_finished_and_hidden_reasoning(
tokenizer, text_chunks, include_reasoning, expected_content
):
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
include_reasoning=include_reasoning,
)
parser = Qwen3ReasoningNoToolParser(tokenizer)
chunks = [tokenizer.encode(t, add_special_tokens=False) for t in text_chunks]
results: list[DeltaMessage | None] = []
prompt_token_ids: list[int] | None = []
for i, chunk in enumerate(chunks):
results.append(
parser.parse_delta(
tokenizer.decode(chunk),
chunk,
request,
prompt_token_ids=prompt_token_ids,
finished=i == len(chunks) - 1,
)
)
prompt_token_ids = None
reasoning, content, tool_calls = collect_fields(results)
assert reasoning == ("let me think" if include_reasoning else "")
assert content == expected_content
assert len(tool_calls) == 0
def _decode_stream_deltas(tokenizer, groups):
"""Decode token-ID groups into ``(delta_text, group)`` pairs via the real
incremental ``DecodeStream``.
+11 -2
View File
@@ -904,8 +904,17 @@ class DelegatingParser(Parser):
(delta_message.content if delta_message else None) or ""
) + ((flush_delta.content if flush_delta else None) or "")
if self._engine_based:
if delta_message and self._tool_parser is not None:
delta_message.content = None
if self._tool_parser is not None:
# The tool-call phase below consumes current_text.
if delta_message:
delta_message.content = None
elif current_text:
# No tool phase follows to consume the flushed text,
# and the engine is not fed again after the
# transition, so emit it as content here.
if delta_message is None:
delta_message = DeltaMessage()
delta_message.content = current_text
else:
delta_text = current_text