mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(dify-builder): pre-fill the test-data form instead of hiding the mock
Mocking the inputs removed the work of inventing test data, but it also
removed the user's sight of what was actually tested. A green check
produced by {"topic": "test"} is weak evidence about a workflow, and an
invisible mock is a quieter version of the same false confidence a
passing-but-unverified test gives.
So keep the gate and pre-fill it: the form arrives with the mocked values
already in it, unfrozen, so the common case is one click rather than N
fields to type, and anyone who cares can edit before running. The cost
item 5 removed was PRODUCING the data, not confirming it.
Upload fields stay empty and still ask, because nothing can mock a file --
and the mock generator falls back to a plain string for types it does not
know, so it would otherwise pre-fill a file field with "test". New
``upload_variable_names`` and ``without_upload_values`` strip those values;
``needs_upload_inputs`` is re-expressed on top of the first rather than
duplicating the type check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
96f98b5804
commit
3838db8aa7
4 files changed
+94
-41
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user