From 4ea5450a3a02bf6a3e66878119783fd3e8815a09 Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Fri, 22 May 2026 17:15:44 +0100 Subject: [PATCH 1/5] Use conditions on function steps --- src/poly/handlers/platform_api.py | 2 +- src/poly/handlers/sync_client.py | 59 ++++----- src/poly/project.py | 12 ++ src/poly/resources/flows.py | 175 ++++++++++++++++++++++++++- src/poly/resources/function.py | 14 +-- src/poly/resources/resource_utils.py | 25 ++++ src/poly/tests/resources_test.py | 114 +++++++++++++++++ src/poly/utils.py | 2 +- 8 files changed, 356 insertions(+), 47 deletions(-) diff --git a/src/poly/handlers/platform_api.py b/src/poly/handlers/platform_api.py index 14268d60..3d985109 100644 --- a/src/poly/handlers/platform_api.py +++ b/src/poly/handlers/platform_api.py @@ -12,7 +12,7 @@ import requests from poly.constants import DEFAULT_VOICE_ID_FALLBACK, DEFAULT_VOICE_IDS -from poly.utils import retrieve_api_key, any_credentials_exist +from poly.utils import any_credentials_exist, retrieve_api_key logger = logging.getLogger(__name__) ACCOUNTS_URL = "/adk/v1/accounts" diff --git a/src/poly/handlers/sync_client.py b/src/poly/handlers/sync_client.py index 11af7c5f..4aef95d6 100644 --- a/src/poly/handlers/sync_client.py +++ b/src/poly/handlers/sync_client.py @@ -520,8 +520,36 @@ def _read_flows_from_projection( for step_id, step in flow_data.get("steps", {}).get("entities", {}).items(): local_resource_id = f"{flow_data['name']}_{step_id}" + is_function_step = step.get("type") == "function_step" + conditions = [ + Condition( + resource_id=condition_data["id"], + name=condition_data["config"]["value"]["details"]["label"], + condition_type=condition_data["config"]["$case"], + description=condition_data["config"]["value"]["details"].get( + "description", "" + ), + required_entities=condition_data["config"]["value"]["details"].get( + "requiredEntities", [] + ), + child_step=condition_data["config"]["value"].get("childStepId", ""), + step_id=step_id, + flow_id=flow_id, + ingress=condition_data["config"]["value"]["details"].get( + "ingressPosition", "top" + ), + position=condition_data["config"]["value"]["details"].get( + "position", {"x": 0.0, "y": 0.0} + ), + exit_flow_position=condition_data["config"]["value"].get( + "exitFlowPosition", None + ), + parent_is_no_code_step=not is_function_step, + ) + for condition_data in step.get("conditions", []) + ] - if step.get("type") == "function_step": + if is_function_step: function = step.get("function", {}) resources.setdefault(FunctionStep, {})[local_resource_id] = FunctionStep( resource_id=local_resource_id, @@ -536,6 +564,7 @@ def _read_flows_from_projection( ), parameters=[], function_id=function.get("id", ""), + conditions=conditions, ) continue @@ -581,32 +610,7 @@ def _read_flows_from_projection( flow_id=flow_id, ), prompt=step.get("prompt", ""), - conditions=[ - Condition( - resource_id=condition_data["id"], - name=condition_data["config"]["value"]["details"]["label"], - condition_type=condition_data["config"]["$case"], - description=condition_data["config"]["value"]["details"].get( - "description", "" - ), - required_entities=condition_data["config"]["value"]["details"].get( - "requiredEntities", [] - ), - child_step=condition_data["config"]["value"].get("childStepId", ""), - step_id=step_id, - flow_id=flow_id, - ingress=condition_data["config"]["value"]["details"].get( - "ingressPosition", "top" - ), - position=condition_data["config"]["value"]["details"].get( - "position", {"x": 0.0, "y": 0.0} - ), - exit_flow_position=condition_data["config"]["value"].get( - "exitFlowPosition", None - ), - ) - for condition_data in step.get("conditions", []) - ], + conditions=conditions, position=step.get("position"), extracted_entities=extracted_entities, ) @@ -935,6 +939,7 @@ def _read_api_integrations_from_projection( # If variable references will change, we should update the variable first so # it isn't pruned by the backend. Variable, + Condition, ] def queue_resources( diff --git a/src/poly/project.py b/src/poly/project.py index 7ed97dca..ab8ad70c 100644 --- a/src/poly/project.py +++ b/src/poly/project.py @@ -1476,6 +1476,16 @@ def _clean_resources_before_push( ) post_push_deleted_resources.setdefault(FlowStep, {})[dummy.resource_id] = dummy + # The backend auto-creates conditions from goto_step() calls in function step + # code. To avoid duplicate condition labels, create new FunctionSteps with stub + # code, let our explicit conditions be created, then update with the real code. + for resource_id, resource in list(new_resources.get(FunctionStep, {}).items()): + if isinstance(resource, FunctionStep) and resource.conditions: + stub = copy.deepcopy(resource) + stub.code = f"def {resource.name}(conv: Conversation, flow: Flow):\n pass\n" + new_resources[FunctionStep][resource_id] = stub + updated_resources.setdefault(FunctionStep, {})[resource_id] = resource + # Deleting flow config deletes all its steps/functions, so we don't need to for flow_config_id in deleted_resources.get(FlowConfig, {}): for resource_type in [FlowStep, Function, FunctionStep]: @@ -2053,6 +2063,7 @@ def read_local_resource( additional_kwargs["known_function_id"] = None additional_kwargs["known_position"] = None additional_kwargs["known_latency_control"] = {} + additional_kwargs["known_conditions"] = [] if original_resource: if not isinstance(original_resource, FunctionStep): @@ -2062,6 +2073,7 @@ def read_local_resource( additional_kwargs["known_function_id"] = original_resource.function_id additional_kwargs["known_position"] = original_resource.position additional_kwargs["known_latency_control"] = original_resource.latency_control + additional_kwargs["known_conditions"] = original_resource.conditions try: resource = resource_class.read_local_resource( diff --git a/src/poly/resources/flows.py b/src/poly/resources/flows.py index 6b156be5..c8da6b58 100644 --- a/src/poly/resources/flows.py +++ b/src/poly/resources/flows.py @@ -22,9 +22,11 @@ CreateNoCodeCondition, CreateNoCodeStep, CreateStep, + CreateStepCondition, DeleteNoCodeCondition, DeleteNoCodeStep, DeleteStep, + DeleteStepCondition, ExitFlowCondition, Flow_CreateFlow, Flow_CreateStep, @@ -48,6 +50,7 @@ UpdateNoCodeCondition, UpdateNoCodeStep, UpdateStep, + UpdateStepCondition, ) from poly.resources.entities import Entity from poly.resources.function import Function, FunctionType @@ -1121,6 +1124,7 @@ class Condition(SubResource): ingress: str step_id: str flow_id: str + parent_is_no_code_step: bool def __init__( self, @@ -1135,6 +1139,7 @@ def __init__( position: dict | None = None, ingress: str = "top", exit_flow_position: dict | None = None, + parent_is_no_code_step: bool = True, ): self.resource_id = resource_id self.name = name @@ -1151,6 +1156,7 @@ def __init__( self.position = position or {} self.ingress = ingress self.exit_flow_position = exit_flow_position or {} + self.parent_is_no_code_step = parent_is_no_code_step def to_yaml_dict(self) -> dict: """Return a dictionary suitable for YAML serialization.""" @@ -1214,11 +1220,20 @@ def from_yaml_dict( @property def command_type(self) -> str: """Get the update type for updating the resource.""" - return "no_code_condition" + if self.parent_is_no_code_step: + return "no_code_condition" + return "step_condition" - def build_update_proto(self) -> UpdateNoCodeCondition: + def build_update_proto(self) -> UpdateNoCodeCondition | UpdateStepCondition: """Create a proto for updating the condition.""" - return UpdateNoCodeCondition( + if self.parent_is_no_code_step: + return UpdateNoCodeCondition( + flow_id=self.flow_id, + step_id=self.step_id, + condition_id=self.resource_id, + **self._get_condition_type_proto(), + ) + return UpdateStepCondition( flow_id=self.flow_id, step_id=self.step_id, condition_id=self.resource_id, @@ -1227,7 +1242,13 @@ def build_update_proto(self) -> UpdateNoCodeCondition: def build_delete_proto(self) -> DeleteNoCodeCondition: """Create a proto for deleting the condition.""" - return DeleteNoCodeCondition( + if self.parent_is_no_code_step: + return DeleteNoCodeCondition( + flow_id=self.flow_id, + step_id=self.step_id, + condition_id=self.resource_id, + ) + return DeleteStepCondition( flow_id=self.flow_id, step_id=self.step_id, condition_id=self.resource_id, @@ -1235,7 +1256,14 @@ def build_delete_proto(self) -> DeleteNoCodeCondition: def build_create_proto(self) -> CreateNoCodeCondition: """Create a proto for creating the condition.""" - return CreateNoCodeCondition( + if self.parent_is_no_code_step: + return CreateNoCodeCondition( + flow_id=self.flow_id, + step_id=self.step_id, + condition_id=self.resource_id, + **self._get_condition_type_proto(), + ) + return CreateStepCondition( flow_id=self.flow_id, step_id=self.step_id, condition_id=self.resource_id, @@ -1344,6 +1372,7 @@ class FunctionStep(Function, BaseFlowStep): """Dataclass representing a function step""" function_id: str + conditions: Optional[list["Condition"]] step_type: StepType = field(default=StepType.FUNCTION_STEP, init=False) function_type: FunctionType = field(default=FunctionType.FUNCTION_STEP, init=False) @@ -1355,6 +1384,7 @@ def __init__( flow_id: str, flow_name: str, code: str, + conditions: Optional[list["Condition | dict"]] = None, description: str = None, parameters: list = None, latency_control: dict = None, @@ -1366,6 +1396,10 @@ def __init__( self.function_id = function_id self.step_type = StepType.FUNCTION_STEP self.position = position or {} + self.conditions = [ + Condition(**condition) if not isinstance(condition, Condition) else condition + for condition in (conditions or []) + ] super().__init__( resource_id=resource_id, name=name, @@ -1410,6 +1444,7 @@ def read_local_resource( known_latency_control: dict, known_function_id: str = None, known_position: dict[str, float] = None, + known_conditions: list["Condition"] = None, **kwargs, ) -> "FunctionStep": code = cls.read_to_raw( @@ -1445,6 +1480,109 @@ def read_local_resource( # Read references from code variable_references = cls._extract_variable_references(code, resource_mappings) + # Extract conditions from code + code_for_validation = utils.remove_comments_from_code(code) + steps = utils.extract_go_to_steps(code_for_validation) + + known_conditions = known_conditions or [] + condition_name_map = { + cond.name: cond + for cond in known_conditions + if cond.command_type != ConditionType.EXIT_FLOW + } + conditions = [] + + for child_step_name, condition_name in steps: + if not condition_name: + continue + # Find Child Step to infer condition type if needed + child_step_type = None + known_condition = condition_name_map.get(condition_name) + child_step_id = "" + for resource in resource_mappings or []: + if ( + issubclass(resource.resource_type, BaseFlowStep) + and resource.flow_name == flow_name + and resource.resource_name == child_step_name + ): + child_step_id = resource.resource_id.removeprefix(resource.flow_name + "_") + + if issubclass(resource.resource_type, FunctionStep): + child_step_type = StepType.FUNCTION_STEP + break + + child_step_contents = cls.read_to_raw( + resource.file_path, + resource_mappings=resource_mappings, + flow_name=flow_name, + ) + child_step_yaml = utils.load_yaml(child_step_contents) + child_step_type = StepType(child_step_yaml.get("step_type")) + break + + if child_step_type == StepType.DEFAULT_STEP: + condition_type = ConditionType.NO_CODE_STEP + elif child_step_type == StepType.FUNCTION_STEP: + condition_type = ConditionType.FUNCTION_STEP + else: + condition_type = ConditionType.STEP + + conditions.append( + Condition( + resource_id=( + known_condition.resource_id + if known_condition + else f"CONDITION-{uuid.uuid4().hex[:8]}" + ), + name=condition_name, + condition_type=condition_type, + step_id=step_id, + flow_id=flow_id, + child_step=child_step_id, + position=known_condition.position if known_condition else None, + ingress=known_condition.ingress if known_condition else None, + exit_flow_position=None, + parent_is_no_code_step=False, + ), + ) + if known_condition: + del condition_name_map[condition_name] + + if "conv.exit_flow()" in code_for_validation: + known_exit_flow_condition = next( + ( + cond + for cond in known_conditions + if cond.condition_type == ConditionType.EXIT_FLOW + ), + None, + ) + + conditions.append( + Condition( + resource_id=( + known_exit_flow_condition.resource_id + if known_exit_flow_condition + else f"CONDITION-{uuid.uuid4().hex[:8]}" + ), + name=condition_name, + condition_type=condition_type, + step_id=step_id, + flow_id=flow_id, + child_step=child_step_id, + position=known_exit_flow_condition.position + if known_exit_flow_condition + else None, + ingress=known_exit_flow_condition.ingress + if known_exit_flow_condition + else None, + exit_flow_position=known_condition.exit_flow_position + if known_exit_flow_condition + else None, + parent_is_no_code_step=False, + ), + ) + return FunctionStep( resource_id=resource_id, step_id=step_id, @@ -1457,6 +1595,7 @@ def read_local_resource( parameters=[], function_id=function_id, variable_references=variable_references, + conditions=conditions, ) @staticmethod @@ -1539,4 +1678,28 @@ def get_new_updated_deleted_subresources( ) -> tuple[list[SubResource], list[SubResource], list[SubResource]]: """LatencyControl is already included in the step update/create protos, so skip emitting it as a separate sub-resource command.""" - return [], [], [] + new = [] + updated = [] + deleted = [] + old_condition_ids = ( + {cond.resource_id for cond in old_resource.conditions} if old_resource else set() + ) + new_condition_ids = {cond.resource_id for cond in self.conditions} + + for condition in self.conditions: + if condition.resource_id not in old_condition_ids: + new.append(condition) + else: + # Check if updated + old_condition = next( + (c for c in old_resource.conditions if c.resource_id == condition.resource_id), + None, + ) + if old_condition and condition != old_condition: + updated.append(condition) + + if old_resource: + for condition in old_resource.conditions: + if condition.resource_id not in new_condition_ids: + deleted.append(condition) + return new, updated, deleted diff --git a/src/poly/resources/function.py b/src/poly/resources/function.py index e8df9fb7..dc25df98 100644 --- a/src/poly/resources/function.py +++ b/src/poly/resources/function.py @@ -513,12 +513,7 @@ def validate(self, **kwargs) -> None: and r.flow_name == self.flow_name } if valid_step_names: - for match in re.finditer( - r'flow\.goto_step\(\s*"((?:[^"\\]|\\.)*)"' - r"|flow\.goto_step\(\s*'((?:[^'\\]|\\.)*)'", - code_for_validation, - ): - target_step = match.group(1) or match.group(2) + for target_step, _ in utils.extract_go_to_steps(code_for_validation): if target_step not in valid_step_names: raise ValueError( f"flow.goto_step('{target_step}') references a step that does not exist " @@ -532,12 +527,7 @@ def validate(self, **kwargs) -> None: if r.resource_type.__name__ == "FlowConfig" } if valid_flow_names: - for match in re.finditer( - r'conv\.goto_flow\(\s*"((?:[^"\\]|\\.)*)"' - r"|conv\.goto_flow\(\s*'((?:[^'\\]|\\.)*)'", - code_for_validation, - ): - target_flow = match.group(1) or match.group(2) + for target_flow in utils.extract_go_to_flows(code_for_validation): if target_flow not in valid_flow_names: raise ValueError( f"conv.goto_flow('{target_flow}') references a flow that does not exist." diff --git a/src/poly/resources/resource_utils.py b/src/poly/resources/resource_utils.py index ac45e5f5..d4516565 100644 --- a/src/poly/resources/resource_utils.py +++ b/src/poly/resources/resource_utils.py @@ -551,6 +551,31 @@ def convert_keys_to_snake_case(dict_obj: dict) -> dict: return {to_snake_case(k): v for k, v in dict_obj.items()} +def extract_go_to_steps(code: str) -> list[tuple[str, Optional[str]]]: + """Extract goto_step calls, returning (step_name, condition_name) tuples.""" + pattern = re.compile( + r"flow\.goto_step\(\s*" + r"""(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')""" + r"(?:\s*,\s*" + r"""(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')""" + r")?" + ) + results: list[tuple[str, Optional[str]]] = [] + for m in pattern.finditer(code): + step_name = m.group(1) or m.group(2) + condition_name = m.group(3) or m.group(4) + results.append((step_name, condition_name)) + return results + + +def extract_go_to_flows(code: str) -> list[str]: + pattern = re.compile( + r"conv\.goto_flow\(\s*" + r"""(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')""" + ) + return [m.group(1) or m.group(2) for m in pattern.finditer(code)] + + def assign_flow_positions( nodes: list["BaseFlowStep"], start_node_id: str, diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py index 7466f4ca..625a1a2f 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -60,6 +60,7 @@ ResourceMapping, _parse_multi_resource_path, ) +from poly.resources.resource_utils import extract_go_to_flows, extract_go_to_steps from poly.resources.safety_filters import ( ChatSafetyFilters, GeneralSafetyFilters, @@ -6988,5 +6989,118 @@ def test_windows_drive_letter_multiple_segments(self): self.assertEqual(segments, ["entities", "customer_name"]) +class ExtractGoToStepsTests(unittest.TestCase): + """Tests for extract_go_to_steps regex extraction.""" + + def test_single_arg_double_quotes(self): + """Single step name in double quotes returns (name, None).""" + code = 'flow.goto_step("my_step")' + self.assertEqual(extract_go_to_steps(code), [("my_step", None)]) + + def test_single_arg_single_quotes(self): + """Single step name in single quotes returns (name, None).""" + code = "flow.goto_step('my_step')" + self.assertEqual(extract_go_to_steps(code), [("my_step", None)]) + + def test_two_args_double_quotes(self): + """Step and condition in double quotes returns both.""" + code = 'flow.goto_step("my_step", "my_cond")' + self.assertEqual(extract_go_to_steps(code), [("my_step", "my_cond")]) + + def test_two_args_single_quotes(self): + """Step and condition in single quotes returns both.""" + code = "flow.goto_step('my_step', 'my_cond')" + self.assertEqual(extract_go_to_steps(code), [("my_step", "my_cond")]) + + def test_mixed_quotes_double_step_single_condition(self): + """Double-quoted step with single-quoted condition.""" + code = """flow.goto_step("my_step", 'my_cond')""" + self.assertEqual(extract_go_to_steps(code), [("my_step", "my_cond")]) + + def test_mixed_quotes_single_step_double_condition(self): + """Single-quoted step with double-quoted condition.""" + code = """flow.goto_step('my_step', "my_cond")""" + self.assertEqual(extract_go_to_steps(code), [("my_step", "my_cond")]) + + def test_multiple_calls(self): + """Multiple goto_step calls are all extracted.""" + code = ( + 'flow.goto_step("step_a")\n' + 'flow.goto_step("step_b", "cond_b")\n' + "flow.goto_step('step_c')\n" + ) + self.assertEqual( + extract_go_to_steps(code), + [("step_a", None), ("step_b", "cond_b"), ("step_c", None)], + ) + + def test_no_matches_returns_empty_list(self): + """Code with no goto_step calls returns an empty list.""" + code = "x = 1\nprint(x)" + self.assertEqual(extract_go_to_steps(code), []) + + def test_whitespace_around_comma(self): + """Extra whitespace around comma and inside parens is tolerated.""" + code = 'flow.goto_step( "step" , "cond" )' + result = extract_go_to_steps(code) + self.assertEqual(result, [("step", "cond")]) + + def test_whitespace_after_opening_paren(self): + """Whitespace after opening paren for single arg.""" + code = 'flow.goto_step( "step" )' + result = extract_go_to_steps(code) + self.assertEqual(result, [("step", None)]) + + def test_escaped_quotes_in_step_name(self): + """Escaped quotes within the step name string are preserved.""" + code = r'flow.goto_step("Don\'t stop")' + result = extract_go_to_steps(code) + self.assertEqual(result, [("Don\\'t stop", None)]) + + def test_escaped_quotes_in_double_quoted_string(self): + """Escaped double quotes within a double-quoted string.""" + code = r'flow.goto_step("say \"hello\"")' + result = extract_go_to_steps(code) + self.assertEqual(result, [('say \\"hello\\"', None)]) + + def test_step_name_with_spaces(self): + """Step names with spaces are extracted correctly.""" + code = 'flow.goto_step("Step One", "Label Two")' + self.assertEqual(extract_go_to_steps(code), [("Step One", "Label Two")]) + + +class ExtractGoToFlowsTests(unittest.TestCase): + """Tests for extract_go_to_flows regex extraction.""" + + def test_single_flow_double_quotes(self): + """Single flow name in double quotes.""" + code = 'conv.goto_flow("billing_flow")' + self.assertEqual(extract_go_to_flows(code), ["billing_flow"]) + + def test_single_flow_single_quotes(self): + """Single flow name in single quotes.""" + code = "conv.goto_flow('billing_flow')" + self.assertEqual(extract_go_to_flows(code), ["billing_flow"]) + + def test_multiple_flows(self): + """Multiple goto_flow calls are all extracted.""" + code = ( + 'conv.goto_flow("flow_a")\n' + "conv.goto_flow('flow_b')\n" + 'conv.goto_flow("flow_c")\n' + ) + self.assertEqual(extract_go_to_flows(code), ["flow_a", "flow_b", "flow_c"]) + + def test_no_matches_returns_empty_list(self): + """Code with no goto_flow calls returns an empty list.""" + code = "x = 1\nconv.some_other_method('test')" + self.assertEqual(extract_go_to_flows(code), []) + + def test_flow_name_with_spaces(self): + """Flow names with spaces are extracted correctly.""" + code = 'conv.goto_flow("My Flow Name")' + self.assertEqual(extract_go_to_flows(code), ["My Flow Name"]) + + if __name__ == "__main__": unittest.main() diff --git a/src/poly/utils.py b/src/poly/utils.py index 57171546..283c2dbd 100644 --- a/src/poly/utils.py +++ b/src/poly/utils.py @@ -7,10 +7,10 @@ import difflib import importlib.resources import inspect +import json import logging import os import re -import json from typing import Callable, Optional from poly.resources import Function, FunctionStep, Resource, ResourceMapping From bc0f60872d4129123202450f97ad2f37cd4a6693 Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Fri, 22 May 2026 17:22:05 +0100 Subject: [PATCH 2/5] Tests --- src/poly/tests/project_test.py | 106 +++++++++++++++++++++++++++++++ src/poly/tests/resources_test.py | 98 ++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/src/poly/tests/project_test.py b/src/poly/tests/project_test.py index 4150091d..ca934d1a 100644 --- a/src/poly/tests/project_test.py +++ b/src/poly/tests/project_test.py @@ -42,6 +42,7 @@ from poly.resources.flows import ( ASRBiasing, Condition, + ConditionType, DTMFConfig, StepType, ) @@ -1650,6 +1651,111 @@ def test_clean_resources_before_push_condition_update_becomes_create_when_origin # And removed from updated self.assertNotIn("CONDITION-cond-1", cleaned_updated.get(Condition, {})) + def test_clean_resources_before_push_new_function_step_with_conditions_uses_stub_code(self): + """New FunctionSteps with conditions should be created with stub code, + then updated with real code in post-push to avoid duplicate condition labels + from the backend auto-creating conditions from goto_step calls.""" + flow_config = FlowConfig( + resource_id="flow-123", + name="Test Flow", + description="A test flow", + start_step="step-1", + ) + flow_step = FlowStep( + resource_id="Test Flow_step-1", + step_id="step-1", + name="Start Step", + flow_id="flow-123", + flow_name="Test Flow", + step_type=StepType.DEFAULT_STEP, + prompt="Hello", + position={"x": 0.0, "y": 0.0}, + conditions=[], + extracted_entities=[], + ) + self.project.resources.setdefault(FlowConfig, {})["flow-123"] = flow_config + self.project.resources.setdefault(FlowStep, {})["Test Flow_step-1"] = flow_step + + real_code = ( + "def fetch_data(conv: Conversation, flow: Flow):\n" + ' flow.goto_step("Start Step", "Data fetched")\n' + ) + function_step = FunctionStep( + resource_id="Test Flow_func-step-1", + step_id="func-step-1", + name="fetch_data", + flow_id="flow-123", + flow_name="Test Flow", + code=real_code, + position={"x": 0.0, "y": 0.0}, + function_id="FUNC-123", + conditions=[ + Condition( + resource_id="CONDITION-abc", + name="Data fetched", + condition_type=ConditionType.NO_CODE_STEP, + step_id="func-step-1", + flow_id="flow-123", + child_step="step-1", + ), + ], + ) + + new_resources = {FunctionStep: {"Test Flow_func-step-1": function_step}} + updated_resources = {} + deleted_resources = {} + + push_changes = self.project._clean_resources_before_push( + {}, + new_resources, + updated_resources, + deleted_resources, + ) + cleaned_new = push_changes.main.new + cleaned_updated = push_changes.main.updated + + # Main push should create with stub code (no goto_step calls) + stub_step = cleaned_new[FunctionStep]["Test Flow_func-step-1"] + self.assertNotIn("goto_step", stub_step.code) + self.assertIn("def fetch_data", stub_step.code) + + # Main push should also update with real code (updates run after creates) + self.assertIn(FunctionStep, cleaned_updated) + real_step = cleaned_updated[FunctionStep]["Test Flow_func-step-1"] + self.assertEqual(real_step.code, real_code) + + def test_clean_resources_before_push_new_function_step_without_conditions_unchanged(self): + """New FunctionSteps without conditions should not be modified.""" + function_step = FunctionStep( + resource_id="Test Flow_func-step-1", + step_id="func-step-1", + name="process_data", + flow_id="flow-123", + flow_name="Test Flow", + code="def process_data(conv: Conversation, flow: Flow):\n pass\n", + position={"x": 0.0, "y": 0.0}, + function_id="FUNC-123", + conditions=[], + ) + + new_resources = {FunctionStep: {"Test Flow_func-step-1": function_step}} + updated_resources = {} + deleted_resources = {} + + push_changes = self.project._clean_resources_before_push( + {}, + new_resources, + updated_resources, + deleted_resources, + ) + cleaned_new = push_changes.main.new + post_push_updated = push_changes.post.updated + + # Should be unchanged - no stub needed + result_step = cleaned_new[FunctionStep]["Test Flow_func-step-1"] + self.assertEqual(result_step.code, function_step.code) + self.assertNotIn(FunctionStep, post_push_updated) + class PushProjectTest(unittest.TestCase): """Tests for the push_project method""" diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py index 625a1a2f..80703d0b 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -3730,6 +3730,104 @@ def test_read_local_resource(self): self.assertIsNotNone(result.function_id) self.assertRegex(result.function_id, r"^FUNCTION-[a-f0-9]{8}$") + def test_read_local_resource_conditions_have_bare_child_step_id(self): + """Conditions extracted from goto_step calls should use bare step IDs + without the flow name prefix.""" + code_with_goto = ( + "from _gen import * # \n\n\n" + "def my_func(conv: Conversation, flow: Flow):\n" + ' flow.goto_step("Target Step", "Step reached")\n' + ) + step_yaml = ( + "step_type: default_step\n" + "name: Target Step\n" + "conditions: []\n" + "extracted_entities: []\n" + "prompt: Some prompt\n" + ) + + resource_mappings = [ + ResourceMapping( + resource_id="test_flow", + resource_name="Test Flow", + resource_type=FlowConfig, + file_path="flows/test_flow/flow_config.yaml", + resource_prefix=None, + flow_name="Test Flow", + ), + ResourceMapping( + resource_id="Test Flow_FLOW_STEPS-abc", + resource_name="Target Step", + resource_type=FlowStep, + file_path="flows/test_flow/steps/target_step.yaml", + resource_prefix=None, + flow_name="Test Flow", + ), + ] + + with mock_read_from_file({ + "flows/test_flow/function_steps/my_func.py": code_with_goto, + "flows/test_flow/steps/target_step.yaml": step_yaml, + }): + result = FunctionStep.read_local_resource( + file_path="flows/test_flow/function_steps/my_func.py", + resource_id="Test Flow_my_func", + resource_name="my_func", + resource_mappings=resource_mappings, + known_latency_control={}, + ) + + self.assertEqual(len(result.conditions), 1) + condition = result.conditions[0] + self.assertEqual(condition.name, "Step reached") + self.assertEqual(condition.child_step, "FLOW_STEPS-abc") + self.assertFalse( + condition.child_step.startswith("Test Flow_"), + "child_step should not contain the flow name prefix", + ) + + def test_read_local_resource_condition_child_step_is_function_step(self): + """Conditions pointing to FunctionStep children should also use bare step IDs.""" + code_with_goto = ( + "from _gen import * # \n\n\n" + "def router(conv: Conversation, flow: Flow):\n" + ' flow.goto_step("other_handler", "Route to handler")\n' + ) + + resource_mappings = [ + ResourceMapping( + resource_id="test_flow", + resource_name="Test Flow", + resource_type=FlowConfig, + file_path="flows/test_flow/flow_config.yaml", + resource_prefix=None, + flow_name="Test Flow", + ), + ResourceMapping( + resource_id="Test Flow_FUNCTION_STEPS-def", + resource_name="other_handler", + resource_type=FunctionStep, + file_path="flows/test_flow/function_steps/other_handler.py", + resource_prefix=None, + flow_name="Test Flow", + ), + ] + + with mock_read_from_file(code_with_goto): + result = FunctionStep.read_local_resource( + file_path="flows/test_flow/function_steps/router.py", + resource_id="Test Flow_router", + resource_name="router", + resource_mappings=resource_mappings, + known_latency_control={}, + ) + + self.assertEqual(len(result.conditions), 1) + condition = result.conditions[0] + self.assertEqual(condition.name, "Route to handler") + self.assertEqual(condition.child_step, "FUNCTION_STEPS-def") + self.assertEqual(condition.condition_type, ConditionType.FUNCTION_STEP) + class ExperimentalConfigTests(unittest.TestCase): def test_validate_experimental_config(self): From f4c55349aaca937d9fa75800341a9ec58b6431ff Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Fri, 22 May 2026 17:35:07 +0100 Subject: [PATCH 3/5] Tests --- src/poly/resources/flows.py | 10 +-- src/poly/tests/resources_test.py | 69 +++++++++++++++++++ .../function_steps/process_payment.py | 2 +- .../test_project/test_project.json | 32 ++++++++- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/poly/resources/flows.py b/src/poly/resources/flows.py index c8da6b58..65d13b7e 100644 --- a/src/poly/resources/flows.py +++ b/src/poly/resources/flows.py @@ -1565,18 +1565,20 @@ def read_local_resource( if known_exit_flow_condition else f"CONDITION-{uuid.uuid4().hex[:8]}" ), - name=condition_name, - condition_type=condition_type, + name=( + known_exit_flow_condition.name if known_exit_flow_condition else "Exit flow" + ), + condition_type=ConditionType.EXIT_FLOW, step_id=step_id, flow_id=flow_id, - child_step=child_step_id, + child_step="", position=known_exit_flow_condition.position if known_exit_flow_condition else None, ingress=known_exit_flow_condition.ingress if known_exit_flow_condition else None, - exit_flow_position=known_condition.exit_flow_position + exit_flow_position=known_exit_flow_condition.exit_flow_position if known_exit_flow_condition else None, parent_is_no_code_step=False, diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py index 80703d0b..774f580c 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -3828,6 +3828,75 @@ def test_read_local_resource_condition_child_step_is_function_step(self): self.assertEqual(condition.child_step, "FUNCTION_STEPS-def") self.assertEqual(condition.condition_type, ConditionType.FUNCTION_STEP) + def test_read_local_resource_exit_flow_condition(self): + """read_local_resource should extract an exit_flow condition from conv.exit_flow().""" + code = ( + "from _gen import * # \n\n\n" + "def my_func(conv: Conversation, flow: Flow):\n" + " if not conv.state.ok:\n" + " conv.exit_flow()\n" + " return\n" + ' flow.goto_step("Target Step", "Step reached")\n' + ) + step_yaml = ( + "step_type: default_step\n" + "name: Target Step\n" + "conditions: []\n" + "extracted_entities: []\n" + "prompt: Some prompt\n" + ) + + resource_mappings = [ + ResourceMapping( + resource_id="test_flow", + resource_name="Test Flow", + resource_type=FlowConfig, + file_path="flows/test_flow/flow_config.yaml", + resource_prefix=None, + flow_name="Test Flow", + ), + ResourceMapping( + resource_id="Test Flow_FLOW_STEPS-abc", + resource_name="Target Step", + resource_type=FlowStep, + file_path="flows/test_flow/steps/target_step.yaml", + resource_prefix=None, + flow_name="Test Flow", + ), + ] + + with mock_read_from_file({ + "flows/test_flow/function_steps/my_func.py": code, + "flows/test_flow/steps/target_step.yaml": step_yaml, + }): + result = FunctionStep.read_local_resource( + file_path="flows/test_flow/function_steps/my_func.py", + resource_id="Test Flow_my_func", + resource_name="my_func", + resource_mappings=resource_mappings, + known_latency_control={}, + ) + + self.assertEqual(len(result.conditions), 2) + + step_cond = next( + c for c in result.conditions if c.condition_type != ConditionType.EXIT_FLOW + ) + self.assertEqual(step_cond.name, "Step reached") + self.assertEqual(step_cond.condition_type, ConditionType.NO_CODE_STEP) + self.assertEqual(step_cond.child_step, "FLOW_STEPS-abc") + self.assertEqual(step_cond.step_id, "my_func") + self.assertEqual(step_cond.flow_id, "test_flow") + + exit_cond = next( + c for c in result.conditions if c.condition_type == ConditionType.EXIT_FLOW + ) + self.assertEqual(exit_cond.name, "Exit flow") + self.assertEqual(exit_cond.condition_type, ConditionType.EXIT_FLOW) + self.assertEqual(exit_cond.child_step, "") + self.assertEqual(exit_cond.step_id, "my_func") + self.assertEqual(exit_cond.flow_id, "test_flow") + class ExperimentalConfigTests(unittest.TestCase): def test_validate_experimental_config(self): diff --git a/src/poly/tests/test_projects/test_project/flows/test_flow/function_steps/process_payment.py b/src/poly/tests/test_projects/test_project/flows/test_flow/function_steps/process_payment.py index 3f1e7227..0a04031f 100644 --- a/src/poly/tests/test_projects/test_project/flows/test_flow/function_steps/process_payment.py +++ b/src/poly/tests/test_projects/test_project/flows/test_flow/function_steps/process_payment.py @@ -6,4 +6,4 @@ def process_payment(conv: Conversation, flow: Flow): # Process payment logic here conv.state.customer_name = "John Doe" conv.state.payment_success = True - return "Payment processed" + flow.goto_step("final_step", "Payment processed") diff --git a/src/poly/tests/test_projects/test_project/test_project.json b/src/poly/tests/test_projects/test_project/test_project.json index 0c94040a..3a3ebfbf 100644 --- a/src/poly/tests/test_projects/test_project/test_project.json +++ b/src/poly/tests/test_projects/test_project/test_project.json @@ -882,7 +882,7 @@ "name": "process_payment", "step_type": "function_step", "description": "", - "code": "def process_payment(conv: Conversation, flow: Flow):\n \"\"\"Process payment for the customer.\"\"\"\n # Process payment logic here\n conv.state.customer_name = \"John Doe\"\n conv.state.payment_success = True\n return \"Payment processed\"\n", + "code": "def process_payment(conv: Conversation, flow: Flow):\n \"\"\"Process payment for the customer.\"\"\"\n # Process payment logic here\n conv.state.customer_name = \"John Doe\"\n conv.state.payment_success = True\n flow.goto_step(\"final_step\", \"Payment processed\")\n", "parameters": [], "latency_control": {}, "function_type": "function_step", @@ -890,7 +890,35 @@ "position": { "x": 0.0, "y": 0.0 - } + }, + "conditions": [ + { + "name": "Payment processed", + "description": "Payment was successful", + "required_entities": [], + "condition_type": "step_condition", + "child_step": "final_step", + "step_id": "process_payment", + "flow_id": "FLOW_CONFIG-test_flow", + "resource_id": "CONDITION-payment_processed", + "position": null, + "exit_flow_position": null, + "ingress": "top" + }, + { + "name": "Payment failed", + "description": "Payment failed, exit flow", + "required_entities": [], + "condition_type": "exit_flow_condition", + "child_step": null, + "step_id": "process_payment", + "flow_id": "FLOW_CONFIG-test_flow", + "resource_id": "CONDITION-payment_failed", + "position": null, + "exit_flow_position": null, + "ingress": "top" + } + ] }, "test_flow_process_data": { "resource_id": "test_flow_with_punctuation!_calculate_discount", From 92776f272bb79c807ddb0898c8d41fc252039bad Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Wed, 27 May 2026 10:24:22 +0100 Subject: [PATCH 4/5] Allow backwards compatibility --- src/poly/resources/flows.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/poly/resources/flows.py b/src/poly/resources/flows.py index 65d13b7e..6a28d330 100644 --- a/src/poly/resources/flows.py +++ b/src/poly/resources/flows.py @@ -7,7 +7,7 @@ import re import uuid from abc import ABC -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from enum import Enum from functools import cached_property from typing import Optional @@ -351,8 +351,11 @@ def __init__( self.dtmf_config = None self.extracted_entities = extracted_entities or [] + condition_names = {f.name for f in fields(Condition) if f.init} self.conditions = [ - Condition(**condition) if not isinstance(condition, Condition) else condition + Condition(**{k: v for k, v in condition.items() if k in condition_names}) + if not isinstance(condition, Condition) + else condition for condition in (conditions or []) ] self.prompt = prompt From d5b449db7c457d9c7772a70e40ae96d63af32cad Mon Sep 17 00:00:00 2001 From: Ruari Phipps Date: Tue, 16 Jun 2026 14:09:51 +0100 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/poly/resources/flows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/poly/resources/flows.py b/src/poly/resources/flows.py index 6a28d330..c476b758 100644 --- a/src/poly/resources/flows.py +++ b/src/poly/resources/flows.py @@ -1491,7 +1491,7 @@ def read_local_resource( condition_name_map = { cond.name: cond for cond in known_conditions - if cond.command_type != ConditionType.EXIT_FLOW + if cond.condition_type != ConditionType.EXIT_FLOW } conditions = []