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