Files
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

999 lines
32 KiB
Python

# 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|>"
CHANNEL_START_ID = 100
CHANNEL_END_ID = 101
REGULAR_TOKEN_ID = 200
TOOL_START = "<tool_call>"
TOOL_END = "</tool_call>"
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"<unk:{ids[0]}>")
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, "<tool_call>"),
TextChunk(" world"),
]
result = bare_scanner._join_decoded_text(items)
assert isinstance(result, str)
assert result == "hello <tool_call> 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="<channel|>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<channel|>",
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="<channel|>",
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<channel|> 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="<channel|> 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.<channel|>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(
"<channel|>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(
"<channel|>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 = "<channel|>"
_TOOL_START_TAG = "<|tool_call>"
_TOOL_END_TAG = "<tool_call|>"
_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]),
("<channel|>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):
"""<channel|> 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 <channel|>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 <channel|> 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 <channel|>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):
"""<channel|> 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(
"<channel|>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):
"""<tool_call|> 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(
"<tool_call|>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 <channel|> 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(
"<channel|>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 <tool_call> like this: <tool_call>{"name":"f"}</tool_call>'
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 "<tool_call>" in joined_text
assert '{"name":"f"}' in joined_text
def test_multiple_tool_calls_with_literal_between(self, tool_scanner):
delta_text = (
'<tool_call>{"name":"a"}</tool_call>'
" see <tool_call> syntax "
'<tool_call>{"name":"b"}</tool_call>'
)
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 "<tool_call> 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"