diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py index 1f262a6a24b..03a08594afc 100644 --- a/api/core/dify_builder/handlers_build.py +++ b/api/core/dify_builder/handlers_build.py @@ -48,10 +48,10 @@ from core.dify_builder.handlers_fix import ( merge_known_keys, mint_checkpoint, model_config_error_text, - needs_upload_inputs, perform_revert, start_schema, testdata_form_fields, + without_upload_values, ) from core.dify_builder.models import ( ConversationItem, @@ -574,31 +574,32 @@ def handle_execution(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) - if fc.test_input_ref == "": graph, _hash = env.dify.read_graph(s.app_id, turn.actor) schema = start_schema(graph) - if needs_upload_inputs(schema): - form_items = append_card( - fc, - FormCard( - variant="testdata", - fields=testdata_form_fields(schema), - values={}, - frozen=False, - ), - ) - turn_items = append_card( - fc, - AssistantTurnItem( - turn_id=str(uuid.uuid4()), - stage_id=str(s.current_state), - execution=ExecutionProgress(status="completed"), - reply_text="Provide test inputs (or use mock data) to run the test.", - cards=["form"], - ), - ) - return StepResult(next=PcState.BUILD_AWAIT_TESTDATA, context=fc, items=[*form_items, *turn_items]) - inputs = env.agent.generate_mock_inputs(schema, {}) - ti = TestInput(session_id=s.id, source="mock", inputs=inputs) - env.repo.save_test_input(ti) - fc.test_input_ref = ti.id + # Pre-fill the form with mock values instead of asking the user to + # invent them: the cost is one click, not N fields. Still SHOW + # them -- a green check produced by inputs nobody ever saw is weak + # evidence about the workflow. Upload fields stay empty because + # nothing can mock a file, so the form asks for those alone. + prefill = without_upload_values(schema, env.agent.generate_mock_inputs(schema, {})) + form_items = append_card( + fc, + FormCard( + variant="testdata", + fields=testdata_form_fields(schema), + values=prefill, + frozen=False, + ), + ) + turn_items = append_card( + fc, + AssistantTurnItem( + turn_id=str(uuid.uuid4()), + stage_id=str(s.current_state), + execution=ExecutionProgress(status="completed"), + reply_text="I filled in test inputs -- edit them if you like, then run the test.", + cards=["form"], + ), + ) + return StepResult(next=PcState.BUILD_AWAIT_TESTDATA, context=fc, items=[*form_items, *turn_items]) emit_canvas(env, "start_test_run") items = append_card(fc, DecisionItem(text="Run tests")) return StepResult(next=PcState.BUILD_TEST_AND_REPAIR, context=fc, items=items) diff --git a/api/core/dify_builder/handlers_fix.py b/api/core/dify_builder/handlers_fix.py index c8ce74562a5..ab28abb041a 100644 --- a/api/core/dify_builder/handlers_fix.py +++ b/api/core/dify_builder/handlers_fix.py @@ -48,6 +48,7 @@ from core.dify_builder.models import ( ConversationItem, DifyBuilderContext, Graph, + Inputs, NodeEvent, NodeOutput, Run, @@ -92,6 +93,8 @@ __all__ = [ "perform_revert", "start_schema", "testdata_form_fields", + "upload_variable_names", + "without_upload_values", ] @@ -253,15 +256,34 @@ def start_schema(graph: Graph) -> StartSchema: _UPLOAD_VARIABLE_TYPES = frozenset({"file", "file-list"}) -def needs_upload_inputs(schema: StartSchema) -> bool: - """Whether any declared start variable takes a file. +def upload_variable_names(schema: StartSchema) -> set[str]: + """Names of the start variables that take a file. - Everything else can be mocked; an upload cannot, so it is the only reason - left to stop and ask a human for test data. + Nothing can invent an upload, so these are the values a human still has to + supply even when every other field is mocked for them. """ - return any( - isinstance(v, dict) and str(v.get("type") or "") in _UPLOAD_VARIABLE_TYPES for v in schema.get("variables", []) - ) + return { + str(v["variable"]) + for v in schema.get("variables", []) + if isinstance(v, dict) and v.get("variable") and str(v.get("type") or "") in _UPLOAD_VARIABLE_TYPES + } + + +def needs_upload_inputs(schema: StartSchema) -> bool: + """Whether any declared start variable takes a file.""" + return bool(upload_variable_names(schema)) + + +def without_upload_values(schema: StartSchema, inputs: Inputs) -> Inputs: + """``inputs`` minus anything bound to an upload variable. + + The mock generator falls back to a plain string for a type it does not + know, so it cheerfully produces ``"test"`` for a ``file`` -- pre-filling a + file field with a value the run cannot use. Drop those and let the form + ask for them instead. + """ + dropped = upload_variable_names(schema) + return {key: value for key, value in inputs.items() if key not in dropped} def _string_list(value: object) -> list[str]: diff --git a/api/core/dify_builder/strings.py b/api/core/dify_builder/strings.py index be95fc5a7a2..f0c236799fe 100644 --- a/api/core/dify_builder/strings.py +++ b/api/core/dify_builder/strings.py @@ -35,7 +35,7 @@ PLAIN: frozenset[str] = frozenset( "No workspace resources matched this plan — continuing without any.", "Plan v1 ready for approval.", "Workflow built on the canvas.", - "Provide test inputs (or use mock data) to run the test.", + "I filled in test inputs -- edit them if you like, then run the test.", "Tests passed; ready for review.", "The run failed on its inputs — provide test data and retry.", "Here's the impact of your change.", diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index a032192c440..7f5d413f2e2 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -1249,10 +1249,19 @@ def test_full_build_flow_goal_to_complete(): assert len(graph["nodes"]) == 4 assert len(graph["edges"]) == 3 - # 5) run_test -> the generated start schema declares no file/file-list - # variable, so inputs are mocked inline (no gate) -> build.test_and_repair + # 5) run_test -> the testdata form arrives PRE-FILLED with mock values and + # rests at build.await_testdata, so the user can see and edit what will be + # tested. Submitting it (one click, nothing to type) -> build.test_and_repair # (working, auto) -> rests at build.review. out = runner.advance(s.id, Turn(action=Action(kind="run_test", base_version=out.version), actor=_actor())) + assert out.current_state == PcState.BUILD_AWAIT_TESTDATA + out = runner.advance( + s.id, + Turn( + action=Action(kind="provide_testdata", payload={"mode": "mock"}, base_version=out.version), + actor=_actor(), + ), + ) assert out.current_state == PcState.BUILD_REVIEW # 6) publish_workflow -> build.publish (auto) -> governance_feedback (auto) @@ -1363,6 +1372,14 @@ def test_full_build_flow_keep_draft_reaches_complete_without_publish(): out = runner.advance(s.id, Turn(action=Action(kind="approve_repair", base_version=out.version), actor=_actor())) # No file/file-list start variable -> mocked inline, no gate. out = runner.advance(s.id, Turn(action=Action(kind="run_test", base_version=out.version), actor=_actor())) + # the testdata form arrives pre-filled; submitting it is one click + out = runner.advance( + s.id, + Turn( + action=Action(kind="provide_testdata", payload={"mode": "mock"}, base_version=out.version), + actor=_actor(), + ), + ) assert out.current_state == PcState.BUILD_REVIEW out = runner.advance(s.id, Turn(action=Action(kind="keep_draft", base_version=out.version), actor=_actor())) @@ -1412,6 +1429,14 @@ def test_review_continue_adjusting_then_reapprove_is_idempotent(): # No file/file-list start variable -> mocked inline, no gate. out = runner.advance(s.id, Turn(action=Action(kind="run_test", base_version=out.version), actor=_actor())) + # the testdata form arrives pre-filled; submitting it is one click + out = runner.advance( + s.id, + Turn( + action=Action(kind="provide_testdata", payload={"mode": "mock"}, base_version=out.version), + actor=_actor(), + ), + ) assert out.current_state == PcState.BUILD_REVIEW # loop back: continue_adjusting (-> re_fix) -> build.initial_plan (re-plan) @@ -1485,8 +1510,11 @@ def test_run_test_skips_gate_when_input_prepared(): assert result.next == PcState.BUILD_TEST_AND_REPAIR -def test_run_test_mocks_its_own_inputs(): - """Nothing to upload, so nothing to ask -- mock the inputs inline and run.""" +def test_run_test_prefills_the_form_with_mock_values(): + """The user never has to invent test data -- but they do get to see and + edit it. Inventing the values is the cost item 5 removed; confirming them + with one click is not, and a green check produced by inputs nobody saw is + weak evidence about the workflow.""" from core.dify_builder.handlers_build import handle_execution from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort @@ -1504,9 +1532,11 @@ def test_run_test_mocks_its_own_inputs(): s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_EXECUTION) fc = DifyBuilderContext(test_input_ref="") result = handle_execution(env, Turn(actor=_actor(), action=Action(kind="run_test")), s, fc) - assert result.next == PcState.BUILD_TEST_AND_REPAIR - assert result.context.test_input_ref # mocked + persisted, not left blank - assert not any(i.kind == "form" for i in result.items) # no gate shown + assert result.next == PcState.BUILD_AWAIT_TESTDATA + form = next(i for i in result.items if i.kind == "form") + assert form.payload["variant"] == "testdata" + assert form.payload["frozen"] is False # editable, not a read-only receipt + assert form.payload["values"] # pre-filled from the mock, not an empty form def test_run_test_still_asks_when_a_file_is_declared_among_other_variables():