diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index bccd2b9df..698b9b82f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 397b7454e..0ed545d0d 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -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() @@ -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 @@ -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: @@ -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 @@ -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. @@ -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(). diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2c4aedbe9..8d7569fa9 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -395,7 +395,8 @@ async def test_emit_exchange_error_event_sends_when_connected(self) -> None: 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: @@ -571,6 +572,153 @@ def test_get_chat_bridge_defaults_end_exchange_true( 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"): + 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.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index e6ae1076e..c463c717e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.44" +version = "2.14.45" source = { editable = "." } dependencies = [ { name = "applicationinsights" },