@@ -142,13 +142,15 @@ async def stream(
142142
143143 if self .suspend_at_message is not None :
144144 # Suspend with API trigger
145+ trigger = UiPathResumeTrigger (
146+ interrupt_id = "interrupt-1" ,
147+ trigger_type = UiPathResumeTriggerType .API ,
148+ payload = {"action" : "confirm_tool_call" },
149+ )
145150 yield UiPathRuntimeResult (
146151 status = UiPathRuntimeStatus .SUSPENDED ,
147- trigger = UiPathResumeTrigger (
148- interrupt_id = "interrupt-1" ,
149- trigger_type = UiPathResumeTriggerType .API ,
150- payload = {"action" : "confirm_tool_call" },
151- ),
152+ trigger = trigger ,
153+ triggers = [trigger ],
152154 )
153155 return
154156 else :
@@ -322,7 +324,7 @@ async def test_chat_runtime_handles_api_trigger_suspension():
322324 # Result should be SUCCESSFUL
323325 assert isinstance (result , UiPathRuntimeResult )
324326 assert result .status == UiPathRuntimeStatus .SUCCESSFUL
325- assert result .output == {"resumed" : True , "input" : {"approved" : True }}
327+ assert result .output == {"resumed" : True , "input" : {"interrupt-1" : { " approved" : True } }}
326328
327329 cast (AsyncMock , bridge .connect ).assert_awaited_once ()
328330 cast (AsyncMock , bridge .disconnect ).assert_awaited_once ()
@@ -369,3 +371,236 @@ async def test_chat_runtime_yields_events_during_suspension_flow():
369371 for event in events :
370372 if isinstance (event , UiPathRuntimeResult ):
371373 assert event .status != UiPathRuntimeStatus .SUSPENDED
374+
375+
376+ class MultiTriggerMockRuntime :
377+ """Mock runtime that suspends with multiple API triggers."""
378+
379+ def __init__ (self ) -> None :
380+ self .execution_count = 0
381+
382+ async def dispose (self ) -> None :
383+ pass
384+
385+ async def execute (
386+ self ,
387+ input : dict [str , Any ] | None = None ,
388+ options : UiPathExecuteOptions | None = None ,
389+ ) -> UiPathRuntimeResult :
390+ """Execute with multiple trigger suspension."""
391+ result : UiPathRuntimeResult | None = None
392+ async for event in self .stream (input , cast (UiPathStreamOptions , options )):
393+ if isinstance (event , UiPathRuntimeResult ):
394+ result = event
395+ return result if result else UiPathRuntimeResult (status = UiPathRuntimeStatus .SUCCESSFUL )
396+
397+ async def stream (
398+ self ,
399+ input : dict [str , Any ] | None = None ,
400+ options : UiPathStreamOptions | None = None ,
401+ ) -> AsyncGenerator [UiPathRuntimeEvent , None ]:
402+ """Stream with multiple trigger suspension."""
403+ self .execution_count += 1
404+ is_resume = options and options .resume
405+
406+ if not is_resume :
407+ # Initial execution - suspend with 3 API triggers
408+ trigger_a = UiPathResumeTrigger (
409+ interrupt_id = "email-confirm" ,
410+ trigger_type = UiPathResumeTriggerType .API ,
411+ payload = {"action" : "send_email" , "to" : "user@example.com" },
412+ )
413+ trigger_b = UiPathResumeTrigger (
414+ interrupt_id = "file-delete" ,
415+ trigger_type = UiPathResumeTriggerType .API ,
416+ payload = {"action" : "delete_file" , "path" : "/logs/old.txt" },
417+ )
418+ trigger_c = UiPathResumeTrigger (
419+ interrupt_id = "api-call" ,
420+ trigger_type = UiPathResumeTriggerType .API ,
421+ payload = {"action" : "call_api" , "endpoint" : "/users" },
422+ )
423+
424+ yield UiPathRuntimeResult (
425+ status = UiPathRuntimeStatus .SUSPENDED ,
426+ trigger = trigger_a ,
427+ triggers = [trigger_a , trigger_b , trigger_c ],
428+ )
429+ else :
430+ # Resumed - verify all triggers resolved
431+ assert input is not None
432+ assert "email-confirm" in input
433+ assert "file-delete" in input
434+ assert "api-call" in input
435+
436+ yield UiPathRuntimeResult (
437+ status = UiPathRuntimeStatus .SUCCESSFUL ,
438+ output = {"resumed" : True , "input" : input },
439+ )
440+
441+ async def get_schema (self ) -> UiPathRuntimeSchema :
442+ raise NotImplementedError ()
443+
444+
445+ class MixedTriggerMockRuntime :
446+ """Mock runtime that suspends with mixed trigger types (API + non-API)."""
447+
448+ async def dispose (self ) -> None :
449+ pass
450+
451+ async def execute (
452+ self ,
453+ input : dict [str , Any ] | None = None ,
454+ options : UiPathExecuteOptions | None = None ,
455+ ) -> UiPathRuntimeResult :
456+ """Execute with mixed triggers."""
457+ result : UiPathRuntimeResult | None = None
458+ async for event in self .stream (input , cast (UiPathStreamOptions , options )):
459+ if isinstance (event , UiPathRuntimeResult ):
460+ result = event
461+ return result if result else UiPathRuntimeResult (status = UiPathRuntimeStatus .SUCCESSFUL )
462+
463+ async def stream (
464+ self ,
465+ input : dict [str , Any ] | None = None ,
466+ options : UiPathStreamOptions | None = None ,
467+ ) -> AsyncGenerator [UiPathRuntimeEvent , None ]:
468+ """Stream with mixed trigger types."""
469+ is_resume = options and options .resume
470+
471+ if not is_resume :
472+ # Initial execution - 2 API + 1 QUEUE trigger
473+ trigger_a = UiPathResumeTrigger (
474+ interrupt_id = "email-confirm" ,
475+ trigger_type = UiPathResumeTriggerType .API ,
476+ payload = {"action" : "send_email" },
477+ )
478+ trigger_b = UiPathResumeTrigger (
479+ interrupt_id = "file-delete" ,
480+ trigger_type = UiPathResumeTriggerType .API ,
481+ payload = {"action" : "delete_file" },
482+ )
483+ trigger_c = UiPathResumeTrigger (
484+ interrupt_id = "queue-item" ,
485+ trigger_type = UiPathResumeTriggerType .QUEUE_ITEM ,
486+ payload = {"queue" : "inbox" , "item" : "123" },
487+ )
488+
489+ yield UiPathRuntimeResult (
490+ status = UiPathRuntimeStatus .SUSPENDED ,
491+ trigger = trigger_a ,
492+ triggers = [trigger_a , trigger_b , trigger_c ],
493+ )
494+ else :
495+ # Resumed - verify only API triggers resolved
496+ assert input is not None
497+ assert "email-confirm" in input
498+ assert "file-delete" in input
499+ # QUEUE trigger should NOT be in input (handled externally)
500+
501+ # Suspend again with only QUEUE trigger
502+ trigger_c = UiPathResumeTrigger (
503+ interrupt_id = "queue-item" ,
504+ trigger_type = UiPathResumeTriggerType .QUEUE_ITEM ,
505+ payload = {"queue" : "inbox" , "item" : "123" },
506+ )
507+
508+ yield UiPathRuntimeResult (
509+ status = UiPathRuntimeStatus .SUSPENDED ,
510+ trigger = trigger_c ,
511+ triggers = [trigger_c ],
512+ )
513+
514+ async def get_schema (self ) -> UiPathRuntimeSchema :
515+ raise NotImplementedError ()
516+
517+
518+ @pytest .mark .asyncio
519+ async def test_chat_runtime_handles_multiple_api_triggers ():
520+ """ChatRuntime should resolve all API triggers before resuming."""
521+
522+ runtime_impl = MultiTriggerMockRuntime ()
523+ bridge = make_chat_bridge_mock ()
524+
525+ # Bridge returns approval for each trigger
526+ cast (AsyncMock , bridge .wait_for_resume ).side_effect = [
527+ {"approved" : True }, # email-confirm
528+ {"approved" : True }, # file-delete
529+ {"approved" : True }, # api-call
530+ ]
531+
532+ chat_runtime = UiPathChatRuntime (
533+ delegate = runtime_impl ,
534+ chat_bridge = bridge ,
535+ )
536+
537+ result = await chat_runtime .execute ({})
538+
539+ await chat_runtime .dispose ()
540+
541+ # Result should be SUCCESSFUL
542+ assert result .status == UiPathRuntimeStatus .SUCCESSFUL
543+ assert result .output ["resumed" ] is True
544+
545+ # Verify all 3 triggers were wrapped with interrupt_ids
546+ resume_input = result .output ["input" ]
547+ assert "email-confirm" in resume_input
548+ assert "file-delete" in resume_input
549+ assert "api-call" in resume_input
550+ assert resume_input ["email-confirm" ] == {"approved" : True }
551+ assert resume_input ["file-delete" ] == {"approved" : True }
552+ assert resume_input ["api-call" ] == {"approved" : True }
553+
554+ # Bridge should have been called 3 times (once per trigger)
555+ assert cast (AsyncMock , bridge .emit_interrupt_event ).await_count == 3
556+ assert cast (AsyncMock , bridge .wait_for_resume ).await_count == 3
557+
558+ # Verify each emit_interrupt_event received a single-trigger result
559+ emit_calls = cast (AsyncMock , bridge .emit_interrupt_event ).await_args_list
560+ assert len (emit_calls [0 ][0 ][0 ].triggers ) == 1 # First call has 1 trigger
561+ assert emit_calls [0 ][0 ][0 ].triggers [0 ].interrupt_id == "email-confirm"
562+ assert len (emit_calls [1 ][0 ][0 ].triggers ) == 1 # Second call has 1 trigger
563+ assert emit_calls [1 ][0 ][0 ].triggers [0 ].interrupt_id == "file-delete"
564+ assert len (emit_calls [2 ][0 ][0 ].triggers ) == 1 # Third call has 1 trigger
565+ assert emit_calls [2 ][0 ][0 ].triggers [0 ].interrupt_id == "api-call"
566+
567+
568+ @pytest .mark .asyncio
569+ async def test_chat_runtime_filters_non_api_triggers ():
570+ """ChatRuntime should only handle API triggers, pass through non-API triggers."""
571+
572+ runtime_impl = MixedTriggerMockRuntime ()
573+ bridge = make_chat_bridge_mock ()
574+
575+ # Bridge returns approval for API triggers only
576+ cast (AsyncMock , bridge .wait_for_resume ).side_effect = [
577+ {"approved" : True }, # email-confirm
578+ {"approved" : True }, # file-delete
579+ ]
580+
581+ chat_runtime = UiPathChatRuntime (
582+ delegate = runtime_impl ,
583+ chat_bridge = bridge ,
584+ )
585+
586+ result = await chat_runtime .execute ({})
587+
588+ await chat_runtime .dispose ()
589+
590+ # Result should be SUSPENDED with QUEUE trigger (non-API)
591+ assert result .status == UiPathRuntimeStatus .SUSPENDED
592+ assert result .triggers is not None
593+ assert len (result .triggers ) == 1
594+ assert result .triggers [0 ].interrupt_id == "queue-item"
595+ assert result .triggers [0 ].trigger_type == UiPathResumeTriggerType .QUEUE_ITEM
596+
597+ # Bridge should have been called only 2 times (for 2 API triggers)
598+ assert cast (AsyncMock , bridge .emit_interrupt_event ).await_count == 2
599+ assert cast (AsyncMock , bridge .wait_for_resume ).await_count == 2
600+
601+ # Verify only API triggers were emitted
602+ emit_calls = cast (AsyncMock , bridge .emit_interrupt_event ).await_args_list
603+ assert emit_calls [0 ][0 ][0 ].triggers [0 ].interrupt_id == "email-confirm"
604+ assert emit_calls [0 ][0 ][0 ].triggers [0 ].trigger_type == UiPathResumeTriggerType .API
605+ assert emit_calls [1 ][0 ][0 ].triggers [0 ].interrupt_id == "file-delete"
606+ assert emit_calls [1 ][0 ][0 ].triggers [0 ].trigger_type == UiPathResumeTriggerType .API
0 commit comments