From 4060cd144003602dd227d8aab2b1dc1b9d740cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96ndery?= Date: Fri, 10 Jul 2026 18:24:04 +0300 Subject: [PATCH] fix(agent): Await Response pauses on every Loop iteration (#16794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What An **Await Response** (`UserFillUp`) node placed inside a **Loop** now pauses and waits for a fresh user response on **every** iteration, instead of only on the first one. ## Problem When a `UserFillUp` node lives inside a `Loop`, it only paused for input on the first iteration. On subsequent iterations the loop ran straight through, silently reusing the answer the user gave the first time. Root cause is in `UserFillUp._invoke` / the canvas wait-check (`agent/canvas.py`). The wait-check decides whether to pause by calling `Canvas._is_input_field_satisfied` on the node's form fields — a field counts as satisfied as soon as its `value` is not `None`: ```python @staticmethod def _is_input_field_satisfied(field): ... if value is None: return False return True ``` The same component object is reused across loop iterations, and `UserFillUp._invoke` writes the answer into `self._param.inputs[...]["value"]` via `set_input_value`. Nothing cleared those values when the node was re-entered for the next iteration, so: | Iteration | Entry (no answer yet) | Field value | Satisfied? | Result | |---|---|---|---|---| | 1 | fresh | `None` | no | pauses ✅ | | 1 | resume w/ answer | `answer` | yes | continues ✅ | | 2 | fresh | `answer` (**stale**) | yes | continues ❌ (should pause) | ## Fix When a `UserFillUp` is entered without a fresh user answer (`merged_inputs` is empty), clear the retained form values so the wait-check treats the form as unsatisfied and pauses again: ```python merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {})) if not merged_inputs: self._clear_form_values() ``` - Fresh entry / new loop iteration → no answer supplied → values cleared → node pauses and waits. - Resume with an answer → `merged_inputs` is non-empty → values applied normally, nothing cleared. - Non-loop behavior is unchanged: the first entry already had `None` values, so clearing is a no-op there. `Begin` overrides `_invoke` and is unaffected. ## Tests Added to `test/testcases/test_web_api/test_canvas_app/test_fillup_unit.py`: - `test_user_fillup_clears_stale_values_on_reentry_without_answer` — a retained value is cleared on a fresh entry with no answer (loop re-entry). - `test_user_fillup_keeps_values_when_answer_supplied` — a supplied answer is applied and not cleared. All unit tests pass and `ruff check` is clean. ## Scope This targets the Python agent runtime (`agent/`). It is independent of any other in-flight Await Response change. --- agent/component/fillup.py | 20 +++++++ .../test_canvas_app/test_fillup_unit.py | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/agent/component/fillup.py b/agent/component/fillup.py index 7c354610d7..c335731662 100644 --- a/agent/component/fillup.py +++ b/agent/component/fillup.py @@ -114,6 +114,13 @@ class UserFillUp(ComponentBase): self.set_output("tips", content) layout_recognize = self._param.layout_recognize or None merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {})) + if not merged_inputs: + # No fresh user answer was supplied on this entry. Clear any values + # retained from a previous response so the canvas wait-check treats + # the form as unsatisfied and pauses for input again. Without this, + # an Await Response node inside a Loop would only pause on the first + # iteration and silently reuse the earlier answer afterwards. + self._clear_form_values() for k, v in merged_inputs.items(): if self.check_if_canceled("UserFillUp processing"): return @@ -121,5 +128,18 @@ class UserFillUp(ComponentBase): self.set_output(k, resolved) self.set_input_value(k, resolved) + def _clear_form_values(self): + for field in self.get_input_elements().values(): + if not isinstance(field, dict): + continue + field_type = str(field.get("type", "")).lower() + # An optional file input is already treated as satisfied when empty + # (see Canvas._is_input_field_satisfied), so clearing it would not + # force a re-prompt and would only drop a previously uploaded file. + # Leave it untouched to avoid unexpected data loss. + if "file" in field_type and field.get("optional"): + continue + field["value"] = None + def thoughts(self) -> str: return "Waiting for your input..." diff --git a/test/testcases/test_web_api/test_canvas_app/test_fillup_unit.py b/test/testcases/test_web_api/test_canvas_app/test_fillup_unit.py index 1bec402f38..9b812d9fcd 100644 --- a/test/testcases/test_web_api/test_canvas_app/test_fillup_unit.py +++ b/test/testcases/test_web_api/test_canvas_app/test_fillup_unit.py @@ -153,3 +153,58 @@ def test_user_fillup_does_not_consume_unmatched_structured_query(monkeypatch): assert component._param.outputs == {} assert component._canvas.globals["sys.__initial_user_input_consumed__"] is False + + +@pytest.mark.p2 +def test_user_fillup_clears_stale_values_on_reentry_without_answer(monkeypatch): + # A fresh entry with no user answer (e.g. a new Loop iteration) must clear + # any value retained from a previous response so the form is treated as + # unsatisfied and the workflow pauses for input again. + module = _load_fillup_module(monkeypatch) + component = _make_fillup( + module, + query="", + inputs={"demo": {"type": "options", "name": "Demo", "value": "previous answer"}}, + ) + + component._invoke(inputs={}) + + assert component._param.inputs["demo"]["value"] is None + + +@pytest.mark.p2 +def test_user_fillup_keeps_values_when_answer_supplied(monkeypatch): + # When the user actually answers, the supplied value must be applied and + # not cleared. + module = _load_fillup_module(monkeypatch) + component = _make_fillup( + module, + query="", + inputs={"demo": {"type": "options", "name": "Demo", "value": "previous answer"}}, + ) + + component._invoke(inputs={"demo": {"value": "new answer"}}) + + assert component._param.inputs["demo"]["value"] == "new answer" + assert component._param.outputs["demo"]["value"] == "new answer" + + +@pytest.mark.p2 +def test_user_fillup_reentry_keeps_optional_file_value(monkeypatch): + # An optional file input is already treated as satisfied when empty, so + # clearing it would not force a re-prompt and would only drop a previously + # uploaded file. It must be preserved on re-entry. + module = _load_fillup_module(monkeypatch) + component = _make_fillup( + module, + query="", + inputs={ + "text": {"type": "line", "name": "Text", "value": "previous answer"}, + "doc": {"type": "file", "name": "Doc", "optional": True, "value": ["file-id"]}, + }, + ) + + component._invoke(inputs={}) + + assert component._param.inputs["text"]["value"] is None + assert component._param.inputs["doc"]["value"] == ["file-id"]