From 2b5bc8a077dfdaebb07295c6452bdf13b5381711 Mon Sep 17 00:00:00 2001 From: Vlad GEORGESCU Date: Wed, 30 Sep 2026 19:23:51 +0300 Subject: [PATCH 1/2] fix(cli): print a run's output whole in the console bridge The console bridge cut every output value at 250 characters and added "...", so `uipath run` could not show a long output field in full. The final output (and the output shown when a run suspends for API input) is the result itself, so it now prints untruncated and at any depth. Intermediate state updates are still shortened. Values and keys are also escaped before they go into rich markup, so text like "[bold]" prints as-is instead of being styled away, and a stray "[/x]" no longer breaks the tree and drops to the JSON fallback. Co-Authored-By: Claude Opus 5.5 --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_debug/_bridge.py | 49 +++++--- .../tests/cli/test_console_debug_bridge.py | 115 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 packages/uipath/tests/cli/test_console_debug_bridge.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 5acc57d48..141da3925 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.27" +version = "2.14.28" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 0094bc012..9451441bf 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from rich.console import Console +from rich.markup import escape from rich.tree import Tree from uipath.core.serialization import serialize_object @@ -166,7 +167,7 @@ async def emit_execution_completed( output_data = runtime_result.output.model_dump() else: output_data = runtime_result.output - self._print_json(output_data, label="output") + self._print_json(output_data, label="output", truncate=False) async def emit_execution_suspended( self, @@ -179,11 +180,13 @@ async def emit_execution_suspended( if runtime_result.trigger.trigger_type == UiPathResumeTriggerType.API: if runtime_result.output is None: - self._print_json({}, label="output") + self._print_json({}, label="output", truncate=False) elif isinstance(runtime_result.output, BaseModel): - self._print_json(runtime_result.output.model_dump(), label="output") + self._print_json( + runtime_result.output.model_dump(), label="output", truncate=False + ) else: - self._print_json(runtime_result.output, label="output") + self._print_json(runtime_result.output, label="output", truncate=False) self.console.print("[dim]Please provide your input:[/dim]") self._waiting_for_resume_data = True @@ -381,8 +384,26 @@ def _print_help(self) -> None: self.console.print(" [yellow]q, quit[/yellow] Exit debugger") self.console.print() - def _print_json(self, data: dict[str, Any] | str, label: str = "data") -> None: - """Print JSON data with enhanced hierarchy.""" + def _print_json( + self, + data: dict[str, Any] | str, + label: str = "data", + truncate: bool = True, + ) -> None: + """Print JSON data with enhanced hierarchy. + + Args: + data: The payload to print. + label: The root label of the tree. + truncate: Shorten long values and deep nesting. Pass False for a + run's output, which is the result itself and must print whole. + """ + + def shorten(val_str: str) -> str: + if truncate and len(val_str) > 250: + return val_str[:250] + "..." + return val_str + try: # Create a tree for nested structure tree = Tree(f"[bold cyan]{label}[/bold cyan]") @@ -405,13 +426,11 @@ def process_value( ) add_to_tree(branch, value, depth + 1) else: - val_str = str(value) - if len(val_str) > 250: - val_str = val_str[:250] + "..." + val_str = escape(shorten(str(value))) node.add(f"{key_label}: [green]{val_str}[/green]") def add_to_tree(node: Tree, payload: Any, depth: int = 0): - if depth > 10: + if truncate and depth > 10: node.add("[dim]...[/dim]") return @@ -424,16 +443,16 @@ def add_to_tree(node: Tree, payload: Any, depth: int = 0): elif isinstance(payload, dict): for key, value in payload.items(): - process_value(node, value, f"[yellow]{key}[/yellow]", depth) + process_value( + node, value, f"[yellow]{escape(str(key))}[/yellow]", depth + ) elif isinstance(payload, list): for i, item in enumerate(payload): process_value(node, item, f"[cyan]#{i}[/cyan]", depth) else: - val_str = str(payload) - if len(val_str) > 250: - val_str = val_str[:250] + "..." + val_str = escape(shorten(str(payload))) node.add(f"[green]{val_str}[/green]") add_to_tree(tree, data) @@ -445,7 +464,7 @@ def add_to_tree(node: Tree, payload: Any, depth: int = 0): except Exception: try: json_str = json.dumps(data, indent=2, default=str) - if len(json_str) > 10000: + if truncate and len(json_str) > 10000: json_str = json_str[:10000] + "\n..." from rich.syntax import Syntax diff --git a/packages/uipath/tests/cli/test_console_debug_bridge.py b/packages/uipath/tests/cli/test_console_debug_bridge.py new file mode 100644 index 000000000..3e6fd5a91 --- /dev/null +++ b/packages/uipath/tests/cli/test_console_debug_bridge.py @@ -0,0 +1,115 @@ +"""Tests for how `ConsoleDebugBridge` renders payloads. + +A run's output is the result the user asked for, so it must print whole. +Intermediate state is a progress stream and may still be shortened. +""" + +from typing import Any + +from pydantic import BaseModel +from rich.console import Console + +from uipath._cli._debug._bridge import ConsoleDebugBridge +from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase + +# 256 characters, the length from the original report. The tail is distinct +# so a cut at 250 cannot pass by accident. +LONG = "x" * 240 + "0123456789ABCDEF" + + +def _bridge() -> ConsoleDebugBridge: + bridge = ConsoleDebugBridge() + # Wide enough that rich never wraps a value across lines. + bridge.console = Console(record=True, width=2000, color_system=None) + return bridge + + +async def _render_output(output: Any) -> str: + bridge = _bridge() + await bridge.emit_execution_completed( + UiPathRuntimeResult(output=output, status=UiPathRuntimeStatus.SUCCESSFUL) + ) + return bridge.console.export_text() + + +async def test_completed_output_prints_long_value_whole() -> None: + text = await _render_output({"dials": LONG}) + + assert LONG in text + assert "..." not in text + + +async def test_completed_output_prints_long_values_nested_and_in_lists() -> None: + text = await _render_output( + {"outer": {"inner": LONG}, "items": [LONG, {"deep": LONG}]} + ) + + assert text.count(LONG) == 3 + + +async def test_completed_output_prints_long_string_output_whole() -> None: + text = await _render_output(LONG) + + assert LONG in text + + +async def test_completed_output_prints_pydantic_model_whole() -> None: + class Output(BaseModel): + dials: str + + text = await _render_output(Output(dials=LONG)) + + assert LONG in text + + +async def test_completed_output_keeps_nesting_deeper_than_ten() -> None: + payload: dict[str, Any] = {"leaf": "bottom-value"} + for i in range(15): + payload = {f"level{i}": payload} + + text = await _render_output(payload) + + assert "bottom-value" in text + + +async def test_completed_output_prints_markup_like_text_literally() -> None: + text = await _render_output( + {"[b]key[/b]": "see [bold]docs[/bold]", "note": "close [/x] tag"} + ) + + assert "see [bold]docs[/bold]" in text + assert "close [/x] tag" in text + assert "[b]key[/b]" in text + # A render error would switch to the JSON fallback, which prints the label + # as "output:" instead of a tree root. + assert "output:" not in text + + +async def test_suspended_api_output_prints_long_value_whole() -> None: + bridge = _bridge() + await bridge.emit_execution_suspended( + UiPathRuntimeResult( + output={"question": LONG}, + status=UiPathRuntimeStatus.SUSPENDED, + trigger=UiPathResumeTrigger(trigger_type=UiPathResumeTriggerType.API), + ) + ) + + assert LONG in bridge.console.export_text() + + +async def test_state_update_still_shortens_long_values() -> None: + bridge = _bridge() + await bridge.emit_state_update( + UiPathRuntimeStateEvent( + node_name="node", + phase=UiPathRuntimeStatePhase.UPDATED, + payload={"state": LONG}, + ) + ) + text = bridge.console.export_text() + + assert LONG not in text + assert LONG[:250] + "..." in text diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..fe23bda33 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.27" +version = "2.14.28" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 62359c2b1d7be9c6442ac5c1a5a43a69bcb23ddd Mon Sep 17 00:00:00 2001 From: Vlad GEORGESCU Date: Thu, 1 Oct 2026 11:41:16 +0300 Subject: [PATCH 2/2] fix(cli): wrap the JSON fallback instead of cropping a run's output When the tree render fails, the bridge prints the payload with rich's Syntax, which crops lines wider than the console. Wrap them for a run's output so that path does not lose any of it either. Also covers the suspended-output branches and the fallback with tests. Co-Authored-By: Claude Opus 5.5 --- .../uipath/src/uipath/_cli/_debug/_bridge.py | 9 ++- .../tests/cli/test_console_debug_bridge.py | 60 ++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 9451441bf..98d8e28b3 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -468,7 +468,14 @@ def add_to_tree(node: Tree, payload: Any, depth: int = 0): json_str = json_str[:10000] + "\n..." from rich.syntax import Syntax - syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False) + # Syntax crops lines wider than the console unless it wraps. + syntax = Syntax( + json_str, + "json", + theme="monokai", + line_numbers=False, + word_wrap=not truncate, + ) self.console.print(f"\n[dim]{label}:") self.console.print(syntax) self.console.print() diff --git a/packages/uipath/tests/cli/test_console_debug_bridge.py b/packages/uipath/tests/cli/test_console_debug_bridge.py index 3e6fd5a91..fc132d01a 100644 --- a/packages/uipath/tests/cli/test_console_debug_bridge.py +++ b/packages/uipath/tests/cli/test_console_debug_bridge.py @@ -6,6 +6,7 @@ from typing import Any +import pytest from pydantic import BaseModel from rich.console import Console @@ -87,17 +88,70 @@ async def test_completed_output_prints_markup_like_text_literally() -> None: assert "output:" not in text -async def test_suspended_api_output_prints_long_value_whole() -> None: +async def _render_suspended(output: Any) -> str: bridge = _bridge() await bridge.emit_execution_suspended( UiPathRuntimeResult( - output={"question": LONG}, + output=output, status=UiPathRuntimeStatus.SUSPENDED, trigger=UiPathResumeTrigger(trigger_type=UiPathResumeTriggerType.API), ) ) + return bridge.console.export_text() + + +async def test_suspended_api_output_prints_long_value_whole() -> None: + assert LONG in await _render_suspended({"question": LONG}) + + +async def test_suspended_api_output_prints_pydantic_model_whole() -> None: + class Question(BaseModel): + question: str + + assert LONG in await _render_suspended(Question(question=LONG)) + + +async def test_suspended_api_output_without_payload_prints_empty_output() -> None: + text = await _render_suspended(None) + + assert "output" in text + assert "Please provide your input" in text + + +class _BrokenTree: + """Stands in for rich's Tree so the JSON fallback path runs.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("tree rendering failed") + + +async def test_json_fallback_prints_completed_output_whole( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("uipath._cli._debug._bridge.Tree", _BrokenTree) + huge = "y" * 20000 + + text = await _render_output({"blob": huge}) + + # The fallback wraps long lines, so compare with the line breaks removed. + assert huge in "".join(text.split()) + + +async def test_json_fallback_still_shortens_state_updates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("uipath._cli._debug._bridge.Tree", _BrokenTree) + huge = "y" * 20000 + bridge = _bridge() + await bridge.emit_state_update( + UiPathRuntimeStateEvent( + node_name="node", + phase=UiPathRuntimeStatePhase.UPDATED, + payload={"blob": huge}, + ) + ) - assert LONG in bridge.console.export_text() + assert huge not in "".join(bridge.console.export_text().split()) async def test_state_update_still_shortens_long_values() -> None: