mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-20 18:13:49 +00:00
* fix(cli): open the conversational TUI for a declarative chat flow `crewai run` refused a declarative conversational flow and told the user to drive it from Python. That was wrong: the conversational TUI already exists and already does this job. `CrewRunApp(conversational=True)` renders a chat pane and drives `handle_turn` per message (crew_run_tui.py:833-935), and `kickoff_flow._run_conversational_flow_tui` launches it for a Python conversational Flow. A declaration-built flow satisfies everything that TUI needs -- `handle_turn`, a settable `defer_trace_finalization`, and `finalize_session_traces()` -- so it now routes there instead of exiting. A chat loop still needs a terminal. A headless run (`is_interactive()` false, which folds in CREWAI_DMN) says what it would have needed rather than kicking off a single turn and presenting that as the whole conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): do not send a human-feedback chat flow to the Textual TUI A declaration can carry both a `conversational:` block and a method with `human_feedback:` -- verified, both predicates return True on the same flow. Routing it to the chat TUI hangs: the runtime collects feedback with a blocking `input()` (flow/runtime/__init__.py:3719) that Textual cannot service, so the prompt is never shown. The STEPS TUI already declines these for exactly this reason. Such a flow now falls back to the terminal REPL, which can prompt. Also updates the guide in en/ar/ko/pt-BR: it still said `crewai run` has no chat loop and exits, which is now the opposite of what the CLI does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): reject --inputs on a conversational flow instead of dropping it The conversational branch returns before `_resolve_flow_inputs`, and the TUI calls `handle_turn(message)` -- which owns the kickoff inputs, passing `{"id": session_id}` itself. So any `--inputs` value was silently discarded and the conversation ran as if it had been applied. It now errors, and says that resuming a session by id is not wired up yet rather than implying it worked. Also corrects the Arabic guide: `مُوجّه محجوز` reads as "reserved router", not the blocking prompt it describes. Both found by CodeRabbit on #7060. * fix(cli): document the conversational routing exceptions Three review follow-ups: - The Arabic guide read خدمته (masculine) against the feminine مُطالبة introduced by the last fix. - Both docstrings described a routing path that now has exceptions: a conversational declaration rejects --inputs and skips state-schema resolution, and a human-feedback one uses the terminal REPL. - The --inputs rejection test accepted SystemExit(0); it now pins code 1. Found by CodeRabbit on #7060. * fix(cli): reject --inputs on a chat flow even when it parses empty parse_inputs_json returns {} both when the option is absent and when the user passes --inputs "{}", so the falsy check started the TUI for the second case while the docs said it was unsupported. The conversational path now takes whether the option was supplied, not what it parsed to. Documents the restriction in en, ar, ko and pt-BR. Found by CodeRabbit on #7060. * test(cli): pin the headless conversational exit status pytest.raises(SystemExit) also accepts SystemExit(0), so the error path could regress to a successful exit unnoticed. Found by CodeRabbit on #7060. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
798 lines
25 KiB
Python
798 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
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()) == {}
|
|
|
|
|
|
CONVERSATIONAL_FLOW_YAML = """schema: crewai.flow/v1
|
|
name: SupportFlow
|
|
conversational:
|
|
llm: gpt-4o-mini
|
|
methods:
|
|
handle_order:
|
|
description: Order status questions.
|
|
listen: order
|
|
do:
|
|
call: expression
|
|
expr: "'shipped'"
|
|
"""
|
|
|
|
|
|
def test_run_declarative_flow_opens_the_conversational_tui(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A declarative chat flow gets the same TUI a Python one does."""
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
launched: list[Any] = []
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui", launched.append
|
|
)
|
|
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path))
|
|
|
|
assert len(launched) == 1
|
|
assert launched[0]._is_conversational_enabled() is True
|
|
|
|
|
|
def test_conversational_flow_does_not_take_the_steps_tui(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The STEPS TUI renders method progress and cannot drive a chat turn."""
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui", lambda flow: None
|
|
)
|
|
monkeypatch.setattr(
|
|
run_declarative_flow_module,
|
|
"_run_declarative_flow_tui",
|
|
lambda *a, **k: pytest.fail("conversational flow reached the STEPS TUI"),
|
|
)
|
|
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path))
|
|
|
|
|
|
def test_conversational_flow_rejects_inputs(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""`--inputs` has nowhere to go: handle_turn owns the kickoff inputs."""
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui",
|
|
lambda flow: pytest.fail("should have rejected --inputs before the TUI"),
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
run_declarative_flow_module.run_declarative_flow(
|
|
str(definition_path), '{"topic": "AI"}'
|
|
)
|
|
|
|
assert exc_info.value.code == 1
|
|
err = capsys.readouterr().err
|
|
assert "`--inputs` is not supported for a conversational flow" in err
|
|
assert "session_id" in err
|
|
|
|
|
|
def test_conversational_flow_rejects_an_empty_inputs_object(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""`--inputs '{}'` asked for something unsupported; answer it, don't ignore it."""
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui",
|
|
lambda flow: pytest.fail("an empty --inputs object still passed --inputs"),
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path), "{}")
|
|
|
|
assert exc_info.value.code == 1
|
|
assert (
|
|
"`--inputs` is not supported for a conversational flow"
|
|
in capsys.readouterr().err
|
|
)
|
|
|
|
|
|
def test_conversational_flow_without_inputs_still_opens_the_tui(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
launched: list[Any] = []
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui", launched.append
|
|
)
|
|
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path), None)
|
|
|
|
assert len(launched) == 1
|
|
|
|
|
|
def test_conversational_human_feedback_flow_avoids_the_tui(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Textual cannot service the runtime's blocking feedback prompt.
|
|
|
|
The STEPS TUI already declines these for that reason; a conversational one
|
|
must too, or the prompt is never shown and the run hangs.
|
|
"""
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(
|
|
CONVERSATIONAL_FLOW_YAML.replace(
|
|
" listen: order\n",
|
|
" listen: order\n human_feedback:\n message: Approve?\n",
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
chatted: list[str] = []
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.kickoff_flow._run_conversational_flow_tui",
|
|
lambda flow: pytest.fail("human-feedback flow reached the Textual TUI"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"crewai.flow.flow.Flow.chat", lambda self, **kw: chatted.append("repl")
|
|
)
|
|
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path))
|
|
|
|
assert chatted == ["repl"]
|
|
|
|
|
|
def test_conversational_flow_headless_explains_instead_of_one_turn(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: False)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path))
|
|
|
|
assert exc_info.value.code == 1
|
|
err = capsys.readouterr().err
|
|
assert "needs an interactive terminal" in err
|
|
assert "handle_turn" in err
|
|
|
|
|
|
def test_run_declarative_flow_still_runs_a_disabled_conversational_flow(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
definition_path = tmp_path / "flow.yaml"
|
|
definition_path.write_text(
|
|
CONVERSATIONAL_FLOW_YAML.replace(
|
|
"conversational:\n llm: gpt-4o-mini",
|
|
"conversational:\n enabled: false",
|
|
).replace(" listen: order\n", " start: true\n"),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
run_declarative_flow_module.run_declarative_flow(str(definition_path))
|
|
|
|
assert capsys.readouterr().out == "shipped\n"
|