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"]