1313
1414from uipath .runtime import (
1515 UiPathExecuteOptions ,
16+ UiPathResumeTrigger ,
17+ UiPathResumeTriggerType ,
1618 UiPathRuntimeResult ,
1719 UiPathRuntimeStatus ,
1820 UiPathStreamOptions ,
@@ -32,6 +34,8 @@ def make_chat_bridge_mock() -> UiPathChatProtocol:
3234 bridge_mock .connect = AsyncMock ()
3335 bridge_mock .disconnect = AsyncMock ()
3436 bridge_mock .emit_message_event = AsyncMock ()
37+ bridge_mock .emit_interrupt_event = AsyncMock ()
38+ bridge_mock .wait_for_resume = AsyncMock ()
3539
3640 return cast (UiPathChatProtocol , bridge_mock )
3741
@@ -94,6 +98,79 @@ async def get_schema(self) -> UiPathRuntimeSchema:
9498 raise NotImplementedError ()
9599
96100
101+ class SuspendingMockRuntime :
102+ """Mock runtime that can suspend with API triggers."""
103+
104+ def __init__ (
105+ self ,
106+ suspend_at_message : int | None = None ,
107+ ) -> None :
108+ self .suspend_at_message = suspend_at_message
109+
110+ async def dispose (self ) -> None :
111+ pass
112+
113+ async def execute (
114+ self ,
115+ input : dict [str , Any ] | None = None ,
116+ options : UiPathExecuteOptions | None = None ,
117+ ) -> UiPathRuntimeResult :
118+ """Fallback execute path."""
119+ return UiPathRuntimeResult (
120+ status = UiPathRuntimeStatus .SUCCESSFUL ,
121+ output = {"mode" : "execute" },
122+ )
123+
124+ async def stream (
125+ self ,
126+ input : dict [str , Any ] | None = None ,
127+ options : UiPathStreamOptions | None = None ,
128+ ) -> AsyncGenerator [UiPathRuntimeEvent , None ]:
129+ """Stream events with potential API trigger suspension."""
130+ is_resume = options and options .resume
131+
132+ if not is_resume :
133+ # Initial execution - yield message and then suspend
134+ message_event = UiPathConversationMessageEvent (
135+ message_id = "msg-0" ,
136+ start = UiPathConversationMessageStartEvent (
137+ role = "assistant" ,
138+ timestamp = "2025-01-01T00:00:00.000Z" ,
139+ ),
140+ )
141+ yield UiPathRuntimeMessageEvent (payload = message_event )
142+
143+ if self .suspend_at_message is not None :
144+ # Suspend with API trigger
145+ yield UiPathRuntimeResult (
146+ status = UiPathRuntimeStatus .SUSPENDED ,
147+ trigger = UiPathResumeTrigger (
148+ trigger_type = UiPathResumeTriggerType .API ,
149+ payload = {"action" : "confirm_tool_call" },
150+ ),
151+ )
152+ return
153+ else :
154+ # Resumed execution - yield another message and complete
155+ message_event = UiPathConversationMessageEvent (
156+ message_id = "msg-1" ,
157+ start = UiPathConversationMessageStartEvent (
158+ role = "assistant" ,
159+ timestamp = "2025-01-01T00:00:01.000Z" ,
160+ ),
161+ )
162+ yield UiPathRuntimeMessageEvent (payload = message_event )
163+
164+ # Final successful result
165+ yield UiPathRuntimeResult (
166+ status = UiPathRuntimeStatus .SUCCESSFUL ,
167+ output = {"resumed" : is_resume , "input" : input },
168+ )
169+
170+ async def get_schema (self ) -> UiPathRuntimeSchema :
171+ raise NotImplementedError ()
172+
173+
97174@pytest .mark .asyncio
98175async def test_chat_runtime_streams_and_emits_messages ():
99176 """UiPathChatRuntime should stream events and emit message events to bridge."""
@@ -221,3 +298,73 @@ async def test_chat_runtime_dispose_suppresses_disconnect_errors():
221298 await chat_runtime .dispose ()
222299
223300 cast (AsyncMock , bridge .disconnect ).assert_awaited_once ()
301+
302+
303+ @pytest .mark .asyncio
304+ async def test_chat_runtime_handles_api_trigger_suspension ():
305+ """UiPathChatRuntime should intercept suspensions and resume execution."""
306+
307+ runtime_impl = SuspendingMockRuntime (suspend_at_message = 0 )
308+ bridge = make_chat_bridge_mock ()
309+
310+ cast (AsyncMock , bridge .wait_for_resume ).return_value = {"approved" : True }
311+
312+ chat_runtime = UiPathChatRuntime (
313+ delegate = runtime_impl ,
314+ chat_bridge = bridge ,
315+ )
316+
317+ result = await chat_runtime .execute ({})
318+
319+ await chat_runtime .dispose ()
320+
321+ # Result should be SUCCESSFUL
322+ assert isinstance (result , UiPathRuntimeResult )
323+ assert result .status == UiPathRuntimeStatus .SUCCESSFUL
324+ assert result .output == {"resumed" : True , "input" : {"approved" : True }}
325+
326+ cast (AsyncMock , bridge .connect ).assert_awaited_once ()
327+ cast (AsyncMock , bridge .disconnect ).assert_awaited_once ()
328+
329+ cast (AsyncMock , bridge .emit_interrupt_event ).assert_awaited_once ()
330+ cast (AsyncMock , bridge .wait_for_resume ).assert_awaited_once ()
331+
332+ # Message events emitted (one before suspend, one after resume)
333+ assert cast (AsyncMock , bridge .emit_message_event ).await_count == 2
334+
335+
336+ @pytest .mark .asyncio
337+ async def test_chat_runtime_yields_events_during_suspension_flow ():
338+ """UiPathChatRuntime.stream() should not yield SUSPENDED result, only final result."""
339+
340+ runtime_impl = SuspendingMockRuntime (suspend_at_message = 0 )
341+ bridge = make_chat_bridge_mock ()
342+
343+ # wait_for_resume returns approval data
344+ cast (AsyncMock , bridge .wait_for_resume ).return_value = {"approved" : True }
345+
346+ chat_runtime = UiPathChatRuntime (
347+ delegate = runtime_impl ,
348+ chat_bridge = bridge ,
349+ )
350+
351+ events = []
352+ async for event in chat_runtime .stream ({}):
353+ events .append (event )
354+
355+ await chat_runtime .dispose ()
356+
357+ # Should have 2 message events + 1 final SUCCESSFUL result
358+ # SUSPENDED result should NOT be yielded
359+ assert len (events ) == 3
360+ assert isinstance (events [0 ], UiPathRuntimeMessageEvent )
361+ assert events [0 ].payload .message_id == "msg-0"
362+ assert isinstance (events [1 ], UiPathRuntimeMessageEvent )
363+ assert events [1 ].payload .message_id == "msg-1"
364+ assert isinstance (events [2 ], UiPathRuntimeResult )
365+ assert events [2 ].status == UiPathRuntimeStatus .SUCCESSFUL
366+
367+ # Verify no SUSPENDED result was yielded
368+ for event in events :
369+ if isinstance (event , UiPathRuntimeResult ):
370+ assert event .status != UiPathRuntimeStatus .SUSPENDED
0 commit comments