77 CommandDefinitionSummary ,
88 ScriptFlowElement ,
99 ScriptParameter ,
10+ ScriptStepArgument ,
11+ ScriptStepDetail ,
12+ ScriptStepParameter ,
1013 ScriptVariableSummary ,
1114 SnapshotListResult ,
1215 SnapshotSummary ,
1316 TestStructure ,
1417)
18+ from tools .ai_scriptless .definitions import (
19+ parameter_label ,
20+ restriction_allowed_values ,
21+ restriction_range ,
22+ )
1523from tools .ai_scriptless .elements import normalize_if_statement_aliases
1624
1725PRIMARY_AI_COMMAND_IDS = (
@@ -30,7 +38,7 @@ def command_selection_policy_info() -> List[str]:
3038 " • ai_user-action — user interactions (open browser/app, navigate to URL, tap, type, dismiss overlays); "
3139 "argument: action (natural language)." ,
3240 " • ai_validation — checkpoints and assertions; argument: validation (natural language)." ,
33- " • ai_visual-comparison — visual/baseline comparison; argument: name ." ,
41+ " • ai_visual-comparison — visual/baseline comparison; argument: baselineId ." ,
3442 "Prefer ai_user-action for navigation (e.g. open browser and go to URL), not browser_goto / browser_open." ,
3543 "Do not use browser_*, touch_tap, webpage.element_*, checkpoint_text, etc. unless the user explicitly "
3644 "requests a non-AI command or agreed that AI commands cannot meet a documented requirement." ,
@@ -42,8 +50,11 @@ def command_selection_policy_info() -> List[str]:
4250 "Keep command arguments nested inside cmd_arguments: the 'action' parameter of ai_user-action "
4351 "collides with the tool's own action key if flattened into args." ,
4452 "Values are constants by default; pass {\" data_source\" : \" VARIABLE\" , \" value\" : \" <variable name>\" } "
45- "to bind an argument to a script variable." ,
53+ "to bind an argument to a script variable, or {\" data_source\" : \" DATATABLE\" , \" table_name\" : \" <table>\" , "
54+ "\" column\" : \" <column>\" } to bind it to a DataTable column." ,
4655 "modify_command merges: only the arguments sent are replaced, the others keep their current value." ,
56+ "Values are validated against the declared type, range, allowed values and data sources; "
57+ "view_test_step reports all four for every argument of an existing step." ,
4758 ]
4859
4960def format_ai_scriptless_tests_filter_values (tests : dict [str , Any ], params : Optional [dict ] = None ) -> dict [str , Any ]:
@@ -168,17 +179,17 @@ def _parameter_display_label(param: dict[str, Any]) -> str:
168179
169180
170181def _format_argument_display_value (element : dict [str , Any ], argument_name : str ) -> Optional [str ]:
171- for argument in element . get ( " arguments" , []):
172- if argument .get ("name" ) != argument_name :
173- continue
174- data = argument . get ( "data" , {})
175- value = data .get ("value" )
176- if value is None :
177- return None
178- if data . get ( "secured" ):
179- return "< secured>"
180- return str ( value )
181- return None
182+ # A multivalued parameter has several arguments under one name; the UI shows the last.
183+ matches = [ a for a in element . get ( "arguments" , []) if a .get ("name" ) == argument_name ]
184+ if not matches :
185+ return None
186+ data = matches [ - 1 ] .get ("data" , {} )
187+ value = data . get ( "value" )
188+ if value is None :
189+ return None
190+ if data . get ( " secured" ):
191+ return "<secured>"
192+ return str ( value )
182193
183194
184195def _command_step_display_name (
@@ -245,19 +256,24 @@ def _step_display_name(element: dict[str, Any], definitions_map: dict[str, dict[
245256
246257 if element_type == "Loop" :
247258 iterator = element .get ("iterator" , {})
259+ variable = iterator .get ("variable" )
260+ if variable :
261+ return f"Loop ({ variable } )"
248262 count = iterator .get ("count" )
249263 if count is not None :
250- return f"Loop ({ count } )"
264+ # The API serializes the count as a float; 2.0 reads as a broken count.
265+ return f"Loop ({ _range_bound (count )} )"
251266 return "Loop"
252267
253268 if element_type == "IfStatement" :
254- expression = element . get ( "expression" ) or element .get ("label" )
255- if expression :
256- return f"Condition ({ expression } )"
269+ label = element .get ("label" )
270+ if label :
271+ return f"Condition ({ label } )"
257272 return "Condition"
258273
259274 if element_type == "LogicalStep" :
260- label = element .get ("label" )
275+ # The UI stores the group title in `name`; `label` is only what older MCP writes used.
276+ label = element .get ("name" ) or element .get ("label" )
261277 if label :
262278 return label
263279 return "Step"
@@ -357,6 +373,196 @@ def format_test_structure(payload: dict[str, Any], params: Optional[dict] = None
357373 )
358374
359375
376+ def _restriction_allowed_values (
377+ param : dict [str , Any ],
378+ command_id : Optional [str ] = None ,
379+ ) -> List [str ]:
380+ return list (restriction_allowed_values (param , command_id ))
381+
382+
383+ def _renamed_declared_label (
384+ command_id : Optional [str ],
385+ name : Optional [str ],
386+ declared : Optional [str ],
387+ ) -> Optional [str ]:
388+ """The declared label, reported only where the editor shows a different one."""
389+ shown = parameter_label (command_id , name , declared )
390+ return declared if shown != declared else None
391+
392+
393+ def _range_bound (value : Any ) -> str :
394+ # The API serializes bounds as floats (0.0, 3600.0); render integral ones as integers.
395+ if isinstance (value , float ) and value .is_integer ():
396+ return str (int (value ))
397+ return str (value )
398+
399+
400+ def _restriction_range (param : dict [str , Any ]) -> Optional [str ]:
401+ minimum , maximum = restriction_range (param )
402+ if minimum is None and maximum is None :
403+ return None
404+ return f"{ _range_bound (minimum )} ..{ _range_bound (maximum )} "
405+
406+
407+ def _step_parameters_map (definition : Optional [dict [str , Any ]]) -> dict [str , dict [str , Any ]]:
408+ if not definition :
409+ return {}
410+ mandatory_names = {
411+ param .get ("name" ) or param .get ("parameterName" )
412+ for param in _definition_data (definition ).get ("mandatoryParameters" ) or []
413+ if isinstance (param , dict )
414+ }
415+ parameters : dict [str , dict [str , Any ]] = {}
416+ for param in _iter_definition_parameters (definition ):
417+ name = param .get ("name" ) or param .get ("parameterName" )
418+ parameters [name ] = {** param , "_mandatory" : name in mandatory_names }
419+ return parameters
420+
421+
422+ def _step_argument (
423+ argument : dict [str , Any ],
424+ parameters : dict [str , dict [str , Any ]],
425+ command_id : Optional [str ] = None ,
426+ ) -> ScriptStepArgument :
427+ name = argument .get ("name" , "" )
428+ data = argument .get ("data" ) or {}
429+ value = data .get ("value" )
430+ if data .get ("secured" ) and value :
431+ value = "<secured>"
432+ param = parameters .get (name )
433+ display = (param or {}).get ("display" ) or {}
434+ return ScriptStepArgument (
435+ name = name ,
436+ value = value ,
437+ data_source = data .get ("dataSource" ),
438+ parameter_type = (param or {}).get ("dataType" ),
439+ mandatory = (param or {}).get ("_mandatory" ) if param else None ,
440+ declared = param is not None or not parameters ,
441+ allowed_data_sources = list ((param or {}).get ("dataSources" ) or []),
442+ allowed_values = _restriction_allowed_values (param or {}, command_id ),
443+ value_range = _restriction_range (param or {}),
444+ label = parameter_label (command_id , name , display .get ("name" )),
445+ declared_label = _renamed_declared_label (command_id , name , display .get ("name" )),
446+ table_name = data .get ("tableName" ),
447+ column = data .get ("column" ),
448+ )
449+
450+
451+ def _step_argument_is_set (argument : ScriptStepArgument ) -> bool :
452+ """A DataTable binding is a value even though it carries no value field."""
453+ if argument .data_source == "DATATABLE" :
454+ return bool (argument .table_name or argument .column )
455+ return argument .value is not None and str (argument .value ).strip () != ""
456+
457+
458+ def _unset_step_parameters (
459+ element : dict [str , Any ],
460+ parameters : dict [str , dict [str , Any ]],
461+ command_id : Optional [str ] = None ,
462+ ) -> List [ScriptStepParameter ]:
463+ set_names = {argument .get ("name" ) for argument in element .get ("arguments" , [])}
464+ unset : List [ScriptStepParameter ] = []
465+ for name , param in parameters .items ():
466+ if name in set_names :
467+ continue
468+ display = param .get ("display" ) or {}
469+ mandatory = bool (param .get ("_mandatory" ))
470+ unset .append (ScriptStepParameter (
471+ name = name ,
472+ parameter_type = param .get ("dataType" ),
473+ mandatory = mandatory ,
474+ default_value = param .get ("defaultValue" ),
475+ allowed_data_sources = list (param .get ("dataSources" ) or []),
476+ allowed_values = _restriction_allowed_values (param , command_id ),
477+ value_range = _restriction_range (param ),
478+ label = parameter_label (command_id , name , display .get ("name" )),
479+ declared_label = _renamed_declared_label (command_id , name , display .get ("name" )),
480+ # Some commands declare ~60 optional parameters with long help texts; carrying them
481+ # all would dwarf the step itself. Names, types and accepted values are enough to
482+ # edit, and get_command_definitions has the full help when it is actually needed.
483+ help_text = (param .get ("helpText" ) or display .get ("helpText" )) if mandatory else None ,
484+ ))
485+ unset .sort (key = lambda parameter : (not parameter .mandatory , parameter .name ))
486+ return unset
487+
488+
489+ def _step_children_paths (element : dict [str , Any ], step_path : str ) -> List [str ]:
490+ if element .get ("@type" ) == "IfStatement" :
491+ return [
492+ f"{ step_path } .b{ branch_index } "
493+ for branch_index , _branch in enumerate (element .get ("branches" , []))
494+ ]
495+ return [
496+ f"{ step_path } .{ child_index } "
497+ for child_index , _child in enumerate (element .get ("flowElements" , []))
498+ ]
499+
500+
501+ def _step_detail_notes (element : dict [str , Any ], detail_arguments : List [ScriptStepArgument ]) -> List [str ]:
502+ notes : List [str ] = []
503+ undeclared = [argument .name for argument in detail_arguments if not argument .declared ]
504+ if undeclared :
505+ notes .append (
506+ f"Argument(s) not declared by the command: { ', ' .join (undeclared )} . "
507+ "Perfecto ignores them at execution time; they were most likely persisted by mistake."
508+ )
509+ if detail_arguments :
510+ notes .append (
511+ "To edit, call modify_command with cmd_arguments keyed by these argument names. "
512+ "Only the arguments you send change; the others keep their current value."
513+ )
514+ if element .get ("active" ) is False :
515+ notes .append ("This step is excluded from the run; re-include it with set_command_enabled." )
516+ empty_mandatory = [
517+ argument .name for argument in detail_arguments
518+ if argument .mandatory and not _step_argument_is_set (argument )
519+ ]
520+ if empty_mandatory :
521+ notes .append (
522+ f"Mandatory argument(s) with no value: { ', ' .join (empty_mandatory )} . "
523+ "The step will not do anything until they are set with modify_command."
524+ )
525+ return notes
526+
527+
528+ def format_step_detail (
529+ element : dict [str , Any ],
530+ item_key : str ,
531+ step_path : str ,
532+ command_definitions : Optional [list ] = None ,
533+ statement_step_path : Optional [str ] = None ,
534+ ) -> ScriptStepDetail :
535+ """Full configuration of one step, joined with what its command declares."""
536+ definitions_map = _definitions_map (command_definitions )
537+ command_id = _command_id (element .get ("command" ), element .get ("subcommand" ))
538+ parameters = _step_parameters_map (definitions_map .get (command_id ) if command_id else None )
539+ arguments = [
540+ _step_argument (argument , parameters , command_id )
541+ for argument in element .get ("arguments" , [])
542+ ]
543+ iterator = element .get ("iterator" ) or {}
544+ return ScriptStepDetail (
545+ item_key = item_key ,
546+ step_path = step_path ,
547+ type = element .get ("@type" , "" ),
548+ name = _step_display_name (element , definitions_map ),
549+ command_id = command_id ,
550+ command = element .get ("command" ),
551+ subcommand = element .get ("subcommand" ),
552+ active = element .get ("active" , True ),
553+ error_policy = element .get ("errorPolicy" ),
554+ comment = element .get ("comment" ),
555+ arguments = arguments ,
556+ unset_parameters = _unset_step_parameters (element , parameters , command_id ),
557+ label = element .get ("name" ) or element .get ("label" ),
558+ statement_step_path = statement_step_path ,
559+ loop_count = iterator .get ("count" ),
560+ loop_variable = iterator .get ("variable" ),
561+ children = _step_children_paths (element , step_path ),
562+ notes = _step_detail_notes (element , arguments ),
563+ )
564+
565+
360566def _flatten_command_catalog (node : dict [str , Any ], category : Optional [str ] = None ) -> List [CommandCatalogEntry ]:
361567 entries : List [CommandCatalogEntry ] = []
362568 node_name = node .get ("name" )
0 commit comments