@@ -257,3 +257,192 @@ async def read_trigger_impl_3(trigger: UiPathResumeTrigger) -> dict[str, Any]:
257257 assert result .output ["completed" ] is True
258258 assert "int-2" in result .output ["resume_data" ]
259259 assert "int-3" in result .output ["resume_data" ]
260+
261+
262+ @pytest .mark .asyncio
263+ async def test_resumable_auto_resumes_when_triggers_already_fired ():
264+ """When triggers are already fired during suspension, runtime should auto-resume."""
265+
266+ runtime_impl = MultiTriggerMockRuntime ()
267+ storage = StatefulStorageMock ()
268+ trigger_manager = make_trigger_manager_mock ()
269+
270+ read_count = {"count" : 0 }
271+
272+ # configure trigger manager to return triggers as already fired only on first batch
273+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
274+ read_count ["count" ] += 1
275+ # first two triggers (int-1, int-2) are immediately available
276+ # subsequent triggers are pending
277+ if trigger .interrupt_id in ["int-1" , "int-2" ] and read_count ["count" ] <= 2 :
278+ return {"approved" : True }
279+ raise UiPathPendingTriggerError ("pending" )
280+
281+ trigger_manager .read_trigger = AsyncMock (side_effect = read_trigger_impl ) # type: ignore
282+
283+ resumable = UiPathResumableRuntime (
284+ delegate = runtime_impl ,
285+ storage = storage ,
286+ trigger_manager = trigger_manager ,
287+ runtime_id = "runtime-1" ,
288+ )
289+
290+ # First execution - should suspend with int-1 and int-2, but since both are
291+ # already fired, it should auto-resume and suspend again with int-2 and int-3
292+ result = await resumable .execute ({})
293+
294+ # The runtime should have auto-resumed once and suspended again
295+ assert result .status == UiPathRuntimeStatus .SUSPENDED
296+ assert result .triggers is not None
297+ assert len (result .triggers ) == 2
298+ # After auto-resume, we should be at second suspension with int-2, int-3
299+ assert {t .interrupt_id for t in result .triggers } == {"int-2" , "int-3" }
300+
301+ # Delegate should have been executed twice (initial + auto-resume)
302+ assert runtime_impl .execution_count == 2
303+
304+
305+ @pytest .mark .asyncio
306+ async def test_resumable_auto_resumes_partial_fired_triggers ():
307+ """When only some triggers are fired during suspension, auto-resume with those."""
308+
309+ runtime_impl = MultiTriggerMockRuntime ()
310+ storage = StatefulStorageMock ()
311+ trigger_manager = make_trigger_manager_mock ()
312+
313+ # Configure trigger manager so int-1 is fired but int-2 is pending
314+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
315+ if trigger .interrupt_id == "int-1" :
316+ return {"approved" : True }
317+ raise UiPathPendingTriggerError ("still pending" )
318+
319+ trigger_manager .read_trigger = AsyncMock (side_effect = read_trigger_impl ) # type: ignore
320+
321+ resumable = UiPathResumableRuntime (
322+ delegate = runtime_impl ,
323+ storage = storage ,
324+ trigger_manager = trigger_manager ,
325+ runtime_id = "runtime-1" ,
326+ )
327+
328+ # First execution - int-1 fires immediately, int-2 stays pending
329+ # Should auto-resume with int-1 and suspend with int-2, int-3
330+ result = await resumable .execute ({})
331+
332+ assert result .status == UiPathRuntimeStatus .SUSPENDED
333+ assert result .triggers is not None
334+ # After auto-resume with int-1, should have int-2 (still pending) + int-3 (new)
335+ assert {t .interrupt_id for t in result .triggers } == {"int-2" , "int-3" }
336+
337+ # Verify int-1 was consumed (deleted from storage)
338+ remaining_triggers = await storage .get_triggers ("runtime-1" )
339+ assert all (t .interrupt_id != "int-1" for t in remaining_triggers )
340+
341+
342+ @pytest .mark .asyncio
343+ async def test_resumable_auto_resumes_multiple_times ():
344+ """When triggers keep being fired immediately, keep auto-resuming until complete."""
345+
346+ runtime_impl = MultiTriggerMockRuntime ()
347+ storage = StatefulStorageMock ()
348+ trigger_manager = make_trigger_manager_mock ()
349+
350+ # All triggers are always immediately available
351+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
352+ return {"approved" : True }
353+
354+ trigger_manager .read_trigger = AsyncMock (side_effect = read_trigger_impl ) # type: ignore
355+
356+ resumable = UiPathResumableRuntime (
357+ delegate = runtime_impl ,
358+ storage = storage ,
359+ trigger_manager = trigger_manager ,
360+ runtime_id = "runtime-1" ,
361+ )
362+
363+ # Execute once - should auto-resume through all suspensions
364+ result = await resumable .execute ({})
365+
366+ # Should complete successfully after auto-resuming twice
367+ # 1st exec: suspend with int-1, int-2 -> auto-resume
368+ # 2nd exec: suspend with int-2, int-3 -> auto-resume
369+ # 3rd exec: complete
370+ assert result .status == UiPathRuntimeStatus .SUCCESSFUL
371+ assert isinstance (result .output , dict )
372+ assert result .output ["completed" ] is True
373+
374+ # Delegate should have been executed 3 times
375+ assert runtime_impl .execution_count == 3
376+
377+
378+ @pytest .mark .asyncio
379+ async def test_resumable_stream_auto_resumes_when_triggers_fired ():
380+ """Stream auto-resume when triggers are already fired."""
381+
382+ runtime_impl = MultiTriggerMockRuntime ()
383+ storage = StatefulStorageMock ()
384+ trigger_manager = make_trigger_manager_mock ()
385+
386+ read_attempts = {"count" : 0 }
387+
388+ # Configure int-1 to be immediately fired, int-2 pending
389+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
390+ read_attempts ["count" ] += 1
391+ if trigger .interrupt_id == "int-1" and read_attempts ["count" ] <= 1 :
392+ return {"approved" : True }
393+ raise UiPathPendingTriggerError ("pending" )
394+
395+ trigger_manager .read_trigger = AsyncMock (side_effect = read_trigger_impl ) # type: ignore
396+
397+ resumable = UiPathResumableRuntime (
398+ delegate = runtime_impl ,
399+ storage = storage ,
400+ trigger_manager = trigger_manager ,
401+ runtime_id = "runtime-1" ,
402+ )
403+
404+ # Stream should auto-resume and yield final result after auto-resume
405+ events = []
406+ async for event in resumable .stream ({}):
407+ events .append (event )
408+
409+ # Should have received exactly one final result (after auto-resume)
410+ assert len (events ) == 1
411+ assert isinstance (events [0 ], UiPathRuntimeResult )
412+ assert events [0 ].status == UiPathRuntimeStatus .SUSPENDED
413+
414+ # Should be at second suspension (after auto-resume with int-1)
415+ assert events [0 ].triggers is not None
416+ assert {t .interrupt_id for t in events [0 ].triggers } == {"int-2" , "int-3" }
417+
418+
419+ @pytest .mark .asyncio
420+ async def test_resumable_no_auto_resume_when_all_triggers_pending ():
421+ """When all triggers are pending, should NOT auto-resume."""
422+
423+ runtime_impl = MultiTriggerMockRuntime ()
424+ storage = StatefulStorageMock ()
425+ trigger_manager = make_trigger_manager_mock ()
426+
427+ # All triggers are pending
428+ async def read_trigger_impl (trigger : UiPathResumeTrigger ) -> dict [str , Any ]:
429+ raise UiPathPendingTriggerError ("pending" )
430+
431+ trigger_manager .read_trigger = AsyncMock (side_effect = read_trigger_impl ) # type: ignore
432+
433+ resumable = UiPathResumableRuntime (
434+ delegate = runtime_impl ,
435+ storage = storage ,
436+ trigger_manager = trigger_manager ,
437+ runtime_id = "runtime-1" ,
438+ )
439+
440+ # Execute - should suspend
441+ result = await resumable .execute ({})
442+
443+ assert result .status == UiPathRuntimeStatus .SUSPENDED
444+ assert result .triggers is not None
445+ assert {t .interrupt_id for t in result .triggers } == {"int-1" , "int-2" }
446+
447+ # Delegate should have been executed only once)
448+ assert runtime_impl .execution_count == 1
0 commit comments