Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 14 additions & 0 deletions packages/uipath/src/uipath/_cli/_chat/_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -115,13 +116,16 @@ 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
self.conversation_id = conversation_id
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()

Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -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,
)


Expand Down
85 changes: 85 additions & 0 deletions packages/uipath/tests/cli/chat/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand Down