Skip to content

Commit bf767b8

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

File tree

7 files changed

+608
-180
lines changed

7 files changed

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

src/uipath/runtime/chat/runtime.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
result: UiPathRuntimeResult | None = None
69+
70+
async for event in self.delegate.stream(input, options=options):
71+
# Send message events to chat bridge
72+
if isinstance(event, UiPathRuntimeMessageEvent):
73+
if event.payload:
74+
await self.chat_bridge.emit_message_event(event.payload)
75+
76+
yield event
77+
78+
if isinstance(event, UiPathRuntimeResult):
79+
result = event
80+
81+
if result:
82+
logger.info(f"Chat execution completed with status: {result.status}")
83+
84+
# Always disconnect from chat bridge
85+
await self.chat_bridge.disconnect()
86+
87+
async def get_schema(self) -> UiPathRuntimeSchema:
88+
"""Get schema from the delegate runtime."""
89+
return await self.delegate.get_schema()
90+
91+
async def dispose(self) -> None:
92+
"""Dispose the delegate runtime."""
93+
await self.delegate.dispose()

tests/test_chat_runtime.py

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

0 commit comments

Comments
 (0)