From d6fe5dca686b3f6c5f018d399f4f119fedf842f1 Mon Sep 17 00:00:00 2001 From: Dhairyashil R G Date: Thu, 8 Oct 2026 04:51:51 +0530 Subject: [PATCH] [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> --- tests/parser/test_streaming.py | 72 ++++++++++++++++++++++++++++++++++ vllm/parser/abstract_parser.py | 13 +++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/tests/parser/test_streaming.py b/tests/parser/test_streaming.py index cad16746be..d5d4339803 100644 --- a/tests/parser/test_streaming.py +++ b/tests/parser/test_streaming.py @@ -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). + ["let me thinka <", "x> b"], + # Whitespace after "" in the same delta as the marker. + ["let me think\n\n", "bold done"], + # The delta that ends reasoning carries only held-back text. + ["let me think", "<", "b>bold 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("", 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). + (["let me thinka <"], True, "a <"), + # Whitespace-only content after the marker at the end of the stream. + (["let me think\n\n"], True, "\n\n"), + # Hiding reasoning must not hide the flushed content. + (["let me think\n\n", "bold"], False, "\n\nbold"), + ], +) +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``. diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py index c28563fabf..f75d7ce902 100644 --- a/vllm/parser/abstract_parser.py +++ b/vllm/parser/abstract_parser.py @@ -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