2323from uipath .runtime .schema import UiPathRuntimeSchema
2424
2525
26- class MultiTriggerMockRuntime :
26+ class SiblingTriggerMockRuntime :
2727 """Mock runtime that simulates parallel branching with multiple interrupts."""
2828
2929 def __init__ (self ) -> None :
@@ -89,6 +89,56 @@ async def get_schema(self) -> UiPathRuntimeSchema:
8989 raise NotImplementedError ()
9090
9191
92+ class MultiTriggerMockRuntime :
93+ """Mock runtime that suspends on a multi-trigger interrupt, then suspends again."""
94+
95+ def __init__ (self ) -> None :
96+ self .execution_count = 0
97+
98+ async def dispose (self ) -> None :
99+ pass
100+
101+ async def execute (
102+ self ,
103+ input : dict [str , Any ] | None = None ,
104+ options : UiPathExecuteOptions | None = None ,
105+ ) -> UiPathRuntimeResult :
106+ self .execution_count += 1
107+ is_resume = options and options .resume
108+
109+ if self .execution_count == 1 :
110+ return UiPathRuntimeResult (
111+ status = UiPathRuntimeStatus .SUSPENDED ,
112+ output = {"child-1" : {"process" : "first-child" }},
113+ )
114+
115+ assert is_resume
116+ assert input == {
117+ "child-1" : {
118+ "completed" : True ,
119+ "__uipath" : {
120+ "triggerType" : UiPathResumeTriggerType .JOB .value ,
121+ "triggerName" : "Unknown" ,
122+ },
123+ }
124+ }
125+ return UiPathRuntimeResult (
126+ status = UiPathRuntimeStatus .SUSPENDED ,
127+ output = {"child-2" : {"process" : "second-child" }},
128+ )
129+
130+ async def stream (
131+ self ,
132+ input : dict [str , Any ] | None = None ,
133+ options : UiPathStreamOptions | None = None ,
134+ ) -> AsyncGenerator [UiPathRuntimeEvent , None ]:
135+ result = await self .execute (input , options )
136+ yield result
137+
138+ async def get_schema (self ) -> UiPathRuntimeSchema :
139+ raise NotImplementedError ()
140+
141+
92142class StatefulStorageMock :
93143 """Stateful storage mock that tracks triggers."""
94144
@@ -134,6 +184,11 @@ async def read_trigger_default(trigger: UiPathResumeTrigger) -> dict[str, Any]:
134184 raise UiPathPendingTriggerError (ErrorCategory .USER , "Trigger not fired yet" )
135185
136186 manager .create_trigger = AsyncMock (side_effect = create_trigger_impl )
187+
188+ async def create_triggers_impl (data : dict [str , Any ]) -> list [UiPathResumeTrigger ]:
189+ return [await manager .create_trigger (data )]
190+
191+ manager .create_triggers = AsyncMock (side_effect = create_triggers_impl )
137192 manager .read_trigger = AsyncMock (side_effect = read_trigger_default )
138193
139194 return cast (UiPathResumeTriggerProtocol , manager )
@@ -146,7 +201,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension(
146201 ) -> None :
147202 """First suspension with parallel branches should create multiple triggers."""
148203
149- runtime_impl = MultiTriggerMockRuntime ()
204+ runtime_impl = SiblingTriggerMockRuntime ()
150205 storage = StatefulStorageMock ()
151206 trigger_manager = make_trigger_manager_mock ()
152207
@@ -179,7 +234,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension(
179234 async def test_resumable_adds_only_new_triggers_on_partial_resume (self ) -> None :
180235 """Partial resume should keep pending trigger and add only new ones."""
181236
182- runtime_impl = MultiTriggerMockRuntime ()
237+ runtime_impl = SiblingTriggerMockRuntime ()
183238 storage = StatefulStorageMock ()
184239 trigger_manager = make_trigger_manager_mock ()
185240
@@ -222,7 +277,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
222277 async def test_resumable_completes_after_all_triggers_resolved (self ) -> None :
223278 """After all triggers resolved, execution should complete successfully."""
224279
225- runtime_impl = MultiTriggerMockRuntime ()
280+ runtime_impl = SiblingTriggerMockRuntime ()
226281 storage = StatefulStorageMock ()
227282 trigger_manager = make_trigger_manager_mock ()
228283
@@ -269,7 +324,7 @@ async def read_trigger_impl_3(trigger: UiPathResumeTrigger) -> dict[str, Any]:
269324 async def test_resumable_auto_resumes_when_triggers_already_fired (self ) -> None :
270325 """When triggers are already fired during suspension, runtime should auto-resume."""
271326
272- runtime_impl = MultiTriggerMockRuntime ()
327+ runtime_impl = SiblingTriggerMockRuntime ()
273328 storage = StatefulStorageMock ()
274329 trigger_manager = make_trigger_manager_mock ()
275330
@@ -308,7 +363,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
308363 async def test_resumable_auto_resumes_partial_fired_triggers (self ) -> None :
309364 """When only some triggers are fired during suspension, auto-resume with those."""
310365
311- runtime_impl = MultiTriggerMockRuntime ()
366+ runtime_impl = SiblingTriggerMockRuntime ()
312367 storage = StatefulStorageMock ()
313368 trigger_manager = make_trigger_manager_mock ()
314369
@@ -344,7 +399,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
344399 async def test_resumable_auto_resumes_multiple_times (self ) -> None :
345400 """When triggers keep being fired immediately, keep auto-resuming until complete."""
346401
347- runtime_impl = MultiTriggerMockRuntime ()
402+ runtime_impl = SiblingTriggerMockRuntime ()
348403 storage = StatefulStorageMock ()
349404 trigger_manager = make_trigger_manager_mock ()
350405
@@ -389,7 +444,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
389444 async def test_resumable_stream_auto_resumes_when_triggers_fired (self ) -> None :
390445 """Stream should auto-resume when triggers are already fired."""
391446
392- runtime_impl = MultiTriggerMockRuntime ()
447+ runtime_impl = SiblingTriggerMockRuntime ()
393448 storage = StatefulStorageMock ()
394449 trigger_manager = make_trigger_manager_mock ()
395450
@@ -426,7 +481,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
426481 async def test_resumable_no_auto_resume_when_all_triggers_pending (self ) -> None :
427482 """When all triggers are pending, should NOT auto-resume."""
428483
429- runtime_impl = MultiTriggerMockRuntime ()
484+ runtime_impl = SiblingTriggerMockRuntime ()
430485 storage = StatefulStorageMock ()
431486 trigger_manager = make_trigger_manager_mock ()
432487
@@ -457,7 +512,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
457512 async def test_resumable_skips_api_triggers_on_auto_resume_check (self ) -> None :
458513 """API triggers should be skipped when checking for auto-resume after suspension."""
459514
460- runtime_impl = MultiTriggerMockRuntime ()
515+ runtime_impl = SiblingTriggerMockRuntime ()
461516 storage = StatefulStorageMock ()
462517 trigger_manager = make_trigger_manager_mock ()
463518
@@ -508,7 +563,7 @@ async def test_resumable_skips_inbox_triggers_on_auto_resume_check(self) -> None
508563 404 and fault the run. They should behave like API triggers here.
509564 """
510565
511- runtime_impl = MultiTriggerMockRuntime ()
566+ runtime_impl = SiblingTriggerMockRuntime ()
512567 storage = StatefulStorageMock ()
513568 trigger_manager = make_trigger_manager_mock ()
514569
@@ -552,7 +607,7 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
552607 async def test_resumable_skips_timer_triggers_on_auto_resume_check (self ) -> None :
553608 """Timer triggers should be skipped when checking for auto-resume."""
554609
555- runtime_impl = MultiTriggerMockRuntime ()
610+ runtime_impl = SiblingTriggerMockRuntime ()
556611 storage = StatefulStorageMock ()
557612 trigger_manager = make_trigger_manager_mock ()
558613
@@ -595,7 +650,7 @@ async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers(
595650 ) -> None :
596651 """Mixed triggers: TASK triggers should trigger auto-resume, API triggers should not."""
597652
598- runtime_impl = MultiTriggerMockRuntime ()
653+ runtime_impl = SiblingTriggerMockRuntime ()
599654 storage = StatefulStorageMock ()
600655 trigger_manager = make_trigger_manager_mock ()
601656
@@ -640,3 +695,67 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
640695
641696 # Delegate should have been executed twice (initial + auto-resume for TASK trigger)
642697 assert runtime_impl .execution_count == 2
698+
699+ @pytest .mark .asyncio
700+ async def test_resumable_removes_sibling_triggers_when_one_trigger_fires (
701+ self ,
702+ ) -> None :
703+ """When one trigger fires, sibling triggers must not leak forward."""
704+
705+ runtime_impl = MultiTriggerMockRuntime ()
706+ storage = StatefulStorageMock ()
707+ trigger_manager = make_trigger_manager_mock ()
708+
709+ async def create_sibling_triggers (
710+ data : dict [str , Any ],
711+ ) -> list [UiPathResumeTrigger ]:
712+ return [
713+ UiPathResumeTrigger (
714+ interrupt_id = "" , # Will be set by resumable runtime
715+ trigger_type = UiPathResumeTriggerType .JOB ,
716+ payload = {"source" : data ["process" ]},
717+ ),
718+ UiPathResumeTrigger (
719+ interrupt_id = "" , # Will be set by resumable runtime
720+ trigger_type = UiPathResumeTriggerType .TIMER ,
721+ payload = {"source" : data ["process" ]},
722+ ),
723+ ]
724+
725+ create_triggers_mock = AsyncMock (side_effect = create_sibling_triggers )
726+ cast (Any , trigger_manager ).create_triggers = create_triggers_mock
727+
728+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
729+ if (
730+ trigger .interrupt_id == "child-1"
731+ and trigger .trigger_type == UiPathResumeTriggerType .JOB
732+ ):
733+ return {"completed" : True }
734+ raise UiPathPendingTriggerError (ErrorCategory .USER , "still pending" )
735+
736+ read_trigger_mock = AsyncMock (side_effect = read_trigger_impl )
737+ cast (Any , trigger_manager ).read_trigger = read_trigger_mock
738+
739+ resumable = UiPathResumableRuntime (
740+ delegate = runtime_impl ,
741+ storage = storage ,
742+ trigger_manager = trigger_manager ,
743+ runtime_id = "runtime-1" ,
744+ )
745+
746+ result = await resumable .execute ({})
747+
748+ assert result .status == UiPathRuntimeStatus .SUSPENDED
749+ assert result .triggers is not None
750+ assert {t .interrupt_id for t in result .triggers } == {"child-2" }
751+ assert len (result .triggers ) == 2
752+ assert {t .trigger_type for t in result .triggers } == {
753+ UiPathResumeTriggerType .JOB ,
754+ UiPathResumeTriggerType .TIMER ,
755+ }
756+ assert runtime_impl .execution_count == 2
757+ assert create_triggers_mock .await_count == 2
758+ assert read_trigger_mock .await_count == 2
759+
760+ saved_triggers = await storage .get_triggers ("runtime-1" )
761+ assert {t .interrupt_id for t in saved_triggers } == {"child-2" }
0 commit comments