1- """Tests for process_tool.py metadata."""
1+ """Tests for process_tool.py."""
2+
3+ from unittest .mock import AsyncMock , MagicMock , patch
24
35import pytest
46from uipath .agent .models .agent import (
57 AgentProcessToolProperties ,
68 AgentProcessToolResourceConfig ,
79 AgentToolType ,
810)
11+ from uipath .platform .common import WaitJob
12+ from uipath .platform .orchestrator import Job
913
1014from uipath_langchain .agent .tools .process_tool import create_process_tool
1115
1216
17+ def _noop_task (fn ):
18+ """No-op replacement for @task so it works outside Pregel context."""
19+ return fn
20+
21+
22+ @pytest .fixture (autouse = True )
23+ def _patch_lg_task ():
24+ """Patch @task decorator to no-op since unit tests run outside Pregel context."""
25+ with patch ("uipath_langchain.agent.tools.process_tool.task" , _noop_task ):
26+ yield
27+
28+
29+ @pytest .fixture
30+ def process_resource ():
31+ """Create a minimal process tool resource config."""
32+ return AgentProcessToolResourceConfig (
33+ type = AgentToolType .PROCESS ,
34+ name = "test_process" ,
35+ description = "Test process description" ,
36+ input_schema = {"type" : "object" , "properties" : {}},
37+ output_schema = {"type" : "object" , "properties" : {}},
38+ properties = AgentProcessToolProperties (
39+ process_name = "MyProcess" ,
40+ folder_path = "/Shared/MyFolder" ,
41+ ),
42+ )
43+
44+
45+ @pytest .fixture
46+ def process_resource_with_inputs ():
47+ """Create a process tool resource config with input arguments."""
48+ return AgentProcessToolResourceConfig (
49+ type = AgentToolType .PROCESS ,
50+ name = "data_processor" ,
51+ description = "Process data with arguments" ,
52+ input_schema = {
53+ "type" : "object" ,
54+ "properties" : {
55+ "name" : {"type" : "string" },
56+ "count" : {"type" : "integer" },
57+ },
58+ },
59+ output_schema = {
60+ "type" : "object" ,
61+ "properties" : {
62+ "result" : {"type" : "string" },
63+ },
64+ },
65+ properties = AgentProcessToolProperties (
66+ process_name = "DataProcessor" ,
67+ folder_path = "/Shared/DataFolder" ,
68+ ),
69+ )
70+
71+
1372class TestProcessToolMetadata :
1473 """Test that process tool has correct metadata for observability."""
1574
16- @pytest .fixture
17- def process_resource (self ):
18- """Create a minimal process tool resource config."""
19- return AgentProcessToolResourceConfig (
20- type = AgentToolType .PROCESS ,
21- name = "test_process" ,
22- description = "Test process description" ,
23- input_schema = {"type" : "object" , "properties" : {}},
24- output_schema = {"type" : "object" , "properties" : {}},
25- properties = AgentProcessToolProperties (
26- process_name = "MyProcess" ,
27- folder_path = "/Shared/MyFolder" ,
28- ),
29- )
30-
3175 def test_process_tool_has_metadata (self , process_resource ):
3276 """Test that process tool has metadata dict."""
3377 tool = create_process_tool (process_resource )
@@ -36,7 +80,7 @@ def test_process_tool_has_metadata(self, process_resource):
3680 assert isinstance (tool .metadata , dict )
3781
3882 def test_process_tool_metadata_has_tool_type (self , process_resource ):
39- """Test that metadata contains tool_type for span detection ."""
83+ """Test that metadata contains tool_type derived from resource type ."""
4084 tool = create_process_tool (process_resource )
4185 assert tool .metadata is not None
4286 assert tool .metadata ["tool_type" ] == "process"
@@ -52,3 +96,207 @@ def test_process_tool_metadata_has_folder_path(self, process_resource):
5296 tool = create_process_tool (process_resource )
5397 assert tool .metadata is not None
5498 assert tool .metadata ["folder_path" ] == "/Shared/MyFolder"
99+
100+ def test_process_tool_metadata_has_span_context (self , process_resource ):
101+ """Test that metadata contains _span_context dict for tracing."""
102+ tool = create_process_tool (process_resource )
103+ assert tool .metadata is not None
104+ assert "_span_context" in tool .metadata
105+ assert isinstance (tool .metadata ["_span_context" ], dict )
106+
107+ def test_process_tool_metadata_tool_type_uses_resource_type (self ):
108+ """Test that tool_type is derived from resource.type.lower()."""
109+ resource = AgentProcessToolResourceConfig (
110+ type = AgentToolType .PROCESS ,
111+ name = "test_process" ,
112+ description = "Test" ,
113+ input_schema = {"type" : "object" , "properties" : {}},
114+ output_schema = {"type" : "object" , "properties" : {}},
115+ properties = AgentProcessToolProperties (
116+ process_name = "MyProcess" ,
117+ folder_path = "/Shared" ,
118+ ),
119+ )
120+ tool = create_process_tool (resource )
121+ assert tool .metadata is not None
122+ assert tool .metadata ["tool_type" ] == resource .type .lower ()
123+
124+
125+ class TestProcessToolInvocation :
126+ """Test process tool invocation behavior: invoke then interrupt."""
127+
128+ @pytest .mark .asyncio
129+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
130+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
131+ async def test_invoke_calls_processes_invoke_async (
132+ self , mock_uipath_class , mock_interrupt , process_resource
133+ ):
134+ """Test that invoking the tool calls client.processes.invoke_async."""
135+ mock_job = MagicMock (spec = Job )
136+ mock_job .folder_key = "folder-key-123"
137+
138+ mock_client = MagicMock ()
139+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
140+ mock_uipath_class .return_value = mock_client
141+
142+ mock_interrupt .return_value = {"output" : "result" }
143+
144+ tool = create_process_tool (process_resource )
145+ await tool .ainvoke ({})
146+
147+ mock_client .processes .invoke_async .assert_called_once_with (
148+ name = "MyProcess" ,
149+ input_arguments = {},
150+ folder_path = "/Shared/MyFolder" ,
151+ attachments = [],
152+ parent_span_id = None ,
153+ )
154+
155+ @pytest .mark .asyncio
156+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
157+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
158+ async def test_invoke_interrupts_with_wait_job (
159+ self , mock_uipath_class , mock_interrupt , process_resource
160+ ):
161+ """Test that after invoking, the tool interrupts with WaitJob."""
162+ mock_job = MagicMock (spec = Job )
163+ mock_job .folder_key = "folder-key-456"
164+
165+ mock_client = MagicMock ()
166+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
167+ mock_uipath_class .return_value = mock_client
168+
169+ mock_interrupt .return_value = {"output" : "done" }
170+
171+ tool = create_process_tool (process_resource )
172+ await tool .ainvoke ({})
173+
174+ mock_interrupt .assert_called_once ()
175+ wait_job_arg = mock_interrupt .call_args [0 ][0 ]
176+ assert isinstance (wait_job_arg , WaitJob )
177+ assert wait_job_arg .job == mock_job
178+ assert wait_job_arg .process_folder_key == "folder-key-456"
179+
180+ @pytest .mark .asyncio
181+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
182+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
183+ async def test_invoke_passes_input_arguments (
184+ self , mock_uipath_class , mock_interrupt , process_resource_with_inputs
185+ ):
186+ """Test that input arguments are correctly passed to invoke_async."""
187+ mock_job = MagicMock (spec = Job )
188+ mock_job .folder_key = "folder-key"
189+
190+ mock_client = MagicMock ()
191+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
192+ mock_uipath_class .return_value = mock_client
193+
194+ mock_interrupt .return_value = {"result" : "processed" }
195+
196+ tool = create_process_tool (process_resource_with_inputs )
197+ await tool .ainvoke ({"name" : "test-data" , "count" : 42 })
198+
199+ call_kwargs = mock_client .processes .invoke_async .call_args [1 ]
200+ assert call_kwargs ["input_arguments" ] == {"name" : "test-data" , "count" : 42 }
201+ assert call_kwargs ["name" ] == "DataProcessor"
202+ assert call_kwargs ["folder_path" ] == "/Shared/DataFolder"
203+
204+ @pytest .mark .asyncio
205+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
206+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
207+ async def test_invoke_returns_interrupt_value (
208+ self , mock_uipath_class , mock_interrupt , process_resource
209+ ):
210+ """Test that the tool returns the value from interrupt()."""
211+ mock_job = MagicMock (spec = Job )
212+ mock_job .folder_key = "folder-key"
213+
214+ mock_client = MagicMock ()
215+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
216+ mock_uipath_class .return_value = mock_client
217+
218+ mock_interrupt .return_value = {"output_arg" : "value123" }
219+
220+ tool = create_process_tool (process_resource )
221+ result = await tool .ainvoke ({})
222+
223+ assert result == {"output_arg" : "value123" }
224+
225+
226+ class TestProcessToolSpanContext :
227+ """Test that _span_context is properly wired for tracing."""
228+
229+ @pytest .mark .asyncio
230+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
231+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
232+ async def test_span_context_parent_span_id_passed_to_invoke (
233+ self , mock_uipath_class , mock_interrupt , process_resource
234+ ):
235+ """Test that parent_span_id from _span_context is forwarded to invoke_async."""
236+ mock_job = MagicMock (spec = Job )
237+ mock_job .folder_key = "folder-key"
238+
239+ mock_client = MagicMock ()
240+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
241+ mock_uipath_class .return_value = mock_client
242+
243+ mock_interrupt .return_value = {}
244+
245+ tool = create_process_tool (process_resource )
246+ assert tool .metadata is not None
247+
248+ # Simulate tracing setting parent_span_id via the shared _span_context
249+ tool .metadata ["_span_context" ]["parent_span_id" ] = "span-abc-123"
250+
251+ await tool .ainvoke ({})
252+
253+ call_kwargs = mock_client .processes .invoke_async .call_args [1 ]
254+ assert call_kwargs ["parent_span_id" ] == "span-abc-123"
255+
256+ @pytest .mark .asyncio
257+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
258+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
259+ async def test_span_context_consumed_after_invoke (
260+ self , mock_uipath_class , mock_interrupt , process_resource
261+ ):
262+ """Test that parent_span_id is popped (consumed) from _span_context after use."""
263+ mock_job = MagicMock (spec = Job )
264+ mock_job .folder_key = "folder-key"
265+
266+ mock_client = MagicMock ()
267+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
268+ mock_uipath_class .return_value = mock_client
269+
270+ mock_interrupt .return_value = {}
271+
272+ tool = create_process_tool (process_resource )
273+ assert tool .metadata is not None
274+ tool .metadata ["_span_context" ]["parent_span_id" ] = "span-xyz"
275+
276+ await tool .ainvoke ({})
277+
278+ # parent_span_id should be consumed (popped) after invocation
279+ assert "parent_span_id" not in tool .metadata ["_span_context" ]
280+
281+ @pytest .mark .asyncio
282+ @patch ("uipath_langchain.agent.tools.process_tool.interrupt" )
283+ @patch ("uipath_langchain.agent.tools.process_tool.UiPath" )
284+ async def test_span_context_defaults_to_none_when_empty (
285+ self , mock_uipath_class , mock_interrupt , process_resource
286+ ):
287+ """Test that parent_span_id defaults to None when _span_context is empty."""
288+ mock_job = MagicMock (spec = Job )
289+ mock_job .folder_key = "folder-key"
290+
291+ mock_client = MagicMock ()
292+ mock_client .processes .invoke_async = AsyncMock (return_value = mock_job )
293+ mock_uipath_class .return_value = mock_client
294+
295+ mock_interrupt .return_value = {}
296+
297+ tool = create_process_tool (process_resource )
298+ # Don't set any parent_span_id
299+ await tool .ainvoke ({})
300+
301+ call_kwargs = mock_client .processes .invoke_async .call_args [1 ]
302+ assert call_kwargs ["parent_span_id" ] is None
0 commit comments