Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/crewai/src/crewai/core/providers/human_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Empty file.
Empty file.
160 changes: 160 additions & 0 deletions lib/crewai/tests/core/providers/test_human_input.py
Original file line number Diff line number Diff line change
@@ -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()