diff --git a/lib/crewai/src/crewai/core/providers/human_input.py b/lib/crewai/src/crewai/core/providers/human_input.py index b82e408d9d..d70fb85735 100644 --- a/lib/crewai/src/crewai/core/providers/human_input.py +++ b/lib/crewai/src/crewai/core/providers/human_input.py @@ -179,6 +179,7 @@ def handle_feedback( Returns: The final answer after feedback processing. """ + self._ensure_result_displayed(formatted_answer, context) feedback = self._prompt_input(context.crew) if context._is_training_mode(): @@ -200,6 +201,7 @@ async def handle_feedback_async( Returns: The final answer after feedback processing. """ + self._ensure_result_displayed(formatted_answer, context) feedback = await self._prompt_input_async(context.crew) if context._is_training_mode(): @@ -259,6 +261,7 @@ def _handle_regular_feedback( else: context.messages.append(context._format_feedback_message(feedback)) answer = context._invoke_loop() + self._ensure_result_displayed(answer, context) feedback = self._prompt_input(context.crew) return answer @@ -311,10 +314,39 @@ async def _handle_regular_feedback_async( else: context.messages.append(context._format_feedback_message(feedback)) answer = await context._ainvoke_loop() + self._ensure_result_displayed(answer, context) feedback = await self._prompt_input_async(context.crew) return answer + @staticmethod + def _ensure_result_displayed( + answer: AgentFinish, + context: ExecutorContext, + ) -> None: + """Render the agent's final result before prompting for feedback. + + The feedback panel asks the operator to review "the Final Result + above", but that result is only rendered when the agent or crew runs + verbose. With ``human_input=True`` and verbose off, the operator would + otherwise be asked to approve output that was never shown (#6072). When + verbose, the result was already displayed, so this is a no-op to avoid + a duplicate panel. + + Args: + answer: The agent answer the operator is being asked to review. + context: Executor context exposing the agent and crew. + """ + agent = getattr(context, "agent", None) + crew = getattr(context, "crew", None) + if getattr(agent, "verbose", False) or getattr(crew, "verbose", False): + return + + from crewai.events.event_listener import event_listener + + role = getattr(agent, "role", "") or "" + event_listener.formatter.handle_agent_logs_execution(role, answer, verbose=True) + @staticmethod def _prompt_input(crew: Crew | None) -> str: """Show rich panel and prompt for input. diff --git a/lib/crewai/tests/core/__init__.py b/lib/crewai/tests/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai/tests/core/providers/__init__.py b/lib/crewai/tests/core/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai/tests/core/providers/test_human_input.py b/lib/crewai/tests/core/providers/test_human_input.py new file mode 100644 index 0000000000..6f5c55a93c --- /dev/null +++ b/lib/crewai/tests/core/providers/test_human_input.py @@ -0,0 +1,160 @@ +"""Regression tests for SyncHumanInputProvider result display (#6072).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import crewai.core.providers.human_input as human_input_module +from crewai.agents.parser import AgentFinish +from crewai.core.providers.human_input import SyncHumanInputProvider +from crewai.events.event_listener import event_listener + + +class _FakeAgent: + def __init__(self, verbose: bool) -> None: + self.verbose = verbose + self.role = "Researcher" + + +class _FakeCrew: + def __init__(self, verbose: bool) -> None: + self.verbose = verbose + self._train = False + + +class _FakeContext: + """Minimal ExecutorContext stand-in for the accept-on-first-round path.""" + + def __init__(self, agent: _FakeAgent, crew: _FakeCrew) -> None: + self.agent = agent + self.crew = crew + self.ask_for_human_input = True + self.messages: list = [] + + def _is_training_mode(self) -> bool: + return False + + +def _answer() -> AgentFinish: + return AgentFinish(thought="", output="THE FINAL RESULT", text="THE FINAL RESULT") + + +class TestResultDisplayBeforeFeedback: + def test_result_shown_before_prompt_when_not_verbose(self) -> None: + """Non-verbose + human_input must render the result before prompting, + otherwise the operator is asked to approve output they never saw. + """ + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=False), _FakeCrew(verbose=False)) + answer = _answer() + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch("builtins.input", return_value=""), + ): + result = provider.handle_feedback(answer, context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_called_once_with( + "Researcher", answer, verbose=True + ) + # The result panel must be rendered BEFORE the feedback prompt panel. + method_calls = [c[0] for c in formatter.mock_calls] + render_idx = method_calls.index("handle_agent_logs_execution") + prompt_idx = method_calls.index("console.print") + assert render_idx < prompt_idx + assert result is answer + + def test_result_not_reshown_when_verbose(self) -> None: + """When verbose, the executor already rendered the result, so the + provider must not re-render it (avoid a duplicate panel). + """ + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=False), _FakeCrew(verbose=True)) + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch("builtins.input", return_value=""), + ): + provider.handle_feedback(_answer(), context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_not_called() + + def test_result_not_reshown_when_agent_verbose(self) -> None: + """Agent-verbose (crew non-verbose) is also a no-op: the executor + already rendered the result via the verbose log path. + """ + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=True), _FakeCrew(verbose=False)) + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch("builtins.input", return_value=""), + ): + provider.handle_feedback(_answer(), context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_not_called() + + +class TestResultDisplayBeforeFeedbackAsync: + """The async feedback path must show the result before prompting too.""" + + @pytest.mark.asyncio + async def test_result_shown_before_prompt_when_not_verbose(self) -> None: + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=False), _FakeCrew(verbose=False)) + answer = _answer() + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch.object( + human_input_module, "_async_readline", new=AsyncMock(return_value="") + ), + ): + result = await provider.handle_feedback_async(answer, context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_called_once_with( + "Researcher", answer, verbose=True + ) + method_calls = [c[0] for c in formatter.mock_calls] + render_idx = method_calls.index("handle_agent_logs_execution") + prompt_idx = method_calls.index("console.print") + assert render_idx < prompt_idx + assert result is answer + + @pytest.mark.asyncio + async def test_result_not_reshown_when_verbose(self) -> None: + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=False), _FakeCrew(verbose=True)) + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch.object( + human_input_module, "_async_readline", new=AsyncMock(return_value="") + ), + ): + await provider.handle_feedback_async(_answer(), context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_not_called() + + @pytest.mark.asyncio + async def test_result_not_reshown_when_agent_verbose(self) -> None: + provider = SyncHumanInputProvider() + context = _FakeContext(_FakeAgent(verbose=True), _FakeCrew(verbose=False)) + + formatter = MagicMock() + with ( + patch.object(event_listener, "formatter", formatter), + patch.object( + human_input_module, "_async_readline", new=AsyncMock(return_value="") + ), + ): + await provider.handle_feedback_async(_answer(), context) # type: ignore[arg-type] + + formatter.handle_agent_logs_execution.assert_not_called()