Skip to content

Commit 1338e47

Browse files
feat: add chat bridge protocol
1 parent 4a4868d commit 1338e47

File tree

7 files changed

+614
-180
lines changed

7 files changed

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

tests/test_chat_runtime.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
We use `spec=UiPathChatBridgeProtocol` so invalid attributes raise at runtime,
32+
but still operate as a unittest.mock.Mock with AsyncMock methods.
33+
"""
34+
bridge_mock: Mock = Mock(spec=UiPathChatBridgeProtocol)
35+
36+
bridge_mock.connect = AsyncMock()
37+
bridge_mock.disconnect = AsyncMock()
38+
bridge_mock.emit_message_event = AsyncMock()
39+
40+
return cast(UiPathChatBridgeProtocol, bridge_mock)
41+
42+
43+
class StreamingMockRuntime:
44+
"""Mock runtime that streams message events and a final result."""
45+
46+
def __init__(
47+
self,
48+
messages: Sequence[str],
49+
*,
50+
error_in_stream: bool = False,
51+
) -> None:
52+
super().__init__()
53+
self.messages: list[str] = list(messages)
54+
self.error_in_stream: bool = error_in_stream
55+
self.execute_called: bool = False
56+
57+
async def dispose(self) -> None:
58+
pass
59+
60+
async def execute(
61+
self,
62+
input: dict[str, Any] | None = None,
63+
options: UiPathExecuteOptions | None = None,
64+
) -> UiPathRuntimeResult:
65+
"""Fallback execute path."""
66+
self.execute_called = True
67+
return UiPathRuntimeResult(
68+
status=UiPathRuntimeStatus.SUCCESSFUL,
69+
output={"mode": "execute"},
70+
)
71+
72+
async def stream(
73+
self,
74+
input: dict[str, Any] | None = None,
75+
options: UiPathStreamOptions | None = None,
76+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
77+
"""Async generator yielding message events and final result."""
78+
if self.error_in_stream:
79+
raise RuntimeError("Stream blew up")
80+
81+
# Yield message events
82+
for idx, message_text in enumerate(self.messages):
83+
# Create proper UiPathConversationMessageEvent
84+
message_event = UiPathConversationMessageEvent(
85+
message_id=f"msg-{idx}",
86+
start=UiPathConversationMessageStartEvent(
87+
role="assistant",
88+
timestamp="2025-01-01T00:00:00.000Z",
89+
),
90+
)
91+
yield UiPathRuntimeMessageEvent(payload=message_event)
92+
93+
# Final result at the end of streaming
94+
yield UiPathRuntimeResult(
95+
status=UiPathRuntimeStatus.SUCCESSFUL,
96+
output={"messages": self.messages},
97+
)
98+
99+
async def get_schema(self) -> UiPathRuntimeSchema:
100+
"""NotImplemented."""
101+
raise NotImplementedError()
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_chat_runtime_streams_and_emits_messages():
106+
"""UiPathChatRuntime should stream events and emit message events to bridge."""
107+
108+
runtime_impl = StreamingMockRuntime(
109+
messages=["Hello", "How are you?", "Goodbye"],
110+
)
111+
bridge = make_chat_bridge_mock()
112+
113+
chat_runtime = UiPathChatRuntime(
114+
delegate=runtime_impl,
115+
chat_bridge=bridge,
116+
)
117+
118+
result = await chat_runtime.execute({})
119+
120+
# Result propagation
121+
assert isinstance(result, UiPathRuntimeResult)
122+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
123+
assert result.output == {"messages": ["Hello", "How are you?", "Goodbye"]}
124+
125+
# Bridge lifecycle
126+
cast(AsyncMock, bridge.connect).assert_awaited_once()
127+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
128+
129+
# Message events emitted
130+
assert cast(AsyncMock, bridge.emit_message_event).await_count == 3
131+
# Verify message events were passed as UiPathConversationMessageEvent objects
132+
calls = cast(AsyncMock, bridge.emit_message_event).await_args_list
133+
assert isinstance(calls[0][0][0], UiPathConversationMessageEvent)
134+
assert calls[0][0][0].message_id == "msg-0"
135+
assert isinstance(calls[1][0][0], UiPathConversationMessageEvent)
136+
assert calls[1][0][0].message_id == "msg-1"
137+
assert isinstance(calls[2][0][0], UiPathConversationMessageEvent)
138+
assert calls[2][0][0].message_id == "msg-2"
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_chat_runtime_stream_yields_all_events():
143+
"""UiPathChatRuntime.stream() should yield all events from delegate."""
144+
145+
runtime_impl = StreamingMockRuntime(
146+
messages=["Message 1", "Message 2"],
147+
)
148+
bridge = make_chat_bridge_mock()
149+
150+
chat_runtime = UiPathChatRuntime(
151+
delegate=runtime_impl,
152+
chat_bridge=bridge,
153+
)
154+
155+
events = []
156+
async for event in chat_runtime.stream({}):
157+
events.append(event)
158+
159+
# Should have 2 message events + 1 final result
160+
assert len(events) == 3
161+
assert isinstance(events[0], UiPathRuntimeMessageEvent)
162+
assert isinstance(events[1], UiPathRuntimeMessageEvent)
163+
assert isinstance(events[2], UiPathRuntimeResult)
164+
165+
# Bridge methods called
166+
cast(AsyncMock, bridge.connect).assert_awaited_once()
167+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
168+
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_chat_runtime_handles_errors():
173+
"""On unexpected errors, UiPathChatRuntime should propagate them."""
174+
175+
runtime_impl = StreamingMockRuntime(
176+
messages=["Message"],
177+
error_in_stream=True,
178+
)
179+
bridge = make_chat_bridge_mock()
180+
181+
chat_runtime = UiPathChatRuntime(
182+
delegate=runtime_impl,
183+
chat_bridge=bridge,
184+
)
185+
186+
# Error should propagate
187+
with pytest.raises(RuntimeError, match="Stream blew up"):
188+
await chat_runtime.execute({})
189+
190+
cast(AsyncMock, bridge.connect).assert_awaited_once()
191+
192+
193+
@pytest.mark.asyncio
194+
async def test_chat_runtime_dispose_calls_delegate_dispose():
195+
"""dispose() should call delegate runtime dispose."""
196+
197+
runtime_impl = StreamingMockRuntime(messages=["Message"])
198+
runtime_impl.dispose = AsyncMock()
199+
bridge = make_chat_bridge_mock()
200+
201+
chat_runtime = UiPathChatRuntime(
202+
delegate=runtime_impl,
203+
chat_bridge=bridge,
204+
)
205+
206+
await chat_runtime.dispose()
207+
208+
cast(AsyncMock, runtime_impl.dispose).assert_awaited_once()
209+
210+
@pytest.mark.asyncio
211+
async def test_chat_runtime_disconnect_always_called():
212+
"""disconnect() should always be called even if no result is yielded."""
213+
214+
class RuntimeWithNoResult:
215+
"""Mock runtime that doesn't yield a result."""
216+
217+
async def dispose(self):
218+
pass
219+
220+
async def execute(self, input=None, options=None):
221+
return UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL)
222+
223+
async def stream(self, input=None, options=None):
224+
# Yield only a message event, no final result
225+
message_event = UiPathConversationMessageEvent(
226+
message_id="test-msg",
227+
start=UiPathConversationMessageStartEvent(
228+
role="assistant",
229+
timestamp="2025-01-01T00:00:00.000Z",
230+
),
231+
)
232+
yield UiPathRuntimeMessageEvent(payload=message_event)
233+
# Generator ends without UiPathRuntimeResult
234+
return
235+
236+
async def get_schema(self):
237+
raise NotImplementedError()
238+
239+
runtime_impl = RuntimeWithNoResult()
240+
bridge = make_chat_bridge_mock()
241+
242+
chat_runtime = UiPathChatRuntime(
243+
delegate=runtime_impl,
244+
chat_bridge=bridge,
245+
)
246+
247+
# Even though no result is yielded, execute completes
248+
result = await chat_runtime.execute({})
249+
250+
# Returns successful result (default)
251+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
252+
253+
# disconnect() still called
254+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()

0 commit comments

Comments
 (0)