Files
crewAI/lib/cli/tests/test_run_declarative_flow.py
João Moura 85c467dfe2
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
feat(cli): run declarative flows on the TUI (headless terminal fallback) (#6484)
* feat(cli): run declarative flows on the TUI with a headless terminal fallback

Declarative flows now run on the CrewRunApp TUI when interactive, matching
declarative crews and conversational flows. Headless contexts — CREWAI_DMN
(deploy), piped output, CI, any non-TTY — fall back to the direct-terminal
kickoff, gated by is_interactive() (folds in the CREWAI_DMN check and requires
a real TTY).

The TUI shows per-method progress: a new STEPS panel driven by flow method
events (FlowStarted / MethodExecutionStarted/Finished/Failed), each labeled
with its declarative call type (crew/agent/expression/…) read from the flow
definition. Crews/agents inside a method keep streaming in the main panel via
the existing crew/task/LLM handlers.

- crew_run_tui.py: _run_flow_worker (flow.kickoff in a thread worker; reuses
  _on_crew_done/_on_crew_failed + _stringify_output), _is_flow_run gate so crew
  rendering is byte-identical, flow-event subscriptions building _flow_steps,
  and the STEPS sidebar + flow-aware header.
- run_declarative_flow.py: is_interactive() branch → _run_declarative_flow_tui
  (EventListener, method-type map from flow._definition, crew-parity exit codes
  and deploy chaining) or the existing terminal path.

Deviation from the approved plan: gate on is_interactive() rather than
is_dmn_mode_enabled() alone, so non-TTY runs (CI/pipes/CliRunner) never launch
a TUI — this also keeps existing headless flow tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): force flow events on for the TUI so STEPS renders under suppress_flow_events

Review follow-up: the STEPS panel and header are driven by flow method events
(FlowStarted / MethodExecution*), but the declarative runtime skips emitting
those when the flow declared config.suppress_flow_events. Interactive TUI runs
would then keep STEPS on "waiting…" and the header on "Starting flow…" while
nested crews still execute.

_run_declarative_flow_tui now forces flow.suppress_flow_events = False for the
interactive run (mirroring how the conversational path mutates the flow for the
TUI). The headless/terminal path never reaches this and keeps the flow's
declared setting. Regression test: test_run_declarative_flow_tui_enables_flow_events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): clear flow header's current method when a method ends

Review follow-up: the flow header keys off _current_method, which was set on
MethodExecutionStarted but never cleared on Finished/Failed. Between steps (or
after a failed method before kickoff exits) the header kept spinning the old
method name while the STEPS sidebar already showed it done/failed.

_clear_current_method now drops the header's active method when it ends,
falling back to another still-active step (methods can overlap) or none. The
header's idle fallback shows "Working…" once a step has run and "Starting
flow…" only before the first method.

Tests: test_current_method_clears_and_falls_back_across_overlap, plus a
_current_method assertion in test_flow_method_events_build_steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix: suppress flow console panels in TUI mode; clear header agent on method change

Two review follow-ups:

1) Method panels break Textual TUI (Cursor): forcing suppress_flow_events off
   so the STEPS panel receives events also un-gated the EventListener's Rich
   flow/method panels (ConsoleFormatter.print_panel prints is_flow=True panels
   regardless of verbose), which interleave with Textual and corrupt the TUI.
   print_panel now skips is_flow panels when is_tui_mode() is set (the same
   context the TUI worker already establishes and the tracing listeners already
   honor). Non-TUI/headless flow runs are unaffected. Test:
   test_console_formatter_tui_mode.

2) Flow header showed a stale agent (CodeRabbit): _current_agent persisted
   across methods. It's now cleared when a method starts and when the active
   method changes, so the header never shows the previous method's agent until
   a new agent event arrives. Test: test_flow_method_transitions_clear_current_agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): keep flow name over nested crews; show paused flow methods

Two review follow-ups on the flow TUI:

1) Crew kickoff renamed the flow (Cursor): CrewKickoffStartedEvent overwrote
   _crew_name / the app title with a nested `call: crew` step's crew name, so
   the post-run summary could be labeled with a child crew. The rename is now
   gated on `not _is_flow_run`, preserving the flow's name; crew runs still
   adopt the crew name. Tests: test_crew_kickoff_does_not_rename_flow_run,
   test_crew_kickoff_renames_in_crew_mode.

2) Paused methods showed active (Cursor): the TUI didn't handle
   MethodExecutionPausedEvent, so a @human_feedback pause left the STEPS
   spinner running (flow status panels are suppressed in TUI mode). It now
   marks the step "paused" (⏸, teal) and the header shows "waiting for
   feedback" instead of a spinner. Test: test_method_paused_marks_step_paused.

Note: interactively *providing* human feedback from the flow TUI is a separate
follow-up; this only makes the pause visible instead of a silent stuck spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): run human-feedback declarative flows on the terminal, not the TUI

Two review follow-ups, both rooted in @human_feedback methods:

- Paused flow marked complete (Cursor): async human feedback makes kickoff
  RETURN a HumanFeedbackPending marker (not raise), which _run_flow_worker
  would stringify and report as a successful completion with exit 0.
- Sync feedback breaks TUI (Cursor): default (sync) @human_feedback collects
  input via the flow runtime's Rich console.print + blocking input(), which
  interleaves with Textual and leaves the user unable to review output or
  submit feedback.

run_declarative_flow now routes any flow whose declarative definition declares
human feedback (_flow_uses_human_feedback) to the terminal path, where blocking
input and Rich prompts work natively — regardless of interactivity. Non-feedback
flows still get the TUI. Tests: test_flow_uses_human_feedback_detection,
test_human_feedback_flow_uses_terminal_even_when_interactive.

Fully interactive human feedback inside the TUI remains a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* refactor(cli): address review — Flow typing, debug logging, flow-vs-crew naming

Review follow-ups from @lucasgomide:

- Type flow helpers as Flow[Any] (via TYPE_CHECKING import) instead of Any and
  drop the defensive getattr chains — _definition is a typed PrivateAttr and
  name/suppress_flow_events are typed fields, so attribute access is safe.
- Replace the silent `except Exception: pass` blocks with logger.debug(...,
  exc_info=True) so unexpected failures are diagnosable in the field
  (_flow_method_types, _flow_uses_human_feedback, suppress_flow_events toggle).
- Flow-vs-crew naming: the flow worker now uses group="flow" (was the
  misleading "crew"), and the shared completion/failure handlers report the
  run with an entity-aware noun ("flow" vs "crew") via _run_noun.

Deferred (separate PR): the os._exit(130) hard-kill on user quit is kept as-is
to match the existing crew convention (run_crew._run_json_crew).

Tests: test_flow_done_uses_flow_wording_for_unfinished_tool; existing crew
wording tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:42:12 -03:00

614 lines
19 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
import crewai_cli.input_prompt as input_prompt_module
import crewai_cli.run_declarative_flow as run_declarative_flow_module
@pytest.fixture(autouse=True)
def _headless_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default these tests to the headless/terminal path.
``run_declarative_flow`` now launches the TUI when interactive, which can't
run under pytest; tests here assert the terminal/headless contract. Tests
that exercise TUI routing override ``is_dmn_mode_enabled`` explicitly.
"""
monkeypatch.setenv("CREWAI_DMN", "true")
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
config:
suppress_flow_events: true
methods:
begin:
start: true
do:
call: expression
expr: state.topic
"""
def test_run_declarative_flow_reads_definition_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"
def test_run_declarative_flow_rejects_non_object_inputs(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '["not", "an", "object"]'
)
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_declarative_flow_reports_missing_file(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow("missing-flow.yaml")
assert (
"Invalid --definition path: missing-flow.yaml does not exist."
in capsys.readouterr().err
)
def test_run_declarative_flow_reports_empty_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(" \n", encoding="utf-8")
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(str(definition_path))
assert "Flow declaration file is empty" in capsys.readouterr().err
@pytest.mark.parametrize(
"contents, expected_error",
[
("[]\n", "Flow declaration must contain a mapping"),
("schema: crewai.flow/v1\nmethods: {}\n", "Field required"),
],
)
def test_load_declarative_flow_reports_invalid_declarations(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
contents: str,
expected_error: str,
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(contents, encoding="utf-8")
with pytest.raises(SystemExit) as exc_info:
run_declarative_flow_module.load_declarative_flow(str(definition_path))
assert exc_info.value.code == 1
stderr = capsys.readouterr().err
assert f"Unable to read --definition path {definition_path}:" in stderr
assert expected_error in stderr
def test_run_declarative_flow_in_project_env_uses_uv(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
monkeypatch.setattr(
run_declarative_flow_module,
"build_env_with_all_tool_credentials",
lambda: {"EXISTING": "value"},
)
monkeypatch.setattr(
run_declarative_flow_module.subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
)
run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml")
assert subprocess_calls == [
(
["uv", "run", "crewai", "run"],
{
"capture_output": False,
"text": True,
"check": True,
"env": {"EXISTING": "value"},
},
)
]
def test_run_declarative_flow_in_process_inside_uv(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow_in_project_env(
"flow.yaml", '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"
def test_run_declarative_flow_in_project_env_forwards_inputs(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
monkeypatch.setattr(
run_declarative_flow_module,
"build_env_with_all_tool_credentials",
lambda: {},
)
monkeypatch.setattr(
run_declarative_flow_module.subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append(command),
)
run_declarative_flow_module.run_declarative_flow_in_project_env(
"flow.yaml", '{"topic":"AI"}'
)
# --inputs is forwarded to the in-env run instead of being rejected.
assert subprocess_calls == [
["uv", "run", "crewai", "run", "--inputs", '{"topic":"AI"}']
]
# ── Schema-driven inputs: prompt, validate, override ────────────────
REQUIRED_FLOW_YAML = """\
schema: crewai.flow/v1
name: RequiredInputFlow
config:
suppress_flow_events: true
state:
type: json_schema
json_schema:
type: object
properties:
prospect_email:
type: string
description: Email address of the prospect to research
required: [prospect_email]
methods:
begin:
start: true
do:
call: expression
expr: state.prospect_email
"""
DEFAULTS_FLOW_YAML = """\
schema: crewai.flow/v1
name: DefaultsFlow
config:
suppress_flow_events: true
state:
type: json_schema
json_schema:
type: object
properties:
topic: {type: string}
audience: {type: string}
required: [topic, audience]
default:
topic: AI
methods:
begin:
start: true
do:
call: expression
expr: state.audience
"""
TYPED_FLOW_YAML = """\
schema: crewai.flow/v1
name: TypedFlow
config:
suppress_flow_events: true
state:
type: json_schema
json_schema:
type: object
properties:
count: {type: integer}
required: [count]
methods:
begin:
start: true
do:
call: expression
expr: state.count
"""
def _write(tmp_path: Path, contents: str) -> Path:
path = tmp_path / "flow.yaml"
path.write_text(contents, encoding="utf-8")
return path
def test_inputs_flag_satisfies_required_field(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert capsys.readouterr().out == "a@b.com\n"
def test_missing_required_reports_pointed_error(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False)
path = _write(tmp_path, REQUIRED_FLOW_YAML)
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(str(path))
assert (
"Missing required input 'prospect_email'"
"Email address of the prospect to research" in capsys.readouterr().err
)
def test_prompts_for_missing_required_when_interactive(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
path = _write(tmp_path, REQUIRED_FLOW_YAML)
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: True)
prompted: list[str] = []
def fake_prompt(text: str, **kwargs: object) -> str:
prompted.append(text)
return "typed@example.com"
monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt)
run_declarative_flow_module.run_declarative_flow(str(path))
assert capsys.readouterr().out == "typed@example.com\n"
assert any("prospect_email" in text for text in prompted)
def test_defaults_satisfy_required_and_are_not_prompted(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False)
path = _write(tmp_path, DEFAULTS_FLOW_YAML)
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(str(path))
err = capsys.readouterr().err
# topic has a state default -> satisfied; only audience is missing.
assert "Missing required input 'audience'" in err
assert "'topic'" not in err
def test_warns_on_unknown_input_with_suggestion(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com","prospect_emai":"typo"}'
)
captured = capsys.readouterr()
assert captured.out == "a@b.com\n"
assert "Ignoring unknown input 'prospect_emai'" in captured.err
assert "Did you mean 'prospect_email'?" in captured.err
def test_validates_input_types_before_kickoff(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
path = _write(tmp_path, TYPED_FLOW_YAML)
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(str(path), '{"count":"nope"}')
assert "Invalid input 'count'" in capsys.readouterr().err
def test_reserved_id_input_is_forwarded_not_dropped(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# `id` is a reserved kickoff key (persistence restore); it must pass through
# instead of being flagged as an unknown key and dropped.
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"id":"run-123","prospect_email":"a@b.com"}'
)
captured = capsys.readouterr()
assert captured.out == "a@b.com\n"
assert "Ignoring unknown input 'id'" not in captured.err
def test_run_declarative_flow_loads_project_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Flow projects must pick up the project's .env, like crew projects do,
# overriding any pre-existing value.
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DECL_FLOW_ENV_PROBE", "old")
(tmp_path / ".env").write_text("DECL_FLOW_ENV_PROBE=from_dotenv\n", encoding="utf-8")
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert os.environ["DECL_FLOW_ENV_PROBE"] == "from_dotenv"
def test_id_only_input_skips_required_validation(tmp_path: Path) -> None:
# Resume via `crewai run --inputs '{"id":"..."}'` must not be blocked by the
# required-field check: kickoff hydrates required state from persistence.
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"})
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
# ── TUI vs terminal (headless/deploy) routing ──────────────────────
def _install_fake_flow_app(monkeypatch, *, status, want_deploy=False):
"""Replace CrewRunApp/EventListener/summary so _run_declarative_flow_tui is
driven by a controllable fake app."""
class FakeEventListener:
pass
class FakeApp:
def __init__(self, crew_name=""):
self._crew_name = crew_name
self._status = status
self._want_deploy = want_deploy
self._crew_result = "result"
def run(self):
pass
monkeypatch.setattr(
"crewai.events.event_listener.EventListener", FakeEventListener
)
monkeypatch.setattr("crewai_cli.crew_run_tui.CrewRunApp", FakeApp)
monkeypatch.setattr(
run_declarative_flow_module, "_print_flow_post_tui_summary", lambda app: None
)
def test_run_declarative_flow_dmn_uses_terminal(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CREWAI_DMN", "true")
monkeypatch.setattr(
run_declarative_flow_module,
"_run_declarative_flow_tui",
lambda *a, **k: pytest.fail("DMN/headless mode must not launch the TUI"),
)
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert capsys.readouterr().out == "a@b.com\n"
def test_run_declarative_flow_interactive_uses_tui(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
captured: dict[str, object] = {}
monkeypatch.setattr(
run_declarative_flow_module,
"_run_declarative_flow_tui",
lambda flow, resolved: captured.update(flow=flow, inputs=resolved),
)
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert captured["inputs"] == {"prospect_email": "a@b.com"}
assert captured["flow"] is not None
def test_run_declarative_flow_tui_failed_exits_nonzero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="failed")
with pytest.raises(SystemExit) as exc_info:
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert exc_info.value.code == 1
def test_run_declarative_flow_tui_user_quit_exits_130(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="chatting")
exit_calls: list[int] = []
monkeypatch.setattr(os, "_exit", lambda code: exit_calls.append(code))
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert exit_calls == [130]
def test_run_declarative_flow_tui_chains_deploy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="completed", want_deploy=True)
deploy_calls: list[bool] = []
monkeypatch.setattr(
"crewai_cli.run_crew._chain_deploy", lambda: deploy_calls.append(True)
)
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert deploy_calls == [True]
def test_run_declarative_flow_tui_no_deploy_when_not_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="completed", want_deploy=False)
deploy_calls: list[bool] = []
monkeypatch.setattr(
"crewai_cli.run_crew._chain_deploy", lambda: deploy_calls.append(True)
)
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert deploy_calls == []
def test_run_declarative_flow_tui_enables_flow_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The STEPS panel depends on flow method events; a flow that declared
# suppress_flow_events must have it forced off for the interactive TUI run.
_install_fake_flow_app(monkeypatch, status="completed")
flow = SimpleNamespace(name="Flow", suppress_flow_events=True)
run_declarative_flow_module._run_declarative_flow_tui(flow, None)
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(
methods={
"fetch": SimpleNamespace(do=SimpleNamespace(call="expression")),
"research": SimpleNamespace(do=SimpleNamespace(call="crew")),
}
)
)
assert run_declarative_flow_module._flow_method_types(flow) == {
"fetch": "expression",
"research": "crew",
}
# No definition → empty map, no error.
assert run_declarative_flow_module._flow_method_types(SimpleNamespace()) == {}