diff --git a/tests/parser/engine/test_parser_engine.py b/tests/parser/engine/test_parser_engine.py
index 36258668215b..c1b1172a99ce 100644
--- a/tests/parser/engine/test_parser_engine.py
+++ b/tests/parser/engine/test_parser_engine.py
@@ -25,6 +25,7 @@
FunctionDefinition,
)
from vllm.parser.abstract_parser import DelegatingParser
+from vllm.parser.qwen3 import _qwen3_arg_converter
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.parser_engine import ParserEngine
@@ -1412,6 +1413,14 @@ def test_safe_arg_prefix(self, json_str, expected):
# ── Coercion instability regression tests ────────────────────────
+def _json_converter(raw_args: str, partial: bool) -> str:
+ """Identity converter: round-trips JSON, returns raw string when partial."""
+ try:
+ return json.dumps(json.loads(raw_args), ensure_ascii=False)
+ except (json.JSONDecodeError, ValueError):
+ return raw_args if partial else ""
+
+
def _growing_kv_converter(raw_args: str, partial: bool) -> str:
"""Converter that produces growing bare values (no delimiter)."""
params: dict[str, str] = {}
@@ -1495,6 +1504,76 @@ def test_int_partial_value_flip_is_safe(self):
assert isinstance(parsed["val"], str)
assert parsed["extra"] == "ok"
+ def test_integer_middle_field_not_truncated_by_coercion(self):
+ """Integer middle field must not cause truncation when coerced at flush.
+
+ Three chunks are required: the first triggers tool-name emission
+ (consuming the chunk without calling _compute_arg_delta), the second
+ leaves the integer value complete in streamed_json as a quoted string
+ ("42") while the JSON is still open, and the third closes the JSON.
+ Without the fix, flush-time coercion changes "42"→42, breaking the
+ startswith invariant and silently dropping the rest of the arguments.
+ """
+ tool = _make_tool(
+ "f", {"n": {"type": "integer"}, "s": {"type": "string"}}
+ )
+ engine = _make_engine(_converter_config(_json_converter), tools=[tool])
+ parsed = _run_streaming_tool(
+ engine, "f", ["{", '"n": "42", "s": "h', 'i"}']
+ )
+ assert parsed == {"n": 42, "s": "hi"}
+ assert isinstance(parsed["n"], int)
+
+ def test_boolean_middle_field_not_truncated_by_coercion(self):
+ """Boolean middle field must not cause truncation when coerced at flush."""
+ tool = _make_tool(
+ "f", {"flag": {"type": "boolean"}, "msg": {"type": "string"}}
+ )
+ engine = _make_engine(_converter_config(_json_converter), tools=[tool])
+ parsed = _run_streaming_tool(
+ engine, "f", ["{", '"flag": "true", "msg": "h', 'i"}']
+ )
+ assert parsed == {"flag": True, "msg": "hi"}
+ assert isinstance(parsed["flag"], bool)
+
+ def test_null_middle_field_not_truncated_by_coercion(self):
+ """Null-typed middle field must not cause truncation when coerced at flush."""
+ tool = _make_tool(
+ "f", {"v": {"type": "null"}, "tag": {"type": "string"}}
+ )
+ engine = _make_engine(_converter_config(_json_converter), tools=[tool])
+ parsed = _run_streaming_tool(
+ engine, "f", ["{", '"v": "null", "tag": "h', 'i"}']
+ )
+ assert parsed == {"v": None, "tag": "hi"}
+ assert parsed["v"] is None
+
+ def test_qwen3_partial_xml_close_tag_not_included_in_value(self):
+ """Qwen3 partial XML close tag must not leak into streamed string value.
+
+ When an XML close tag () is split across chunks, the
+ partial tag text must not appear in the streamed string value for
+ the preceding key.
+ """
+ tool = _make_tool(
+ "f", {"n": {"type": "integer"}, "s": {"type": "string"}}
+ )
+ engine = _make_engine(
+ _converter_config(_qwen3_arg_converter), tools=[tool]
+ )
+ parsed = _run_streaming_tool(
+ engine,
+ "f",
+ [
+ "42ok",
+ ],
+ )
+ assert parsed == {"n": 42, "s": "ok"}
+ assert isinstance(parsed["n"], int)
+ assert parsed["s"] == "ok"
+
_DROP_VOCAB: dict[str, int] = {
**_VOCAB,
diff --git a/vllm/parser/engine/parser_engine.py b/vllm/parser/engine/parser_engine.py
index fc2d653779ed..8fbe28a0a8f1 100644
--- a/vllm/parser/engine/parser_engine.py
+++ b/vllm/parser/engine/parser_engine.py
@@ -281,13 +281,16 @@ def _coerce_dict(args: dict, properties: dict) -> tuple[dict, bool]:
def _safe_arg_prefix(json_str: str, string_keys: set[str] | None = None) -> str:
"""Return the prefix of *json_str* up to the last top-level value.
- Middle values (followed by a comma) are stable across streaming
- ticks and included. The trailing value is excluded for non-string
- values because type coercion may change its serialised form between
- ticks, which would violate the ``startswith(prev)`` prefix invariant.
- String values for keys in ``string_keys`` are prefix-stable, so stream
- their unterminated content instead of buffering long arguments until
- the closing tag arrives.
+ The trailing value is excluded for non-string keys because type
+ coercion may change its serialised form, which would violate the
+ ``startswith(prev)`` prefix invariant. String values for keys in
+ ``string_keys`` are prefix-stable, so their unterminated content is
+ streamed incrementally.
+
+ Middle (comma-terminated) values for non-string keys are also excluded
+ for the same coercion reason: the prefix is clipped at the start of the
+ first non-string-typed value so that ``_fix_arg_types`` at flush time
+ never rewrites anything already sent to the client.
"""
last_colon = -1
last_key: str | None = None
@@ -296,6 +299,9 @@ def _safe_arg_prefix(json_str: str, string_keys: set[str] | None = None) -> str:
escape = False
string_start = -1
depth = 0
+ # Index of the first non-string-typed value start; -1 if none seen yet.
+ non_string_clip: int = -1
+
for i, c in enumerate(json_str):
if escape:
escape = False
@@ -319,8 +325,24 @@ def _safe_arg_prefix(json_str: str, string_keys: set[str] | None = None) -> str:
last_colon = i
last_key = pending_key
pending_key = None
+ if string_keys is not None and last_key not in string_keys:
+ # Record where this key's value starts; clip here so the
+ # value is never partially sent before flush-time coercion.
+ val_start = i + 1
+ while val_start < len(json_str) and json_str[val_start] in (
+ " ", "\t", "\n", "\r"
+ ):
+ val_start += 1
+ if non_string_clip < 0:
+ non_string_clip = val_start
+
if last_colon < 0:
return ""
+
+ # If a non-string-typed key's value has started, clip before it.
+ if non_string_clip >= 0:
+ return json_str[:non_string_clip]
+
end = last_colon + 1
while end < len(json_str) and json_str[end] in (" ", "\t", "\n", "\r"):
end += 1