diff --git a/strix/interface/streaming_parser.py b/strix/interface/streaming_parser.py index 2ea69fa3..da4e963e 100644 --- a/strix/interface/streaming_parser.py +++ b/strix/interface/streaming_parser.py @@ -25,7 +25,15 @@ def _get_safe_content(content: str) -> tuple[str, str]: suffix = content[last_lt:] - if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix): + if ">" in suffix: + return content, "" + + if ( + _FUNCTION_TAG_PREFIX.startswith(suffix) + or _INVOKE_TAG_PREFIX.startswith(suffix) + or suffix.startswith(_FUNCTION_TAG_PREFIX) + or suffix.startswith(_INVOKE_TAG_PREFIX) + ): return content[:last_lt], suffix return content, "" diff --git a/tests/interface/test_streaming_parser.py b/tests/interface/test_streaming_parser.py new file mode 100644 index 00000000..e364c663 --- /dev/null +++ b/tests/interface/test_streaming_parser.py @@ -0,0 +1,168 @@ +"""Tests for streaming_parser._get_safe_content. + +In particular, tests for the edge case where the LLM has streamed enough characters +that the suffix goes *past* the tag prefix (e.g. '' yet. Before the fix, _get_safe_content would treat such a +suffix as safe text and expose the partial tag in the TUI. +""" + +import importlib.util +from pathlib import Path + + +def _load_streaming_parser(): + module_path = ( + Path(__file__).resolve().parents[2] + / "strix" + / "interface" + / "streaming_parser.py" + ) + spec = importlib.util.spec_from_file_location("streaming_parser_test", module_path) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load streaming_parser for tests") + module = importlib.util.module_from_spec(spec) + + # streaming_parser imports normalize_tool_format; provide a minimal stub so + # the module can be loaded without the full strix package installed. + import types, sys + + fake_llm_utils = types.ModuleType("strix.llm.utils") + fake_llm_utils.normalize_tool_format = lambda s: s # type: ignore[attr-defined] + sys.modules.setdefault("strix", types.ModuleType("strix")) + sys.modules.setdefault("strix.llm", types.ModuleType("strix.llm")) + sys.modules["strix.llm.utils"] = fake_llm_utils + + spec.loader.exec_module(module) + return module + + +_mod = _load_streaming_parser() +_get_safe_content = _mod._get_safe_content + + +class TestGetSafeContentEmptyAndNoTag: + def test_empty_string(self): + assert _get_safe_content("") == ("", "") + + def test_no_angle_bracket(self): + assert _get_safe_content("hello world") == ("hello world", "") + + def test_non_function_tag(self): + # A regular XML tag that is NOT a function/invoke tag must pass through. + assert _get_safe_content("text'. + + This is the regression case: previously _FUNCTION_TAG_PREFIX.startswith(suffix) + returned False for these (the suffix is longer than the prefix), so the partial + tag leaked into the 'safe' portion of the content. + """ + + def test_partial_function_name(self): + """'') must still be held as pending.""" + safe, pending = _get_safe_content("text') must be held as pending.""" + safe, pending = _get_safe_content("text') must be held.""" + safe, pending = _get_safe_content("text') is present, it is safe text — not pending.""" + + def test_complete_function_tag(self): + """A complete '' must NOT be held as pending.""" + content = "text" + safe, pending = _get_safe_content(content) + assert safe == content + assert pending == "" + + def test_complete_invoke_tag(self): + """A complete '' must NOT be held as pending.""" + content = "text" + safe, pending = _get_safe_content(content) + assert safe == content + assert pending == "" + + +class TestGetSafeContentMultipleTags: + """Content that already contains a complete function tag followed by a partial one.""" + + def test_complete_then_partial(self): + content = "pre more text more text" + assert pending == " text" + assert pending == "