From 60671a86ad10146cee292b04ed03a63e429e5d88 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 4 Aug 2026 14:28:59 +0300 Subject: [PATCH] feat: emit conversation metadata events --- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 57 +++++--- .../testcases/langchain-cross/pyproject.toml | 7 +- packages/uipath/tests/cli/chat/test_bridge.py | 125 ++++++++++++++++++ packages/uipath/uv.lock | 12 +- 5 files changed, 178 insertions(+), 27 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b59c5e95b..9dd996bd7 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.13.21" +version = "2.13.22" 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.30, <0.6.0", - "uipath-runtime>=0.12.2, <0.13.0", + "uipath-runtime>=0.13.0, <0.14.0", "uipath-platform>=0.2.14, <0.3.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 bdf620caa..397b7454e 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -273,6 +273,14 @@ async def disconnect(self) -> None: finally: await self._cleanup_client() + def _require_client(self) -> Any: + client = self._client + if client is None: + raise RuntimeError("WebSocket client not connected. Call connect() first.") + if not self._connected_event.is_set() and not self._websocket_disabled: + raise RuntimeError("WebSocket client not in connected state") + return client + async def emit_message_event( self, message_event: UiPathConversationMessageEvent ) -> None: @@ -284,11 +292,7 @@ async def emit_message_event( Raises: RuntimeError: If client is not connected """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: # Wrap message event with conversation/exchange IDs @@ -309,7 +313,7 @@ async def emit_message_event( f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) # Store the current message ID, used for emitting interrupt events. self._current_message_id = message_event.message_id @@ -318,6 +322,31 @@ async def emit_message_event( logger.error(f"Error sending conversation event to WebSocket: {e}") raise RuntimeError(f"Failed to send conversation event: {e}") from e + async def emit_meta_event(self, meta_event: dict[str, Any]) -> None: + """Send an exchange-scoped conversation metadata event.""" + client = self._require_client() + + try: + event = UiPathConversationEvent( + conversation_id=self.conversation_id, + exchange=UiPathConversationExchangeEvent( + exchange_id=self.exchange_id, + meta_event=meta_event, + ), + ) + event_data = event.model_dump(mode="json", exclude_none=True, by_alias=True) + + if self._websocket_disabled: + logger.info( + "SocketIOChatBridge is in debug mode. Not sending event: %s", + json.dumps(event_data), + ) + else: + await client.emit("ConversationEvent", event_data) + 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 + async def emit_exchange_end_event(self) -> None: """Send an exchange end event. @@ -331,11 +360,7 @@ async def emit_exchange_end_event(self) -> None: 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.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: exchange_end_event = UiPathConversationEvent( @@ -355,7 +380,7 @@ async def emit_exchange_end_event(self) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending conversation event to WebSocket: {e}") @@ -371,11 +396,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: Args: error: The exception that caused the error. """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() # Extract and map error to CAS-specific error ID and message. cas_error_id, cas_message = _resolve_cas_error(error) @@ -403,7 +424,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending exchange error event to WebSocket: {e}") diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index 153ea57d8..58c3c38d2 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -18,4 +18,9 @@ uipath-platform = { path = "../../../uipath-platform", editable = true } # the published uipath-langchain package. Mirrors the uv override used in the # cross-repo test workflows. [tool.uv] -override-dependencies = ["uipath", "uipath-core", "uipath-platform"] +override-dependencies = [ + "uipath", + "uipath-core", + "uipath-platform", + "uipath-runtime>=0.13.0,<0.14.0", +] diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 6c67bc6a9..2c4aedbe9 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -10,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.chat import UiPathConversationMessageEvent from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, @@ -362,6 +363,130 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() + @pytest.mark.anyio + async def test_emit_message_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_message_event( + UiPathConversationMessageEvent(message_id="msg-123") + ) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_exchange_error_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_exchange_error_event(ValueError("failed")) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_meta_event_sends_exchange_scoped_event(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_meta_event( + {"workspaceFiles": [{"path": "plan.md", "attachmentKey": "key-1"}]} + ) + + bridge._client.emit.assert_awaited_once_with( + "ConversationEvent", + { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "metaEvent": { + "workspaceFiles": [ + {"path": "plan.md", "attachmentKey": "key-1"} + ] + }, + }, + }, + ) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_client(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + with pytest.raises(RuntimeError, match="not connected"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_connected_client(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + + with pytest.raises(RuntimeError, match="not in connected state"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_does_not_send_in_debug_mode(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._websocket_disabled = True + + await bridge.emit_meta_event({"workspaceFiles": []}) + + bridge._client.emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_meta_event_wraps_send_error(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._client.emit.side_effect = ValueError("socket failed") + bridge._connected_event.set() + + with pytest.raises(RuntimeError, match="Failed to send conversation event"): + await bridge.emit_meta_event({}) + class TestSocketIOChatBridgeEndExchange: """The bridge owns whether to honor the exchange-end event (CAS-specific).""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3eba8c870..632126ab7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:41.8582141Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.21" +version = "2.13.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ requires-dist = [ { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-ipc", marker = "extra == 'ipc'", specifier = ">=2.5.1,<2.6.0" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, ] provides-extras = ["ipc"] @@ -2800,16 +2800,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.12.2" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/c0/d55cf48ee43758c2c1c65f52fd0596fd75f5bd5067a5016e6553b4d2c5fe/uipath_runtime-0.13.0.tar.gz", hash = "sha256:8ae3150df5fb0043210faacf39148789c1fb252c6a8d6bc27174c0d9ce7304f5", size = 243594, upload-time = "2026-08-04T11:19:04.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/9518dcbeedaa8117e5fd3d0424c4a7354bae1f3ede1b2e3f48524e4b7efc/uipath_runtime-0.13.0-py3-none-any.whl", hash = "sha256:2a7c128cf14fdbef899b272ee5995da63baeb882acc904f39ef8f8f52bcb019f", size = 96349, upload-time = "2026-08-04T11:19:03.376Z" }, ] [[package]]