diff --git a/src/poly/cli.py b/src/poly/cli.py index 02c436ec..a29ff343 100644 --- a/src/poly/cli.py +++ b/src/poly/cli.py @@ -24,6 +24,7 @@ from poly.cli_commands.rtc import RTCCommand from poly.cli_commands.sync import ( DiffCommand, + FetchCommand, FormatCommand, PullCommand, PushCommand, @@ -45,6 +46,7 @@ StudioCommand, ProjectCommand, TemplateCommand, + FetchCommand, PullCommand, PushCommand, StatusCommand, diff --git a/src/poly/resources/api_integration.py b/src/poly/resources/api_integration.py index c8c68971..1225a40f 100644 --- a/src/poly/resources/api_integration.py +++ b/src/poly/resources/api_integration.py @@ -338,7 +338,9 @@ def to_yaml_dict(self) -> dict: "name": self.name, "description": self.description, "environments": self.environments.to_yaml_dict(), - "operations": [op.to_yaml_dict() for op in self.operations], + "operations": [ + op.to_yaml_dict() for op in sorted(self.operations, key=lambda op: op.name) + ], } @classmethod diff --git a/src/poly/resources/flows.py b/src/poly/resources/flows.py index 18e70818..6b2519e9 100644 --- a/src/poly/resources/flows.py +++ b/src/poly/resources/flows.py @@ -430,7 +430,10 @@ def to_yaml_dict(self) -> dict: output.update(flow_settings_dict) if self.step_type == StepType.DEFAULT_STEP: - output["conditions"] = [condition.to_yaml_dict() for condition in self.conditions] + output["conditions"] = [ + condition.to_yaml_dict() + for condition in sorted(self.conditions, key=lambda condition: condition.name) + ] output["extracted_entities"] = sorted(self.extracted_entities) output["prompt"] = self.prompt diff --git a/src/poly/resources/test_suite.py b/src/poly/resources/test_suite.py index 6248c0fc..8fa57eb1 100644 --- a/src/poly/resources/test_suite.py +++ b/src/poly/resources/test_suite.py @@ -125,7 +125,13 @@ def __init__(self, name: str, arguments: list[FunctionCallArgumentAssertion | di ] def to_yaml_dict(self) -> dict: - return {"name": self.name, "arguments": [arg.to_yaml_dict() for arg in self.arguments]} + return { + "name": self.name, + "arguments": [ + arg.to_yaml_dict() + for arg in sorted(self.arguments, key=lambda arg: arg.parameter_name) + ], + } def to_proto(self) -> FunctionCallAssertionProto: return FunctionCallAssertionProto( @@ -166,7 +172,8 @@ def to_yaml_dict(self) -> dict: response["prompt_assertions"] = self.prompts if self.function_calls: response["function_call_assertions"] = [ - function_call.to_yaml_dict() for function_call in self.function_calls + function_call.to_yaml_dict() + for function_call in sorted(self.function_calls, key=lambda call: call.name) ] return response @@ -481,12 +488,16 @@ class TestCaseApiMocks: mocks: dict[str, dict[str, list[ApiResponseRule]]] = field(default_factory=dict) def to_yaml_dict(self) -> dict: + # Integration and operation names are sorted so pulls produce stable YAML; the + # rules within an operation are a sequence (see `repeat`) and keep their order. return { integration_name: { - operation_name: [rule.to_yaml_dict() for rule in rules] - for operation_name, rules in operations.items() + operation_name: [ + rule.to_yaml_dict() for rule in self.mocks[integration_name][operation_name] + ] + for operation_name in sorted(self.mocks[integration_name]) } - for integration_name, operations in self.mocks.items() + for integration_name in sorted(self.mocks) } @classmethod diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py index e6401b7e..9514255b 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -2551,6 +2551,35 @@ def test_get_raw_no_code_step(self): """Test that raw property returns correct YAML representation for no code step.""" self.assertEqual(TEST_NO_CODE_FLOW_STEP.raw, FLOW_NO_CODE_STEP_RAW) + def test_conditions_sorted_by_name(self): + """Test that conditions are serialized in alphabetical order by name.""" + step = FlowStep( + resource_id="flow-123_step-1", + step_id="step-1", + name="Test Step", + flow_id="flow-123", + flow_name="Test Flow", + step_type=StepType.DEFAULT_STEP, + conditions=[ + Condition( + resource_id=f"cond-{name}", + name=name, + description="", + condition_type=ConditionType.STEP, + child_step="step-2", + step_id="step-1", + flow_id="flow-123", + ) + for name in ["zebra", "apple", "monkey"] + ], + prompt="Hello, how can I help you?", + position={"x": 0.0, "y": 0.0}, + extracted_entities=[], + ) + + condition_names = [c["name"] for c in step.to_yaml_dict()["conditions"]] + self.assertEqual(condition_names, ["apple", "monkey", "zebra"]) + def test_to_pretty(self): """Test converting flow step to pretty format with function name mapping.""" resource_mappings = [ @@ -5122,6 +5151,22 @@ def test_api_integration_to_yaml_dict_and_from_yaml_dict(self): self.assertEqual(i2.operations[0].name, "get") self.assertEqual(i2.operations[0].resource_id, "") + def test_api_integration_operations_sorted_by_name(self): + """Operations serialize in alphabetical order by name.""" + integration = ApiIntegration( + resource_id="int-1", + name="TestAPI", + operations=[ + ApiIntegrationOperation( + resource_id=f"op-{name}", name=name, method="GET", resource="/x" + ) + for name in ["refund", "charge", "get_customer"] + ], + ) + + operation_names = [op["name"] for op in integration.to_yaml_dict()["operations"]] + self.assertEqual(operation_names, ["charge", "get_customer", "refund"]) + def test_api_integration_build_protos(self): """ApiIntegration build_create_proto, build_update_proto, build_delete_proto set id and environments.""" env = ApiIntegrationEnvironments.from_dict( @@ -7179,6 +7224,39 @@ def _sample_test_case(self) -> TestCase: language="en-GB", ) + def test_assertions_sorted_by_name(self): + """Function call assertions and their arguments serialize in alphabetical order.""" + assertions = TestCaseAssertion( + resource_id="TEST-ordering", + name="assertions", + prompts=[], + function_calls=[ + FunctionCallAssertion( + name=name, + arguments=[ + FunctionCallArgumentAssertion( + parameter_name=parameter_name, + expected_value="value", + value_type="string", + ) + for parameter_name in ["zebra", "apple", "monkey"] + ], + ) + for name in ["transfer_call", "book_appointment", "lookup_order"] + ], + ) + + function_calls = assertions.to_yaml_dict()["function_call_assertions"] + + self.assertEqual( + [call["name"] for call in function_calls], + ["book_appointment", "lookup_order", "transfer_call"], + ) + self.assertEqual( + [arg["parameter_name"] for arg in function_calls[0]["arguments"]], + ["apple", "monkey", "zebra"], + ) + def test_to_yaml_dict_from_yaml_dict_roundtrip(self): test_case = self._sample_test_case() yaml_dict = test_case.to_yaml_dict() @@ -7967,6 +8045,30 @@ def test_omits_empty_values_from_yaml(self): self.assertNotIn("api_mocks", yaml_dict) + def test_yaml_sorts_integration_and_operation_names(self): + """Integration/operation names serialize alphabetically; rule order is preserved.""" + api_mocks = self._api_mocks( + { + "payments": { + "refund": [ApiResponseRule(respond=ApiResponse(status=200))], + "charge": [ + ApiResponseRule(respond=ApiResponse(status=500), repeat=2), + ApiResponseRule(respond=ApiResponse(status=201)), + ], + }, + "crm": {"get_customer": [ApiResponseRule(respond=ApiResponse(status=200))]}, + } + ) + + mocks_yaml = self._test_case(api_mocks=api_mocks).to_yaml_dict()["api_mocks"] + + self.assertEqual(list(mocks_yaml), ["crm", "payments"]) + self.assertEqual(list(mocks_yaml["payments"]), ["charge", "refund"]) + self.assertEqual( + mocks_yaml["payments"]["charge"], + [{"respond": {"status": 500}, "repeat": 2}, {"respond": {"status": 201}}], + ) + def _operation_mock(self, **overrides) -> TestCaseApiOperationMock: defaults = { "resource_id": f"{self.RESOURCE_ID}:crm:get_customer", diff --git a/uv.lock b/uv.lock index ecb1a2d7..e484fa7e 100644 --- a/uv.lock +++ b/uv.lock @@ -359,7 +359,7 @@ wheels = [ [[package]] name = "polyai-adk" -version = "0.44.3" +version = "0.44.4" source = { editable = "." } dependencies = [ { name = "argcomplete" },