mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-22 18:03:10 +02:00
* fix(tracing): turning tracing on is the answer; stop asking again
Two things a user who ran `crewai eval` sees today.
`crewai eval` offers to turn tracing on and run the crew. The run finishes —
and the ephemeral buffer asks "Share this execution trace with CrewAI?", which
is the same question again. Turning tracing on IS consent: `tracing_asked_for()`
is true when `CREWAI_TRACING_ENABLED` says so or when `tracing=True` was passed
in this context, and the buffer takes it as the answer. First-time
auto-collection still asks, because nobody asked for that one.
And the offer named the mechanism rather than the effect — "Turn tracing on
(CREWAI_TRACING_ENABLED=true stays in .env) and run the crew now?", then
"Tracing is on for this project (CREWAI_TRACING_ENABLED=true in .env)." The
variable is how it works, not what the user is agreeing to; both lines now say
what happens and leave the plumbing to the .env file it is written in.
491 telemetry tests and 62 eval-CLI tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tracing): the TUI's modal is a prompt too, and say where traces go
Two follow-ups from running it.
The terminal UI installs its own consent callback, so turning tracing on still
bought a modal at the end of the run — the callback is consulted before the
default prompt, and the check only guarded the prompt. A host's callback stays
authoritative, because an embedder may answer no for reasons of its own; what
changed is that the TUI's callback, which exists to ASK, now answers for itself
when tracing was asked for.
And having taken the variable out of the offer, the offer should say what saying
yes means: "Turn tracing on and run the crew now? The run's trace is sent to
CrewAI AMP", and afterwards "Tracing is on for this project — its runs are
traced to CrewAI AMP."
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(tui): an Evaluate button beside View Traces and Deploy
A run finishes in the terminal UI and the next question is usually "was that
any good?" — which `crewai eval` answers, from the command line, about the run
that just happened. The button is there now, with `e` bound to it.
It behaves like Deploy rather than like View Traces: it leaves the UI and then
runs the command, because `crewai eval` talks — it prints the link to follow,
waits for the verdict and prints that — and none of it belongs inside a
full-screen app. It calls `eval_crew()` itself rather than reimplementing any
of it, so the button and the command can never say different things; a clean
SystemExit is the command deciding, and a failure is reported without failing
the run that already succeeded.
Declarative flows get it too — same button, same tail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(telemetry): count `crewai eval`, not only the button that runs it
The TUI's buttons each record a `cli_usage:` feature span, so the new Evaluate
button was counted — but the command it runs was not, and most people will type
`crewai eval` rather than press a button. Every evaluation now records
`cli_usage:eval`; the button keeps its own `cli_usage:evaluate`, so the
difference between the two is how many were started from the TUI.
It counts the command and nothing about the run: no id, no project, no verdict.
And it is wrapped like every other telemetry call here — a missing tracer or a
refused span never stops an evaluation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(telemetry): say which run was evaluated, and by whom
`project_id` already rides every span. What only this command knows is which
run is being graded, whether the caller was logged in, and — when they are —
which organization they are logged in to. A feature span can carry attributes
now, and `cli_usage:eval` carries those three.
What it does not carry is anything about the work: no inputs, no output, no
verdict, no criteria. The execution id is the join key; what was found lives in
AMP, where the evaluation itself is.
An anonymous caller reports `authenticated=false` and an empty organization, and
is not asked for one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address the review: a terminal, a thread, and the right run
Three findings worth acting on, one already fixed, and ruff.
**The security review is right.** `prompt_user_for_trace_viewing` fails closed
where no one can answer, so an anonymous run in CI has never uploaded. Treating
the switch as consent everywhere would have changed that: a project `.env`
copied into CI carries the variable, not the person. `tracing_asked_for()` now
answers only where a prompt could have been shown — the double-ask this PR is
for happens in a terminal, and nothing else moves.
**The ContextVar never crosses the worker.** `set_tracing_enabled` runs in the
crew's validator, on the thread the crew was BUILT on, and a flow never sets it
at all — so `tracing=True` without the env var still opened the modal. The TUI
reads the declaration off the object as well: the same yes, wherever written.
**The button now names the run it watched.** `_chain_eval()` called `crewai eval`
with no id, which looks up the last recorded run — a PREVIOUS run when this one
was not traced, and an offer to run the crew again when there is no record at
all. The TUI remembers what was recorded before the run, hands the new id to the
command, and says "this run was not traced" rather than grading someone else's.
**Already fixed**: telemetry is recorded after `get_or_create_project_id()`, not
before — that moved when the span gained its attributes.
492 telemetry tests, 187 CLI tests. Ruff format and check clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Close all three doors the prompt closes, not just the terminal
The security review named three places `prompt_user_for_trace_viewing` fails
closed — no terminal, a suite under test, and a host that suppressed tracing
messages — and the first pass only honoured the terminal. A run under test, or
inside a host that asked for silence, could still have uploaded on the strength
of a variable.
The three conditions are one function now, `_prompt_can_be_shown()`, used by the
prompt itself and by `tracing_asked_for`, so the rule and the prompt it answers
cannot drift apart. Where the question could not have been put to anybody, there
is nothing to answer and nothing leaves the machine.
The terminal UI decides for itself, and says why: it silences crewAI's console
to keep it out of its own layout, which is a reason a PROMPT cannot be shown,
not a reason to ask a second time — it has a screen and somebody in front of it.
494 telemetry tests, 88 TUI tests. Ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Tests: a real session for the consent rule, and a laid-out screen for a click
The rule that a declared CREWAI_TRACING_ENABLED needs no modal only holds
where a prompt could have been shown, and a test suite is one of the places
it could not — so the three cases about a real session say a person is here.
The consent click waited for the screen, which is not the same as waiting for
its buttons to have a region; it now waits for the layout it clicks on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* A run started for an evaluation closes itself, even in a child process
A project's crew and flow are run through `uv run …`, so the app lives in
another process and the ContextVar saying "an evaluation is waiting" never
reaches it: the screen stayed open and `crewai eval` waited behind it, which
is the trap the handshake was meant to remove. The environment is the one
thing that crosses into that process, so the command sets a variable there and
clears it after; the id travels back the way it already did, through the
project's last-run record. A run that FAILED leaves too — the command behind
the screen has its own way of saying there is nothing to grade, and it cannot
say it while somebody has to quit a screen first.
And the spans a run exports reach AMP a moment after the run ends, sometimes
later than that, so a command that just watched the run would ask for a trace
still in flight and be told, correctly and uselessly, that there is nothing
there. A run we know is fresh now waits up to two minutes and says it is
waiting; an id somebody typed fails at once, as it always has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The evaluation happens in the app, on the screen the reader is already on
Closing the app the moment the run ended and leaving somebody in a bare
terminal, waiting some seconds for a link to appear and a browser to open, is
worse than the screen it replaced. So the evaluation runs where the run does:
`Evaluating this run` under the run's own line, the report link beside it, the
browser opened from there, and `Goal gate PASSED · goal 5/5 · …` when it comes
back. Nothing closes; the reader leaves when they are done reading, and the
link is printed once more on the way out so it outlives the screen.
The Evaluate button does the same thing now instead of quitting the app and
chaining into the command, so there is one implementation of one experience —
`_chain_eval` and `_want_eval` are gone. Pressing it again opens the report it
already made rather than paying for a second evaluation.
Underneath, `crewai eval`'s two steps became callable as values: they raise
`EvaluationStoppedError` with the sentence to show instead of printing and
exiting, and `evaluate_run()` is the app's one door — the url as soon as AMP
gives it, every unfinished answer while it waits. The command keeps the
terminal wording it had; when it ran the crew itself, it now has nothing left
to say and steps aside for the app it opened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The button says which report it opens
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The button works while it waits: it spins, and it stays pressable
A dimmed label that never moves reads as a screen that has stopped. The
Evaluate button now carries the same spinner the run's own line does, the
clock goes back up to eight frames a second while the evaluation runs, and the
button is no longer disabled — the report page is where the progress is, so
pressing it while it waits is the useful thing to do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* View Traces opens the traces
The button never had a link. crewAI grants one for every exported run and
prints it after a run — but never under a TUI, where its console is kept out
of the layout — so the app had nothing to open and said so, which read as a
feature that had stopped working.
The link is now recorded beside the rest of what crewAI writes when a run's
spans reach AMP, and the button reads it off that record, matched to THIS
app's execution rather than to whatever ran last in the project. When there is
no link the notice says which of the two reasons it is: a run nobody traced,
or a traced run AMP granted no viewer for — only one of those is the reader's
to fix.
Verified against a real anonymous run: pressing `t` opens
app.crewai.com/crewai_plus/ephemeral_traces/<id> and shows it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The review's five: a trust boundary, a fallback, a retry, an exit and a count
**A project's `.env` cannot lend itself the login.** `evaluate_run()` built its
AMP client after the project's `.env` had been loaded with override, so a
`CREWAI_PLUS_URL` a project put there landed in the set of origins trusted with
the saved `crewai login` bearer. The command reads that variable BEFORE the
`.env` — a shell export is this machine speaking — but inside the app there is
no such "before", so the variable is left out of the set entirely. The request
still follows it; the credential does not.
**A run no app evaluated is still evaluated.** The command returned as soon as
a trace existed, assuming the app had graded it — but a conversational session
never ends the way a crew does and a flow with human feedback takes the
terminal instead, so `crewai eval` could exit 0 having graded nothing. The app
now leaves a marker naming the run it started evaluating, and the command
grades anything that marker does not name.
**An evaluation that stopped can be tried again.** The button re-enabled itself
after a failure but the action still treated any existing evaluation as
finished, so a 502 could only be escaped by re-running the crew.
**A login that exits is shown on screen.** `saved_login()` fails by exiting,
which is not an `Exception` and so slipped past the worker's hands: the button
could sit on "Evaluating…" forever with the reason gone with the exit.
**An evaluation the app runs is counted.** `cli_usage:eval` is the count of
evaluations that begin, and the ones that begin in the app were missing from
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* A stop is a sentence, and the app's own word settles what it graded
**"1" is not a reason.** A credential that exists but cannot be read stopped
the evaluation by printing its sentence and exiting — under the app the print
lands beneath the layout and the exit carries only a code, so the screen said
`1`. `saved_login()` now raises the stop like everything else that stops an
evaluation, the command catches it where it builds the client and prints it as
before, and the app shows it. The test that claimed to cover this raised a
`SystemExit` carrying a sentence, which is not what `_fail` raises; it now
breaks the credential store and reads what reaches the screen. The worker
still catches an exit, and says that the reason did not survive rather than
echoing its code.
**The record is the project's last finish, not this run.** Another run
finishing in the same project replaces it while the app is open, so "is the
marker about the id in the record" could send the command off to evaluate a
different execution. The marker is now the app's own word about the run it
watched — the id it evaluated, or null when it looked and found nothing to
grade — and the command trusts it over the record, matching on time rather
than on an id that may already have been overwritten. No marker still means no
app got here, which is the conversational session and the flow that takes the
terminal, and the command grades those itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* A project can say what good means, and `crewai eval` carries it
An evaluation grades a run against the crew's own `expected_output`s — which
is the prompt that produced the output, so it catches a crew that ignored its
own contract and nothing else. What discriminates lives in criteria somebody
writes, and there was nowhere to write them.
Now there is. After the first evaluation the criteria it used are written to
`eval.jsonc` in the project — once, never over a file that exists, because the
one thing worse than no criteria is criteria that vanish whenever somebody
evaluates — and the line that says so points at what to do with it. From then
on the file travels with every evaluation, as it was written, comments and
all: what a criterion means is the grader's to read, not this command's.
Both doors do it, since most evaluations now happen inside the run app: the
app writes the file and names it in the line that outlives its screen, and
`crewai eval` in a terminal says it there.
The file is bounded at the same 64KB AMP holds the field to, so one that would
be refused is never uploaded and the run is graded anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The review's asks: anonymous eval stats, a handshake a project cannot forge
Usage stats are anonymous, so the `crewai eval` count says whether the caller
was logged in and nothing else — no execution id, no organization (the product
decision on this PR). Which runs were evaluated, and by whom, is AMP's record.
A feature span now keeps only the keys its own feature lists, in both
telemetry classes; everything else — the dimensions every span carries
included — is dropped.
The run app's "an evaluation is waiting" handshake was an environment variable
any project's `.env` could set, which would start an evaluation with the
machine's login on a plain `crewai run`. Its value is now a random token that
counts only while the command's file of that name exists in the user's crewAI
data directory; a `.env` cannot create a file.
And three smaller ones: when no app evaluated the run, the command grades the
project's record only if it was written after this run began; the "Wrote
eval.jsonc" line after the app closes now appears (the key it read was never
set); an oversized eval.jsonc is said once, through the caller's note, rather
than printed under the TUI on every retry.
uv.lock is back to main's: the change was re-resolution drift, nothing this PR
needs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* A report link is a web address, and an anonymous request names no organization
Two from the security review of 52fa5f3ae.
The report url comes from whichever AMP answered — which a project's `.env`
may choose — and is printed as a link and opened without a click. It is now
checked where it arrives, before anything shows or opens it: HTTPS, or plain
HTTP to this machine, no credentials in it, no control characters. Anything
else costs the link and nothing more. The host is not pinned: the report is
served by the evaluation service, not by AMP.
A request sent without the login — nobody logged in, or an AMP this machine is
not logged in to — no longer carries the saved organization id. AMP reads it
only beside a credential, so it said nothing to AMP and told an AMP the login
does not go to which organization was asking. Dropped, as anonymous trace
grants already drop it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2234 lines
71 KiB
Python
2234 lines
71 KiB
Python
import contextvars
|
|
from datetime import datetime
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
from crewai.events.event_bus import crewai_event_bus
|
|
from crewai.events.types.crew_events import CrewKickoffStartedEvent
|
|
from crewai.events.types.flow_events import (
|
|
FlowStartedEvent,
|
|
MethodExecutionFailedEvent,
|
|
MethodExecutionFinishedEvent,
|
|
MethodExecutionPausedEvent,
|
|
MethodExecutionStartedEvent,
|
|
)
|
|
from crewai.events.types.memory_events import (
|
|
MemorySaveCompletedEvent,
|
|
MemorySaveFailedEvent,
|
|
MemorySaveStartedEvent,
|
|
)
|
|
from crewai.events.types.observation_events import (
|
|
GoalAchievedEarlyEvent,
|
|
PlanRefinementEvent,
|
|
PlanReplanTriggeredEvent,
|
|
PlanStepCompletedEvent,
|
|
PlanStepStartedEvent,
|
|
StepObservationCompletedEvent,
|
|
StepObservationFailedEvent,
|
|
StepObservationStartedEvent,
|
|
)
|
|
from crewai.events.types.tool_usage_events import (
|
|
ToolUsageErrorEvent,
|
|
ToolUsageFinishedEvent,
|
|
ToolUsageStartedEvent,
|
|
)
|
|
from crewai_cli import crew_run_tui, run_crew
|
|
from crewai_cli.command import AuthenticationRequiredError
|
|
from crewai_cli.crew_run_tui import (
|
|
_LOG_ARGS_TEXT_LIMIT,
|
|
_LOG_RESULT_TEXT_LIMIT,
|
|
_LOG_TRUNCATION_SUFFIX,
|
|
CrewRunApp,
|
|
_format_json_in_text,
|
|
_try_parse_structured,
|
|
)
|
|
import pytest
|
|
from rich.text import Text
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _awaiting_files_stay_in_tmp(tmp_path, monkeypatch):
|
|
"""The waiting-evaluation token file belongs to the user's crewAI data
|
|
directory; a test keeps it in its own."""
|
|
monkeypatch.setattr(
|
|
"crewai_cli.crew_run_tui._awaiting_dir", lambda: tmp_path / "eval-awaiting"
|
|
)
|
|
|
|
|
|
|
|
def _app_with_plan() -> CrewRunApp:
|
|
app = CrewRunApp()
|
|
app._plan = {
|
|
"plan": "Demo plan",
|
|
"steps": [
|
|
{"step_number": 1, "description": "First"},
|
|
{"step_number": 2, "description": "Second"},
|
|
{"step_number": 3, "description": "Third"},
|
|
],
|
|
}
|
|
app._plan_step_status = {1: "pending", 2: "pending", 3: "pending"}
|
|
return app
|
|
|
|
|
|
def _log_entry(name: str) -> dict:
|
|
now = time.time()
|
|
return {
|
|
"tool_name": name,
|
|
"status": "success",
|
|
"args": None,
|
|
"result": f"{name} result",
|
|
"error": None,
|
|
"start_time": now,
|
|
"duration": 1.0,
|
|
"task_idx": 1,
|
|
}
|
|
|
|
|
|
def _emit_event(event: object) -> None:
|
|
future = crewai_event_bus.emit(None, event)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
|
|
def test_chain_deploy_skips_validation_after_auth_retry(monkeypatch) -> None:
|
|
create_calls: list[dict[str, object]] = []
|
|
login_calls: list[bool] = []
|
|
|
|
class FakeDeployCommand:
|
|
attempts = 0
|
|
|
|
def create_crew(self, **kwargs) -> None:
|
|
create_calls.append(kwargs)
|
|
FakeDeployCommand.attempts += 1
|
|
if FakeDeployCommand.attempts == 1:
|
|
raise AuthenticationRequiredError
|
|
|
|
class FakeAuthenticationCommand:
|
|
def login(self) -> None:
|
|
login_calls.append(True)
|
|
|
|
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.authentication.main.AuthenticationCommand",
|
|
FakeAuthenticationCommand,
|
|
)
|
|
|
|
run_crew._chain_deploy()
|
|
|
|
assert create_calls == [
|
|
{"confirm": True, "skip_validate": True, "source": "tui"},
|
|
{"confirm": True, "skip_validate": True, "source": "tui"},
|
|
]
|
|
assert login_calls == [True]
|
|
|
|
|
|
def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> None:
|
|
create_calls: list[dict[str, object]] = []
|
|
login_calls: list[bool] = []
|
|
|
|
class FakeDeployCommand:
|
|
def create_crew(self, **kwargs) -> None:
|
|
create_calls.append(kwargs)
|
|
raise SystemExit(42)
|
|
|
|
class FakeAuthenticationCommand:
|
|
def login(self) -> None:
|
|
login_calls.append(True)
|
|
|
|
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.authentication.main.AuthenticationCommand",
|
|
FakeAuthenticationCommand,
|
|
)
|
|
|
|
run_crew._chain_deploy()
|
|
|
|
assert create_calls == [{"confirm": True, "skip_validate": True, "source": "tui"}]
|
|
assert login_calls == []
|
|
assert "Deploy failed with exit code 42" in capsys.readouterr().out
|
|
|
|
|
|
def test_view_traces_opens_the_link_crewai_recorded_for_this_run(monkeypatch) -> None:
|
|
"""crewAI prints that link after a run but never under a TUI, so the button
|
|
reads it off the record crewAI wrote — matched to THIS app's execution."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = "run-this-app"
|
|
monkeypatch.setattr(
|
|
crew_run_tui,
|
|
"_recorded_run",
|
|
lambda uuid: {"execution_id": uuid, "trace_url": "https://amp.example/t/1"}
|
|
if uuid == "run-this-app"
|
|
else None,
|
|
)
|
|
opened: list[str] = []
|
|
monkeypatch.setattr(CrewRunApp, "_open_report", lambda self, url: opened.append(url))
|
|
monkeypatch.setattr(app, "notify", Mock())
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
|
|
|
|
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
|
|
assert opened == ["https://amp.example/t/1"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("record", "said"),
|
|
[
|
|
(None, "not traced"),
|
|
({"execution_id": "run-this-app"}, "granted no link"),
|
|
],
|
|
)
|
|
def test_view_traces_says_which_of_the_two_reasons_there_is_no_link(
|
|
monkeypatch, record, said
|
|
) -> None:
|
|
"""A run nobody traced and a traced run AMP gave no link for are different
|
|
problems, and only one of them is the reader's to fix."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = "run-this-app"
|
|
monkeypatch.setattr(crew_run_tui, "_recorded_run", lambda uuid: record)
|
|
monkeypatch.setattr(
|
|
CrewRunApp, "_open_report", lambda self, url: pytest.fail("nothing to open")
|
|
)
|
|
notices: list[str] = []
|
|
monkeypatch.setattr(app, "notify", lambda message, **kwargs: notices.append(message))
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
|
|
|
|
assert said in notices[0]
|
|
|
|
|
|
def test_deploy_button_click_records_telemetry() -> None:
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._crew_result = object()
|
|
app._telemetry = Mock()
|
|
app._unsubscribe = lambda: None # type: ignore[method-assign]
|
|
exits: list[object] = []
|
|
app.exit = lambda result: exits.append(result) # type: ignore[method-assign]
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-deploy")))
|
|
|
|
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:deploy")
|
|
assert app._want_deploy is True
|
|
assert exits == [app._crew_result]
|
|
|
|
|
|
def test_evaluate_button_evaluates_here_and_does_not_leave(monkeypatch) -> None:
|
|
"""The evaluation takes minutes, has a link worth showing and a verdict
|
|
worth reading. Sending somebody to a bare terminal to wait for them is the
|
|
worst of both, so it runs on this screen — and it names THIS app's
|
|
execution, not whichever run finished last in the project."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = "run-this-app"
|
|
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
|
started: list[str] = []
|
|
monkeypatch.setattr(
|
|
CrewRunApp, "_evaluate_worker", lambda self, execution_id: started.append(execution_id)
|
|
)
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: uuid == "run-this-app")
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-eval")))
|
|
|
|
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:evaluate")
|
|
assert started == ["run-this-app"]
|
|
assert app._evaluation == {"state": "starting", "execution_id": "run-this-app"}
|
|
|
|
|
|
def test_the_button_spins_while_it_evaluates_and_still_opens_the_report(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""A label that never moves reads as a screen that has stopped — and the
|
|
report page is where the progress is, so the button stays pressable."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = "run-this-app"
|
|
monkeypatch.setattr(CrewRunApp, "_evaluate_worker", lambda self, execution_id: None)
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: True)
|
|
labels: list[str] = []
|
|
button = SimpleNamespace(label="Evaluate", disabled=False)
|
|
monkeypatch.setattr(CrewRunApp, "query_one", lambda self, *a: button)
|
|
monkeypatch.setattr(CrewRunApp, "_open_report", lambda self, url: None)
|
|
|
|
app.action_evaluate_crew()
|
|
labels.append(str(button.label))
|
|
app._frame += 1
|
|
app._refresh_eval_button()
|
|
labels.append(str(button.label))
|
|
|
|
assert all(label.endswith("Evaluating…") for label in labels)
|
|
assert labels[0] != labels[1] # it moves
|
|
assert button.disabled is False # and it is not a dead button
|
|
|
|
app._evaluation_started({"id": "ev-1", "url": "https://optimize.example/e/1"})
|
|
app._evaluation_finished({"status": "done", "verdict": {"gate": "passed"}})
|
|
assert str(button.label) == "Open eval report"
|
|
|
|
|
|
def test_pressing_evaluate_again_opens_the_report_it_already_made(monkeypatch) -> None:
|
|
"""One evaluation per run: the second press is somebody looking for the
|
|
report, not asking to pay for another."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._evaluation = {"state": "done", "url": "https://optimize.example/e/1"}
|
|
monkeypatch.setattr(
|
|
CrewRunApp, "_evaluate_worker", lambda self, execution_id: pytest.fail("evaluated twice")
|
|
)
|
|
opened: list[str] = []
|
|
monkeypatch.setattr(CrewRunApp, "_open_report", lambda self, url: opened.append(url))
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-eval")))
|
|
|
|
assert opened == ["https://optimize.example/e/1"]
|
|
app._telemetry.feature_usage_span.assert_not_called()
|
|
|
|
|
|
def test_an_evaluation_that_stopped_can_be_tried_again(monkeypatch) -> None:
|
|
"""A timeout or a 502 is worth another press, and the run is still there to
|
|
grade — only a finished one sends the reader to its report instead."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = "run-this-app"
|
|
app._evaluation = {"state": "failed", "error": "AMP answered 502."}
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: True)
|
|
started: list[str] = []
|
|
monkeypatch.setattr(
|
|
CrewRunApp,
|
|
"_evaluate_worker",
|
|
lambda self, execution_id: started.append(execution_id),
|
|
)
|
|
|
|
app.action_evaluate_crew()
|
|
|
|
assert started == ["run-this-app"]
|
|
|
|
|
|
def test_a_login_that_cannot_be_read_is_shown_on_screen(monkeypatch) -> None:
|
|
"""A credential that exists but cannot be read stops the evaluation, and
|
|
the sentence has to reach the screen: printing it would land under the
|
|
layout, and exiting would take it away."""
|
|
from crewai_cli.experimental import eval_crew
|
|
|
|
app = CrewRunApp()
|
|
app._evaluation = {"state": "starting"}
|
|
monkeypatch.setattr(
|
|
CrewRunApp, "call_from_thread", lambda self, handler, *args: handler(*args)
|
|
)
|
|
|
|
def broken_store():
|
|
raise OSError("permission denied")
|
|
|
|
monkeypatch.setattr(eval_crew, "get_auth_token", broken_store)
|
|
|
|
app._evaluate_now("run-this-app")
|
|
|
|
assert app._evaluation["state"] == "failed"
|
|
assert "Could not read the saved login" in app._evaluation["error"]
|
|
assert "crewai login" in app._evaluation["error"]
|
|
|
|
|
|
def test_an_exit_with_no_reason_says_that_rather_than_its_code(monkeypatch) -> None:
|
|
"""Nothing on this path should exit any more, but an exit carries a code
|
|
and `1` on screen is not an answer."""
|
|
app = CrewRunApp()
|
|
app._evaluation = {"state": "starting"}
|
|
monkeypatch.setattr(
|
|
CrewRunApp, "call_from_thread", lambda self, handler, *args: handler(*args)
|
|
)
|
|
monkeypatch.setattr(
|
|
"crewai_cli.experimental.eval_crew.evaluate_run",
|
|
Mock(side_effect=SystemExit(1)),
|
|
)
|
|
|
|
app._evaluate_now("run-this-app")
|
|
|
|
assert app._evaluation["state"] == "failed"
|
|
assert app._evaluation["error"] == (
|
|
"The evaluation stopped without saying why (exit 1)."
|
|
)
|
|
|
|
|
|
def test_the_evaluation_shows_its_link_its_progress_and_its_verdict() -> None:
|
|
"""Everything the reader came for is on the run's own screen."""
|
|
app = CrewRunApp()
|
|
app._evaluation = {"state": "starting"}
|
|
app._evaluation_started({"id": "ev-1", "url": "https://optimize.example/e/1"})
|
|
app._open_report = lambda url: None # type: ignore[method-assign]
|
|
|
|
line = Text()
|
|
app._append_evaluation(line)
|
|
assert "Evaluating this run" in line.plain
|
|
assert "https://optimize.example/e/1" in line.plain
|
|
|
|
app._evaluation_progress({"events": [{"data": {"subject": "the final output", "planned": 9}}]})
|
|
line = Text()
|
|
app._append_evaluation(line)
|
|
assert "judged 1 of 9" in line.plain
|
|
|
|
app._evaluation_finished(
|
|
{"status": "done", "verdict": {"gate": "passed", "grades": {"goal": 5, "tools": None}}}
|
|
)
|
|
line = Text()
|
|
app._append_evaluation(line)
|
|
assert "Goal gate PASSED" in line.plain
|
|
assert "goal 5/5" in line.plain and "tools —/5" in line.plain
|
|
assert "https://optimize.example/e/1" in line.plain
|
|
|
|
|
|
def test_an_evaluation_that_stopped_says_so_where_it_was_running() -> None:
|
|
app = CrewRunApp()
|
|
app._evaluation = {"state": "running", "url": "https://optimize.example/e/1"}
|
|
app._evaluation_failed("AMP answered 404 for run x.")
|
|
|
|
line = Text()
|
|
app._append_evaluation(line)
|
|
assert "Evaluation stopped" in line.plain
|
|
assert "AMP answered 404 for run x." in line.plain
|
|
|
|
|
|
@pytest.mark.parametrize("why", ["not traced at all", "another run replaced the record"])
|
|
def test_evaluate_says_so_when_this_run_was_not_traced(monkeypatch, why) -> None:
|
|
"""A run with no session of its own, or whose trace never reached AMP, has
|
|
nothing to grade — and a record naming a DIFFERENT run is not evidence that
|
|
this one was traced."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
app._execution_uuid = None if why == "not traced at all" else "run-this-app"
|
|
notices: list[str] = []
|
|
app.notify = lambda message, **kwargs: notices.append(message) # type: ignore[method-assign]
|
|
app.exit = lambda result=None: pytest.fail("the TUI must not leave for nothing") # type: ignore[method-assign]
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: False)
|
|
|
|
app.action_evaluate_crew()
|
|
|
|
assert app._evaluation is None
|
|
assert "not traced" in notices[0]
|
|
|
|
|
|
def test_tracing_asked_for_reads_the_declaration_but_keeps_the_other_guards(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""The ContextVar is set where the crew is CONSTRUCTED, not on the worker
|
|
thread that later asks — but an app with nobody in front of it is still
|
|
nobody's yes."""
|
|
utils = "crewai.events.listeners.tracing.utils"
|
|
monkeypatch.setattr(f"{utils}._is_test_environment", lambda: False)
|
|
monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: True)
|
|
app = CrewRunApp()
|
|
app._crew = SimpleNamespace(tracing=True)
|
|
|
|
assert app._tracing_was_asked_for() is True
|
|
assert app._request_trace_consent() is True
|
|
|
|
app._crew = SimpleNamespace(tracing=None)
|
|
assert app._tracing_was_asked_for() is False
|
|
|
|
# embedded, redirected, or under test: the declaration is not a person
|
|
app._crew = SimpleNamespace(tracing=True)
|
|
monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: False)
|
|
assert app._tracing_was_asked_for() is False
|
|
monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: True)
|
|
monkeypatch.setattr(f"{utils}._is_test_environment", lambda: True)
|
|
assert app._tracing_was_asked_for() is False
|
|
|
|
|
|
def test_a_run_started_for_an_evaluation_evaluates_it_here(monkeypatch) -> None:
|
|
"""`crewai eval` runs the crew when nothing is traced. The evaluation it
|
|
came for starts on this screen when the run ends — the app does not close
|
|
and leave somebody in a bare terminal watching for a link."""
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: True)
|
|
started: list[str] = []
|
|
monkeypatch.setattr(
|
|
CrewRunApp,
|
|
"_evaluate_worker",
|
|
lambda self, execution_id: started.append(execution_id),
|
|
)
|
|
with crew_run_tui.evaluating_after_run() as watched:
|
|
app = CrewRunApp()
|
|
app._execution_uuid = "run-this-app"
|
|
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
|
|
|
app._evaluate_if_one_is_waiting()
|
|
|
|
assert started == ["run-this-app"]
|
|
assert watched["execution_id"] == "run-this-app"
|
|
|
|
|
|
def test_a_child_process_run_reads_the_waiting_evaluation_off_its_environment(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""A project's crew runs through `uv run …`, so the app is in another
|
|
process — the one thing that crosses is the environment the command passed
|
|
it."""
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: True)
|
|
started: list[str] = []
|
|
monkeypatch.setattr(
|
|
CrewRunApp,
|
|
"_evaluate_worker",
|
|
lambda self, execution_id: started.append(execution_id),
|
|
)
|
|
|
|
def the_child() -> None:
|
|
# a fresh context: that variable, and nothing this process holds in memory
|
|
assert crew_run_tui._AUTO_EVAL.get() is None
|
|
app = CrewRunApp()
|
|
app._execution_uuid = "run-in-the-child"
|
|
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
|
app._evaluate_if_one_is_waiting()
|
|
|
|
with crew_run_tui.evaluating_after_run():
|
|
contextvars.Context().run(the_child)
|
|
|
|
assert started == ["run-in-the-child"]
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "0" * 32])
|
|
def test_a_project_env_cannot_say_an_evaluation_is_waiting(monkeypatch, value) -> None:
|
|
"""The child loads the project's `.env` over its environment. A value the
|
|
command did not mint — with no token file behind it — is no handshake, so
|
|
a plain `crewai run` never starts an evaluation on its own."""
|
|
monkeypatch.setenv("CREWAI_EVAL_AWAITING_RUN", value)
|
|
|
|
assert crew_run_tui._an_evaluation_is_waiting() is None
|
|
|
|
|
|
def test_the_token_stops_counting_when_the_command_is_done(monkeypatch) -> None:
|
|
with crew_run_tui.evaluating_after_run():
|
|
passed = crew_run_tui.os.environ["CREWAI_EVAL_AWAITING_RUN"]
|
|
|
|
monkeypatch.setenv("CREWAI_EVAL_AWAITING_RUN", passed)
|
|
assert crew_run_tui._an_evaluation_is_waiting() is None
|
|
|
|
|
|
def test_a_run_with_no_trace_says_so_instead_of_evaluating(monkeypatch) -> None:
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: False)
|
|
marked: list[str | None] = []
|
|
monkeypatch.setattr(
|
|
"crewai_cli.experimental.eval_crew.record_evaluation_outcome", marked.append
|
|
)
|
|
monkeypatch.setattr(
|
|
CrewRunApp,
|
|
"_evaluate_worker",
|
|
lambda self, execution_id: pytest.fail("nothing to grade"),
|
|
)
|
|
with crew_run_tui.evaluating_after_run() as watched:
|
|
app = CrewRunApp()
|
|
app._execution_uuid = "run-this-app"
|
|
notices: list[str] = []
|
|
app.notify = lambda message, **kwargs: notices.append(message) # type: ignore[method-assign]
|
|
|
|
app._evaluate_if_one_is_waiting()
|
|
|
|
assert watched["execution_id"] is None
|
|
assert "not traced" in notices[0]
|
|
# and the command waiting behind the screen is told, since it cannot see it
|
|
assert marked == [None]
|
|
|
|
|
|
def test_a_run_that_failed_is_still_evaluated(monkeypatch) -> None:
|
|
"""Where a run went wrong is what somebody asking for an evaluation wants
|
|
to know, and the trace it exported is still there."""
|
|
monkeypatch.setattr(crew_run_tui, "_trace_was_recorded", lambda uuid: True)
|
|
started: list[str] = []
|
|
monkeypatch.setattr(
|
|
CrewRunApp,
|
|
"_evaluate_worker",
|
|
lambda self, execution_id: started.append(execution_id),
|
|
)
|
|
with crew_run_tui.evaluating_after_run():
|
|
app = CrewRunApp()
|
|
app._status = "failed"
|
|
app._execution_uuid = "the-run-that-broke"
|
|
|
|
app._evaluate_if_one_is_waiting()
|
|
|
|
assert started == ["the-run-that-broke"]
|
|
|
|
|
|
def test_a_run_nobody_is_waiting_on_evaluates_nothing() -> None:
|
|
app = CrewRunApp()
|
|
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
|
|
|
app._evaluate_if_one_is_waiting()
|
|
|
|
assert app._evaluation is None
|
|
|
|
|
|
def test_evaluate_before_completion_records_nothing() -> None:
|
|
app = CrewRunApp()
|
|
app._status = "running"
|
|
app._telemetry = Mock()
|
|
|
|
app.action_evaluate_crew()
|
|
|
|
app._telemetry.feature_usage_span.assert_not_called()
|
|
assert app._evaluation is None
|
|
|
|
|
|
def test_conversation_turn_done_records_assistant_message() -> None:
|
|
class RawResult:
|
|
raw = "hello from the flow"
|
|
|
|
app = CrewRunApp(conversational=True)
|
|
app._conversation_turn_in_progress = True
|
|
app._enable_conversation_input = lambda: None # type: ignore[method-assign]
|
|
app._tick = lambda: None # type: ignore[method-assign]
|
|
app._scroll_to_result = lambda: None # type: ignore[method-assign]
|
|
|
|
app._on_conversation_turn_done(RawResult())
|
|
|
|
assert app._conversation_messages == [("assistant", "hello from the flow")]
|
|
assert app._conversation_turn_in_progress is False
|
|
assert app._status == "chatting"
|
|
assert isinstance(app._crew_result, RawResult)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_input_submits_turn() -> None:
|
|
class FakeFlow:
|
|
defer_trace_finalization = False
|
|
|
|
def handle_turn(self, message: str) -> str:
|
|
return f"reply: {message}"
|
|
|
|
def finalize_session_traces(self) -> None:
|
|
pass
|
|
|
|
app = CrewRunApp(crew_name="Demo", conversational=True)
|
|
app._flow = FakeFlow()
|
|
|
|
async with app.run_test() as pilot:
|
|
await pilot.click("#conversation-input")
|
|
await pilot.press("h", "i", "enter")
|
|
for _ in range(50):
|
|
await pilot.pause(0.05)
|
|
if app._conversation_messages[-1:] == [("assistant", "reply: hi")]:
|
|
break
|
|
|
|
assert app._conversation_messages == [
|
|
("user", "hi"),
|
|
("assistant", "reply: hi"),
|
|
]
|
|
|
|
|
|
def test_plan_step_status_updates_only_the_explicit_step() -> None:
|
|
app = _app_with_plan()
|
|
|
|
app._set_plan_step_status(2, "done")
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_step_observation_events_update_the_explicit_step() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
StepObservationStartedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "active",
|
|
3: "pending",
|
|
}
|
|
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
StepObservationCompletedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
step_completed_successfully=True,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_plan_step_lifecycle_events_update_the_explicit_step() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
PlanStepStartedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
)
|
|
)
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "active",
|
|
3: "pending",
|
|
}
|
|
|
|
_emit_event(
|
|
PlanStepCompletedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
success=True,
|
|
result="done",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_failed_plan_step_lifecycle_event_marks_exact_step_failed() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
PlanStepCompletedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
success=False,
|
|
error="Step failed",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "failed",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_tool_usage_events_do_not_advance_plan_steps() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
now = datetime.now()
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
ToolUsageFinishedEvent(
|
|
tool_name="search",
|
|
tool_args={"query": "CrewAI"},
|
|
started_at=now,
|
|
finished_at=now,
|
|
output="result",
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "pending",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_next_tool_does_not_mark_unfinished_tool_successful() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
|
|
)
|
|
_emit_event(
|
|
ToolUsageStartedEvent(tool_name="scrape", tool_args={"url": "https://x"}),
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["status"] == "timeout"
|
|
assert app._log_entries[0]["result"] is None
|
|
assert app._log_entries[0]["error"] == (
|
|
"No result received before the next tool started"
|
|
)
|
|
assert app._log_entries[1]["status"] == "running"
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "pending",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
ToolUsageStartedEvent(
|
|
tool_name="create_reasoning_plan",
|
|
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
now = datetime.now()
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
ToolUsageFinishedEvent(
|
|
tool_name="create_reasoning_plan",
|
|
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
|
started_at=now,
|
|
finished_at=now,
|
|
output='{"plan":"Plan","steps":[],"ready":true}',
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
ToolUsageErrorEvent(
|
|
tool_name="create_reasoning_plan",
|
|
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
|
error="internal planning fallback",
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries == []
|
|
assert app._current_task_steps == []
|
|
|
|
|
|
def test_memory_save_events_are_shown_in_activity_log() -> None:
|
|
app = _app_with_plan()
|
|
app._current_task_idx = 1
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="2 memories (background)",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="2 memories saved",
|
|
metadata={},
|
|
save_time_ms=123,
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert len(app._log_entries) == 1
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "success"
|
|
assert app._log_entries[0]["args"] == "2 memories (background)"
|
|
assert app._log_entries[0]["result"] == "2 memories saved"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[0]["duration"] == 0.123
|
|
assert app._log_entries[0]["task_idx"] == 1
|
|
|
|
|
|
def test_nested_memory_save_event_is_hidden_for_save_to_memory_tool() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
tool_args = {"contents": ["Fact to remember."]}
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="save_to_memory",
|
|
tool_args=tool_args,
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="Fact to remember.",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="Fact to remember.",
|
|
metadata={},
|
|
save_time_ms=123,
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
now = datetime.now()
|
|
_emit_event(
|
|
ToolUsageFinishedEvent(
|
|
tool_name="save_to_memory",
|
|
tool_args=tool_args,
|
|
started_at=now,
|
|
finished_at=now,
|
|
output="Saved to memory.",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert len(app._log_entries) == 1
|
|
assert app._log_entries[0]["tool_name"] == "save_to_memory"
|
|
assert app._log_entries[0]["status"] == "success"
|
|
assert app._log_entries[0]["result"] == "Saved to memory."
|
|
|
|
|
|
def test_memory_save_failure_is_shown_in_activity_log() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="background save",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveFailedEvent(
|
|
value="background save",
|
|
metadata={},
|
|
error="embedding connection failed",
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "error"
|
|
assert app._log_entries[0]["error"] == "embedding connection failed"
|
|
assert app._log_expanded == {0}
|
|
|
|
|
|
def test_memory_save_completion_updates_timed_out_row() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="9 memories (background)",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
|
|
app._log_entries[0]["status"] = "timeout"
|
|
app._log_entries[0]["error"] = "No result received before crew completed"
|
|
app._log_entries[0]["duration"] = 8.3
|
|
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="9 memories saved",
|
|
metadata={},
|
|
save_time_ms=8300,
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert len(app._log_entries) == 1
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "success"
|
|
assert app._log_entries[0]["result"] == "9 memories saved"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[0]["duration"] == 8.3
|
|
|
|
|
|
def test_memory_save_completion_with_unmatched_id_does_not_update_running_row() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="first background save",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="second background save",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="orphan save completed",
|
|
metadata={},
|
|
save_time_ms=2800,
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
started_event_id="missing-memory-save-start",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert [entry["status"] for entry in app._log_entries] == [
|
|
"running",
|
|
"running",
|
|
"success",
|
|
]
|
|
assert app._log_entries[0]["args"] == "first background save"
|
|
assert app._log_entries[1]["args"] == "second background save"
|
|
assert app._log_entries[2]["result"] == "orphan save completed"
|
|
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
|
|
|
|
|
|
def test_memory_save_failure_with_unmatched_id_does_not_update_running_row() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="first background save",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="second background save",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
|
|
_emit_event(
|
|
MemorySaveFailedEvent(
|
|
value="orphan save failed",
|
|
metadata={},
|
|
error="embedding connection failed",
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
started_event_id="missing-memory-save-start",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert [entry["status"] for entry in app._log_entries] == [
|
|
"running",
|
|
"running",
|
|
"error",
|
|
]
|
|
assert app._log_entries[0]["args"] == "first background save"
|
|
assert app._log_entries[1]["args"] == "second background save"
|
|
assert app._log_entries[2]["args"] == "orphan save failed"
|
|
assert app._log_entries[2]["error"] == "embedding connection failed"
|
|
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
|
|
assert app._log_expanded == {2}
|
|
|
|
|
|
def test_memory_save_completion_without_id_does_not_update_stale_row() -> None:
|
|
app = _app_with_plan()
|
|
now = time.time()
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "running",
|
|
"args": "current background save",
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": now,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "success",
|
|
"args": "stale background save",
|
|
"result": "stale save completed",
|
|
"error": None,
|
|
"start_time": now - 10,
|
|
"duration": 1.0,
|
|
"task_idx": 1,
|
|
},
|
|
]
|
|
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="current save completed",
|
|
metadata={},
|
|
save_time_ms=2800,
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert [entry["status"] for entry in app._log_entries] == [
|
|
"success",
|
|
"success",
|
|
]
|
|
assert app._log_entries[0]["args"] == "current background save"
|
|
assert app._log_entries[0]["result"] == "current save completed"
|
|
assert app._log_entries[1]["args"] == "stale background save"
|
|
assert app._log_entries[1]["result"] == "stale save completed"
|
|
|
|
|
|
def test_memory_save_failure_without_id_does_not_update_stale_row() -> None:
|
|
app = _app_with_plan()
|
|
now = time.time()
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "running",
|
|
"args": "current background save",
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": now,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "success",
|
|
"args": "stale background save",
|
|
"result": "stale save completed",
|
|
"error": None,
|
|
"start_time": now - 10,
|
|
"duration": 1.0,
|
|
"task_idx": 1,
|
|
},
|
|
]
|
|
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveFailedEvent(
|
|
value="current save failed",
|
|
metadata={},
|
|
error="embedding connection failed",
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert [entry["status"] for entry in app._log_entries] == ["error", "success"]
|
|
assert app._log_entries[0]["args"] == "current background save"
|
|
assert app._log_entries[0]["error"] == "embedding connection failed"
|
|
assert app._log_entries[1]["args"] == "stale background save"
|
|
assert app._log_entries[1]["result"] == "stale save completed"
|
|
assert app._log_entries[1]["error"] is None
|
|
assert app._log_expanded == {0}
|
|
|
|
|
|
def test_memory_save_payloads_are_truncated_in_activity_log() -> None:
|
|
app = _app_with_plan()
|
|
long_args = "a" * (_LOG_ARGS_TEXT_LIMIT + 10)
|
|
long_result = "r" * (_LOG_RESULT_TEXT_LIMIT + 10)
|
|
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value=long_args,
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value=long_result,
|
|
metadata={},
|
|
save_time_ms=8300,
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert len(app._log_entries[0]["args"]) == _LOG_ARGS_TEXT_LIMIT
|
|
assert app._log_entries[0]["args"].endswith(_LOG_TRUNCATION_SUFFIX)
|
|
assert len(app._log_entries[0]["result"]) == _LOG_RESULT_TEXT_LIMIT
|
|
assert app._log_entries[0]["result"].endswith(_LOG_TRUNCATION_SUFFIX)
|
|
|
|
|
|
def test_starting_next_tool_does_not_timeout_memory_save() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MemorySaveStartedEvent(
|
|
value="9 memories (background)",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
)
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="read_website_content",
|
|
tool_args={"url": "https://example.com"},
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "running"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[1]["tool_name"] == "read_website_content"
|
|
assert app._log_entries[1]["status"] == "running"
|
|
|
|
|
|
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
PlanStepStartedEvent(
|
|
agent_role="Agent",
|
|
step_number=1,
|
|
step_description="First",
|
|
)
|
|
)
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
plan_step_number=1,
|
|
plan_step_description="First",
|
|
)
|
|
)
|
|
_emit_event(
|
|
ToolUsageErrorEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
plan_step_number=1,
|
|
plan_step_description="First",
|
|
error="No results",
|
|
)
|
|
)
|
|
_emit_event(
|
|
PlanStepCompletedEvent(
|
|
agent_role="Agent",
|
|
step_number=1,
|
|
step_description="First",
|
|
success=True,
|
|
result="Recovered with another source",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "done",
|
|
2: "pending",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_tool_event_step_metadata_is_stored_in_activity_log() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
plan_step_number=2,
|
|
plan_step_description="Second",
|
|
)
|
|
)
|
|
now = datetime.now()
|
|
_emit_event(
|
|
ToolUsageFinishedEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
plan_step_number=2,
|
|
plan_step_description="Second",
|
|
started_at=now,
|
|
finished_at=now,
|
|
output="Found official source",
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["plan_step_number"] == 2
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "pending",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_starting_next_tool_does_not_infer_plan_step_progress() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
)
|
|
)
|
|
_emit_event(
|
|
ToolUsageErrorEvent(
|
|
tool_name="search_the_internet_with_serper",
|
|
tool_args={"search_query": "CrewAI release"},
|
|
error="No results",
|
|
)
|
|
)
|
|
_emit_event(
|
|
ToolUsageStartedEvent(
|
|
tool_name="read_website_content",
|
|
tool_args={"url": "https://example.com"},
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["status"] == "error"
|
|
assert app._log_entries[1]["status"] == "running"
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "pending",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
|
|
app = _app_with_plan()
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "search",
|
|
"status": "running",
|
|
"args": '{"query": "CrewAI"}',
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 2,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
}
|
|
]
|
|
|
|
app._on_crew_done("final output")
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["status"] == "timeout"
|
|
assert app._log_entries[0]["result"] is None
|
|
assert app._log_entries[0]["error"] == "No result received before crew completed"
|
|
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flow_done_uses_flow_wording_for_unfinished_tool() -> None:
|
|
# The shared completion handler reports "flow" (not "crew") in flow mode.
|
|
app = CrewRunApp(crew_name="Demo Flow")
|
|
app._flow = SimpleNamespace()
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "search",
|
|
"status": "running",
|
|
"args": None,
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 2,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
}
|
|
]
|
|
app._on_crew_done("final output")
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["error"] == "No result received before flow completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crew_done_does_not_timeout_memory_save() -> None:
|
|
app = _app_with_plan()
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "running",
|
|
"args": "9 memories (background)",
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 8,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
{
|
|
"tool_name": "search",
|
|
"status": "running",
|
|
"args": '{"query": "CrewAI"}',
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 2,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
]
|
|
|
|
app._on_crew_done("final output")
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["status"] == "running"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[1]["status"] == "timeout"
|
|
assert app._log_entries[1]["error"] == "No result received before crew completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crew_done_keeps_memory_save_subscription_until_completion(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
|
|
)
|
|
app = _app_with_plan()
|
|
auto_unsubscribed = False
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
try:
|
|
assert app._event_handlers
|
|
started_event = MemorySaveStartedEvent(
|
|
value="9 memories (background)",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
)
|
|
_emit_event(started_event)
|
|
|
|
app._on_crew_done("final output")
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["status"] == "running"
|
|
assert app._event_handlers
|
|
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="9 memories saved",
|
|
metadata={},
|
|
save_time_ms=8300,
|
|
source_type="unified_memory",
|
|
started_event_id=started_event.event_id,
|
|
)
|
|
)
|
|
await pilot.pause()
|
|
|
|
assert app._event_handlers
|
|
await pilot.pause(0.08)
|
|
auto_unsubscribed = not app._event_handlers
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "success"
|
|
assert app._log_entries[0]["result"] == "9 memories saved"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[0]["duration"] == 8.3
|
|
assert auto_unsubscribed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crew_done_waits_for_queued_memory_save_events(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
|
|
)
|
|
app = _app_with_plan()
|
|
auto_unsubscribed = False
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
try:
|
|
assert app._event_handlers
|
|
|
|
app._on_crew_done("final output")
|
|
|
|
assert app._event_handlers
|
|
started_event = MemorySaveStartedEvent(
|
|
value="9 memories (background)",
|
|
metadata={},
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
)
|
|
_emit_event(started_event)
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "running"
|
|
|
|
_emit_event(
|
|
MemorySaveCompletedEvent(
|
|
value="9 memories saved",
|
|
metadata={},
|
|
save_time_ms=8300,
|
|
source_type="unified_memory",
|
|
parent_event_id="manual-parent",
|
|
started_event_id=started_event.event_id,
|
|
)
|
|
)
|
|
await pilot.pause()
|
|
|
|
assert app._event_handlers
|
|
await pilot.pause(0.08)
|
|
auto_unsubscribed = not app._event_handlers
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._log_entries[0]["tool_name"] == "memory_save"
|
|
assert app._log_entries[0]["status"] == "success"
|
|
assert app._log_entries[0]["args"] == "9 memories (background)"
|
|
assert app._log_entries[0]["result"] == "9 memories saved"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[0]["duration"] == 8.3
|
|
assert auto_unsubscribed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crew_failed_does_not_timeout_memory_save() -> None:
|
|
app = _app_with_plan()
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
app._log_entries = [
|
|
{
|
|
"tool_name": "memory_save",
|
|
"status": "running",
|
|
"args": "9 memories (background)",
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 8,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
{
|
|
"tool_name": "search",
|
|
"status": "running",
|
|
"args": '{"query": "CrewAI"}',
|
|
"result": None,
|
|
"error": None,
|
|
"start_time": time.time() - 2,
|
|
"duration": None,
|
|
"task_idx": 1,
|
|
},
|
|
]
|
|
|
|
app._on_crew_failed("boom")
|
|
await pilot.pause()
|
|
|
|
assert app._log_entries[0]["status"] == "running"
|
|
assert app._log_entries[0]["error"] is None
|
|
assert app._log_entries[1]["status"] == "error"
|
|
assert app._log_entries[1]["error"] == "No result received before crew failed"
|
|
|
|
|
|
def test_streamed_step_observation_updates_named_step_only() -> None:
|
|
app = _app_with_plan()
|
|
|
|
updated = app._try_parse_step_observation(
|
|
'{"step_completed_successfully":true,'
|
|
'"key_information_learned":"Step 2 succeeded with the official source."}'
|
|
)
|
|
|
|
assert updated is True
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_failed_streamed_step_observation_marks_named_step_failed() -> None:
|
|
app = _app_with_plan()
|
|
|
|
updated = app._try_parse_step_observation(
|
|
'{"step_completed_successfully":false,'
|
|
'"key_information_learned":"Step 2 failed because the tool failed."}'
|
|
)
|
|
|
|
assert updated is True
|
|
assert app._plan_step_status == {
|
|
1: "pending",
|
|
2: "failed",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_streamed_goal_achieved_observation_collapses_remaining_steps_done() -> None:
|
|
app = _app_with_plan()
|
|
|
|
updated = app._try_parse_step_observation(
|
|
'{"step_number":2,'
|
|
'"step_completed_successfully":true,'
|
|
'"key_information_learned":"Goal is already satisfied.",'
|
|
'"goal_already_achieved":true}'
|
|
)
|
|
|
|
assert updated is True
|
|
assert app._plan_step_status == {
|
|
1: "done",
|
|
2: "done",
|
|
3: "done",
|
|
}
|
|
|
|
|
|
def test_task_completion_collapses_pending_plan_steps_but_preserves_failed() -> None:
|
|
app = _app_with_plan()
|
|
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
|
|
|
|
app._collapse_plan_on_task_done()
|
|
|
|
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
|
|
|
|
|
|
def test_observation_failure_collapses_to_done_because_executor_continues() -> None:
|
|
app = _app_with_plan()
|
|
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
StepObservationFailedEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
step_description="Second",
|
|
error="observer timeout",
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "done",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
|
|
|
|
def test_goal_achieved_event_collapses_remaining_steps_done() -> None:
|
|
app = _app_with_plan()
|
|
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
GoalAchievedEarlyEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
steps_completed=2,
|
|
steps_remaining=1,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "done",
|
|
2: "done",
|
|
3: "done",
|
|
}
|
|
|
|
|
|
def test_replan_event_keeps_old_plan_until_next_streamed_plan_replaces_it() -> None:
|
|
app = _app_with_plan()
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
PlanReplanTriggeredEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
replan_reason="Need updated sources",
|
|
replan_count=1,
|
|
completed_steps_preserved=1,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan is not None
|
|
assert app._plan_step_status == {1: "pending", 2: "pending", 3: "pending"}
|
|
assert app._awaiting_replan is True
|
|
|
|
app._try_parse_plan(
|
|
'{"plan":"Updated plan","steps":['
|
|
'{"step_number":1,"description":"Updated first"},'
|
|
'{"step_number":2,"description":"Updated second"}]}'
|
|
)
|
|
|
|
assert app._plan == {
|
|
"plan": "Updated plan",
|
|
"steps": [
|
|
{"step_number": 1, "description": "Updated first"},
|
|
{"step_number": 2, "description": "Updated second"},
|
|
],
|
|
}
|
|
assert app._plan_step_status == {1: "pending", 2: "pending"}
|
|
assert app._awaiting_replan is False
|
|
|
|
|
|
def test_plan_refinement_updates_descriptions_without_new_statuses() -> None:
|
|
app = _app_with_plan()
|
|
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
|
app._subscribe()
|
|
try:
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
PlanRefinementEvent(
|
|
agent_role="Agent",
|
|
step_number=2,
|
|
refined_step_count=1,
|
|
refinements=["Step 3: Write the final answer from verified facts"],
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._plan_step_status == {
|
|
1: "done",
|
|
2: "done",
|
|
3: "pending",
|
|
}
|
|
assert app._plan["steps"][2]["description"] == (
|
|
"Write the final answer from verified facts"
|
|
)
|
|
|
|
|
|
def test_step_observation_json_is_hidden_from_streaming_text() -> None:
|
|
app = _app_with_plan()
|
|
|
|
assert (
|
|
app._strip_step_observation_json(
|
|
'Visible before {"step_completed_successfully":true,'
|
|
'"key_information_learned":"Step 2 succeeded."} visible after'
|
|
)
|
|
== "Visible before visible after"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_run_keeps_activity_log_keyboard_navigation_active() -> None:
|
|
app = CrewRunApp()
|
|
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
app._log_entries = [_log_entry("search"), _log_entry("scrape")]
|
|
|
|
app._on_crew_done("final output")
|
|
await pilot.pause()
|
|
|
|
assert app.focused is app.query_one("#log-panel")
|
|
|
|
await pilot.press("down", "enter")
|
|
await pilot.pause()
|
|
|
|
assert app._log_cursor == 1
|
|
assert app._log_expanded == {1}
|
|
|
|
await pilot.press("up")
|
|
await pilot.pause()
|
|
|
|
assert app._log_cursor == 0
|
|
|
|
|
|
class _FakeTask:
|
|
fingerprint = None
|
|
|
|
def __init__(self, task_id: str, name: str) -> None:
|
|
self.id = task_id
|
|
self.name = name
|
|
self.description = name
|
|
|
|
|
|
def test_async_task_completion_marks_the_right_sidebar_row() -> None:
|
|
"""Overlapping tasks: completing task 1 while task 2 runs must not
|
|
mark task 2 done, and starting task 2 must not mark task 1 done."""
|
|
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
|
|
from crewai.tasks.task_output import TaskOutput
|
|
|
|
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
|
app._subscribe()
|
|
try:
|
|
task1 = _FakeTask("id-1", "first")
|
|
task2 = _FakeTask("id-2", "second")
|
|
|
|
for task in (task1, task2):
|
|
future = crewai_event_bus.emit(
|
|
None, TaskStartedEvent(context=None, task=task)
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
# Both started: neither prematurely done
|
|
assert app._task_statuses == {1: "active", 2: "active"}
|
|
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
TaskCompletedEvent(
|
|
output=TaskOutput(description="first", raw="done", agent="a"),
|
|
task=task1,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
assert app._task_statuses == {1: "done", 2: "active"}
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
def test_pop_task_state_falls_back_to_current_task() -> None:
|
|
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
|
app._current_task_idx = 2
|
|
app._current_task_desc = "second"
|
|
|
|
class _Evt:
|
|
task = None
|
|
task_name = "unknown"
|
|
|
|
state = app._pop_task_state(_Evt())
|
|
assert state["idx"] == 2
|
|
assert state["desc"] == "second"
|
|
|
|
|
|
def test_overlapping_task_logs_keep_their_own_state() -> None:
|
|
"""Task 1 completing after task 2 started must log its own description,
|
|
agent, and output — and must not steal or reset task 2's stream state."""
|
|
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
|
|
from crewai.tasks.task_output import TaskOutput
|
|
|
|
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
|
app._subscribe()
|
|
try:
|
|
task1 = _FakeTask("id-1", "first")
|
|
task2 = _FakeTask("id-2", "second")
|
|
|
|
for task in (task1, task2):
|
|
future = crewai_event_bus.emit(
|
|
None, TaskStartedEvent(context=None, task=task)
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
# Task 2 is current and has streamed state in flight
|
|
app._task_full_output = "task two streaming output"
|
|
app._current_task_steps = [{"type": "llm", "summary": "thinking"}]
|
|
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
TaskCompletedEvent(
|
|
output=TaskOutput(
|
|
description="first", raw="task one result", agent="a1"
|
|
),
|
|
task=task1,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
# Task 1's entry carries its own identity and output
|
|
entry1 = app._task_logs[-1]
|
|
assert entry1["idx"] == 1
|
|
assert entry1["desc"] == "first"
|
|
assert entry1["output"] == "task one result"
|
|
assert entry1["steps"] == []
|
|
|
|
# Task 2's in-flight stream state was not consumed or reset
|
|
assert app._task_full_output == "task two streaming output"
|
|
assert app._current_task_steps == [{"type": "llm", "summary": "thinking"}]
|
|
|
|
future = crewai_event_bus.emit(
|
|
None,
|
|
TaskCompletedEvent(
|
|
output=TaskOutput(
|
|
description="second", raw="task two result", agent="a2"
|
|
),
|
|
task=task2,
|
|
),
|
|
)
|
|
if future:
|
|
future.result(timeout=5)
|
|
|
|
entry2 = app._task_logs[-1]
|
|
assert entry2["idx"] == 2
|
|
assert entry2["desc"] == "second"
|
|
assert entry2["output"] == "task two streaming output"
|
|
assert any(step.get("summary") == "thinking" for step in entry2["steps"])
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
# ── Declarative-flow (non-conversational) TUI support ───────
|
|
|
|
|
|
def test_is_flow_run_gating() -> None:
|
|
"""The flow-render gate must be true only for a non-conversational flow."""
|
|
crew_app = CrewRunApp(total_tasks=1)
|
|
crew_app._crew = SimpleNamespace()
|
|
assert crew_app._is_flow_run is False
|
|
|
|
conv_app = CrewRunApp(conversational=True)
|
|
conv_app._flow = SimpleNamespace()
|
|
assert conv_app._is_flow_run is False
|
|
|
|
flow_app = CrewRunApp()
|
|
flow_app._flow = SimpleNamespace()
|
|
assert flow_app._is_flow_run is True
|
|
|
|
|
|
def test_flow_method_events_build_steps() -> None:
|
|
app = CrewRunApp(crew_name="Demo")
|
|
app._flow = SimpleNamespace()
|
|
app._flow_method_types = {"research": "crew", "summarize": "agent"}
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(FlowStartedEvent(flow_name="Demo"))
|
|
assert app._status == "working"
|
|
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(
|
|
flow_name="Demo", method_name="research", state={}
|
|
)
|
|
)
|
|
assert app._flow_steps == [
|
|
{"name": "research", "call_type": "crew", "status": "active"}
|
|
]
|
|
assert app._current_method == "research"
|
|
|
|
_emit_event(
|
|
MethodExecutionFinishedEvent(
|
|
flow_name="Demo", method_name="research", result="ok", state={}
|
|
)
|
|
)
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(
|
|
flow_name="Demo", method_name="summarize", state={}
|
|
)
|
|
)
|
|
_emit_event(
|
|
MethodExecutionFailedEvent(
|
|
flow_name="Demo",
|
|
method_name="summarize",
|
|
error=RuntimeError("boom"),
|
|
)
|
|
)
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
assert app._flow_steps == [
|
|
{"name": "research", "call_type": "crew", "status": "done"},
|
|
{"name": "summarize", "call_type": "agent", "status": "failed"},
|
|
]
|
|
# The header must not keep spinning a method that already ended.
|
|
assert app._current_method is None
|
|
|
|
|
|
def test_current_method_clears_and_falls_back_across_overlap() -> None:
|
|
app = CrewRunApp(crew_name="Demo")
|
|
app._flow = SimpleNamespace()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(flow_name="Demo", method_name="a", state={})
|
|
)
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(flow_name="Demo", method_name="b", state={})
|
|
)
|
|
assert app._current_method == "b"
|
|
|
|
# 'a' finishes while 'b' is still active → header stays on 'b'.
|
|
_emit_event(
|
|
MethodExecutionFinishedEvent(
|
|
flow_name="Demo", method_name="a", result=None, state={}
|
|
)
|
|
)
|
|
assert app._current_method == "b"
|
|
|
|
# 'b' finishes → nothing active left → header clears.
|
|
_emit_event(
|
|
MethodExecutionFinishedEvent(
|
|
flow_name="Demo", method_name="b", result=None, state={}
|
|
)
|
|
)
|
|
assert app._current_method is None
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
def test_flow_method_transitions_clear_current_agent() -> None:
|
|
app = CrewRunApp(crew_name="Demo")
|
|
app._flow = SimpleNamespace()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(flow_name="Demo", method_name="a", state={})
|
|
)
|
|
app._current_agent = "Researcher" # an agent ran during method 'a'
|
|
|
|
# Starting a new method clears the previous method's agent.
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(flow_name="Demo", method_name="b", state={})
|
|
)
|
|
assert app._current_agent == ""
|
|
|
|
app._current_agent = "Writer"
|
|
# 'b' ending switches the active method ('a' still active) → agent clears.
|
|
_emit_event(
|
|
MethodExecutionFinishedEvent(
|
|
flow_name="Demo", method_name="b", result=None, state={}
|
|
)
|
|
)
|
|
assert app._current_agent == ""
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
def test_crew_kickoff_does_not_rename_flow_run() -> None:
|
|
# A `call: crew` step must not relabel the flow with the nested crew's name.
|
|
app = CrewRunApp(crew_name="My Flow")
|
|
app._flow = SimpleNamespace()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(CrewKickoffStartedEvent(crew_name="Nested Crew", inputs=None))
|
|
assert app._crew_name == "My Flow"
|
|
assert app._status == "working"
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
def test_crew_kickoff_renames_in_crew_mode() -> None:
|
|
# Regression: crew runs still adopt the crew name from the event.
|
|
app = CrewRunApp(crew_name="Crew")
|
|
app._crew = SimpleNamespace()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(CrewKickoffStartedEvent(crew_name="Real Crew", inputs=None))
|
|
assert app._crew_name == "Real Crew"
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
def test_method_paused_marks_step_paused() -> None:
|
|
app = CrewRunApp(crew_name="Demo")
|
|
app._flow = SimpleNamespace()
|
|
app._subscribe()
|
|
try:
|
|
_emit_event(
|
|
MethodExecutionStartedEvent(flow_name="Demo", method_name="ask", state={})
|
|
)
|
|
_emit_event(
|
|
MethodExecutionPausedEvent(
|
|
flow_name="Demo",
|
|
method_name="ask",
|
|
state={},
|
|
flow_id="flow-1",
|
|
message="Need your input",
|
|
)
|
|
)
|
|
assert app._flow_steps == [
|
|
{"name": "ask", "call_type": None, "status": "paused"}
|
|
]
|
|
assert app._current_method == "ask"
|
|
finally:
|
|
app._unsubscribe()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declarative_flow_runs_on_tui() -> None:
|
|
"""End-to-end: on_mount dispatches _run_flow_worker → flow.kickoff →
|
|
_on_crew_done, and any still-active step is swept to done on completion."""
|
|
kicked: dict[str, object] = {}
|
|
|
|
class FakeFlow:
|
|
name = "Demo Flow"
|
|
|
|
def kickoff(self, inputs=None):
|
|
kicked["inputs"] = inputs
|
|
return "flow result"
|
|
|
|
app = CrewRunApp(crew_name="Demo Flow")
|
|
app._flow = FakeFlow()
|
|
app._flow_inputs = {"topic": "AI"}
|
|
# A step left active (no Finished event) must be swept to done by _on_crew_done.
|
|
app._flow_steps = [
|
|
{"name": "compute", "call_type": "expression", "status": "active"}
|
|
]
|
|
|
|
async with app.run_test() as pilot:
|
|
for _ in range(100):
|
|
await pilot.pause(0.05)
|
|
if app._status == "completed":
|
|
break
|
|
|
|
assert kicked["inputs"] == {"topic": "AI"}
|
|
assert app._status == "completed"
|
|
assert app._final_output == "flow result"
|
|
assert app._crew_result == "flow result"
|
|
assert app._flow_steps[0]["status"] == "done"
|
|
|
|
|
|
def test_view_traces_keybinding_records_telemetry(monkeypatch) -> None:
|
|
"""The `t` binding reaches the action directly, never on_button_pressed."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
notice = Mock()
|
|
monkeypatch.setattr(app, "notify", notice)
|
|
|
|
app.action_view_traces()
|
|
|
|
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
|
|
notice.assert_called_once()
|
|
|
|
|
|
def test_deploy_keybinding_records_telemetry() -> None:
|
|
"""The `d` binding reaches the action directly, never on_button_pressed."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._crew_result = object()
|
|
app._telemetry = Mock()
|
|
app._unsubscribe = lambda: None # type: ignore[method-assign]
|
|
exits: list[object] = []
|
|
app.exit = lambda result: exits.append(result) # type: ignore[method-assign]
|
|
|
|
app.action_deploy_crew()
|
|
|
|
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:deploy")
|
|
assert app._want_deploy is True
|
|
assert exits == [app._crew_result]
|
|
|
|
|
|
def test_view_traces_before_completion_records_nothing() -> None:
|
|
"""A keypress mid-run is a no-op, so it must not be counted as usage."""
|
|
app = CrewRunApp()
|
|
app._status = "running"
|
|
app._telemetry = Mock()
|
|
|
|
app.action_view_traces()
|
|
|
|
app._telemetry.feature_usage_span.assert_not_called()
|
|
|
|
|
|
def test_deploy_before_completion_records_nothing() -> None:
|
|
app = CrewRunApp()
|
|
app._status = "running"
|
|
app._telemetry = Mock()
|
|
|
|
app.action_deploy_crew()
|
|
|
|
app._telemetry.feature_usage_span.assert_not_called()
|
|
assert app._want_deploy is False
|
|
|
|
|
|
def test_button_press_records_exactly_once(monkeypatch) -> None:
|
|
"""Recording moved into the action; the button must not double-count."""
|
|
app = CrewRunApp()
|
|
app._status = "completed"
|
|
app._telemetry = Mock()
|
|
monkeypatch.setattr(app, "notify", Mock())
|
|
|
|
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
|
|
|
|
assert app._telemetry.feature_usage_span.call_count == 1
|
|
|
|
|
|
def test_try_parse_structured_rejects_non_serializable_literals() -> None:
|
|
"""ast.literal_eval("[...]") is a valid [Ellipsis] list but cannot be JSON-encoded."""
|
|
assert _try_parse_structured("[...]") is None
|
|
assert _try_parse_structured("{'a': ...}") is None
|
|
assert _try_parse_structured("[1+2j]") is None
|
|
|
|
|
|
def test_try_parse_structured_still_accepts_serializable_values() -> None:
|
|
assert _try_parse_structured('{"a": 1}') == {"a": 1}
|
|
assert _try_parse_structured("['x', 'y']") == ["x", "y"]
|
|
assert _try_parse_structured("{'a': 1}") == {"a": 1}
|
|
|
|
|
|
def test_format_json_in_text_survives_literal_ellipsis() -> None:
|
|
"""A streamed [...] must render as-is instead of crashing the TUI (issue #7434)."""
|
|
assert _format_json_in_text("pandas,[...]") == "pandas,[...]"
|
|
|
|
|
|
def test_try_parse_structured_rejects_deeply_nested_input() -> None:
|
|
"""A nesting depth no JSON backend can walk must parse as nothing, not recurse."""
|
|
deep = "[" * 100_000 + "]" * 100_000
|
|
assert _try_parse_structured(deep) is None
|
|
|
|
|
|
def test_format_json_in_text_survives_deep_nesting() -> None:
|
|
"""The render path must contain the failure instead of losing the TUI update."""
|
|
deep = "data: " + "[" * 4000 + "]" * 4000
|
|
assert isinstance(_format_json_in_text(deep), str)
|
|
|
|
|
|
def test_format_json_in_text_still_pretty_prints_valid_json() -> None:
|
|
assert _format_json_in_text('data: {"a": 1} and [...]') == (
|
|
"data: " + '{\n "a": 1\n}' + " and [...]"
|
|
)
|