diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ffaa7f881..f5dafedb9 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.10.81" +version = "2.10.82" 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" dependencies = [ "uipath-core>=0.5.17, <0.6.0", - "uipath-runtime>=0.11.0, <0.12.0", + "uipath-runtime>=0.12.0, <0.13.0", "uipath-platform>=0.1.63, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 96566e898..2b5fa5a2f 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -106,6 +106,7 @@ def __init__( exchange_id: str, headers: dict[str, str], auth: dict[str, Any] | None = None, + end_exchange: bool = True, ): """Initialize the WebSocket chat bridge. @@ -115,6 +116,8 @@ def __init__( exchange_id: The exchange ID for this session headers: HTTP headers to send during connection auth: Optional authentication data to send during connection + end_exchange: Whether to send the exchange-end event to CAS on + completion. """ self.websocket_url = websocket_url self.websocket_path = websocket_path @@ -122,6 +125,7 @@ def __init__( self.exchange_id = exchange_id self.auth = auth self.headers = headers + self.end_exchange = end_exchange self._client: Any | None = None self._connected_event = asyncio.Event() @@ -283,9 +287,18 @@ async def emit_message_event( async def emit_exchange_end_event(self) -> None: """Send an exchange end event. + 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. + Raises: RuntimeError: If client is not connected """ + if not self.end_exchange: + logger.info( + "end_exchange is False; leaving the exchange open." + ) + return + if self._client is None: raise RuntimeError("WebSocket client not connected. Call connect() first.") @@ -531,6 +544,7 @@ def get_chat_bridge( conversation_id=context.conversation_id, exchange_id=context.exchange_id, headers=headers, + end_exchange=context.end_exchange, ) diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index bbd385def..12db61375 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -22,11 +22,13 @@ def __init__( exchange_id: str = "test-exchange-id", tenant_id: str = "test-tenant-id", org_id: str = "test-org-id", + end_exchange: bool = True, ): self.conversation_id = conversation_id self.exchange_id = exchange_id self.tenant_id = tenant_id self.org_id = org_id + self.end_exchange = end_exchange class TestSocketIOChatBridgeDebugMode: @@ -310,6 +312,89 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() +class TestSocketIOChatBridgeEndExchange: + """The bridge owns whether to honor the exchange-end event (CAS-specific).""" + + def _make_connected_bridge(self, end_exchange: bool) -> 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 + + def test_end_exchange_defaults_true(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + assert bridge.end_exchange is True + + @pytest.mark.anyio + async def test_emit_exchange_end_sends_when_end_exchange_true(self) -> None: + bridge = self._make_connected_bridge(end_exchange=True) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_awaited_once() + assert ( + cast(AsyncMock, bridge._client).emit.await_args.args[0] + == "ConversationEvent" + ) + + @pytest.mark.anyio + async def test_emit_exchange_end_suppressed_when_end_exchange_false(self) -> None: + bridge = self._make_connected_bridge(end_exchange=False) + + await bridge.emit_exchange_end_event() + + cast(AsyncMock, bridge._client).emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_exchange_end_false_does_not_require_client(self) -> None: + """With the exchange kept open, suppression happens before the connection check.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + end_exchange=False, + ) + + # Should not raise even though _client is None. + await bridge.emit_exchange_end_event() + + def test_get_chat_bridge_propagates_end_exchange_false( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext(end_exchange=False) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is False + + def test_get_chat_bridge_defaults_end_exchange_true( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + context = MockRuntimeContext() + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.end_exchange is True + + class TestSignalRDebugBridgeSendMethod: """Tests for SignalRDebugBridge."""