diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index d263d9fa0..58f7ad307 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -69,7 +69,11 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N # The TUI is the interactive default. Headless contexts run directly on the # terminal: deploy/CREWAI_DMN, piped output, CI — anything without an # interactive TTY. is_interactive() already folds in the CREWAI_DMN check. - if is_interactive(): + # Human-feedback flows also run on the terminal: their methods collect input + # via the flow runtime's blocking input()/Rich prompts (and async feedback + # returns a pending marker rather than completing), neither of which the + # Textual TUI can handle correctly. + if is_interactive() and not _flow_uses_human_feedback(flow): _run_declarative_flow_tui(flow, resolved_inputs or None) return @@ -140,6 +144,21 @@ def _run_declarative_flow_tui(flow: Any, resolved_inputs: dict[str, Any] | None) return app._crew_result +def _flow_uses_human_feedback(flow: Any) -> bool: + """True if any declarative method declares ``@human_feedback``. + + Such flows need the flow runtime's interactive stdin / Rich prompts, which + don't compose with Textual — so they run on the terminal, not the TUI. + """ + try: + methods = getattr(getattr(flow, "_definition", None), "methods", None) or {} + return any( + getattr(m, "human_feedback", None) is not None for m in methods.values() + ) + except Exception: + return False + + def _flow_method_types(flow: Any) -> dict[str, str]: """Map each declarative method name to its ``call`` type (crew/agent/…). diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index 10167c820..378156725 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -553,6 +553,48 @@ def test_run_declarative_flow_tui_enables_flow_events( assert flow.suppress_flow_events is False +def test_flow_uses_human_feedback_detection() -> None: + hf_flow = SimpleNamespace( + _definition=SimpleNamespace( + methods={ + "ask": SimpleNamespace(human_feedback=SimpleNamespace(emit=None)), + "plain": SimpleNamespace(human_feedback=None), + } + ) + ) + assert run_declarative_flow_module._flow_uses_human_feedback(hf_flow) is True + + no_hf = SimpleNamespace( + _definition=SimpleNamespace( + methods={"a": SimpleNamespace(human_feedback=None)} + ) + ) + assert run_declarative_flow_module._flow_uses_human_feedback(no_hf) is False + # No definition → False, no error. + assert run_declarative_flow_module._flow_uses_human_feedback(SimpleNamespace()) is False + + +def test_human_feedback_flow_uses_terminal_even_when_interactive( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + # A human-feedback flow must run on the terminal (blocking input / Rich + # prompts) even in an interactive session, never on the TUI. + monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True) + monkeypatch.setattr( + run_declarative_flow_module, "_flow_uses_human_feedback", lambda flow: True + ) + monkeypatch.setattr( + run_declarative_flow_module, + "_run_declarative_flow_tui", + lambda *a, **k: pytest.fail("human-feedback flow must run on the terminal"), + ) + path = _write(tmp_path, FLOW_YAML) + + run_declarative_flow_module.run_declarative_flow(str(path), '{"topic":"AI"}') + + assert capsys.readouterr().out == "AI\n" + + def test_flow_method_types_from_definition() -> None: flow = SimpleNamespace( _definition=SimpleNamespace(