From 9f2c81ec9ae1444cf8913f0c71c2d286ff57ef92 Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Thu, 27 Aug 2026 11:05:02 +0100 Subject: [PATCH 1/2] fix: sort flow step conditions and register fetch command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort conditions alphabetically by name when serializing a default step so pulls produce stable YAML instead of ordering that follows whatever the platform returns. Also register FetchCommand in the CLI command list — it was implemented but never wired up, so `poly fetch` was unavailable. Co-Authored-By: Claude Opus 5 (1M context) --- src/poly/cli.py | 2 ++ src/poly/resources/flows.py | 5 ++++- src/poly/tests/resources_test.py | 29 +++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 36 insertions(+), 2 deletions(-) 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/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/tests/resources_test.py b/src/poly/tests/resources_test.py index e6401b7e..096ebc22 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 = [ 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" }, From dcc2e826423c6133b47db000d72878a6bbd96326 Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Thu, 27 Aug 2026 18:30:53 +0100 Subject: [PATCH 2/2] fix: sort API integration and test case collections in YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort API integration operations, test case function call assertions and their arguments, and the integration/operation keys of test case API mocks when serializing. All of these are built from platform projection maps, so pulls previously produced whatever order the map iteration gave and unrelated pulls reordered blocks in the YAML. The rules within a mocked operation are left alone — they are a sequence and `repeat` depends on their order. Co-Authored-By: Claude Opus 5 (1M context) --- src/poly/resources/api_integration.py | 4 +- src/poly/resources/test_suite.py | 21 ++++++-- src/poly/tests/resources_test.py | 73 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) 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/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 096ebc22..9514255b 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -5151,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( @@ -7208,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() @@ -7996,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",