Files
EvoScientist-Multi/tests/test_cli_channel_slash.py

685 lines
22 KiB
Python

"""Tests for ``dispatch_channel_slash_command`` in ``cli/channel.py``.
Regression coverage for issue #181 — slash commands arriving over a
messaging channel must route through ``cmd_manager`` instead of being
fed to the LLM as a plain prompt, on every UI surface (Rich CLI, TUI,
headless ``serve``).
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from EvoScientist.cli.channel import (
ChannelMessage,
)
from EvoScientist.cli.channel import (
dispatch_channel_slash_command as _dispatch_channel_slash_command,
)
from tests.fakes import FakeGraphGateway, FakeThreadStore
def _thread_store() -> FakeThreadStore:
return FakeThreadStore()
def dispatch_channel_slash_command(*args, **kwargs):
kwargs.setdefault("graph_gateway", FakeGraphGateway())
return _dispatch_channel_slash_command(*args, **kwargs)
def _make_msg(
content: str = "/evoskills core", msg_id: str = "msg-1"
) -> ChannelMessage:
return ChannelMessage(
msg_id=msg_id,
content=content,
sender="+44XXXXXX",
channel_type="imessage",
metadata={},
channel_ref=None,
bus_ref=None,
chat_id="+44XXXXXX",
message_id="ts-1",
)
async def test_non_slash_returns_false():
"""Plain text messages must fall through to the agent."""
msg = _make_msg(content="hello agent")
append = MagicMock()
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is False
append.assert_not_called()
async def test_unresolved_slash_returns_unknown_command_response():
"""Unknown slash commands must never fall through to the agent."""
msg = _make_msg(content="/unknown-cmd")
append = MagicMock()
with patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=None,
):
with patch("EvoScientist.cli.channel._set_channel_response") as mock_response:
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is True
mock_response.assert_called_once_with(
"msg-1",
"Unknown command: /unknown-cmd\nType /help to see available commands.",
)
async def test_successful_slash_execution_sets_response_and_breadcrumb():
"""Known slash command: cmd_manager.execute ran, helper returns True,
sends a confirmation to the channel user, and appends a local log line."""
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
append = MagicMock()
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["core"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(return_value=True),
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
)
assert handled is True
mock_execute.assert_awaited_once()
mock_set_resp.assert_called_once()
assert mock_set_resp.call_args[0][0] == "msg-1"
assert "Command executed" in mock_set_resp.call_args[0][1]
breadcrumbs = [call.args[0] for call in append.call_args_list]
assert any("Executed command from" in t for t in breadcrumbs)
async def test_slash_with_flushed_output_suppresses_executed_ack():
"""When the command's own output already reached the channel, the
response is the already-sent sentinel, not a second ack message."""
from EvoScientist.cli.channel import COMMAND_OUTPUT_ALREADY_SENT
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
async def _execute_with_output(content, ctx):
ctx.ui.sent_to_channel = True
return True
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["core"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(side_effect=_execute_with_output),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=MagicMock(),
)
assert handled is True
mock_set_resp.assert_called_once_with("msg-1", COMMAND_OUTPUT_ALREADY_SENT)
async def test_real_help_command_publishes_help_once():
"""Exercise the registered /help command rather than a mocked command."""
from EvoScientist.cli.channel import COMMAND_OUTPUT_ALREADY_SENT
bus = SimpleNamespace(publish_outbound=AsyncMock())
msg = _make_msg(content="/help")
msg.channel_type = "telegram"
msg.bus_ref = bus
with (
patch("EvoScientist.cli.channel._bus_loop", asyncio.get_running_loop()),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
)
await asyncio.sleep(0)
await asyncio.sleep(0)
assert handled is True
bus.publish_outbound.assert_awaited_once()
outbound = bus.publish_outbound.await_args.args[0]
assert outbound.channel == "telegram"
assert "Available commands:" in outbound.content
assert "/help" in outbound.content
mock_set_resp.assert_called_once_with("msg-1", COMMAND_OUTPUT_ALREADY_SENT)
async def test_slash_dispatch_passes_graph_gateway_to_command_context():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
append = MagicMock()
graph_gateway = FakeGraphGateway(thread_store=_thread_store())
captured = {}
async def _execute(_content, ctx):
captured["graph_gateway"] = ctx.graph_gateway
return True
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["core"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(side_effect=_execute),
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
graph_gateway=graph_gateway,
)
assert handled is True
assert captured["graph_gateway"] is graph_gateway
async def test_needs_agent_awaits_loader_and_passes_result():
"""Commands with needs_agent=True must await the loader and the
resulting agent must flow through the CommandContext."""
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = True
append = MagicMock()
await_called = MagicMock()
async def _await_ready():
await_called()
return "ready-agent"
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(return_value=True),
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
)
assert handled is True
await_called.assert_called_once()
await_args = mock_execute.await_args
assert await_args is not None
ctx_arg = await_args.args[1]
assert ctx_arg.agent == "ready-agent"
async def test_await_agent_ready_failure_sets_error_response():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = True
append = MagicMock()
async def _await_ready():
raise RuntimeError("agent blew up")
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
)
assert handled is True
mock_set_resp.assert_called_once()
resp_text = mock_set_resp.call_args[0][1]
assert "Command error" in resp_text
assert "agent blew up" in resp_text
async def test_cmd_manager_raises_returns_true_with_error():
"""If cmd_manager.execute raises past its own try/except, the helper
must absorb it, return True, and report via _set_channel_response."""
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
append = MagicMock()
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(side_effect=RuntimeError("boom")),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is True
mock_set_resp.assert_called_once()
resp_text = mock_set_resp.call_args[0][1]
assert "Command error" in resp_text
assert "boom" in resp_text
async def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
"""After a successful slash execute, the on_cmd_completed hook must
be awaited with (ctx, original_agent, cmd) so Rich CLI can adopt an
``/model`` agent swap and refresh status for state-mutating commands."""
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
fake_cmd.name = "/model"
append = MagicMock()
captured: dict[str, object] = {}
async def _fake_execute(command_str, ctx):
# Simulate /model swapping ctx.agent to a new handle.
ctx.agent = "swapped-agent"
return True
async def _on_completed(ctx, original_agent, cmd):
captured["ctx_agent"] = ctx.agent
captured["original_agent"] = original_agent
captured["cmd_name"] = getattr(cmd, "name", None)
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=_fake_execute,
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent="original-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
on_cmd_completed=_on_completed,
)
assert handled is True
assert captured["ctx_agent"] == "swapped-agent"
assert captured["original_agent"] == "original-agent"
assert captured["cmd_name"] == "/model"
async def test_on_cmd_completed_receives_cmd_for_new_and_compact():
"""``/new`` / ``/compact`` invoked via channel must flow the cmd into
the hook so the callback can still refresh status when the agent
didn't swap — mirrors REPL ``interactive.py:1027-1030``."""
for cmd_name in ("/new", "/compact"):
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
fake_cmd.name = cmd_name
captured: dict[str, str | None] = {}
async def _on_completed(ctx, original_agent, cmd, _captured=captured):
_captured["cmd_name"] = getattr(cmd, "name", None)
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(return_value=True),
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(content=cmd_name),
agent="same-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_on_completed,
)
assert captured["cmd_name"] == cmd_name, cmd_name
async def test_on_cmd_completed_skipped_on_fall_through_and_error():
"""The hook must NOT fire for unresolved slash, non-slash text, or
when cmd_manager.execute raised."""
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
completed = MagicMock()
async def _noop(*args, **kwargs):
completed(*args, **kwargs)
# Non-slash
with patch("EvoScientist.cli.channel._set_channel_response"):
await dispatch_channel_slash_command(
_make_msg(content="hi"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
# Unresolved slash
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=None,
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(content="/nope"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
# Execute raises
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(side_effect=RuntimeError("boom")),
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
completed.assert_not_called()
async def test_command_error_skips_completion_hook_and_reports_error():
"""A command caught as failed by CommandManager must not look successful."""
msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
completed = AsyncMock()
async def _execute(_command, ctx):
ctx.command_error = "workspace conflict"
ctx.ui.append_system("Error executing /resume: workspace conflict", style="red")
await ctx.ui.flush()
return True
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["abc"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
side_effect=_execute,
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
)
assert handled is True
completed.assert_not_awaited()
mock_set_resp.assert_called_once_with("msg-1", "Command error: workspace conflict")
async def test_command_error_with_flushed_output_suppresses_second_error():
"""CommandManager already flushes its error text to channel UIs."""
from EvoScientist.cli.channel import COMMAND_OUTPUT_ALREADY_SENT
msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
async def _execute(_command, ctx):
ctx.command_error = "workspace conflict"
ctx.ui.sent_to_channel = True
return True
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["abc"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
side_effect=_execute,
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
)
assert handled is True
mock_set_resp.assert_called_once_with("msg-1", COMMAND_OUTPUT_ALREADY_SENT)
async def test_empty_command_error_still_reports_error():
"""An empty string error is still a command failure sentinel."""
msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
completed = AsyncMock()
async def _execute(_command, ctx):
ctx.command_error = ""
return True
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, ["abc"]),
),
patch(
"EvoScientist.commands.manager.manager.execute",
side_effect=_execute,
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
)
assert handled is True
completed.assert_not_awaited()
mock_set_resp.assert_called_once_with("msg-1", "Command error: (no details)")
async def test_on_cmd_completed_exception_is_absorbed():
"""A raising hook must NOT prevent the channel response from being set."""
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
async def _boom(ctx, original_agent, cmd):
raise RuntimeError("hook blew up")
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(return_value=True),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent="orig",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_boom,
)
assert handled is True
mock_set_resp.assert_called_once()
assert "Command executed" in mock_set_resp.call_args[0][1]
async def test_top_level_exception_is_absorbed():
"""Last-ditch safety net: if anything inside the dispatch pipeline
raises unexpectedly (lazy import failure, ChannelCommandUI ctor,
terminal I/O from append_system, ...), the helper must NOT
propagate — it sets an error response and returns True so the
caller's polling loop stays alive and doesn't fall through to the
agent streaming path."""
msg = _make_msg()
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
side_effect=RuntimeError("exploded during resolve"),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
)
assert handled is True
mock_set_resp.assert_called_once()
resp_text = mock_set_resp.call_args[0][1]
assert "Command error" in resp_text
assert "exploded during resolve" in resp_text
async def test_cmd_execute_returning_false_falls_through():
"""When cmd_manager.execute returns False (empty/unparseable input),
the helper must return False so the caller falls through to the agent."""
msg = _make_msg(content="/")
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
append = MagicMock()
with (
patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=(fake_cmd, []),
),
patch(
"EvoScientist.commands.manager.manager.execute",
new=AsyncMock(return_value=False),
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is False
mock_set_resp.assert_not_called()