Skip to content

Commit 018284e

Browse files
tests
1 parent b48671c commit 018284e

5 files changed

Lines changed: 731 additions & 24 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Tests for json_utils.py — JSONPath extraction from Pydantic models."""
2+
3+
from typing import Optional
4+
5+
from pydantic import BaseModel
6+
7+
from uipath_langchain.agent.react.json_utils import (
8+
_create_type_matcher,
9+
_is_pydantic_model,
10+
_unwrap_optional,
11+
extract_values_by_paths,
12+
get_json_paths_by_type,
13+
)
14+
15+
# --- Test models ---
16+
17+
18+
class Attachment(BaseModel):
19+
id: str
20+
filename: str
21+
22+
23+
class NestedContainer(BaseModel):
24+
attachment: Attachment
25+
label: str
26+
27+
28+
class ModelWithSimpleField(BaseModel):
29+
attachment: Attachment
30+
name: str
31+
32+
33+
class ModelWithArrayField(BaseModel):
34+
attachments: list[Attachment]
35+
count: int
36+
37+
38+
class ModelWithOptionalField(BaseModel):
39+
attachment: Optional[Attachment] = None
40+
name: str
41+
42+
43+
class ModelWithNestedModel(BaseModel):
44+
container: NestedContainer
45+
title: str
46+
47+
48+
class ModelWithArrayOfNested(BaseModel):
49+
containers: list[NestedContainer]
50+
51+
52+
class ModelWithNoTargetType(BaseModel):
53+
name: str
54+
value: int
55+
56+
57+
class ModelWithMixedFields(BaseModel):
58+
single: Attachment
59+
multiple: list[Attachment]
60+
label: str
61+
62+
63+
# --- Tests for get_json_paths_by_type ---
64+
65+
66+
class TestGetJsonPathsByType:
67+
"""Tests for get_json_paths_by_type."""
68+
69+
def test_simple_field(self) -> None:
70+
paths = get_json_paths_by_type(ModelWithSimpleField, "Attachment")
71+
assert paths == ["$.attachment"]
72+
73+
def test_array_field(self) -> None:
74+
paths = get_json_paths_by_type(ModelWithArrayField, "Attachment")
75+
assert paths == ["$.attachments[*]"]
76+
77+
def test_optional_field_unwrapped(self) -> None:
78+
paths = get_json_paths_by_type(ModelWithOptionalField, "Attachment")
79+
assert paths == ["$.attachment"]
80+
81+
def test_nested_model_field(self) -> None:
82+
paths = get_json_paths_by_type(ModelWithNestedModel, "Attachment")
83+
assert paths == ["$.container.attachment"]
84+
85+
def test_array_of_nested_models(self) -> None:
86+
paths = get_json_paths_by_type(ModelWithArrayOfNested, "Attachment")
87+
assert paths == ["$.containers[*].attachment"]
88+
89+
def test_no_matching_type_returns_empty(self) -> None:
90+
paths = get_json_paths_by_type(ModelWithNoTargetType, "Attachment")
91+
assert paths == []
92+
93+
def test_mixed_simple_and_array(self) -> None:
94+
paths = get_json_paths_by_type(ModelWithMixedFields, "Attachment")
95+
assert "$.single" in paths
96+
assert "$.multiple[*]" in paths
97+
assert len(paths) == 2
98+
99+
100+
# --- Tests for extract_values_by_paths ---
101+
102+
103+
class TestExtractValuesByPaths:
104+
"""Tests for extract_values_by_paths."""
105+
106+
def test_extract_from_dict_simple_path(self) -> None:
107+
obj = {"attachment": {"id": "123", "filename": "doc.pdf"}, "name": "test"}
108+
result = extract_values_by_paths(obj, ["$.attachment"])
109+
assert result == [{"id": "123", "filename": "doc.pdf"}]
110+
111+
def test_extract_from_dict_array_path(self) -> None:
112+
obj = {
113+
"attachments": [
114+
{"id": "1", "filename": "a.pdf"},
115+
{"id": "2", "filename": "b.pdf"},
116+
],
117+
"count": 2,
118+
}
119+
result = extract_values_by_paths(obj, ["$.attachments[*]"])
120+
assert len(result) == 2
121+
assert result[0]["id"] == "1"
122+
assert result[1]["id"] == "2"
123+
124+
def test_extract_from_basemodel(self) -> None:
125+
obj = ModelWithSimpleField(
126+
attachment=Attachment(id="456", filename="img.png"),
127+
name="test",
128+
)
129+
result = extract_values_by_paths(obj, ["$.attachment"])
130+
assert len(result) == 1
131+
assert result[0]["id"] == "456"
132+
133+
def test_extract_multiple_paths(self) -> None:
134+
obj = {
135+
"single": {"id": "s1", "filename": "s.pdf"},
136+
"multiple": [
137+
{"id": "m1", "filename": "m1.pdf"},
138+
{"id": "m2", "filename": "m2.pdf"},
139+
],
140+
"label": "test",
141+
}
142+
result = extract_values_by_paths(obj, ["$.single", "$.multiple[*]"])
143+
assert len(result) == 3
144+
145+
def test_extract_no_match_returns_empty(self) -> None:
146+
obj = {"name": "test"}
147+
result = extract_values_by_paths(obj, ["$.nonexistent"])
148+
assert result == []
149+
150+
def test_extract_empty_paths_returns_empty(self) -> None:
151+
obj = {"name": "test"}
152+
result = extract_values_by_paths(obj, [])
153+
assert result == []
154+
155+
def test_extract_nested_path(self) -> None:
156+
obj = {
157+
"container": {
158+
"attachment": {"id": "nested", "filename": "n.pdf"},
159+
"label": "c",
160+
},
161+
"title": "t",
162+
}
163+
result = extract_values_by_paths(obj, ["$.container.attachment"])
164+
assert len(result) == 1
165+
assert result[0]["id"] == "nested"
166+
167+
168+
# --- Tests for helper functions ---
169+
170+
171+
class TestUnwrapOptional:
172+
"""Tests for _unwrap_optional."""
173+
174+
def test_unwraps_optional_type(self) -> None:
175+
result = _unwrap_optional(Optional[str])
176+
assert result is str
177+
178+
def test_non_optional_unchanged(self) -> None:
179+
result = _unwrap_optional(str)
180+
assert result is str
181+
182+
def test_unwraps_optional_basemodel(self) -> None:
183+
result = _unwrap_optional(Optional[Attachment])
184+
assert result is Attachment
185+
186+
187+
class TestIsPydanticModel:
188+
"""Tests for _is_pydantic_model."""
189+
190+
def test_basemodel_returns_true(self) -> None:
191+
assert _is_pydantic_model(Attachment) is True
192+
193+
def test_str_returns_false(self) -> None:
194+
assert _is_pydantic_model(str) is False
195+
196+
def test_int_returns_false(self) -> None:
197+
assert _is_pydantic_model(int) is False
198+
199+
def test_none_returns_false(self) -> None:
200+
assert _is_pydantic_model(None) is False
201+
202+
def test_instance_returns_false(self) -> None:
203+
assert _is_pydantic_model(Attachment(id="1", filename="f")) is False
204+
205+
206+
class TestCreateTypeMatcher:
207+
"""Tests for _create_type_matcher."""
208+
209+
def test_matches_by_name(self) -> None:
210+
matcher = _create_type_matcher("Attachment", None)
211+
assert matcher(Attachment) is True
212+
213+
def test_matches_by_target_type(self) -> None:
214+
matcher = _create_type_matcher("Attachment", Attachment)
215+
assert matcher(Attachment) is True
216+
217+
def test_no_match_returns_false(self) -> None:
218+
matcher = _create_type_matcher("Attachment", None)
219+
assert matcher(str) is False
220+
221+
def test_string_annotation_match(self) -> None:
222+
matcher = _create_type_matcher("Attachment", None)
223+
assert matcher("Attachment") is True
224+
225+
def test_string_annotation_no_match(self) -> None:
226+
matcher = _create_type_matcher("Attachment", None)
227+
assert matcher("OtherType") is False

tests/agent/react/test_merge_objects.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from pydantic import BaseModel
55

6+
from uipath_langchain.agent.react.reducers import merge_dicts as reducer_merge_dicts
67
from uipath_langchain.agent.react.reducers import merge_objects
78

89

@@ -184,3 +185,39 @@ def test_invalid_field_names_ignored(self):
184185
assert result.name == "updated"
185186
# Invalid field should not exist
186187
assert not hasattr(result, "invalid_field")
188+
189+
190+
class TestMergeDicts:
191+
"""Test merge_dicts reducer from reducers.py."""
192+
193+
def test_empty_right_returns_left(self):
194+
left = {"a": 1, "b": 2}
195+
result = reducer_merge_dicts(left, {})
196+
assert result is left
197+
198+
def test_empty_left_returns_right(self):
199+
right = {"a": 1}
200+
result = reducer_merge_dicts({}, right)
201+
assert result is right
202+
203+
def test_both_empty_returns_left(self):
204+
result = reducer_merge_dicts({}, {})
205+
assert result == {}
206+
207+
def test_disjoint_keys_merged(self):
208+
left = {"a": 1}
209+
right = {"b": 2}
210+
result = reducer_merge_dicts(left, right)
211+
assert result == {"a": 1, "b": 2}
212+
213+
def test_overlapping_keys_right_wins(self):
214+
left = {"a": 1, "b": 2}
215+
right = {"b": 3, "c": 4}
216+
result = reducer_merge_dicts(left, right)
217+
assert result == {"a": 1, "b": 3, "c": 4}
218+
219+
def test_none_values_in_right_override(self):
220+
left = {"a": 1}
221+
right = {"a": None}
222+
result = reducer_merge_dicts(left, right)
223+
assert result == {"a": None}

tests/agent/react/test_router.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,91 @@ def test_empty_ai_response_raises_exception(self, route_function_no_limit):
278278
match="Agent produced empty response without tool calls",
279279
):
280280
route_function_no_limit(state)
281+
282+
283+
class TestRouteAgentMultipleToolCallSequencing:
284+
"""Test sequential dispatching of multiple tool calls in a single AI message."""
285+
286+
def test_three_tools_dispatched_sequentially(self):
287+
"""Router dispatches each tool in order when multiple tool calls exist."""
288+
route_func = create_route_agent(thinking_messages_limit=0)
289+
290+
ai_message = AIMessage(
291+
content="Using three tools",
292+
tool_calls=[
293+
{"name": "tool_a", "args": {}, "id": "call_a"},
294+
{"name": "tool_b", "args": {}, "id": "call_b"},
295+
{"name": "tool_c", "args": {}, "id": "call_c"},
296+
],
297+
)
298+
299+
# Step 1: No tool results yet — route to first tool
300+
state_0 = MockAgentGraphState(
301+
messages=[HumanMessage(content="query"), ai_message]
302+
)
303+
assert route_func(state_0) == "tool_a"
304+
305+
# Step 2: First tool done — route to second
306+
state_1 = MockAgentGraphState(
307+
messages=[
308+
HumanMessage(content="query"),
309+
ai_message,
310+
ToolMessage(content="result_a", tool_call_id="call_a"),
311+
]
312+
)
313+
assert route_func(state_1) == "tool_b"
314+
315+
# Step 3: Two tools done — route to third
316+
state_2 = MockAgentGraphState(
317+
messages=[
318+
HumanMessage(content="query"),
319+
ai_message,
320+
ToolMessage(content="result_a", tool_call_id="call_a"),
321+
ToolMessage(content="result_b", tool_call_id="call_b"),
322+
]
323+
)
324+
assert route_func(state_2) == "tool_c"
325+
326+
# Step 4: All done — route back to agent
327+
state_3 = MockAgentGraphState(
328+
messages=[
329+
HumanMessage(content="query"),
330+
ai_message,
331+
ToolMessage(content="result_a", tool_call_id="call_a"),
332+
ToolMessage(content="result_b", tool_call_id="call_b"),
333+
ToolMessage(content="result_c", tool_call_id="call_c"),
334+
]
335+
)
336+
assert route_func(state_3) == AgentGraphNode.AGENT
337+
338+
def test_flow_control_tool_among_multiple_terminates(self):
339+
"""Router should terminate when the next tool is a flow control tool."""
340+
route_func = create_route_agent(thinking_messages_limit=0)
341+
342+
ai_message = AIMessage(
343+
content="Using tools then ending",
344+
tool_calls=[
345+
{"name": "regular_tool", "args": {}, "id": "call_1"},
346+
{
347+
"name": END_EXECUTION_TOOL.name,
348+
"args": {"reason": "done"},
349+
"id": "call_2",
350+
},
351+
],
352+
)
353+
354+
# First tool is regular
355+
state_0 = MockAgentGraphState(
356+
messages=[HumanMessage(content="query"), ai_message]
357+
)
358+
assert route_func(state_0) == "regular_tool"
359+
360+
# After first tool done, next is flow control — terminate
361+
state_1 = MockAgentGraphState(
362+
messages=[
363+
HumanMessage(content="query"),
364+
ai_message,
365+
ToolMessage(content="done", tool_call_id="call_1"),
366+
]
367+
)
368+
assert route_func(state_1) == AgentGraphNode.TERMINATE

0 commit comments

Comments
 (0)