# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Tests for the streaming parser engine core pipeline.""" from unittest.mock import MagicMock import pytest from tests.parser.engine.conftest import make_mock_tokenizer from vllm.parser.engine.events import EventType, SemanticEvent from vllm.parser.engine.incremental_lexer import ( LexerShape, TerminalDef, terminals_from_literals, ) from vllm.parser.engine.parser_engine_config import ( ParserEngineConfig, ParserState, Transition, ) from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine def _hermes_config() -> ParserEngineConfig: """Simple Hermes-style config: JSON.""" return ParserEngineConfig( name="hermes_test", terminals={ "TOOL_START": "", "TOOL_END": "", }, token_id_terminals={ "TOOL_START": "", "TOOL_END": "", }, transitions={ (ParserState.CONTENT, "TOOL_START"): Transition( ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,), ), (ParserState.TOOL_ARGS, "TOOL_END"): Transition( ParserState.CONTENT, (EventType.TOOL_CALL_END,), ), }, content_events={ ParserState.CONTENT: EventType.TEXT_CHUNK, ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, }, ) def _think_config() -> ParserEngineConfig: """Simple think-tag reasoning config: ....""" return ParserEngineConfig( name="think_test", terminals={ "THINK_START": "", "THINK_END": "", }, transitions={ (ParserState.CONTENT, "THINK_START"): Transition( ParserState.REASONING, (EventType.REASONING_START,), ), (ParserState.REASONING, "THINK_END"): Transition( ParserState.CONTENT, (EventType.REASONING_END,), ), }, ) class TestNonStreaming: def test_plain_text(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) events = engine.parse_complete("Hello, world!") assert len(events) == 1 assert events[0].type == EventType.TEXT_CHUNK assert events[0].value == "Hello, world!" def test_single_tool_call(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) text = ( '{"name": "get_weather",' ' "arguments": {"city": "SF"}}' "" ) events = engine.parse_complete(text) types = [e.type for e in events] assert EventType.TOOL_CALL_START in types assert EventType.TOOL_CALL_END in types assert EventType.ARG_VALUE_CHUNK in types arg_text = "".join( e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK ) assert '"name": "get_weather"' in arg_text assert '"city": "SF"' in arg_text def test_text_then_tool_call(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) text = 'Sure!{"name": "add"}' events = engine.parse_complete(text) types = [e.type for e in events] assert types[0] == EventType.TEXT_CHUNK assert events[0].value == "Sure!" assert EventType.TOOL_CALL_START in types assert EventType.TOOL_CALL_END in types def test_multiple_tool_calls(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) text = ( '{"name": "a"}{"name": "b"}' ) events = engine.parse_complete(text) starts = [e for e in events if e.type == EventType.TOOL_CALL_START] ends = [e for e in events if e.type == EventType.TOOL_CALL_END] assert len(starts) == 2 assert len(ends) == 2 assert starts[0].tool_index == 0 assert starts[1].tool_index == 1 def test_reasoning(self): engine = StreamingParserEngine(_think_config(), tokenizer=None) text = "Let me think...The answer is 42." events = engine.parse_complete(text) types = [e.type for e in events] assert types[0] == EventType.REASONING_START assert EventType.REASONING_CHUNK in types assert EventType.REASONING_END in types assert EventType.TEXT_CHUNK in types reasoning = "".join( e.value for e in events if e.type == EventType.REASONING_CHUNK ) assert "Let me think..." in reasoning content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert "The answer is 42." in content class TestStreaming: @staticmethod def _feed_chars( engine: StreamingParserEngine, text: str, ) -> list[SemanticEvent]: """Feed text one character at a time.""" all_events = [] for ch in text: all_events.extend(engine.feed(ch, [])) all_events.extend(engine.finish()) return all_events @staticmethod def _feed_chunks( engine: StreamingParserEngine, text: str, chunk_size: int, ) -> list[SemanticEvent]: """Feed text in fixed-size chunks.""" all_events = [] for i in range(0, len(text), chunk_size): chunk = text[i : i + chunk_size] all_events.extend(engine.feed(chunk, [])) all_events.extend(engine.finish()) return all_events def test_char_by_char_tool_call(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) text = '{"name": "add", "arguments": {"a": 1}}' events = self._feed_chars(engine, text) types = [e.type for e in events] assert EventType.TOOL_CALL_START in types assert EventType.TOOL_CALL_END in types assert EventType.ARG_VALUE_CHUNK in types arg_text = "".join( e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK ) assert '"name": "add"' in arg_text @pytest.mark.parametrize( "text", [ '{"name": "get", "arguments": {"x": "hello"}}', '{"name": "f", "arguments": ' '{"items": [1, [2, 3]], "obj": {"k": "v"}}}' "", ], ids=["flat_args", "nested_arrays"], ) def test_chunk_sizes_produce_same_content(self, text): """Different chunk sizes must produce identical concatenated content.""" results = {} for chunk_size in [1, 2, 3, 5, 7, len(text)]: engine = StreamingParserEngine(_hermes_config(), tokenizer=None) events = self._feed_chunks(engine, text, chunk_size) arg_text = "".join( e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK ) results[chunk_size] = arg_text values = list(results.values()) for v in values[1:]: assert v == values[0], f"Mismatch: {results}" def test_prefix_buffering_prevents_premature_emit(self): """Text like '", []) starts = [e for e in events2 if e.type == EventType.TOOL_CALL_START] assert len(starts) == 1 def test_prefix_buffering_flush_on_mismatch(self): """Text like 'rest", []) events2.extend(engine.finish()) content = "".join(e.value for e in events2 if e.type == EventType.TEXT_CHUNK) assert content == "rest" def test_reasoning_streaming(self): engine = StreamingParserEngine(_think_config(), tokenizer=None) events = self._feed_chars(engine, "hmmanswer") reasoning = "".join( e.value for e in events if e.type == EventType.REASONING_CHUNK ) content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert "hmm" in reasoning assert "answer" in content def test_text_between_tool_calls(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) text = ( 'Hi{"name":"a"}' 'mid{"name":"b"}end' ) events = self._feed_chunks(engine, text, 3) texts = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert "Hi" in texts assert "mid" in texts assert "end" in texts starts = [e for e in events if e.type == EventType.TOOL_CALL_START] assert len(starts) == 2 def test_unmatched_close_brace_does_not_poison_depth(self): """A stray } in malformed JSON must not kill streaming for all subsequent content.""" engine = StreamingParserEngine(_hermes_config(), tokenizer=None) engine.feed("", []) malformed = '}{{"a": 1}}' events = self._feed_chars(engine, malformed + "") arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK] assert len(arg_chunks) > 1, ( "Content after stray } should still stream incrementally" ) arg_text = "".join(arg_chunks) assert '"a": 1' in arg_text def test_json_args_no_premature_close_brace(self): """Closing braces of the top-level JSON shouldn't be streamed until confirmed by the end tag.""" engine = StreamingParserEngine(_hermes_config(), tokenizer=None) engine.feed("", []) events = engine.feed('{"name": "f"}', []) arg_text = "".join( e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK ) assert "}" not in arg_text, "Top-level } should be held back" events2 = engine.feed("", []) arg_text2 = "".join( e.value for e in events2 if e.type == EventType.ARG_VALUE_CHUNK ) assert "}" in arg_text2, "} should flush on end tag" _START_ID = 50 _END_ID = 51 _TOOL_START_ID = 60 _TOOL_END_ID = 61 def _make_think_tokenizer(): tok = MagicMock() tok.encode.return_value = [1, 2, 3] tok.get_vocab.return_value = {"": _START_ID, "": _END_ID} tok.decode.side_effect = lambda ids: { _START_ID: "", _END_ID: "", }.get(ids[0], f"tok{ids[0]}") return tok def _make_hermes_tokenizer(): """Tokenizer that resolves tool_call tags to special IDs.""" _special = {_TOOL_START_ID: "", _TOOL_END_ID: ""} tok = MagicMock() tok.encode.return_value = [1, 2, 3] tok.get_vocab.return_value = { "": _TOOL_START_ID, "": _TOOL_END_ID, } tok.decode.side_effect = lambda ids: "".join( _special.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids ) return tok class TestLexerBufferFlush: """Lexer buffer must be flushed before PreLexedTerminal transitions.""" def test_buffered_prefix_emitted_in_current_state(self): """Text buffered by the lexer (e.g. '<') must be emitted as REASONING_CHUNK before THINK_END transitions to CONTENT.""" engine = StreamingParserEngine(_think_config(), _make_think_tokenizer()) events = engine.feed("", [_START_ID]) assert any(e.type == EventType.REASONING_START for e in events) events = engine.feed("reasoning text<", []) reasoning_text = "".join( e.value for e in events if e.type == EventType.REASONING_CHUNK ) assert "reasoning text" in reasoning_text events = engine.feed("", [_END_ID]) event_types = [e.type for e in events] if EventType.REASONING_CHUNK in event_types: rc_idx = event_types.index(EventType.REASONING_CHUNK) re_idx = event_types.index(EventType.REASONING_END) assert rc_idx < re_idx, ( "'<' must be emitted as REASONING_CHUNK before REASONING_END" ) flushed = events[rc_idx].value assert "<" in flushed def test_empty_buffer_no_extra_events(self): """When the lexer buffer is empty, flushing is a no-op.""" engine = StreamingParserEngine(_think_config(), _make_think_tokenizer()) engine.feed("", [_START_ID]) engine.feed("clean text", []) events = engine.feed("", [_END_ID]) assert any(e.type == EventType.REASONING_END for e in events) chunk_events = [e for e in events if e.type == EventType.REASONING_CHUNK] assert all(e.value for e in chunk_events) class TestTokenIdFiltering: """When token IDs are available, lex-matched terminals that also have token_id_terminal entries should be demoted to content.""" def test_lex_matched_terminal_demoted_after_token_ids_seen(self): """After receiving token IDs, text that matches a token-ID terminal should be treated as content, not trigger a transition.""" engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer()) # First feed with a non-special token ID to set _ever_had_token_ids engine.feed("prefix ", [1]) # Now feed text containing as literal text events = engine.feed( "Use to invoke tools.", [2, 3, 4, 5] ) events.extend(engine.finish()) types = [e.type for e in events] assert EventType.TOOL_CALL_START not in types assert EventType.TEXT_CHUNK in types text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert "" in text def test_scanner_matched_terminal_bypasses_filter(self): """PreLexedTerminals from the scanner bypass the filter and still trigger state transitions.""" engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer()) events = engine.feed("", [_TOOL_START_ID]) assert any(e.type == EventType.TOOL_CALL_START for e in events) events = engine.feed('{"name": "f"}', [2, 3]) events.extend(engine.feed("", [_TOOL_END_ID])) events.extend(engine.finish()) assert any(e.type == EventType.TOOL_CALL_END for e in events) def test_no_filtering_without_token_ids(self): """When no token IDs are ever provided (non-streaming), text matching still triggers transitions.""" engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer()) events = engine.feed('{"name": "f"}', []) events.extend(engine.finish()) types = [e.type for e in events] assert EventType.TOOL_CALL_START in types assert EventType.TOOL_CALL_END in types def test_mixed_text_then_real_tool_call(self): """Text mentioning tool syntax followed by a real special-token tool call.""" engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer()) events1 = engine.feed("Mention in text. ", [1, 2, 3, 4]) events2 = engine.feed("", [_TOOL_START_ID]) events3 = engine.feed('{"name": "a"}', [5, 6]) events4 = engine.feed("", [_TOOL_END_ID]) events4.extend(engine.finish()) all_events = events1 + events2 + events3 + events4 content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK) assert "" in content assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1 def _func_prefix_config() -> ParserEngineConfig: """Config mixing token-ID terminals (TOOL_START/END) with text-only terminals (FUNC_PREFIX) and fallback transitions.""" return ParserEngineConfig( name="func_prefix_test", terminals={ "TOOL_START": "", "TOOL_END": "", "FUNC_PREFIX": "", "CLOSE_ANGLE": ">", }, token_id_terminals={ "TOOL_START": "", "TOOL_END": "", }, transitions={ (ParserState.CONTENT, "TOOL_START"): Transition( ParserState.TOOL_PREAMBLE, (EventType.TOOL_CALL_START,), ), (ParserState.CONTENT, "FUNC_PREFIX"): Transition( ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), skip_in_token_id_mode=True, ), (ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition( ParserState.TOOL_NAME, (), ), (ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition( ParserState.TOOL_ARGS, (), ), (ParserState.TOOL_ARGS, "FUNC_END"): Transition( ParserState.TOOL_BETWEEN, (EventType.TOOL_CALL_END,), ), (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( ParserState.CONTENT, (), ), (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition( ParserState.TOOL_PREAMBLE, (EventType.TOOL_CALL_START,), ), (ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition( ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), skip_in_token_id_mode=True, ), }, content_events={ ParserState.CONTENT: EventType.TEXT_CHUNK, ParserState.TOOL_NAME: EventType.TOOL_NAME, ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, }, ) def _make_func_prefix_tokenizer(): return make_mock_tokenizer( { "": _TOOL_START_ID, "": _TOOL_END_ID, } ) class TestTextOnlyFallbackFiltering: """When token IDs are available, transitions marked skip_in_token_id_mode should be skipped.""" def test_func_prefix_in_prose_demoted_in_strict_mode(self): """ in prose should NOT trigger a tool call when strict mode is active.""" engine = StreamingParserEngine( _func_prefix_config(), _make_func_prefix_tokenizer() ) engine.feed("prefix ", [1]) events = engine.feed("Use to check.", [2, 3, 4, 5]) events.extend(engine.finish()) types = [e.type for e in events] assert EventType.TOOL_CALL_START not in types assert EventType.TEXT_CHUNK in types text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert " FUNC_PREFIX (text) should still parse a tool call normally in strict mode.""" engine = StreamingParserEngine( _func_prefix_config(), _make_func_prefix_tokenizer() ) events1 = engine.feed("", [_TOOL_START_ID]) assert any(e.type == EventType.TOOL_CALL_START for e in events1) events2 = engine.feed("", [2, 3]) events3 = engine.feed("args", [4]) events4 = engine.feed("", [5, 6]) events4.extend(engine.feed("", [_TOOL_END_ID])) events4.extend(engine.finish()) all_events = events1 + events2 + events3 + events4 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1 def test_fallback_fires_without_token_ids(self): """When no token IDs are provided, fallback transitions should still fire normally.""" engine = StreamingParserEngine( _func_prefix_config(), _make_func_prefix_tokenizer() ) events = engine.feed("args", []) events.extend(engine.finish()) types = [e.type for e in events] assert EventType.TOOL_CALL_START in types assert EventType.TOOL_CALL_END in types def test_tool_between_fallback_blocked_in_strict_mode(self): """The (TOOL_BETWEEN, FUNC_PREFIX) fallback should also be blocked in strict mode.""" engine = StreamingParserEngine( _func_prefix_config(), _make_func_prefix_tokenizer() ) engine.feed("", [_TOOL_START_ID]) engine.feed("", [2, 3]) engine.feed("args", [4]) engine.feed("", [5, 6]) engine.feed("", [_TOOL_END_ID]) events = engine.feed("more", [7, 8, 9]) events.extend(engine.finish()) types = [e.type for e in events] assert EventType.TOOL_CALL_START not in types class TestNoUnusedTokenizerAttr: """StreamingParserEngine no longer stores a redundant _tokenizer.""" def test_no_tokenizer_attribute(self): config = ParserEngineConfig(name="test") engine = StreamingParserEngine(config, tokenizer=None) assert not hasattr(engine, "_tokenizer") class TestArgsResetOnReentry: """When leaving TOOL_ARGS and later re-entering (e.g. two tool calls), the entering-TOOL_ARGS block resets args tracking. The redundant reset on exit was removed.""" @staticmethod def _multi_tool_config() -> ParserEngineConfig: return ParserEngineConfig( name="multi_tool", terminals={ "TOOL_START": "", "TOOL_END": "", "TOOL_SEP": "", }, transitions={ (ParserState.CONTENT, "TOOL_START"): Transition( ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,), ), (ParserState.TOOL_ARGS, "TOOL_END"): Transition( ParserState.TOOL_BETWEEN, (EventType.TOOL_CALL_END,), ), (ParserState.TOOL_BETWEEN, "TOOL_SEP"): Transition( ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,), ), }, content_events={ ParserState.CONTENT: EventType.TEXT_CHUNK, ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, }, ) def test_args_tracking_across_reentry(self): engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None) events = engine.feed( '{"city": "SF"}' "" '{"name": "bar"}', [], ) tool_starts = [e for e in events if e.type == EventType.TOOL_CALL_START] tool_ends = [e for e in events if e.type == EventType.TOOL_CALL_END] arg_chunks = [e for e in events if e.type == EventType.ARG_VALUE_CHUNK] assert len(tool_starts) == 2 assert len(tool_ends) == 2 assert tool_starts[0].tool_index == 0 assert tool_starts[1].tool_index == 1 first_args = "".join(e.value for e in arg_chunks if e.tool_index == 0) second_args = "".join(e.value for e in arg_chunks if e.tool_index == 1) assert '"city"' in first_args assert '"name"' in second_args def test_brace_depth_resets_on_reentry(self): """Verify _args_brace_depth resets when re-entering TOOL_ARGS.""" engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None) engine.feed("", []) assert engine.state == ParserState.TOOL_ARGS assert engine._args_brace_depth == 0 engine.feed('{"a": 1}', []) engine.feed("", []) assert engine.state == ParserState.TOOL_BETWEEN engine.feed("", []) assert engine.state == ParserState.TOOL_ARGS assert engine._args_brace_depth == 0 assert engine._args_in_string is False assert engine._args_escape_next is False class TestToolPreambleFinish: """finish() in TOOL_PREAMBLE state emits TOOL_CALL_END when a tool call was started (tool_index >= 0), but not when tool_index is -1.""" @staticmethod def _preamble_with_tool_call_start_config() -> ParserEngineConfig: return ParserEngineConfig( name="preamble_tcs", terminals={"TOOL_START": ""}, transitions={ (ParserState.CONTENT, "TOOL_START"): Transition( ParserState.TOOL_PREAMBLE, (EventType.TOOL_CALL_START,), ), }, content_events={ParserState.CONTENT: EventType.TEXT_CHUNK}, ) @staticmethod def _preamble_without_tool_call_start_config() -> ParserEngineConfig: return ParserEngineConfig( name="preamble_no_tcs", terminals={"TOOL_CALLS_START": ""}, transitions={ (ParserState.CONTENT, "TOOL_CALLS_START"): Transition( ParserState.TOOL_PREAMBLE, (), ), }, content_events={ParserState.CONTENT: EventType.TEXT_CHUNK}, ) def test_finish_emits_tool_call_end_with_tool_index(self): config = self._preamble_with_tool_call_start_config() engine = StreamingParserEngine(config, tokenizer=None) engine.feed("", []) assert engine.state == ParserState.TOOL_PREAMBLE assert engine.tool_index == 0 finish_events = engine.finish() end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END] assert len(end_events) == 1 assert end_events[0].tool_index == 0 def test_finish_no_tool_call_end_without_tool_index(self): config = self._preamble_without_tool_call_start_config() engine = StreamingParserEngine(config, tokenizer=None) engine.feed("", []) assert engine.state == ParserState.TOOL_PREAMBLE assert engine.tool_index == -1 finish_events = engine.finish() end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END] assert len(end_events) == 0 assert engine.state == ParserState.CONTENT class TestRegexTerminalInfraRemoved: """TerminalDef.priority, LexerShape.regex_terminals, and the regex matching loop were removed.""" def test_terminal_def_no_priority(self): import regex as re td = TerminalDef(name="X", pattern=re.compile("x")) assert not hasattr(td, "priority") def test_lexer_shape_no_regex_terminals(self): shape = LexerShape([]) assert not hasattr(shape, "regex_terminals") def test_terminals_from_literals_still_works(self): literals = {"TOOL_START": "", "TOOL_END": ""} defs = terminals_from_literals(literals) assert len(defs) == 2 names = {d.name for d in defs} assert names == {"TOOL_START", "TOOL_END"} for d in defs: assert d.is_literal assert d.literal in ("", "") class TestMultiCharTerminalInArgs: """Regression: multi-char terminals falling through in TOOL_ARGS must be fed char-by-char via _feed_args_text, not _feed_args_char.""" @staticmethod def _newline_config() -> ParserEngineConfig: return ParserEngineConfig( name="newline_test", terminals={ "TOOL_START": "", "TOOL_END": "", "NEWLINE": "\n", }, transitions={ (ParserState.CONTENT, "TOOL_START"): Transition( ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,), ), (ParserState.TOOL_ARGS, "TOOL_END"): Transition( ParserState.CONTENT, (EventType.TOOL_CALL_END,), ), }, content_events={ ParserState.CONTENT: EventType.TEXT_CHUNK, ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, }, ) def test_newline_in_args_parsed_correctly(self): engine = StreamingParserEngine(self._newline_config(), tokenizer=None) text = '{"name": "f",\n"arguments": {"a": 1}}' events = engine.parse_complete(text) arg_text = "".join( e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK ) assert '"name": "f"' in arg_text assert '"arguments"' in arg_text def test_newline_in_args_streaming(self): engine = StreamingParserEngine(self._newline_config(), tokenizer=None) all_events = TestStreaming._feed_chars( engine, '{"name": "f",\n"a": 1}' ) arg_text = "".join( e.value for e in all_events if e.type == EventType.ARG_VALUE_CHUNK ) assert '"name": "f"' in arg_text assert '"a": 1' in arg_text class TestSkipToolParsing: """When skip_tool_parsing is set, tool tags become content.""" def test_tool_tags_emitted_as_content(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) engine.skip_tool_parsing = True text = '{"name": "f"}' events = engine.parse_complete(text) types = [e.type for e in events] assert EventType.TOOL_CALL_START not in types assert EventType.TOOL_CALL_END not in types content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) assert "" in content assert "" in content def test_skip_tool_streaming(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) engine.skip_tool_parsing = True all_events = TestStreaming._feed_chars( engine, '{"name": "f"}' ) types = [e.type for e in all_events] assert EventType.TOOL_CALL_START not in types content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK) assert "" in content def test_reset_preserves_skip_tool_parsing(self): engine = StreamingParserEngine(_hermes_config(), tokenizer=None) engine.skip_tool_parsing = True engine.reset() assert engine.skip_tool_parsing is True