Skip to content

Commit 8afbebb

Browse files
committed
Merge branch 'ARG-AMBIGUITY' of https://github.com/PerfectoCode/perfecto-mcp into AI-SCRIPTLESS-ALIGNMENT
2 parents ec75cff + 923efe9 commit 8afbebb

9 files changed

Lines changed: 163 additions & 44 deletions

tests/test_ai_scriptless_manager.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,62 @@ def test_routes_add_command(self, perfecto_token, monkeypatch):
12081208
assert result.result["command_id"] == "wait"
12091209
assert len(captured["script"]["flowElements"]) == 1
12101210

1211+
def test_add_command_accepts_arguments_alias(self, perfecto_token, monkeypatch):
1212+
captured: dict = {}
1213+
_mock_load_and_mutate(monkeypatch, captured=captured)
1214+
1215+
tool = _register_tool(perfecto_token)
1216+
result = asyncio.run(_call_tool(tool, "add_command", {
1217+
"test_id": TEST_ID,
1218+
"command_id": "wait",
1219+
"arguments": {"duration": "7"},
1220+
}))
1221+
1222+
assert result.error is None
1223+
values = {
1224+
argument["name"]: argument["data"]["value"]
1225+
for argument in captured["script"]["flowElements"][0]["arguments"]
1226+
}
1227+
assert values["duration"] == "7"
1228+
1229+
def test_add_command_prefers_cmd_arguments_over_arguments_alias(
1230+
self, perfecto_token, monkeypatch):
1231+
captured: dict = {}
1232+
_mock_load_and_mutate(monkeypatch, captured=captured)
1233+
1234+
tool = _register_tool(perfecto_token)
1235+
result = asyncio.run(_call_tool(tool, "add_command", {
1236+
"test_id": TEST_ID,
1237+
"command_id": "wait",
1238+
"cmd_arguments": {"duration": "4"},
1239+
"arguments": {"duration": "9"},
1240+
}))
1241+
1242+
assert result.error is None
1243+
values = {
1244+
argument["name"]: argument["data"]["value"]
1245+
for argument in captured["script"]["flowElements"][0]["arguments"]
1246+
}
1247+
assert values["duration"] == "4"
1248+
1249+
def test_modify_command_accepts_arguments_alias(self, perfecto_token, monkeypatch):
1250+
captured: dict = {}
1251+
_mock_load_and_mutate(monkeypatch, _script_with_steps("wait"), captured)
1252+
1253+
tool = _register_tool(perfecto_token)
1254+
result = asyncio.run(_call_tool(tool, "modify_command", {
1255+
"test_id": TEST_ID,
1256+
"step_path": "0",
1257+
"arguments": {"duration": "5"},
1258+
}))
1259+
1260+
assert result.error is None
1261+
values = {
1262+
argument["name"]: argument["data"]["value"]
1263+
for argument in captured["script"]["flowElements"][0]["arguments"]
1264+
}
1265+
assert values["duration"] == "5"
1266+
12111267
def test_routes_list_test_variables(self, perfecto_token, monkeypatch):
12121268
script = new_empty_script()
12131269
script["variables"] = [{
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Copyright 2025 Perforce Software, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
10|Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
from tools.utils import normalize_action_args
18+
19+
20+
class TestNormalizeActionArgs:
21+
def test_none_returns_empty_action_and_args(self):
22+
action, args = normalize_action_args(None)
23+
assert action == ""
24+
assert args == {}
25+
26+
def test_empty_dict_returns_empty_action_and_args(self):
27+
action, args = normalize_action_args({})
28+
assert action == ""
29+
assert args == {}
30+
31+
def test_nested_action_and_args(self):
32+
action, args = normalize_action_args({
33+
"action": "add_command",
34+
"args": {"test_id": "PRIVATE:Folder/Test.xml", "command_id": "wait"},
35+
})
36+
assert action == "add_command"
37+
assert args == {"test_id": "PRIVATE:Folder/Test.xml", "command_id": "wait"}
38+
39+
def test_flattened_top_level_params_merge_into_args(self):
40+
action, args = normalize_action_args({
41+
"action": "add_command",
42+
"test_id": "PRIVATE:Folder/Test.xml",
43+
"command_id": "wait",
44+
"cmd_arguments": {"duration": "1"},
45+
})
46+
assert action == "add_command"
47+
assert args == {
48+
"test_id": "PRIVATE:Folder/Test.xml",
49+
"command_id": "wait",
50+
"cmd_arguments": {"duration": "1"},
51+
}
52+
53+
def test_unwraps_double_wrapped_arguments(self):
54+
action, args = normalize_action_args({
55+
"arguments": {
56+
"action": "add_command",
57+
"args": {"test_id": "PRIVATE:Folder/Test.xml"},
58+
}
59+
})
60+
assert action == "add_command"
61+
assert args == {"test_id": "PRIVATE:Folder/Test.xml"}
62+
63+
def test_does_not_unwrap_arguments_when_other_keys_present(self):
64+
action, args = normalize_action_args({
65+
"action": "add_command",
66+
"args": {"test_id": "PRIVATE:Folder/Test.xml", "command_id": "wait"},
67+
"arguments": {"duration": "3"},
68+
})
69+
assert action == "add_command"
70+
assert args["test_id"] == "PRIVATE:Folder/Test.xml"
71+
assert args["command_id"] == "wait"
72+
assert args["arguments"] == {"duration": "3"}
73+
74+
def test_strips_action_whitespace(self):
75+
action, args = normalize_action_args({"action": " list_tests ", "args": {}})
76+
assert action == "list_tests"
77+
assert args == {}
78+
79+
def test_none_args_value_becomes_empty_dict(self):
80+
action, args = normalize_action_args({"action": "list_tests", "args": None})
81+
assert action == "list_tests"
82+
assert args == {}
83+
84+
def test_args_is_never_none(self):
85+
_, args = normalize_action_args(None)
86+
assert args is not None
87+
_, args = normalize_action_args({"action": "x"})
88+
assert args is not None
89+
90+
def test_top_level_keys_overlay_nested_args(self):
91+
action, args = normalize_action_args({
92+
"action": "add_command",
93+
"args": {"test_id": "nested", "command_id": "wait"},
94+
"test_id": "top-level",
95+
})
96+
assert action == "add_command"
97+
assert args["test_id"] == "top-level"
98+
assert args["command_id"] == "wait"

tools/ai_scriptless_manager.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@
8181
)
8282

8383

84+
def _command_arguments(args: Dict[str, Any]) -> Any:
85+
"""Prefer cmd_arguments; accept arguments for older clients that used that key."""
86+
return args.get("cmd_arguments") or args.get("arguments")
87+
88+
8489
def _unknown_action_error(action: str, args: Dict[str, Any]) -> str:
8590
error = f"Action {action} not found in AI Scriptless manager tool"
8691
# A command argument flattened to the top level overwrites the dispatcher action with free text.
@@ -875,6 +880,7 @@ def register(mcp, token: Optional[PerfectoToken]):
875880
'action', which would collide with the action key of this tool. Always nest them in cmd_arguments,
876881
e.g. {"action": "add_command", "args": {"test_id": "...", "command_id": "ai_user-action",
877882
"cmd_arguments": {"action": "Tap on the Login button"}}}.
883+
'arguments' is accepted as a backward-compatible alias for cmd_arguments.
878884
after_path (str, optional): Insert after this step (step_path from view_test_structure).
879885
parent_path (str, optional): Insert inside a container (step_path of LogicalStep, Loop, or Branch).
880886
- modify_command: Update command arguments and persist.
@@ -884,6 +890,7 @@ def register(mcp, token: Optional[PerfectoToken]):
884890
cmd_arguments (dict): Argument names to new values. Merge semantics: only the arguments you send are
885891
replaced, the rest keep their current value, and arguments cannot be removed (delete_command removes
886892
the whole step). Same key rules as add_command (declared parameter names, optional data_source form).
893+
'arguments' is accepted as a backward-compatible alias for cmd_arguments.
887894
- delete_command: Remove a command from a test and persist.
888895
args(dict): Dictionary with the following required parameters:
889896
test_id (str): Test itemKey from list_tests.
@@ -1040,8 +1047,6 @@ async def ai_scriptless(
10401047
ctx: Context = Field(description="Context object providing access to MCP capabilities")
10411048
) -> BaseResult:
10421049
action, args = normalize_action_args(arguments)
1043-
if args is None:
1044-
args = {}
10451050
ai_scriptless_manager = AiScriptlessManager(token, ctx)
10461051

10471052
async def _dispatch():
@@ -1069,15 +1074,15 @@ async def _dispatch():
10691074
return await ai_scriptless_manager.add_command(
10701075
args.get("test_id", ""),
10711076
args.get("command_id", ""),
1072-
args.get("cmd_arguments"),
1077+
_command_arguments(args),
10731078
args.get("after_path"),
10741079
args.get("parent_path"),
10751080
)
10761081
case "modify_command":
10771082
return await ai_scriptless_manager.modify_command(
10781083
args.get("test_id", ""),
10791084
args.get("step_path", ""),
1080-
args.get("cmd_arguments", {}),
1085+
_command_arguments(args) or {},
10811086
)
10821087
case "delete_command":
10831088
return await ai_scriptless_manager.delete_command(

tools/device_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ async def devices(
8080
ctx: Context = Field(description="Context object providing access to MCP capabilities")
8181
) -> BaseResult:
8282
action, args = normalize_action_args(arguments)
83-
if args is None:
84-
args = {}
8583
device_manager = DeviceManager(token, ctx)
8684

8785
async def _dispatch():

tools/execution_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,6 @@ async def execution(
259259
ctx: Context = Field(description="Context object providing access to MCP capabilities")
260260
) -> BaseResult:
261261
action, args = normalize_action_args(arguments)
262-
if args is None:
263-
args = {}
264262
execution_manager = ExecutionManager(token, ctx)
265263

266264
async def _dispatch():

tools/help_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,6 @@ async def help_main(
270270
ctx: Context = Field(description="Context object providing access to MCP capabilities")
271271
) -> BaseResult:
272272
action, args = normalize_action_args(arguments)
273-
if args is None:
274-
args = {}
275273
help_manager = HelpManager(token, ctx)
276274

277275
async def _dispatch():

tools/tools_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,6 @@ async def tools(
339339
ctx: Context = Field(description="Context object providing access to MCP capabilities")
340340
) -> BaseResult:
341341
action, args = normalize_action_args(arguments)
342-
if args is None:
343-
args = {}
344342
tools_manager = ToolsManager(token, ctx)
345343

346344
async def _dispatch():

tools/user_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,6 @@ async def user(
5050
ctx: Context = Field(description="Context object providing access to MCP capabilities")
5151
) -> BaseResult:
5252
action, args = normalize_action_args(arguments)
53-
if args is None:
54-
args = {}
5553
user_manager = UserManager(token, ctx)
5654

5755
async def _dispatch():

tools/utils.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -239,33 +239,3 @@ def normalize_action_args(arguments: Optional[Dict[str, Any]] = None) -> tuple[s
239239
args[key] = value
240240
return action, args
241241

242-
243-
def validate_required_args(action: str, args: Optional[Dict[str, Any]], required: list[str]) -> Optional[BaseResult]:
244-
args = args or {}
245-
missing = [key for key in required if key not in args or args[key] is None]
246-
if not missing:
247-
return None
248-
missing_str = ", ".join(missing)
249-
required_str = ", ".join(required)
250-
return BaseResult(
251-
error=(
252-
f"Missing required args for action '{action}': {missing_str} not found within 'args'. "
253-
f"Required args: {required_str}. Ensure parameters are passed inside the 'args' argument."
254-
)
255-
)
256-
257-
258-
def validate_non_empty_str_arg(
259-
action: str, args: Optional[Dict[str, Any]], key: str
260-
) -> Optional[BaseResult]:
261-
"""Return BaseResult error if args[key] is missing, not a str, or only whitespace."""
262-
args = args or {}
263-
value = args.get(key)
264-
if not isinstance(value, str) or not value.strip():
265-
return BaseResult(
266-
error=(
267-
f"Missing required args for action '{action}': {key} must be a non-empty string "
268-
f"within 'args'. Required args: {key}."
269-
)
270-
)
271-
return None

0 commit comments

Comments
 (0)