Skip to content

Commit d05052d

Browse files
committed
fix: handle empty dict resume
1 parent ab30aa8 commit d05052d

File tree

2 files changed

+129
-1
lines changed

2 files changed

+129
-1
lines changed

src/uipath/runtime/resumable/runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async def _restore_resume_input(
111111
Input to use for resume, either provided or from storage
112112
"""
113113
# If user provided explicit input, use it
114-
if input is not None:
114+
if input is not None and bool(input):
115115
return input
116116

117117
# Otherwise, fetch from storage

tests/test_resumable_runtime.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Tests for UiPathResumableRuntime."""
2+
3+
from typing import Any, AsyncGenerator
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
from uipath.runtime import UiPathExecuteOptions, UiPathRuntimeEvent
9+
from uipath.runtime.base import UiPathStreamOptions
10+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
11+
from uipath.runtime.resumable.runtime import UiPathResumableRuntime
12+
from uipath.runtime.schema import UiPathRuntimeSchema
13+
14+
15+
class MockDelegateRuntime:
16+
"""Mock delegate runtime for testing."""
17+
18+
def __init__(self):
19+
self.last_input: dict[str, Any] | None = None
20+
21+
async def execute(
22+
self,
23+
input: dict[str, Any] | None = None,
24+
options: UiPathExecuteOptions | None = None,
25+
) -> UiPathRuntimeResult:
26+
self.last_input = input
27+
return UiPathRuntimeResult(
28+
output={"received_input": input}, status=UiPathRuntimeStatus.SUCCESSFUL
29+
)
30+
31+
async def stream(
32+
self,
33+
input: dict[str, Any] | None = None,
34+
options: UiPathStreamOptions | None = None,
35+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
36+
self.last_input = input
37+
yield UiPathRuntimeResult(
38+
output={"received_input": input}, status=UiPathRuntimeStatus.SUCCESSFUL
39+
)
40+
41+
async def get_schema(self) -> UiPathRuntimeSchema:
42+
raise NotImplementedError()
43+
44+
async def dispose(self) -> None:
45+
pass
46+
47+
48+
class MockStorage:
49+
"""Mock storage for testing."""
50+
51+
def __init__(self, trigger: Any = None):
52+
self._trigger = trigger
53+
self.saved_trigger: Any = None
54+
55+
async def save_trigger(self, trigger: Any) -> None:
56+
self.saved_trigger = trigger
57+
58+
async def get_latest_trigger(self) -> Any:
59+
return self._trigger
60+
61+
62+
class MockTriggerManager:
63+
"""Mock trigger manager for testing."""
64+
65+
def __init__(self, resume_data: dict[str, Any] | None = None):
66+
self._resume_data = resume_data
67+
68+
async def create_trigger(self, suspend_value: Any) -> Any:
69+
raise NotImplementedError()
70+
71+
async def read_trigger(self, trigger: Any) -> Any | None:
72+
return self._resume_data
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_restore_resume_input_with_empty_dict_fetches_from_storage():
77+
"""Test that empty dict input triggers fetching from storage on resume."""
78+
delegate = MockDelegateRuntime()
79+
stored_trigger = MagicMock()
80+
storage = MockStorage(trigger=stored_trigger)
81+
resume_data = {"key": "value_from_storage"}
82+
trigger_manager = MockTriggerManager(resume_data=resume_data)
83+
84+
runtime = UiPathResumableRuntime(delegate, storage, trigger_manager)
85+
86+
options = UiPathExecuteOptions(resume=True)
87+
result = await runtime.execute(input={}, options=options)
88+
89+
assert delegate.last_input == resume_data
90+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_restore_resume_input_with_non_empty_dict_uses_provided_input():
95+
"""Test that non-empty dict input is used directly, not fetched from storage."""
96+
delegate = MockDelegateRuntime()
97+
stored_trigger = MagicMock()
98+
storage = MockStorage(trigger=stored_trigger)
99+
resume_data = {"key": "value_from_storage"}
100+
trigger_manager = MockTriggerManager(resume_data=resume_data)
101+
102+
runtime = UiPathResumableRuntime(delegate, storage, trigger_manager)
103+
104+
provided_input = {"user_provided": "data"}
105+
options = UiPathExecuteOptions(resume=True)
106+
result = await runtime.execute(input=provided_input, options=options)
107+
108+
assert delegate.last_input == provided_input
109+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_stream_restore_resume_input_with_empty_dict_fetches_from_storage():
114+
"""Test that empty dict input triggers fetching from storage on resume in stream mode."""
115+
delegate = MockDelegateRuntime()
116+
stored_trigger = MagicMock()
117+
storage = MockStorage(trigger=stored_trigger)
118+
resume_data = {"key": "value_from_storage"}
119+
trigger_manager = MockTriggerManager(resume_data=resume_data)
120+
121+
runtime = UiPathResumableRuntime(delegate, storage, trigger_manager)
122+
123+
options = UiPathStreamOptions(resume=True)
124+
async for event in runtime.stream(input={}, options=options):
125+
if isinstance(event, UiPathRuntimeResult):
126+
assert event.status == UiPathRuntimeStatus.SUCCESSFUL
127+
128+
assert delegate.last_input == resume_data

0 commit comments

Comments
 (0)