mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-21 17:36:42 +02:00
Vulnerability Scan / Detect changes (push) Canceled after 0s
Vulnerability Scan / pip-audit (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (python) (push) Canceled after 0s
Check Documentation Broken Links / Check broken links (push) Canceled after 0s
* feat(cli): crewai eval runs the crew itself, so a coding agent gets a verdict from one command
Most `crewai eval` attempts stopped before an evaluation began because the
project had no traced run. Telemetry since Sept 25: 117 projects stopped as
"untraced", 97 of them with no terminal (coding agents), and 67 of those never
got further. With no terminal the command could only print steps, and without
a login those steps could not work: an unattended anonymous run never uploads
its trace.
- With no traced run, or when the project's files changed since the last one,
`crewai eval` runs the crew and evaluates that run. No question, with or
without a terminal: running the command is the request to have the run
graded, and the grade is read off its trace. Tracing is on for that run only;
nothing is written to the project's `.env`.
- crewAI's tracing approves the upload of that run through the existing
handshake: a token in CREWAI_EVAL_AWAITING_RUN that counts only while its file
exists in the user's crewAI data folder, so a `.env` cannot claim it. Shared
as `crewai_core.eval_handshake`; the run app keeps its own copy because it
also runs against a project's older crewai-core, and a test pins the two.
- The run app's Evaluate on an untraced run closes the app and runs the crew
again, traced, through `crewai eval`'s own path, instead of a notice.
- Counts: `eval_ran_crew:{no_traced_run,project_changed,run_again}`,
`eval_finished:{passed,failed,inconclusive,unknown}`, and
`eval_stopped:untraced_after_run` replacing the catch-all `untraced`
(`declined` is gone with the question). The CLI tests no longer send usage
counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): a project that turned tracing off is asked before crewai eval runs it
`CREWAI_TRACING_ENABLED=false` is the project's no, and nothing overrules it,
not even a run `crewai eval` started.
- `crewai eval` at a terminal asks to turn tracing on before running the crew.
A yes writes it to the project's `.env`; a no runs nothing (exit 0,
`eval_stopped:declined`). With no terminal it stops and says how to turn it
on (`eval_stopped:tracing_off`), which a coding agent can do itself.
- The run app's Evaluate on an untraced run asks the same question in a modal
(TurnTracingOnScreen) before running the crew again. Otherwise it still runs
it again with no question.
- crewAI's tracing respects an explicit off again: `should_enable_tracing`
checks it before a run `crewai eval` started, and `execution._tracing_disabled`
is back to what main has.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tracing): the telemetry opt-out no longer turns tracing off; an evaluated run says where its trace goes
- `OTEL_SDK_DISABLED` is the telemetry opt-out, a separate setting from
tracing, which has its own (`CREWAI_TRACING_ENABLED`, `tracing=`).
`execution._tracing_disabled` no longer reads it. The trace session's own
TracerProvider records even when the OpenTelemetry SDK is switched off
(`session._trace_provider`), because the SDK otherwise hands out tracers that
record nothing. A provider a host passes in is left as it is. Tests that used
`OTEL_SDK_DISABLED` as a way to stop a trace now cover the two real switches;
new tests show a traced run, anonymous or logged in, recorded and uploaded
with it set.
- A run app opened to run the crew for an evaluation (`crewai eval`, or
Evaluate on a run that was not traced) shows one plain note on mount: "This
run will be evaluated: its trace is sent to CrewAI AMP, which grades it."
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): say where the trace goes only when the yes is inferred from crewai eval
The note, in the run app ("This run will be evaluated: its trace is sent to
CrewAI AMP, which grades it.") and in `crewai eval`'s line, is for the case
where nobody turned tracing on: not `tracing=True` on the crew or flow, not
`CREWAI_TRACING_ENABLED=true`, not a consent a person gave once
(`crewai_core.user_data.is_tracing_enabled`), and not the yes they just gave to
turn it on. A project or a person who opted in is told nothing new.
The run `crewai eval` starts no longer sets `CREWAI_TRACING_ENABLED=true`:
the handshake alone turns tracing on for it, so the setting a run reads is the
project's own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): turn tracing on once with a note, pin the eval handshake, and keep the saved login to trusted AMPs
From the review on #8270, and João's call on the note:
- The first `crewai eval` that infers the yes turns tracing on for the
project (`CREWAI_TRACING_ENABLED=true` in `.env` and for the run) and says
so once, in the command and in the run app: "Tracing is now on for this
project…". Afterwards the project traces and nothing more is said. A run
whose crew or flow sets `tracing=False` is never told its trace is sent.
- The handshake's folder is found from the account's home in the account
database, never `HOME` or `XDG_DATA_HOME`, so a project's `.env` cannot plant
a token file that approves an upload. Both copies (crewai-core, the run app)
are pinned to the same folder and file by a test.
- The token file carries the AMP origins `crewai eval` trusted before the
project's `.env` was loaded. A run it started sends the saved login only
there over HTTPS or loopback; an AMP the `.env` chose gets an anonymous
grant.
- Evaluate on an untraced run carries that run's definition, inputs (prompted
ones included) and trained-agents file into the re-run. The conversational
flow consumes the request too, and a crew or flow that sets `tracing=False`
says why it cannot be evaluated instead of paying for another run.
- "The project changed" also sees deleted files: each evaluation keeps the
list of the project's files (`.crewai/eval_project_state.json`) and the next
one compares against it. That list is taken after this command's own
project-id write, so the write is no change.
- The OpenTelemetry override probes its provider and warns if an SDK version
still records nothing, instead of failing silently.
- The stale `sdk` case of `test_disabled_execution_does_not_collect_or_request_grants`
is gone; the handshake tests are typed (type-checker CI).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): keep the saved login off a project's AMP on every path crewai eval runs
From the second review round on #8270:
- Evaluate's re-run runs in a process that already loaded the project's
`.env`, so it passes this machine's AMPs (`_machine_amp_origins`) as the
trusted set instead of reading `CREWAI_PLUS_URL` there (`eval_crew(
trusted_amp_origins=…)`).
- A project's `.env` can neither set nor replace `crewai eval`'s token:
`run_crew.load_project_env` keeps the token the process was started with and
drops one a `.env` sets. The JSON-crew, declarative-flow and eval paths all
load `.env` through it.
- `crewai eval` turns tracing on only when every credential this machine holds
(the saved login, or a PAT or integration token from before the `.env`) may
go to the AMP the run would trace to. A project's crewai older than the
handshake would otherwise send it to an AMP the `.env` names. A newer crewai
still traces the run anonymously through the handshake.
- A conversational flow named by its definition is re-run by that definition.
- Code quality: no redundant assignment in `_awaiting_dir`, no unused names in
the credential test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): crewai eval does not run a crew whose trace would carry the login to an untrusted AMP
When tracing is on for the run (the project's .env, or a consent given once)
and a credential of this machine would go to an AMP the command does not
trust, the crew is not run. A project's crewai older than the handshake would
send the login there, and the CLI cannot tell which crewai the project runs.
The command stops (`eval_stopped:untrusted_amp`) and says how to go on: log
in to that AMP, or remove CREWAI_PLUS_URL from the project's .env. A project
that turned tracing off is not asked to turn it on toward such an AMP. With
no credential on the machine, there is nothing to protect and the run goes on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Ask at a terminal before tracing is first turned on
A person running `crewai eval` at a terminal, in a project that never
turned tracing on, is now asked on crewAI's trace consent screen before
the crew runs; a yes turns tracing on for the project, a no (or the
screen timing out) runs nothing and is counted as `declined`. The run
app's Evaluate on an untraced run asks on its own consent screen the same
way. With nobody at a terminal (a coding agent, a script) the yes is
still inferred from the command, tracing is turned on and said once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep main's Iterator import, which the rebase over #8187 dropped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* A run-app test no longer leaves .crewai/last_eval.json in the repo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Two run-app tests from #7963 no longer leave .crewai/last_eval.json in the repo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Never run the crew toward an AMP the login must not reach; check before asking
`crewai eval` now refuses to run the crew whenever a credential of this
machine would go with the trace to an AMP it does not trust, whatever the
tracing setting: `tracing=True` in code is invisible to the CLI, and a
project's crewai older than the handshake sends the credential wherever
the run traces. The run app's Evaluate makes the same check before it
asks anything, so a yes can never write CREWAI_TRACING_ENABLED=true to
`.env` for a re-run that is then refused.
Which credentials are this machine's is read from the environment as it
was before any project `.env` was loaded (`run_crew.environment_before_project`),
so on Evaluate's re-run a CREWAI_USER_PAT the project's own `.env` brought
is the project's, as on any run of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Run the crew only toward an AMP this machine trusts, login or not
Whether this machine holds a login right now no longer decides: with no
login yet, `crewai eval` still wrote CREWAI_TRACING_ENABLED=true toward a
CREWAI_PLUS_URL the project's `.env` chose, and the first run after
`crewai login` would have carried the login there. Toward an AMP this
command does not trust, the crew is now never run, nobody is asked and
nothing is written, in `crewai eval` and the run app's Evaluate alike.
That also drops the credential bookkeeping from b26ccaf44, which the
rule no longer needs.
The telemetry docs no longer say OTEL_SDK_DISABLED turns off everything:
it disables OpenTelemetry instrumentation and CrewAI's telemetry, not AMP
tracing, which has its own switches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pin that a project's own token never blocks Evaluate's re-run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
856 lines
27 KiB
Python
856 lines
27 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, evaluation=None, want_traced_rerun=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._want_traced_rerun = want_traced_rerun
|
|
self._evaluation = evaluation
|
|
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, definition=None: captured.update(
|
|
flow=flow, inputs=resolved, definition=definition
|
|
),
|
|
)
|
|
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
|
|
assert captured["definition"] == str(path) # what a traced re-run runs again
|
|
|
|
|
|
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_evaluate_on_an_untraced_flow_run_runs_it_again_for_its_evaluation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The app closed because its run was not traced: what follows is
|
|
`crewai eval`'s own path, told to run the flow again whatever the record."""
|
|
_install_fake_flow_app(monkeypatch, status="completed", want_traced_rerun=True)
|
|
evaluated: list[dict[str, object]] = []
|
|
monkeypatch.setattr(
|
|
"crewai_cli.experimental.eval_crew.eval_crew",
|
|
lambda **kwargs: evaluated.append(kwargs),
|
|
)
|
|
|
|
run_declarative_flow_module._run_declarative_flow_tui(
|
|
SimpleNamespace(name="Flow"), None
|
|
)
|
|
|
|
assert [{k: v for k, v in e.items() if k != "trusted_amp_origins"} for e in evaluated] == [
|
|
{"run_again": True, "run_args": {}}
|
|
]
|
|
|
|
|
|
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_leaves_the_report_link_behind(capsys) -> None:
|
|
"""A declarative flow evaluates inside the app like a crew does. What the
|
|
terminal is for is afterwards: the link, once the screen that held it is
|
|
gone."""
|
|
app = SimpleNamespace(
|
|
_crew_name="Flow",
|
|
_status="completed",
|
|
_elapsed_frozen=1.0,
|
|
_start_time=0.0,
|
|
_input_tokens=0,
|
|
_output_tokens=0,
|
|
_live_out_tokens=0,
|
|
_final_output=None,
|
|
_error=None,
|
|
_evaluation={
|
|
"state": "done",
|
|
"url": "https://optimize.example/e/1",
|
|
"verdict": {"gate": "passed"},
|
|
},
|
|
)
|
|
|
|
run_declarative_flow_module._print_flow_post_tui_summary(app)
|
|
|
|
printed = capsys.readouterr().out
|
|
assert "goal gate PASSED" in printed
|
|
assert "https://optimize.example/e/1" in printed
|
|
|
|
|
|
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",
|
|
lambda flow, definition=None: launched.append(flow)
|
|
)
|
|
|
|
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, definition=None: 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, definition=None: 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, definition=None: 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",
|
|
lambda flow, definition=None: launched.append(flow)
|
|
)
|
|
|
|
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, definition=None: 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"
|