Skip to content

Commit 73e286f

Browse files
feat: add chat bridge protocol
1 parent 4a4868d commit 73e286f

File tree

7 files changed

+587
-180
lines changed

7 files changed

+587
-180
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
UiPathStreamNotSupportedError,
88
UiPathStreamOptions,
99
)
10+
from uipath.runtime.chat.bridge import UiPathChatBridgeProtocol
11+
from uipath.runtime.chat.runtime import UiPathChatRuntime
1012
from uipath.runtime.context import UiPathRuntimeContext
1113
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
1214
from uipath.runtime.debug.bridge import UiPathDebugBridgeProtocol
@@ -66,4 +68,6 @@
6668
"UiPathBreakpointResult",
6769
"UiPathStreamNotSupportedError",
6870
"UiPathResumeTriggerName",
71+
"UiPathChatBridgeProtocol",
72+
"UiPathChatRuntime",
6973
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Chat bridge protocol and runtime for conversational agents."""
2+
3+
from uipath.runtime.chat.bridge import UiPathChatBridgeProtocol
4+
from uipath.runtime.chat.runtime import UiPathChatRuntime
5+
6+
__all__ = ["UiPathChatBridgeProtocol", "UiPathChatRuntime"]

src/uipath/runtime/chat/bridge.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Abstract conversation bridge interface."""
2+
3+
from typing import Any, Protocol
4+
5+
from uipath.core.chat import UiPathConversationMessageEvent
6+
7+
8+
class UiPathChatBridgeProtocol(Protocol):
9+
"""Abstract interface for chat communication.
10+
11+
Implementations: WebSocket, etc.
12+
"""
13+
14+
async def connect(self) -> None:
15+
"""Establish connection to chat service."""
16+
...
17+
18+
async def disconnect(self) -> None:
19+
"""Close connection and send exchange end event."""
20+
...
21+
22+
async def emit_message_event(self, message_event: UiPathConversationMessageEvent) -> None:
23+
"""Wrap and send a message event.
24+
25+
Args:
26+
message_event: UiPathConversationMessageEvent to wrap and send
27+
"""
28+
...

src/uipath/runtime/chat/runtime.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Chat runtime implementation."""
2+
3+
import logging
4+
from typing import Any, AsyncGenerator, cast
5+
6+
from uipath.runtime.base import (
7+
UiPathExecuteOptions,
8+
UiPathRuntimeProtocol,
9+
UiPathStreamOptions,
10+
)
11+
from uipath.runtime.chat.bridge import UiPathChatBridgeProtocol
12+
from uipath.runtime.events import (
13+
UiPathRuntimeEvent,
14+
UiPathRuntimeMessageEvent,
15+
)
16+
from uipath.runtime.result import (
17+
UiPathRuntimeResult,
18+
UiPathRuntimeStatus,
19+
)
20+
from uipath.runtime.schema import UiPathRuntimeSchema
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class UiPathChatRuntime:
26+
"""Specialized runtime for chat mode that streams message events to a chat bridge."""
27+
28+
def __init__(
29+
self,
30+
delegate: UiPathRuntimeProtocol,
31+
chat_bridge: UiPathChatBridgeProtocol,
32+
):
33+
"""Initialize the UiPathChatRuntime.
34+
35+
Args:
36+
delegate: The underlying runtime to wrap
37+
chat_bridge: Bridge for chat event communication
38+
"""
39+
super().__init__()
40+
self.delegate = delegate
41+
self.chat_bridge = chat_bridge
42+
43+
async def execute(
44+
self,
45+
input: dict[str, Any] | None = None,
46+
options: UiPathExecuteOptions | None = None,
47+
) -> UiPathRuntimeResult:
48+
"""Execute the workflow with chat support."""
49+
result: UiPathRuntimeResult | None = None
50+
async for event in self.stream(input, cast(UiPathStreamOptions, options)):
51+
if isinstance(event, UiPathRuntimeResult):
52+
result = event
53+
54+
return (
55+
result
56+
if result
57+
else UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL)
58+
)
59+
60+
async def stream(
61+
self,
62+
input: dict[str, Any] | None = None,
63+
options: UiPathStreamOptions | None = None,
64+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
65+
"""Stream execution events with chat support."""
66+
await self.chat_bridge.connect()
67+
68+
async for event in self.delegate.stream(input, options=options):
69+
if isinstance(event, UiPathRuntimeMessageEvent):
70+
if event.payload:
71+
await self.chat_bridge.emit_message_event(event.payload)
72+
73+
yield event
74+
75+
await self.chat_bridge.disconnect()
76+
77+
async def get_schema(self) -> UiPathRuntimeSchema:
78+
"""Get schema from the delegate runtime."""
79+
return await self.delegate.get_schema()
80+
81+
async def dispose(self) -> None:
82+
"""Cleanup runtime resources."""
83+
try:
84+
await self.chat_bridge.disconnect()
85+
except Exception as e:
86+
logger.warning(f"Error disconnecting chat bridge: {e}")

tests/test_chat_runtime.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Tests for UiPathChatRuntime with mocked runtime and chat bridge."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, AsyncGenerator, Sequence, cast
6+
from unittest.mock import AsyncMock, Mock
7+
8+
import pytest
9+
from uipath.core.chat import (
10+
UiPathConversationMessageEvent,
11+
UiPathConversationMessageStartEvent,
12+
)
13+
14+
from uipath.runtime import (
15+
UiPathExecuteOptions,
16+
UiPathRuntimeResult,
17+
UiPathRuntimeStatus,
18+
UiPathStreamOptions,
19+
)
20+
from uipath.runtime.chat import (
21+
UiPathChatBridgeProtocol,
22+
UiPathChatRuntime,
23+
)
24+
from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent
25+
from uipath.runtime.schema import UiPathRuntimeSchema
26+
27+
28+
def make_chat_bridge_mock() -> UiPathChatBridgeProtocol:
29+
"""Create a chat bridge mock with all methods that UiPathChatRuntime uses.
30+
"""
31+
bridge_mock: Mock = Mock(spec=UiPathChatBridgeProtocol)
32+
33+
bridge_mock.connect = AsyncMock()
34+
bridge_mock.disconnect = AsyncMock()
35+
bridge_mock.emit_message_event = AsyncMock()
36+
37+
return cast(UiPathChatBridgeProtocol, bridge_mock)
38+
39+
40+
class StreamingMockRuntime:
41+
"""Mock runtime that streams message events and a final result."""
42+
43+
def __init__(
44+
self,
45+
messages: Sequence[str],
46+
*,
47+
error_in_stream: bool = False,
48+
) -> None:
49+
super().__init__()
50+
self.messages: list[str] = list(messages)
51+
self.error_in_stream: bool = error_in_stream
52+
self.execute_called: bool = False
53+
54+
async def dispose(self) -> None:
55+
pass
56+
57+
async def execute(
58+
self,
59+
input: dict[str, Any] | None = None,
60+
options: UiPathExecuteOptions | None = None,
61+
) -> UiPathRuntimeResult:
62+
"""Fallback execute path."""
63+
self.execute_called = True
64+
return UiPathRuntimeResult(
65+
status=UiPathRuntimeStatus.SUCCESSFUL,
66+
output={"mode": "execute"},
67+
)
68+
69+
async def stream(
70+
self,
71+
input: dict[str, Any] | None = None,
72+
options: UiPathStreamOptions | None = None,
73+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
74+
"""Async generator yielding message events and final result."""
75+
if self.error_in_stream:
76+
raise RuntimeError("Stream blew up")
77+
78+
for idx, message_text in enumerate(self.messages):
79+
message_event = UiPathConversationMessageEvent(
80+
message_id=f"msg-{idx}",
81+
start=UiPathConversationMessageStartEvent(
82+
role="assistant",
83+
timestamp="2025-01-01T00:00:00.000Z",
84+
),
85+
)
86+
yield UiPathRuntimeMessageEvent(payload=message_event)
87+
88+
# Final result at the end of streaming
89+
yield UiPathRuntimeResult(
90+
status=UiPathRuntimeStatus.SUCCESSFUL,
91+
output={"messages": self.messages},
92+
)
93+
94+
async def get_schema(self) -> UiPathRuntimeSchema:
95+
raise NotImplementedError()
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_chat_runtime_streams_and_emits_messages():
100+
"""UiPathChatRuntime should stream events and emit message events to bridge."""
101+
102+
runtime_impl = StreamingMockRuntime(
103+
messages=["Hello", "How are you?", "Goodbye"],
104+
)
105+
bridge = make_chat_bridge_mock()
106+
107+
chat_runtime = UiPathChatRuntime(
108+
delegate=runtime_impl,
109+
chat_bridge=bridge,
110+
)
111+
112+
result = await chat_runtime.execute({})
113+
114+
# Result propagation
115+
assert isinstance(result, UiPathRuntimeResult)
116+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
117+
assert result.output == {"messages": ["Hello", "How are you?", "Goodbye"]}
118+
119+
# Bridge lifecycle
120+
cast(AsyncMock, bridge.connect).assert_awaited_once()
121+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
122+
123+
assert cast(AsyncMock, bridge.emit_message_event).await_count == 3
124+
125+
# Verify message events were passed as UiPathConversationMessageEvent objects
126+
calls = cast(AsyncMock, bridge.emit_message_event).await_args_list
127+
assert isinstance(calls[0][0][0], UiPathConversationMessageEvent)
128+
assert calls[0][0][0].message_id == "msg-0"
129+
assert isinstance(calls[1][0][0], UiPathConversationMessageEvent)
130+
assert calls[1][0][0].message_id == "msg-1"
131+
assert isinstance(calls[2][0][0], UiPathConversationMessageEvent)
132+
assert calls[2][0][0].message_id == "msg-2"
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_chat_runtime_stream_yields_all_events():
137+
"""UiPathChatRuntime.stream() should yield all events from delegate."""
138+
139+
runtime_impl = StreamingMockRuntime(
140+
messages=["Message 1", "Message 2"],
141+
)
142+
bridge = make_chat_bridge_mock()
143+
144+
chat_runtime = UiPathChatRuntime(
145+
delegate=runtime_impl,
146+
chat_bridge=bridge,
147+
)
148+
149+
events = []
150+
async for event in chat_runtime.stream({}):
151+
events.append(event)
152+
153+
# Should have 2 message events + 1 final result
154+
assert len(events) == 3
155+
assert isinstance(events[0], UiPathRuntimeMessageEvent)
156+
assert isinstance(events[1], UiPathRuntimeMessageEvent)
157+
assert isinstance(events[2], UiPathRuntimeResult)
158+
159+
# Bridge methods called
160+
cast(AsyncMock, bridge.connect).assert_awaited_once()
161+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
162+
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_chat_runtime_handles_errors():
167+
"""On unexpected errors, UiPathChatRuntime should propagate them."""
168+
169+
runtime_impl = StreamingMockRuntime(
170+
messages=["Message"],
171+
error_in_stream=True,
172+
)
173+
bridge = make_chat_bridge_mock()
174+
175+
chat_runtime = UiPathChatRuntime(
176+
delegate=runtime_impl,
177+
chat_bridge=bridge,
178+
)
179+
180+
# Error should propagate
181+
with pytest.raises(RuntimeError, match="Stream blew up"):
182+
await chat_runtime.execute({})
183+
184+
cast(AsyncMock, bridge.connect).assert_awaited_once()
185+
186+
187+
@pytest.mark.asyncio
188+
async def test_chat_runtime_dispose_calls_disconnect():
189+
"""dispose() should call chat bridge disconnect."""
190+
191+
runtime_impl = StreamingMockRuntime(messages=["Message"])
192+
bridge = make_chat_bridge_mock()
193+
194+
chat_runtime = UiPathChatRuntime(
195+
delegate=runtime_impl,
196+
chat_bridge=bridge,
197+
)
198+
199+
await chat_runtime.dispose()
200+
201+
# Bridge disconnect should be called
202+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
203+
204+
205+
@pytest.mark.asyncio
206+
async def test_chat_runtime_dispose_suppresses_disconnect_errors():
207+
"""Errors from chat_bridge.disconnect should be suppressed."""
208+
209+
runtime_impl = StreamingMockRuntime(messages=["Message"])
210+
bridge = make_chat_bridge_mock()
211+
cast(AsyncMock, bridge.disconnect).side_effect = RuntimeError("disconnect failed")
212+
213+
chat_runtime = UiPathChatRuntime(
214+
delegate=runtime_impl,
215+
chat_bridge=bridge,
216+
)
217+
218+
await chat_runtime.dispose()
219+
220+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()

0 commit comments

Comments
 (0)