From a3c2c861a3b1ad33cd92ec3eaee9f7ea9e06590f Mon Sep 17 00:00:00 2001 From: al3xanndru Date: Fri, 30 Oct 2026 01:54:10 +0000 Subject: [PATCH] PC-4990: end the CAS exchange after an exchange error A faulted conversational run (e.g. max iterations) sent exchangeError but never endExchange. The CAS chat UI leaves its generating state (Stop button, "Generating...") only on endExchange, so the user could not send the message the max-steps error tells them to send. emit_exchange_error_event now ends the exchange after the error, honouring end_exchange=False; emit_exchange_end_event is sent at most once. Co-Authored-By: Claude Opus 5.5 --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 14 +- packages/uipath/tests/cli/chat/test_bridge.py | 150 +++++++++++++++++- packages/uipath/uv.lock | 2 +- 4 files changed, 164 insertions(+), 4 deletions(-) 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" },