diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py index 75390f2a2..d1ff1a37f 100644 --- a/lib/cli/src/crewai_cli/crew_run_tui.py +++ b/lib/cli/src/crewai_cli/crew_run_tui.py @@ -86,13 +86,19 @@ def _try_parse_structured(text: str) -> Any | None: """Try JSON first, then Python repr (single-quoted dicts/lists).""" try: return _json.loads(text) - except (ValueError, TypeError): + except (ValueError, TypeError, RecursionError): pass try: import ast obj = ast.literal_eval(text) + # literal_eval accepts values json.dumps cannot encode (e.g. [Ellipsis] + # from "[...]"), which would crash the renderer later; reject those. + # Validate with the exact kwargs the render path uses so a structure + # the C decoder accepts but the indent encoder cannot walk is also + # rejected here instead of raising inside _tick. if isinstance(obj, (dict, list)): + _json.dumps(obj, indent=2, ensure_ascii=False) return obj except Exception: # noqa: S110 pass diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py index 6cca73eee..23c16dc6e 100644 --- a/lib/cli/tests/test_crew_run_tui.py +++ b/lib/cli/tests/test_crew_run_tui.py @@ -41,6 +41,8 @@ from crewai_cli.crew_run_tui import ( _LOG_ARGS_TEXT_LIMIT, _LOG_RESULT_TEXT_LIMIT, _LOG_TRUNCATION_SUFFIX, + _format_json_in_text, + _try_parse_structured, ) @@ -1804,3 +1806,39 @@ def test_finished_traces_button_still_records(monkeypatch) -> None: app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces-done"))) app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces") + + +def test_try_parse_structured_rejects_non_serializable_literals() -> None: + """ast.literal_eval("[...]") is a valid [Ellipsis] list but cannot be JSON-encoded.""" + assert _try_parse_structured("[...]") is None + assert _try_parse_structured("{'a': ...}") is None + assert _try_parse_structured("[1+2j]") is None + + +def test_try_parse_structured_still_accepts_serializable_values() -> None: + assert _try_parse_structured('{"a": 1}') == {"a": 1} + assert _try_parse_structured("['x', 'y']") == ["x", "y"] + assert _try_parse_structured("{'a': 1}") == {"a": 1} + + +def test_format_json_in_text_survives_literal_ellipsis() -> None: + """A streamed [...] must render as-is instead of crashing the TUI (issue #7434).""" + assert _format_json_in_text("pandas,[...]") == "pandas,[...]" + + +def test_try_parse_structured_rejects_deeply_nested_input() -> None: + """A nesting depth no JSON backend can walk must parse as nothing, not recurse.""" + deep = "[" * 100_000 + "]" * 100_000 + assert _try_parse_structured(deep) is None + + +def test_format_json_in_text_survives_deep_nesting() -> None: + """The render path must contain the failure instead of losing the TUI update.""" + deep = "data: " + "[" * 4000 + "]" * 4000 + assert isinstance(_format_json_in_text(deep), str) + + +def test_format_json_in_text_still_pretty_prints_valid_json() -> None: + assert _format_json_in_text('data: {"a": 1} and [...]') == ( + 'data: ' + '{\n "a": 1\n}' + ' and [...]' + )