Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath"
version = "2.14.44"
version = "2.14.45"
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"
Expand Down
14 changes: 13 additions & 1 deletion packages/uipath/src/uipath/_cli/_chat/_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def __init__(
self.auth = auth
self.headers = headers
self.end_exchange = end_exchange
self._exchange_ended = False
self._client: Any | None = None
self._connected_event = asyncio.Event()

Expand Down Expand Up @@ -352,6 +353,7 @@ async def emit_exchange_end_event(self) -> None:

When end_exchange is False the exchange is left open — the event is not
sent to CAS so a downstream consumer can continue and end it later.
The event is sent at most once per exchange.

Raises:
RuntimeError: If client is not connected
Expand All @@ -360,6 +362,9 @@ async def emit_exchange_end_event(self) -> None:
logger.info("end_exchange is False; leaving the exchange open.")
return

if self._exchange_ended:
return

client = self._require_client()

try:
Expand All @@ -382,6 +387,8 @@ async def emit_exchange_end_event(self) -> None:
else:
await client.emit("ConversationEvent", event_data)

self._exchange_ended = True

except Exception as e:
logger.error(f"Error sending conversation event to WebSocket: {e}")
raise RuntimeError(f"Failed to send conversation event: {e}") from e
Expand All @@ -391,7 +398,10 @@ async def emit_exchange_error_event(self, error: Exception) -> None:

Extracts error information from the exception and maps it to
CAS-specific error IDs and messages matching the Temporal backend
for frontend consistency.
for frontend consistency, then ends the exchange: an error is terminal
for the exchange, and the chat UI only leaves its generating state
(Stop button, "Generating...") on the exchange-end event. Without it,
the user cannot send the next message the error tells them to send.

Args:
error: The exception that caused the error.
Expand Down Expand Up @@ -430,6 +440,8 @@ async def emit_exchange_error_event(self, error: Exception) -> None:
logger.error(f"Error sending exchange error event to WebSocket: {e}")
raise RuntimeError(f"Failed to send exchange error event: {e}") from e

await self.emit_exchange_end_event()

async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger):
"""Register the trigger's tool_call_id for the upcoming wait_for_resume().

Expand Down
150 changes: 149 additions & 1 deletion packages/uipath/tests/cli/chat/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@

await bridge.emit_exchange_error_event(ValueError("failed"))

bridge._client.emit.assert_awaited_once()
# The error event, then the exchange-end event.
assert bridge._client.emit.await_count == 2

@pytest.mark.anyio
async def test_emit_meta_event_sends_exchange_scoped_event(self) -> None:
Expand Down Expand Up @@ -571,6 +572,153 @@
assert bridge.end_exchange is True


class TestSocketIOChatBridgeErrorEndsExchange:
"""An exchange error ends the exchange, so the chat UI leaves its generating state.

The CAS chat UI only drops the Stop button and "Generating..." on endExchange;
an exchangeError alone shows the error banner but leaves the input unusable
(PC-4990: "Maximum number of sequential steps reached. You may send a new
message..." with no way to send one).
"""

def _make_connected_bridge(self, end_exchange: bool = True) -> SocketIOChatBridge:
bridge = SocketIOChatBridge(
websocket_url="wss://test.example.com",
websocket_path="/socket.io",
conversation_id="conv-123",
exchange_id="exch-456",
headers={},
end_exchange=end_exchange,
)
bridge._websocket_disabled = False
bridge._client = AsyncMock()
bridge._connected_event.set()
return bridge

@staticmethod
def _sent_events(bridge: SocketIOChatBridge) -> list[dict[str, Any]]:
emit = cast(AsyncMock, bridge._client).emit
return [call.args[1] for call in emit.await_args_list]

@pytest.mark.anyio
async def test_max_iterations_error_is_followed_by_exchange_end(self) -> None:
from uipath.runtime.errors import UiPathBaseRuntimeError

bridge = self._make_connected_bridge()
error = UiPathBaseRuntimeError(
code="TERMINATION_MAX_ITERATIONS",
title="Maximum iterations of '3' reached.",
detail="Verify the agent's trajectory.",
prefix="AGENT_RUNTIME",
)

await bridge.emit_exchange_error_event(error)

events = self._sent_events(bridge)
assert len(events) == 2
assert events[0] == {
"conversationId": "conv-123",
"exchange": {
"exchangeId": "exch-456",
"exchangeError": {
"errorId": "AGENT_MAXIMUM_SEQUENTIAL_STEPS_REACHED",
"startError": {
"message": "Maximum number of sequential steps reached. You may send a new message to tell the agent to continue."
},
},
},
}
assert events[1] == {
"conversationId": "conv-123",
"exchange": {"exchangeId": "exch-456", "endExchange": {}},
}

@pytest.mark.anyio
async def test_error_keeps_exchange_open_when_end_exchange_false(self) -> None:
bridge = self._make_connected_bridge(end_exchange=False)

await bridge.emit_exchange_error_event(ValueError("failed"))

events = self._sent_events(bridge)
assert len(events) == 1
assert "exchangeError" in events[0]["exchange"]

@pytest.mark.anyio
async def test_exchange_end_sent_once_when_error_follows_end(self) -> None:
"""A failure after the exchange already ended does not end it again."""
bridge = self._make_connected_bridge()

await bridge.emit_exchange_end_event()
await bridge.emit_exchange_error_event(ValueError("failed"))

events = self._sent_events(bridge)
assert [list(e["exchange"])[1] for e in events] == [
"endExchange",
"exchangeError",
]

@pytest.mark.anyio
async def test_exchange_not_ended_when_error_send_fails(self) -> None:
bridge = self._make_connected_bridge()
cast(AsyncMock, bridge._client).emit.side_effect = ValueError("socket failed")

with pytest.raises(RuntimeError, match="Failed to send exchange error event"):

Check warning on line 665 in packages/uipath/tests/cli/chat/test_bridge.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AaGL5BfsVu4C9hTy2wKh&open=AaGL5BfsVu4C9hTy2wKh&pullRequest=1964
await bridge.emit_exchange_error_event(ValueError("failed"))

assert cast(AsyncMock, bridge._client).emit.await_count == 1

@pytest.mark.anyio
async def test_failed_exchange_end_can_be_retried(self) -> None:
bridge = self._make_connected_bridge()
emit = cast(AsyncMock, bridge._client).emit
emit.side_effect = [ValueError("socket failed"), None, None]

with pytest.raises(RuntimeError):
await bridge.emit_exchange_end_event()
await bridge.emit_exchange_error_event(ValueError("failed"))

assert emit.await_count == 3
assert "endExchange" in emit.await_args_list[2].args[1]["exchange"]

@pytest.mark.anyio
async def test_faulted_chat_runtime_ends_the_exchange(self) -> None:
"""Through UiPathChatRuntime: a run faulting on max iterations ends the exchange."""
from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus
from uipath.runtime.chat import UiPathChatRuntime
from uipath.runtime.errors import UiPathErrorCategory, UiPathErrorContract

class FaultingDelegate:
async def stream(self, input: Any = None, options: Any = None):
yield UiPathRuntimeResult(
status=UiPathRuntimeStatus.FAULTED,
error=UiPathErrorContract(
code="AGENT_RUNTIME.TERMINATION_MAX_ITERATIONS",
title="Maximum iterations of '3' reached.",
detail="Verify the agent's trajectory.",
category=UiPathErrorCategory.USER,
),
)

bridge = self._make_connected_bridge()
bridge.connect = AsyncMock() # type: ignore[method-assign]
runtime = UiPathChatRuntime(
delegate=cast(Any, FaultingDelegate()), chat_bridge=bridge
)

result = await runtime.execute({})

assert result.status == UiPathRuntimeStatus.FAULTED
events = self._sent_events(bridge)
assert [list(e["exchange"])[1] for e in events] == [
"exchangeError",
"endExchange",
]
assert (
events[0]["exchange"]["exchangeError"]["errorId"]
== "AGENT_MAXIMUM_SEQUENTIAL_STEPS_REACHED"
)


class TestSignalRDebugBridgeSendMethod:
"""Tests for SignalRDebugBridge."""

Expand Down
2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading