mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-19 09:35:55 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
* feat(tracing): port enterprise event sessions to OSS * fix(tracing): address review findings and verify concurrent exports * fix(tracing): keep redactor ownership in enterprise integrations * test(tracing): isolate intentional failures from cleanup assertions
235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
"""The terminal UI resolves local-session consent without the legacy uploader."""
|
|
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
from crewai_cli.crew_run_tui import CrewRunApp, TraceConsentScreen
|
|
import pytest
|
|
from textual.events import Mount
|
|
from textual.widgets import Button
|
|
|
|
|
|
class ConsentApp(CrewRunApp):
|
|
def on_mount(self, event: Mount | None = None) -> None:
|
|
"""Show the real UI without starting a crew or a refresh worker."""
|
|
if event is not None:
|
|
event.prevent_default()
|
|
|
|
|
|
async def wait_for_consent(app, pilot):
|
|
for _ in range(50):
|
|
await pilot.pause(0.01)
|
|
if isinstance(app.screen, TraceConsentScreen):
|
|
return app.screen
|
|
raise AssertionError("The execution worker did not open the consent prompt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("action", "approved"),
|
|
[
|
|
("y", True),
|
|
("n", False),
|
|
("escape", False),
|
|
("click-yes", True),
|
|
("click-no", False),
|
|
],
|
|
)
|
|
async def test_session_consent_buttons_and_keys_resolve_execution_worker(
|
|
monkeypatch, action, approved
|
|
):
|
|
app = ConsentApp()
|
|
legacy = Mock(side_effect=AssertionError("Session consent invoked legacy upload"))
|
|
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
result = asyncio.create_task(asyncio.to_thread(app._request_trace_consent))
|
|
screen = await wait_for_consent(app, pilot)
|
|
assert str(screen.query_one("#btn-consent-yes", Button).label) == "Share Trace"
|
|
content = screen._build_content().plain
|
|
assert "stored locally" in content and "Sharing uploads it" in content
|
|
assert "prompts, inputs, outputs, and tool calls" in content
|
|
assert "20 seconds" in content
|
|
assert not result.done()
|
|
if action.startswith("click-"):
|
|
assert await pilot.click(f"#btn-consent-{action.removeprefix('click-')}")
|
|
else:
|
|
await pilot.press(action)
|
|
assert await asyncio.wait_for(result, timeout=5) is approved
|
|
await pilot.pause()
|
|
assert not isinstance(app.screen, TraceConsentScreen)
|
|
legacy.assert_not_called()
|
|
assert app._trace_consent_pending is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consent_timeout_returns_false_and_dismisses_modal(monkeypatch):
|
|
app = ConsentApp()
|
|
waits = []
|
|
event = threading.Event()
|
|
|
|
class ExpiredEvent:
|
|
def set(self):
|
|
event.set()
|
|
|
|
def wait(self, *, timeout):
|
|
waits.append(timeout)
|
|
return False
|
|
|
|
# Replace only this module's event factory, leaving Textual's threads alone.
|
|
monkeypatch.setattr(
|
|
"crewai_cli.crew_run_tui.threading", SimpleNamespace(Event=ExpiredEvent)
|
|
)
|
|
legacy = Mock(side_effect=AssertionError("Timeout invoked legacy upload"))
|
|
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
result = await asyncio.wait_for(
|
|
asyncio.to_thread(app._request_trace_consent), timeout=5
|
|
)
|
|
assert result is False
|
|
await pilot.pause()
|
|
assert not isinstance(app.screen, TraceConsentScreen)
|
|
assert waits == [20]
|
|
assert app._trace_consent_pending is None
|
|
legacy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quit_rejects_pending_consent_and_releases_worker(monkeypatch):
|
|
app = ConsentApp()
|
|
legacy = Mock(side_effect=AssertionError("Quit invoked legacy upload"))
|
|
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
result = asyncio.create_task(asyncio.to_thread(app._request_trace_consent))
|
|
await wait_for_consent(app, pilot)
|
|
await app.action_quit()
|
|
assert await asyncio.wait_for(result, timeout=5) is False
|
|
assert app._trace_consent_pending is None
|
|
legacy.assert_not_called()
|
|
|
|
|
|
def test_consent_callback_refuses_nonboolean_screen_results(monkeypatch):
|
|
app = CrewRunApp()
|
|
monkeypatch.setattr(app, "call_from_thread", lambda callback: callback())
|
|
monkeypatch.setattr(app, "push_screen", lambda screen, callback: callback("yes"))
|
|
monkeypatch.setattr(app, "_dismiss_consent_modal", lambda: None)
|
|
assert app._request_trace_consent() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_quit_can_ask_for_deferred_session_consent(monkeypatch):
|
|
app = ConsentApp(conversational=True)
|
|
decisions = []
|
|
|
|
class DeferredFlow:
|
|
defer_trace_finalization = True
|
|
|
|
def finalize_session_traces(self):
|
|
decisions.append(app._request_trace_consent())
|
|
|
|
app._flow = DeferredFlow()
|
|
app._conversation_previous_defer_trace_finalization = False
|
|
legacy = Mock(side_effect=AssertionError("Conversation quit invoked legacy upload"))
|
|
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
quit_task = asyncio.create_task(app.action_quit())
|
|
await wait_for_consent(app, pilot)
|
|
await pilot.press("n")
|
|
await asyncio.wait_for(quit_task, timeout=5)
|
|
assert decisions == [False]
|
|
assert app._flow.defer_trace_finalization is False
|
|
assert app._trace_consent_pending is None
|
|
legacy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("prior_turn", [False, True])
|
|
async def test_quit_during_turn_discards_trace_after_worker_finishes(
|
|
monkeypatch, prior_turn
|
|
):
|
|
entered, release, finalized = (
|
|
threading.Event(),
|
|
threading.Event(),
|
|
threading.Event(),
|
|
)
|
|
app = ConsentApp(conversational=True)
|
|
completions = []
|
|
|
|
class RunningFlow:
|
|
defer_trace_finalization = True
|
|
trace = "prior turn" if prior_turn else None
|
|
|
|
def handle_turn(self, message):
|
|
entered.set()
|
|
release.wait(timeout=5)
|
|
# A first kickoff stores its deferred lifetime just before returning.
|
|
self.trace = "completed turn"
|
|
return "done"
|
|
|
|
def finalize_session_traces(self, *, discard=False):
|
|
completions.append(discard)
|
|
self.trace = None
|
|
finalized.set()
|
|
|
|
app._flow = RunningFlow()
|
|
app._conversation_previous_defer_trace_finalization = False
|
|
app._conversation_turn_in_progress = True
|
|
app._status = "working"
|
|
prompt = Mock(side_effect=AssertionError("Cancelled execution asked for consent"))
|
|
monkeypatch.setattr(app, "push_screen", prompt)
|
|
async with app.run_test(size=(100, 40)):
|
|
app._run_conversation_turn_worker("hello")
|
|
assert await asyncio.to_thread(entered.wait, 5)
|
|
try:
|
|
await app.action_quit()
|
|
assert completions == []
|
|
assert app._request_trace_consent() is False
|
|
finally:
|
|
release.set()
|
|
assert await asyncio.to_thread(finalized.wait, 5)
|
|
assert completions == [True]
|
|
assert app._flow.trace is None
|
|
assert app._flow.defer_trace_finalization is False
|
|
prompt.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("kind", ["crew", "flow"])
|
|
async def test_quit_during_execution_rejects_late_consent(monkeypatch, kind):
|
|
entered, release = threading.Event(), threading.Event()
|
|
app = ConsentApp()
|
|
decisions = []
|
|
|
|
class RunningExecution:
|
|
def kickoff(self, inputs=None):
|
|
entered.set()
|
|
release.wait(timeout=5)
|
|
decisions.append(app._request_trace_consent())
|
|
return "done"
|
|
|
|
setattr(app, f"_{kind}", RunningExecution())
|
|
app._status = "working"
|
|
prompt = Mock(side_effect=AssertionError("Cancelled execution asked for consent"))
|
|
completed = Mock()
|
|
failed = Mock()
|
|
monkeypatch.setattr(app, "push_screen", prompt)
|
|
monkeypatch.setattr(app, "_on_crew_done", completed)
|
|
monkeypatch.setattr(app, "_on_crew_failed", failed)
|
|
async with app.run_test(size=(100, 40)):
|
|
# Own the worker thread so the test awaits its body even after Textual
|
|
# cancels its worker wrappers on exit (running threads are not stopped).
|
|
run_worker = getattr(app, f"_run_{kind}_worker").__wrapped__
|
|
worker = asyncio.create_task(asyncio.to_thread(run_worker, app))
|
|
assert await asyncio.to_thread(entered.wait, 5)
|
|
try:
|
|
await app.action_quit()
|
|
finally:
|
|
release.set()
|
|
await asyncio.wait_for(worker, timeout=5)
|
|
assert decisions == [False]
|
|
assert app._trace_consent_pending is None
|
|
prompt.assert_not_called()
|
|
completed.assert_not_called()
|
|
failed.assert_not_called()
|