# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Tests for TokenIDScanner.""" from unittest.mock import MagicMock import pytest from vllm.parser.engine.events import EventType from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine from vllm.parser.engine.token_id_scanner import ( PreLexedTerminal, TextChunk, TokenIDScanner, ) from vllm.parser.gemma4 import gemma4_config CHANNEL_START = "<|channel>" CHANNEL_END = "" CHANNEL_START_ID = 100 CHANNEL_END_ID = 101 REGULAR_TOKEN_ID = 200 TOOL_START = "" TOOL_END = "" TOOL_START_ID = 110 TOOL_END_ID = 111 @pytest.fixture def tokenizer(): tok = MagicMock() tok.get_vocab.return_value = { CHANNEL_START: CHANNEL_START_ID, CHANNEL_END: CHANNEL_END_ID, } tok.decode.side_effect = lambda ids: { CHANNEL_START_ID: CHANNEL_START, CHANNEL_END_ID: CHANNEL_END, REGULAR_TOKEN_ID: "regular", }.get(ids[0], f"") return tok @pytest.fixture def scanner(tokenizer): return TokenIDScanner( token_id_to_terminal={ CHANNEL_START_ID: "THINK_START", CHANNEL_END_ID: "THINK_END", }, tokenizer=tokenizer, ) class TestJoinDecodedTextReturnsStr: """_join_decoded_text always returns str.""" @pytest.fixture def bare_scanner(self): return TokenIDScanner({}, tokenizer=None) def test_mixed_items(self, bare_scanner): items = [ TextChunk("hello "), PreLexedTerminal("TOOL_START", 42, ""), TextChunk(" world"), ] result = bare_scanner._join_decoded_text(items) assert isinstance(result, str) assert result == "hello world" def test_empty_list(self, bare_scanner): result = bare_scanner._join_decoded_text([]) assert isinstance(result, str) assert result == "" def test_only_text_chunks(self, bare_scanner): result = bare_scanner._join_decoded_text([TextChunk("abc"), TextChunk("def")]) assert result == "abcdef" class TestHoldbackTextRecovery: def test_holdback_text_with_special_token_text_absent(self, scanner): """Terminal deferred when its text is absent from delta_text.""" result = scanner.scan( delta_text="processed is appropriate.", delta_token_ids=[CHANNEL_END_ID], ) assert len(result) == 0 result2 = scanner.scan( delta_text="Understood.", delta_token_ids=[20, 21], ) pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)] assert len(pre_lexed) == 1 assert pre_lexed[0].terminal == "THINK_END" texts = [r.text for r in result2 if isinstance(r, TextChunk)] combined = "".join(texts) assert "processed is appropriate." in combined assert "Understood." in combined def test_holdback_text_with_special_token_text_present(self, scanner): """Hold-back text + special token text both in delta_text.""" result = scanner.scan( delta_text="holdback text", delta_token_ids=[CHANNEL_END_ID], ) assert len(result) == 2 assert isinstance(result[0], TextChunk) assert result[0].text == "holdback text" assert isinstance(result[1], PreLexedTerminal) assert result[1].terminal == "THINK_END" def test_no_holdback_text(self, scanner): """delta_text is exactly the special token text.""" result = scanner.scan( delta_text="", delta_token_ids=[CHANNEL_END_ID], ) assert len(result) == 1 assert isinstance(result[0], PreLexedTerminal) assert result[0].terminal == "THINK_END" def test_empty_delta_text(self, scanner): """Empty delta_text defers the terminal until text arrives.""" result = scanner.scan( delta_text="", delta_token_ids=[CHANNEL_END_ID], ) assert len(result) == 0 flushed = scanner.flush_pending() assert len(flushed) == 1 assert isinstance(flushed[0], PreLexedTerminal) assert flushed[0].terminal == "THINK_END" def test_empty_delta_text_drops_individual_decode_text(self, tokenizer): """Empty delta_text with multiple tokens: all results deferred.""" tool_start_id = 400 tok_a = 201 tok_b = 202 tokenizer.decode.side_effect = lambda ids: { tool_start_id: "<|tool_call>", tok_a: "call:", tok_b: "get_weather", }.get(ids[0], "?") scanner = TokenIDScanner( token_id_to_terminal={tool_start_id: "TOOL_START"}, tokenizer=tokenizer, ) result = scanner.scan( delta_text="", delta_token_ids=[tool_start_id, tok_a, tok_b], ) assert len(result) == 0 flushed = scanner.flush_pending() assert len(flushed) == 1 assert isinstance(flushed[0], PreLexedTerminal) assert flushed[0].terminal == "TOOL_START" def test_holdback_before_start_tag(self, scanner): result = scanner.scan( delta_text="prefix text<|channel>", delta_token_ids=[CHANNEL_START_ID], ) assert len(result) == 2 assert isinstance(result[0], TextChunk) assert result[0].text == "prefix text" assert isinstance(result[1], PreLexedTerminal) assert result[1].terminal == "THINK_START" def test_multi_token_batch_special_in_middle(self, scanner, tokenizer): """Multi-token batch with special token in the middle.""" tok_a = 201 tok_b = 202 tokenizer.decode.side_effect = lambda ids: { tok_a: "wordA", tok_b: "wordB", CHANNEL_END_ID: CHANNEL_END, }.get(ids[0], "?") scanner_multi = TokenIDScanner( token_id_to_terminal={CHANNEL_END_ID: "THINK_END"}, tokenizer=tokenizer, ) result = scanner_multi.scan( delta_text="holdback wordA wordB", delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b], ) texts = [r.text for r in result if isinstance(r, TextChunk)] terminals = [r.terminal for r in result if isinstance(r, PreLexedTerminal)] assert "THINK_END" in terminals assert "holdback wordA" in "".join(texts) def test_multi_token_batch_special_token_text_absent(self, scanner, tokenizer): """Multi-token batch where special token text is absent.""" tok_a = 201 tok_b = 202 tokenizer.decode.side_effect = lambda ids: { tok_a: "alpha", tok_b: "beta", CHANNEL_END_ID: CHANNEL_END, }.get(ids[0], "?") scanner_multi = TokenIDScanner( token_id_to_terminal={CHANNEL_END_ID: "THINK_END"}, tokenizer=tokenizer, ) result = scanner_multi.scan( delta_text="holdback alpha", delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b], ) assert len(result) == 0 result2 = scanner_multi.scan( delta_text=" more text", delta_token_ids=[300], ) pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)] assert len(pre_lexed) == 1 assert pre_lexed[0].terminal == "THINK_END" text_chunks = [r for r in result2 if isinstance(r, TextChunk)] combined = "".join(t.text for t in text_chunks) assert "holdback alpha" in combined assert "more text" in combined def test_holdback_with_content_after_special_token(self, tokenizer): """Hold-back + special token + content after in one delta.""" tok_content = 210 tokenizer.decode.side_effect = lambda ids: { CHANNEL_END_ID: CHANNEL_END, tok_content: "content start", }.get(ids[0], "?") scanner = TokenIDScanner( token_id_to_terminal={CHANNEL_END_ID: "THINK_END"}, tokenizer=tokenizer, ) result = scanner.scan( delta_text="reasoning end.content start", delta_token_ids=[CHANNEL_END_ID, tok_content], ) pre_lexed = [r for r in result if isinstance(r, PreLexedTerminal)] assert len(pre_lexed) == 1 assert pre_lexed[0].terminal == "THINK_END" text_chunks = [r for r in result if isinstance(r, TextChunk)] combined = "".join(t.text for t in text_chunks) assert "reasoning end." in combined class TestEndToEndReasoningHoldback: """End-to-end engine tests with detokenizer hold-back.""" def test_reasoning_content_not_truncated(self): config = gemma4_config() tok = MagicMock() vocab = { CHANNEL_START: CHANNEL_START_ID, CHANNEL_END: CHANNEL_END_ID, } tok.get_vocab.return_value = vocab tok.decode.side_effect = lambda ids: { CHANNEL_START_ID: CHANNEL_START, CHANNEL_END_ID: CHANNEL_END, }.get(ids[0], f"tok{ids[0]}") engine = StreamingParserEngine(config, tok) all_events = [] all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID])) all_events.extend( engine.feed( "thought\nThe request was received and ", [10, 11, 12, 13, 14], ) ) # CHANNEL_END token arrives but its text is held back. all_events.extend( engine.feed( "processed is appropriate.", [CHANNEL_END_ID], ) ) # Detokenizer flushes the held-back text. all_events.extend( engine.feed( "Understood.", [20, 21], ) ) all_events.extend(engine.finish()) reasoning_text = "".join( e.value for e in all_events if e.type == EventType.REASONING_CHUNK ) content_text = "".join( e.value for e in all_events if e.type == EventType.TEXT_CHUNK ) assert "processed is appropriate." in reasoning_text assert "Understood." in content_text def test_backtick_content_not_truncated(self): config = gemma4_config() tok = MagicMock() vocab = { CHANNEL_START: CHANNEL_START_ID, CHANNEL_END: CHANNEL_END_ID, } tok.get_vocab.return_value = vocab tok.decode.side_effect = lambda ids: { CHANNEL_START_ID: CHANNEL_START, CHANNEL_END_ID: CHANNEL_END, }.get(ids[0], f"tok{ids[0]}") engine = StreamingParserEngine(config, tok) all_events = [] all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID])) all_events.extend( engine.feed( "thought\n1/10 completed. Next: ", [10, 11, 12, 13], ) ) all_events.extend( engine.feed( "`hostname`.\n", [CHANNEL_END_ID], ) ) all_events.extend( engine.feed( "tool output", [20, 21], ) ) all_events.extend(engine.finish()) reasoning_text = "".join( e.value for e in all_events if e.type == EventType.REASONING_CHUNK ) assert "`hostname`." in reasoning_text _CHANNEL_START_TAG = "<|channel>" _CHANNEL_END_TAG = "" _TOOL_START_TAG = "<|tool_call>" _TOOL_END_TAG = "" _QUOTE_TAG = '<|"|>' _CHANNEL_START_TID = 100 _CHANNEL_END_TID = 101 _TOOL_START_TID = 102 _TOOL_END_TID = 103 _QUOTE_TID = 104 _TOK = list(range(200, 215)) def _gemma4_vocab() -> dict[str, int]: return { _CHANNEL_START_TAG: _CHANNEL_START_TID, _CHANNEL_END_TAG: _CHANNEL_END_TID, _TOOL_START_TAG: _TOOL_START_TID, _TOOL_END_TAG: _TOOL_END_TID, _QUOTE_TAG: _QUOTE_TID, } def _make_gemma4_tokenizer( extra_decode: dict[int, str] | None = None, ) -> MagicMock: special = { _CHANNEL_START_TID: _CHANNEL_START_TAG, _CHANNEL_END_TID: _CHANNEL_END_TAG, _TOOL_START_TID: _TOOL_START_TAG, _TOOL_END_TID: _TOOL_END_TAG, _QUOTE_TID: _QUOTE_TAG, } decode_map = {**special, **(extra_decode or {})} tok = MagicMock() tok.get_vocab.return_value = _gemma4_vocab() tok.decode.side_effect = lambda ids: decode_map.get(ids[0], f"tok{ids[0]}") return tok def _collect_events(engine, deltas): from vllm.parser.engine.events import SemanticEvent all_events: list[SemanticEvent] = [] for delta_text, delta_token_ids in deltas: all_events.extend(engine.feed(delta_text, delta_token_ids)) all_events.extend(engine.finish()) return all_events def _reasoning_text(events) -> str: return "".join(e.value for e in events if e.type == EventType.REASONING_CHUNK) def _content_text(events) -> str: return "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) def _arg_text(events) -> str: return "".join(e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK) def _has_event(events, event_type) -> bool: return any(e.type == event_type for e in events) class TestMultiTokenBoundaryPreservation: """No text lost at state boundaries with multi-token deltas.""" def test_empty_delta_text_at_channel_end_unified(self): """Empty delta_text when CHANNEL_END arrives; text comes later.""" tok = _make_gemma4_tokenizer() engine = StreamingParserEngine(gemma4_config(), tok) events = _collect_events( engine, [ ("", [_CHANNEL_START_TID]), ("<|channel>thought\nSome reasoning.", [_TOK[0], _TOK[1]]), ("", [_CHANNEL_END_TID]), ("Final answer.", [_TOK[2], _TOK[3]]), ], ) reasoning = _reasoning_text(events) content = _content_text(events) assert "Some reasoning." in reasoning assert "Final answer." in content assert _has_event(events, EventType.REASONING_START) assert _has_event(events, EventType.REASONING_END) def test_deferred_channel_end_flushed_at_finish_unified(self): """Deferred CHANNEL_END flushed at end-of-stream.""" tok = _make_gemma4_tokenizer() engine = StreamingParserEngine(gemma4_config(), tok) events = _collect_events( engine, [ (_CHANNEL_START_TAG, [_CHANNEL_START_TID]), ("thought\nReasoning text.", [_TOK[0]]), (" Final thought.", [_CHANNEL_END_TID]), ], ) reasoning = _reasoning_text(events) assert "Reasoning text. Final thought." in reasoning assert _has_event(events, EventType.REASONING_END) def test_reasoning_to_tool_call_handoff_unified(self): """Full reasoning -> content -> tool call flow.""" tok = _make_gemma4_tokenizer() engine = StreamingParserEngine(gemma4_config(), tok) events = _collect_events( engine, [ (_CHANNEL_START_TAG, [_CHANNEL_START_TID]), ("thought\nI need to check the weather.", [_TOK[0], _TOK[1], _TOK[2]]), (_CHANNEL_END_TAG, [_CHANNEL_END_TID]), ("Let me call a tool.", [_TOK[3], _TOK[4]]), (_TOOL_START_TAG, [_TOOL_START_TID]), ("call:get_weather{city:", [_TOK[5], _TOK[6]]), ('<|"|>SF<|"|>}', [_QUOTE_TID, _TOK[7], _QUOTE_TID, _TOK[8]]), (_TOOL_END_TAG, [_TOOL_END_TID]), ], ) reasoning = _reasoning_text(events) content = _content_text(events) assert "I need to check the weather." in reasoning assert "Let me call a tool." in content assert _has_event(events, EventType.REASONING_START) assert _has_event(events, EventType.REASONING_END) assert _has_event(events, EventType.TOOL_CALL_START) assert _has_event(events, EventType.TOOL_CALL_END) assert "SF" in _arg_text(events) def test_multiple_tool_calls_rapid_transitions_unified(self): """Two back-to-back tool calls with correct tool_index tracking.""" tok = _make_gemma4_tokenizer() engine = StreamingParserEngine(gemma4_config(), tok) events = _collect_events( engine, [ (_TOOL_START_TAG, [_TOOL_START_TID]), ("call:get_weather{city:", [_TOK[0], _TOK[1]]), ('<|"|>NYC<|"|>}', [_QUOTE_TID, _TOK[2], _QUOTE_TID, _TOK[3]]), (_TOOL_END_TAG, [_TOOL_END_TID]), (_TOOL_START_TAG, [_TOOL_START_TID]), ("call:get_time{tz:", [_TOK[4], _TOK[5]]), ('<|"|>EST<|"|>}', [_QUOTE_TID, _TOK[6], _QUOTE_TID, _TOK[7]]), (_TOOL_END_TAG, [_TOOL_END_TID]), ], ) 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 names = "".join(e.value for e in events if e.type == EventType.TOOL_NAME) assert "get_weather" in names assert "get_time" in names def test_deferred_channel_end_before_tool_call_unified(self): """Deferred CHANNEL_END followed by a tool call.""" tok = _make_gemma4_tokenizer() engine = StreamingParserEngine(gemma4_config(), tok) events = _collect_events( engine, [ (_CHANNEL_START_TAG, [_CHANNEL_START_TID]), ("thought\nNeed to call a tool.", [_TOK[0], _TOK[1]]), (" Let me proceed.", [_CHANNEL_END_TID]), (_CHANNEL_END_TAG, [_TOK[2]]), (_TOOL_START_TAG, [_TOOL_START_TID]), ("call:get_weather{city:", [_TOK[3], _TOK[4]]), ('<|"|>Tokyo<|"|>}', [_QUOTE_TID, _TOK[5], _QUOTE_TID, _TOK[6]]), (_TOOL_END_TAG, [_TOOL_END_TID]), ], ) reasoning = _reasoning_text(events) assert "Need to call a tool. Let me proceed." in reasoning assert _has_event(events, EventType.REASONING_END) assert _has_event(events, EventType.TOOL_CALL_START) assert _has_event(events, EventType.TOOL_CALL_END) assert "Tokyo" in _arg_text(events) class TestStreamInterval10: """Tests with stream_interval=10 (large multi-token batches).""" def test_channel_end_mid_batch_text_present(self): """ mid-batch with its text present in delta_text.""" tok = _make_gemma4_tokenizer({_TOK[i]: f"word{i} " for i in range(15)}) engine = StreamingParserEngine(gemma4_config(), tok) events: list = [] events.extend( engine.feed( "<|channel>thought\nword0 word1 word2 word3 word4 " "word5 word6 word7 word8 ", [ _CHANNEL_START_TID, _TOK[0], _TOK[1], _TOK[2], _TOK[3], _TOK[4], _TOK[5], _TOK[6], _TOK[7], _TOK[8], ], ) ) events.extend( engine.feed( "word9 word10 word11 word12 word13 word14 word0 word1 word2 ", [ _TOK[9], _TOK[10], _TOK[11], _CHANNEL_END_TID, _TOK[12], _TOK[13], _TOK[14], _TOK[0], _TOK[1], _TOK[2], ], ) ) events.extend(engine.finish()) reasoning = _reasoning_text(events) content = _content_text(events) for w in ("word9", "word10", "word11"): assert w in reasoning, f"{w!r} missing from reasoning" for w in ("word12", "word13", "word14"): assert w in content, f"{w!r} missing from content" assert _has_event(events, EventType.REASONING_END) def test_channel_end_and_tool_start_same_batch_unified(self): """Both and <|tool_call> in a single batch.""" tok = _make_gemma4_tokenizer({_TOK[i]: f"w{i} " for i in range(15)}) engine = StreamingParserEngine(gemma4_config(), tok) events: list = [] events.extend( engine.feed( "<|channel>thought\nw0 w1 w2 w3 w4 w5 w6 w7 w8 ", [ _CHANNEL_START_TID, _TOK[0], _TOK[1], _TOK[2], _TOK[3], _TOK[4], _TOK[5], _TOK[6], _TOK[7], _TOK[8], ], ) ) events.extend( engine.feed( "w9 w10 w11 <|tool_call>", [ _TOK[9], _TOK[10], _CHANNEL_END_TID, _TOK[11], _TOOL_START_TID, _TOK[12], _TOK[13], _TOK[14], _TOK[0], _TOK[1], ], ) ) events.extend(engine.finish()) reasoning = _reasoning_text(events) assert "w9" in reasoning assert "w10" in reasoning assert _has_event(events, EventType.REASONING_END) assert _has_event(events, EventType.TOOL_CALL_START) def test_channel_end_mid_batch_text_absent(self): """ mid-batch with its text absent from delta_text.""" tok = _make_gemma4_tokenizer({_TOK[i]: f"word{i} " for i in range(15)}) engine = StreamingParserEngine(gemma4_config(), tok) events: list = [] events.extend( engine.feed( "<|channel>thought\nword0 word1 word2 word3 word4 " "word5 word6 word7 word8 ", [ _CHANNEL_START_TID, _TOK[0], _TOK[1], _TOK[2], _TOK[3], _TOK[4], _TOK[5], _TOK[6], _TOK[7], _TOK[8], ], ) ) events.extend( engine.feed( "word9 word10 word11 ", [ _TOK[9], _TOK[10], _TOK[11], _CHANNEL_END_TID, _TOK[12], _TOK[13], _TOK[14], _TOK[0], _TOK[1], _TOK[2], ], ) ) events.extend( engine.feed( "word12 word13 word14 word0 word1 word2 ", [_TOK[3], _TOK[4], _TOK[5]], ) ) events.extend(engine.finish()) reasoning = _reasoning_text(events) content = _content_text(events) for w in ("word9", "word10", "word11"): assert w in reasoning, f"{w!r} missing from reasoning" for w in ("word12", "word13", "word14"): assert w in content, f"{w!r} missing from content" assert _has_event(events, EventType.REASONING_END) def test_tool_end_mid_batch_text_absent_unified(self): """ mid-batch with text absent.""" tok = _make_gemma4_tokenizer({_TOK[i]: f"w{i}" for i in range(15)}) engine = StreamingParserEngine(gemma4_config(), tok) events: list = [] events.extend( engine.feed( _CHANNEL_START_TAG, [_CHANNEL_START_TID], ) ) events.extend( engine.feed( "thought\nNeed a tool.", [_TOK[0], _TOK[1]], ) ) events.extend( engine.feed( _TOOL_START_TAG, [_TOOL_START_TID], ) ) events.extend( engine.feed( "call:get_weather{city:", [_TOK[2], _TOK[3], _TOK[4]], ) ) events.extend( engine.feed( '<|"|>San Francisco<|"|>}', [ _QUOTE_TID, _TOK[5], _TOK[6], _QUOTE_TID, _TOK[7], _TOOL_END_TID, _TOK[8], _TOK[9], _TOK[10], _TOK[11], ], ) ) events.extend( engine.feed( "w8w9w10w11w12", [_TOK[12], _TOK[13]], ) ) events.extend(engine.finish()) assert _has_event(events, EventType.TOOL_CALL_END) assert "San Francisco" in _arg_text(events) def test_large_batch_holdback_spans_two_batches(self): """Holdback text spanning two batches with in the second.""" tok = _make_gemma4_tokenizer({_TOK[i]: f"w{i} " for i in range(15)}) engine = StreamingParserEngine(gemma4_config(), tok) events: list = [] events.extend( engine.feed( "<|channel>thought\nThe user asked about machine learning " "and I need to think about the best approach to", [ _CHANNEL_START_TID, _TOK[0], _TOK[1], _TOK[2], _TOK[3], _TOK[4], _TOK[5], _TOK[6], _TOK[7], _TOK[8], ], ) ) events.extend( engine.feed( " explain this complex topic. Let me organize my thoughts.", [ _TOK[9], _TOK[10], _TOK[11], _TOK[12], _TOK[13], _TOK[14], _CHANNEL_END_TID, _TOK[0], _TOK[1], _TOK[2], ], ) ) events.extend( engine.feed( "w0 w1 w2 Here is what I recommend: start with " "the fundamentals and build up from there.", [ _TOK[3], _TOK[4], _TOK[5], _TOK[6], _TOK[7], _TOK[8], _TOK[9], _TOK[10], _TOK[11], _TOK[12], ], ) ) events.extend(engine.finish()) reasoning = _reasoning_text(events) content = _content_text(events) assert "organize my thoughts." in reasoning assert "explain" in reasoning assert "recommend" in content assert _has_event(events, EventType.REASONING_START) assert _has_event(events, EventType.REASONING_END) class TestRebuildFromAnchorsLiteralLookalike: """Literal token text in prose must not be consumed as an anchor.""" @pytest.fixture def tool_scanner(self): tok = MagicMock() tok.get_vocab.return_value = { TOOL_START: TOOL_START_ID, TOOL_END: TOOL_END_ID, } tok.decode.side_effect = lambda ids: { TOOL_START_ID: TOOL_START, TOOL_END_ID: TOOL_END, }.get(ids[0], f"t{ids[0]}") return TokenIDScanner( {TOOL_START_ID: "TOOL_START", TOOL_END_ID: "TOOL_END"}, tok, ) def test_literal_before_real_anchor(self, tool_scanner): delta_text = 'Use like this: {"name":"f"}' delta_token_ids = [1, 2, 3, 4, 5, TOOL_START_ID, 6, 7, TOOL_END_ID] items = tool_scanner.scan(delta_text, delta_token_ids) text_parts = [it.text for it in items if isinstance(it, TextChunk)] terminals = [it for it in items if isinstance(it, PreLexedTerminal)] assert len(terminals) == 2 assert terminals[0].terminal == "TOOL_START" assert terminals[1].terminal == "TOOL_END" joined_text = "".join(text_parts) assert "" in joined_text assert '{"name":"f"}' in joined_text def test_multiple_tool_calls_with_literal_between(self, tool_scanner): delta_text = ( '{"name":"a"}' " see syntax " '{"name":"b"}' ) delta_token_ids = [ TOOL_START_ID, 1, TOOL_END_ID, 2, 3, 4, TOOL_START_ID, 5, TOOL_END_ID, ] items = tool_scanner.scan(delta_text, delta_token_ids) terminals = [it for it in items if isinstance(it, PreLexedTerminal)] assert len(terminals) == 4 text_parts = [it.text for it in items if isinstance(it, TextChunk)] joined_text = "".join(text_parts) assert " syntax" in joined_text class TestRebuildFromAnchorsCascadingDeferral: """Missing middle anchor defers only itself, not subsequent ones.""" @pytest.fixture def bare_scanner(self): tok = MagicMock() tok.decode.side_effect = lambda ids: f"t{ids[0]}" return TokenIDScanner({}, tok) def test_middle_anchor_missing_does_not_cascade(self, bare_scanner): a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START) b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END) c = PreLexedTerminal("TOOL_END", TOOL_END_ID, TOOL_END) delta_text = f"prefix{TOOL_START}middle{TOOL_END}suffix" results = [a, b, c] rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results) terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)] texts = [r for r in rebuilt if isinstance(r, TextChunk)] joined = "".join(t.text for t in texts) assert len(terminals) == 2 assert terminals[0].terminal == "TOOL_START" assert terminals[1].terminal == "TOOL_END" assert "prefix" in joined assert "middle" in joined assert "suffix" in joined assert len(bare_scanner._deferred_terminals) == 1 assert bare_scanner._deferred_terminals[0].terminal == "THINK_END" assert bare_scanner._deferred_post_text == "" def test_first_anchor_missing_rest_still_emitted(self, bare_scanner): a = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END) b = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START) delta_text = f"text{TOOL_START}more" results = [a, b] rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results) terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)] assert len(terminals) == 1 assert terminals[0].terminal == "TOOL_START" assert len(bare_scanner._deferred_terminals) == 1 assert bare_scanner._deferred_terminals[0].terminal == "THINK_END" def test_last_anchor_missing_preceding_still_emitted(self, bare_scanner): a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START) b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END) delta_text = f"text{TOOL_START}more" results = [a, b] rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results) terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)] assert len(terminals) == 1 assert terminals[0].terminal == "TOOL_START" texts = [r for r in rebuilt if isinstance(r, TextChunk)] joined = "".join(t.text for t in texts) assert "text" in joined assert bare_scanner._deferred_post_text == "more" assert len(bare_scanner._deferred_terminals) == 1 assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"