diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index aafd649c9..6061d369e 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -89,13 +89,6 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: # dict / unschematized state — nothing to derive; pass inputs through. return dict(provided) - # ``id`` signals a persistence restore: kickoff hydrates the full state from - # storage, so required fields may come from the restored state rather than - # --inputs. Forward the inputs unchanged instead of prompting/erroring for - # fields the resume will supply. - if "id" in provided: - return dict(provided) - properties = { name: spec for name, spec in (schema.get("properties") or {}).items() @@ -104,10 +97,21 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: state_model = type(flow.state) defaults = _flow_state_defaults(flow) + # ``id`` signals a persistence restore: kickoff hydrates the full state from + # storage, so required fields may come from the restored state rather than + # --inputs. We still filter the rest of the payload below, but skip the + # required-field prompt and pre-kickoff validation, which would otherwise + # fail on fields the resume will supply. + restoring = "id" in provided + # Unknown keys are almost always typos — warn and drop them (they'd fail - # structured-state validation at kickoff anyway). + # structured-state validation at kickoff anyway). ``id`` is a reserved + # kickoff key rather than a state field, so forward it untouched. collected: dict[str, Any] = {} for key, value in provided.items(): + if key == "id": + collected["id"] = value + continue if key in properties: collected[key] = value continue @@ -119,6 +123,9 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: err=True, ) + if restoring: + return collected + missing = _missing_required(state_model, {**defaults, **collected}) if missing and _is_interactive(): collected.update( diff --git a/lib/cli/tests/test_input_prompt.py b/lib/cli/tests/test_input_prompt.py index e43deb417..22bb334a9 100644 --- a/lib/cli/tests/test_input_prompt.py +++ b/lib/cli/tests/test_input_prompt.py @@ -4,7 +4,6 @@ from __future__ import annotations import pytest -import crewai_cli.input_prompt as input_prompt_module from crewai_cli.input_prompt import ( closest_name, parse_inputs_json, @@ -51,7 +50,7 @@ def test_prompt_for_inputs_uses_describe_and_coerce(monkeypatch, capsys): seen.append(text) return "42" - monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt) + monkeypatch.setattr("crewai_cli.input_prompt.click.prompt", fake_prompt) result = prompt_for_inputs( ["count"], @@ -71,7 +70,7 @@ def test_prompt_for_inputs_uses_describe_and_coerce(monkeypatch, capsys): def test_prompt_for_inputs_keeps_raw_string_without_coerce(monkeypatch): monkeypatch.setattr( - input_prompt_module.click, "prompt", lambda text, **kwargs: "AI" + "crewai_cli.input_prompt.click.prompt", lambda text, **kwargs: "AI" ) result = prompt_for_inputs( diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index 4951615c5..abc49396b 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -381,3 +381,22 @@ def test_id_only_input_skips_required_validation(tmp_path: Path) -> None: resolved = run_declarative_flow_module._resolve_flow_inputs(flow, {"id": "run-123"}) assert resolved == {"id": "run-123"} + + +def test_id_restore_still_drops_unknown_keys( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # A persistence restore (`id` present) still filters typo keys so they don't + # reach kickoff and trip strict (extra="forbid") state models — it only + # skips the required-field prompt/validation, not the unknown-key warning. + path = _write(tmp_path, REQUIRED_FLOW_YAML) + flow = run_declarative_flow_module.load_declarative_flow(str(path)) + + resolved = run_declarative_flow_module._resolve_flow_inputs( + flow, {"id": "run-123", "prospect_emai": "typo"} + ) + + captured = capsys.readouterr() + assert resolved == {"id": "run-123"} # id kept, typo dropped + assert "Ignoring unknown input 'prospect_emai'" in captured.err + assert "Ignoring unknown input 'id'" not in captured.err