This is an automated email from the ASF dual-hosted git repository.

guan404ming pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 0aeec0c7962 Render multi-branch review choices as a multi-select 
(#71046)
0aeec0c7962 is described below

commit 0aeec0c79626ae0deada0022636601419526a2ef
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Aug 5 02:37:29 2026 +0900

    Render multi-branch review choices as a multi-select (#71046)
    
    * Render multi-branch review choices as a multi-select
    
    * Fix multi-select review form validation
    
    * Fix test imports for older Airflow compat
---
 providers/common/ai/docs/operators/llm_branch.rst  |  4 +-
 .../airflow/providers/common/ai/mixins/approval.py | 40 ++++++++++++--
 .../providers/common/ai/operators/llm_branch.py    |  6 ++-
 .../tests/unit/common/ai/mixins/test_approval.py   | 62 ++++++++++++++++++++++
 .../unit/common/ai/operators/test_llm_branch.py    | 42 +++++++++++++--
 5 files changed, 143 insertions(+), 11 deletions(-)

diff --git a/providers/common/ai/docs/operators/llm_branch.rst 
b/providers/common/ai/docs/operators/llm_branch.rst
index 52affb9af86..21558b2277f 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -83,8 +83,8 @@ branch(es) and wait for a human reviewer to approve the 
choice before any
 downstream task is skipped. The review form shows the LLM's choice and the
 valid downstream task IDs. When ``allow_modifications=True``, the reviewer
 can also change the choice — rendered as a dropdown of the downstream task
-IDs, or a free-text JSON list of task IDs (e.g. ``["task_a", "task_b"]``)
-with ``allow_multiple_branches=True``. The reviewed branch(es) are validated
+IDs, or a multi-select of them with ``allow_multiple_branches=True``. The
+reviewed branch(es) are validated
 against the downstream task IDs before branching:
 
 .. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py 
b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
index 21c2e4c9de4..cccff32bfd1 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import json
 import logging
 from datetime import timedelta
 from typing import TYPE_CHECKING, Any, Protocol
@@ -109,7 +110,11 @@ class LLMApprovalMixin:
         :param modification_schema: JSON schema for the editable ``output`` 
param
             when ``allow_modifications=True``. Defaults to ``{"type": 
"string"}``.
             Pass e.g. ``{"type": "string", "enum": [...]}`` to render a 
dropdown
-            of valid values in the review form.
+            of valid values in the review form, or ``{"type": "array", "items":
+            {"type": "string", "enum": [...]}, "examples": [...]}`` to render a
+            multi-select (JSON Schema forbids ``enum`` at the array level, so 
the
+            options come from ``examples``); a list submitted by the reviewer 
is
+            returned from ``execute_complete`` re-serialized as a compact JSON 
string.
         """
         from airflow.providers.standard.triggers.hitl import HITLTrigger
         from airflow.sdk.execution_time.hitl import upsert_hitl_detail
@@ -117,6 +122,7 @@ class LLMApprovalMixin:
 
         self.validate_approval_prompt()
 
+        raw_output = output
         if isinstance(output, BaseModel):
             output = output.model_dump_json()
         elif not isinstance(output, str):
@@ -133,9 +139,17 @@ class LLMApprovalMixin:
 
         hitl_params: dict[str, dict[str, Any]] = {}
         if self.allow_modifications:
+            # The multi-select rendered for an array schema needs the list, 
not its JSON string
+            param_value: Any = output
+            if (
+                modification_schema is not None
+                and modification_schema.get("type") == "array"
+                and isinstance(raw_output, list)
+            ):
+                param_value = raw_output
             hitl_params = {
                 "output": {
-                    "value": output,
+                    "value": param_value,
                     "description": "Edit the output before approving 
(optional).",
                     "schema": modification_schema or {"type": "string"},
                 },
@@ -215,13 +229,33 @@ class LLMApprovalMixin:
         # when allow_modifications=False, bypassing the read-only approval 
flow.
         if getattr(self, "allow_modifications", False) and params_input:
             modified = params_input.get("output")
+            if "output" in params_input and modified is None:
+                raise HITLTriggerEventError(
+                    {
+                        "error": "Modified output must not be empty; edit it 
or reject instead.",
+                        "error_type": "validation",
+                    }
+                )
+            if isinstance(modified, list):
+                for item in modified:
+                    if not isinstance(item, str):
+                        raise HITLTriggerEventError(
+                            {
+                                "error": f"Modified output list items must be 
strings, "
+                                f"got {type(item).__name__}.",
+                                "error_type": "validation",
+                            }
+                        )
+                # Compact so an unchanged selection compares equal to 
generated_output
+                modified = json.dumps(modified, separators=(",", ":"))
             if modified is not None and not isinstance(modified, str):
                 # On the awaiting_input path nothing upstream schema-validates 
params_input
                 # (HITLTrigger did on the legacy path), so enforce the string 
contract here
                 # rather than returning a non-string as the task's output.
                 raise HITLTriggerEventError(
                     {
-                        "error": f"Modified output must be a string, got 
{type(modified).__name__}.",
+                        "error": f"Modified output must be a string or a list 
of strings, "
+                        f"got {type(modified).__name__}.",
                         "error_type": "validation",
                     }
                 )
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
index 620b36e6809..88c29943259 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
@@ -56,7 +56,7 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
     unselected downstream tasks once a reviewer approves. The review form
     lists the valid downstream task IDs; with ``allow_modifications=True``
     the editable choice is rendered as a dropdown of those IDs (single-branch
-    mode) or a free-text JSON list (``allow_multiple_branches=True``), and
+    mode) or a multi-select of them (``allow_multiple_branches=True``), and
     the reviewed branch(es) are validated against the downstream task IDs
     before branching.
     """
@@ -121,7 +121,9 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
                 f"```\nPrompt: {self.prompt}\n\nChosen branch(es): 
{chosen}\n```"
             )
             modification_schema = (
-                None if self.allow_multiple_branches else {"type": "string", 
"enum": choices}
+                {"type": "array", "items": {"type": "string", "enum": 
choices}, "examples": choices}
+                if self.allow_multiple_branches
+                else {"type": "string", "enum": choices}
             )
             self.defer_for_approval(  # type: ignore[misc]
                 context, branches, body=body, 
modification_schema=modification_schema
diff --git a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py 
b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
index 23c97bd2470..ace3ce7d3d9 100644
--- a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
+++ b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
@@ -158,6 +158,22 @@ class TestDeferForApproval:
         param = mock_upsert.call_args[1]["params"]["output"]
         assert param["schema"] == schema
 
+    @patch(HITL_TRIGGER_PATH, autospec=True)
+    @patch(UPSERT_HITL_PATH)
+    def test_array_schema_passes_list_param_value(
+        self, mock_upsert, mock_trigger_cls, approval_op_with_modifications, 
context
+    ):
+        choices = ["task_a", "task_b"]
+        schema = {"type": "array", "items": {"type": "string", "enum": 
choices}, "examples": choices}
+
+        approval_op_with_modifications.defer_for_approval(context, ["task_a"], 
modification_schema=schema)
+
+        param = mock_upsert.call_args[1]["params"]["output"]
+        assert param["value"] == ["task_a"]
+        assert param["schema"] == schema
+        defer_kwargs = approval_op_with_modifications.defer.call_args[1]
+        assert defer_kwargs["kwargs"]["generated_output"] == '["task_a"]'
+
     @patch(HITL_TRIGGER_PATH, autospec=True)
     @patch(UPSERT_HITL_PATH)
     def test_no_modifications_params_empty(self, mock_upsert, 
mock_trigger_cls, approval_op, context):
@@ -292,6 +308,52 @@ class TestDeferForApproval:
                 {}, generated_output="original output", event=event
             )
 
+    def test_approved_with_list_modified_output_is_serialized(self, 
approval_op_with_modifications):
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "editor",
+            "params_input": {"output": ["task_b", "task_c"]},
+        }
+
+        result = approval_op_with_modifications.execute_complete(
+            {}, generated_output='["task_a"]', event=event
+        )
+
+        assert result == '["task_b","task_c"]'
+
+    def test_approved_with_unmodified_list_output_returns_original(self, 
approval_op_with_modifications):
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "editor",
+            "params_input": {"output": ["task_a"]},
+        }
+
+        result = approval_op_with_modifications.execute_complete(
+            {}, generated_output='["task_a"]', event=event
+        )
+
+        assert result == '["task_a"]'
+
+    def test_approved_with_non_string_list_items_raises(self, 
approval_op_with_modifications):
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "editor",
+            "params_input": {"output": ["task_a", 2]},
+        }
+
+        with pytest.raises(HITLTriggerEventError, match="items must be 
strings, got int"):
+            approval_op_with_modifications.execute_complete({}, 
generated_output='["task_a"]', event=event)
+
+    def test_approved_with_cleared_output_raises(self, 
approval_op_with_modifications):
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "editor",
+            "params_input": {"output": None},
+        }
+
+        with pytest.raises(HITLTriggerEventError, match="must not be empty"):
+            approval_op_with_modifications.execute_complete({}, 
generated_output='["task_a"]', event=event)
+
     def test_approved_with_unmodified_output(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
index 78107daf138..7f2d11bf263 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
@@ -25,7 +25,7 @@ import pytest
 from airflow.providers.common.ai.mixins.approval import LLMApprovalMixin
 from airflow.providers.common.ai.operators.llm import LLMOperator
 from airflow.providers.common.ai.operators.llm_branch import LLMBranchOperator
-from airflow.providers.common.compat.sdk import TaskDeferred
+from airflow.providers.common.compat.sdk import Param, ParamValidationError, 
TaskDeferred
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS, 
AIRFLOW_V_3_3_PLUS
 
@@ -313,10 +313,10 @@ class TestLLMBranchOperatorApproval:
     @patch("airflow.providers.standard.triggers.hitl.HITLTrigger", 
autospec=True)
     @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
-    def test_review_form_multi_branch_keeps_string_schema(
+    def test_review_form_multi_branch_renders_multiselect(
         self, mock_hook_cls, mock_upsert, mock_trigger_cls, mock_do_branch
     ):
-        """With allow_multiple_branches the editable param stays free-text 
(JSON list)."""
+        """With allow_multiple_branches the editable param is an array enum 
(multi-select)."""
         downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a", 
"task_b": "task_b"})
 
         mock_agent = MagicMock(spec=["run_sync"])
@@ -338,7 +338,17 @@ class TestLLMBranchOperatorApproval:
 
         call_kwargs = mock_upsert.call_args.kwargs
         assert "Valid branches: `task_a`, `task_b`" in call_kwargs["body"]
-        assert call_kwargs["params"]["output"]["schema"] == {"type": "string"}
+        assert call_kwargs["params"]["output"]["schema"] == {
+            "type": "array",
+            "items": {"type": "string", "enum": ["task_a", "task_b"]},
+            "examples": ["task_a", "task_b"],
+        }
+        assert call_kwargs["params"]["output"]["value"] == ["task_a"]
+
+        schema = call_kwargs["params"]["output"]["schema"]
+        assert Param(schema=schema).resolve(["task_a"]) == ["task_a"]
+        with pytest.raises(ParamValidationError):
+            Param(schema=schema).resolve(["task_x"])
 
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_rejects_sequence_prompt_with_require_approval(self, 
mock_hook_cls):
@@ -405,6 +415,30 @@ class TestLLMBranchOperatorApproval:
         assert result == "task_b"
         mock_do_branch.assert_called_once_with(ctx, "task_b")
 
+    @patch.object(LLMBranchOperator, "do_branch")
+    def test_execute_complete_with_multiselect_modified_branches(self, 
mock_do_branch):
+        """A list submitted by the multi-select review form branches into 
those tasks."""
+        mock_do_branch.return_value = ["task_b", "task_c"]
+        op = LLMBranchOperator(
+            task_id="t",
+            prompt="p",
+            llm_conn_id="c",
+            allow_multiple_branches=True,
+            allow_modifications=True,
+        )
+        op.downstream_task_ids = {"task_a", "task_b", "task_c"}
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "admin",
+            "params_input": {"output": ["task_b", "task_c"]},
+        }
+        ctx = _make_context()
+
+        result = op.execute_complete(ctx, generated_output='["task_a"]', 
event=event)
+
+        assert result == ["task_b", "task_c"]
+        mock_do_branch.assert_called_once_with(ctx, ["task_b", "task_c"])
+
     @patch.object(LLMBranchOperator, "do_branch")
     def test_execute_complete_rejects_invalid_modified_branch(self, 
mock_do_branch):
         """A reviewer-modified branch outside downstream_task_ids fails 
validation."""

Reply via email to