Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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