diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index cd7e11cb..e035c876 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -3998,8 +3998,8 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]: raise # Type-appropriate zero values for optional inputs with no declared default. - # Using None causes templates to render "None" instead of empty string, - # and | default() won't catch None without the boolean=true flag. + # Using None causes templates to render the literal "None" instead of a clean + # value, so we substitute a type-appropriate zero value up front. # Note: mutable types (array, object) return fresh copies via the method below. _TYPE_ZERO_VALUES: dict[str, Any] = { "string": "", diff --git a/src/conductor/executor/template.py b/src/conductor/executor/template.py index f2bba67b..8254bccd 100644 --- a/src/conductor/executor/template.py +++ b/src/conductor/executor/template.py @@ -16,6 +16,7 @@ StrictUndefined, TemplateNotFound, TemplateSyntaxError, + Undefined, ) from jinja2 import UndefinedError as Jinja2UndefinedError @@ -116,16 +117,23 @@ def _json_filter(value: Any, indent: int = 2) -> str: return json.dumps(value, indent=indent, default=str) @staticmethod - def _default_filter(value: Any, default: Any = "") -> Any: + def _default_filter(value: Any, default: Any = "", boolean: bool = False) -> Any: """Return default if value is None or undefined. Args: value: The value to check. - default: Default value to return if value is None. + default: Value returned for None, Undefined, or for any falsey value + when boolean=True. + boolean: If True, return default for any falsey value. Returns: - The value if not None, otherwise the default. + The value if not None/Undefined (or not falsey if boolean=True), + otherwise the default. """ + if isinstance(value, Undefined): + return default + if boolean and not value: + return default if value is None: return default return value diff --git a/tests/test_executor/test_template.py b/tests/test_executor/test_template.py index d20311f3..4dc430ce 100644 --- a/tests/test_executor/test_template.py +++ b/tests/test_executor/test_template.py @@ -120,6 +120,198 @@ def test_default_filter_empty_string(self) -> None: ) assert result == "Value: " + def test_default_filter_boolean_true_falsey(self) -> None: + """Test that default filter with boolean=True returns default for falsey values. + + Requirements: + - When boolean=True, falsey values like None, empty string, 0, False, + empty list, and empty dict must return the fallback default value. + """ + renderer = TemplateRenderer() + falsey_values = [None, "", 0, False, [], {}] + for val in falsey_values: + result = renderer.render( + "Value: {{ value | default('fallback', boolean=True) }}", + {"value": val}, + ) + assert result == "Value: fallback" + + result_shorthand = renderer.render( + "Value: {{ value | default('fallback', true) }}", + {"value": val}, + ) + assert result_shorthand == "Value: fallback" + + def test_default_filter_boolean_true_truthy(self) -> None: + """Test that default filter with boolean=True returns the truthy value. + + Requirements: + - When boolean=True, truthy values must be returned unchanged. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=True) }}", + {"value": "actual"}, + ) + assert result == "Value: actual" + + def test_default_filter_two_arg_none(self) -> None: + """Test default filter with two arguments returns default when value is None.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": None}, + ) + assert result == "Value: fallback" + + def test_default_filter_two_arg_empty_string_preserved(self) -> None: + """Test default filter with two arguments preserves empty string.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": ""}, + ) + assert result == "Value: " + + def test_default_filter_two_arg_zero_preserved(self) -> None: + """Test default filter with two arguments preserves 0 and returns '0'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": 0}, + ) + assert result == "Value: 0" + + def test_default_filter_two_arg_false_preserved(self) -> None: + """Test default filter with two arguments preserves False and returns 'False'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": False}, + ) + assert result == "Value: False" + + def test_default_filter_two_arg_empty_list_preserved(self) -> None: + """Test default filter with two arguments preserves empty list and returns '[]'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": []}, + ) + assert result == "Value: []" + + def test_default_filter_two_arg_empty_dict_preserved(self) -> None: + """Test default filter with two arguments preserves empty dict and returns '{}'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback') }}", + {"value": {}}, + ) + assert result == "Value: {}" + + def test_default_filter_boolean_false_none(self) -> None: + """Test default filter with boolean=False returns default when value is None.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": None}, + ) + assert result == "Value: fallback" + + def test_default_filter_boolean_false_empty_string_preserved(self) -> None: + """Test default filter with boolean=False preserves empty string.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": ""}, + ) + assert result == "Value: " + + def test_default_filter_boolean_false_zero_preserved(self) -> None: + """Test default filter with boolean=False preserves 0 and returns '0'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": 0}, + ) + assert result == "Value: 0" + + def test_default_filter_boolean_false_false_preserved(self) -> None: + """Test default filter with boolean=False preserves False and returns 'False'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": False}, + ) + assert result == "Value: False" + + def test_default_filter_boolean_false_empty_list_preserved(self) -> None: + """Test default filter with boolean=False preserves empty list and returns '[]'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": []}, + ) + assert result == "Value: []" + + def test_default_filter_boolean_false_empty_dict_preserved(self) -> None: + """Test default filter with boolean=False preserves empty dict and returns '{}'.""" + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ value | default('fallback', boolean=False) }}", + {"value": {}}, + ) + assert result == "Value: {}" + + def test_default_filter_missing_variable_returns_default(self) -> None: + """Test default filter returns fallback for an undefined variable. + + Requirements: + - A missing variable guarded by default() must render the fallback instead + of raising TemplateError under StrictUndefined. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ missing | default('fallback') }}", + {}, + ) + assert result == "Value: fallback" + + def test_default_filter_boolean_true_missing_variable_returns_default(self) -> None: + """Test default filter with boolean=True returns fallback for undefined values. + + Requirements: + - A missing variable guarded by default(..., boolean=True) or default(..., true) + must render the fallback instead of evaluating StrictUndefined truthiness. + """ + renderer = TemplateRenderer() + + result = renderer.render( + "Value: {{ missing | default('fallback', boolean=True) }}", + {}, + ) + assert result == "Value: fallback" + + shorthand_result = renderer.render( + "Value: {{ missing | default('fallback', true) }}", + {}, + ) + assert shorthand_result == "Value: fallback" + + def test_default_filter_missing_nested_key_returns_default(self) -> None: + """Test default filter returns fallback for a missing nested dict key. + + Requirements: + - A missing key on an existing dict guarded by default() must render the + fallback instead of raising TemplateError. + """ + renderer = TemplateRenderer() + result = renderer.render( + "Value: {{ data.missing | default('fallback') }}", + {"data": {}}, + ) + assert result == "Value: fallback" + class TestTemplateRendererConditionals: """Tests for conditional expressions in templates."""