ab1a6b0062
* feat: detect expert skills by AGENTS.md presence * feat: let the orchestrator choose the expert dispatch tool * refactor: drop per-skill expert dispatch classification * chore: remove duplicate TestSkillManager test classes left by rebase * fix: clear legacy actor fields when AGENTS.md declares the expert * refactor: move the expert prompt into ActiveTeamMiddleware
259 lines
9.9 KiB
Python
259 lines
9.9 KiB
Python
"""Unit tests for /experts and /expert slash commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.commands.base import ChannelRuntime, CommandContext
|
|
from EvoScientist.commands.implementation.experts import (
|
|
ExpertCommand,
|
|
ExpertsCommand,
|
|
invalidate_experts_cache,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _bust_experts_cache_between_tests():
|
|
"""The dispatchable-experts cache in ``experts.py`` is module-level; without
|
|
resetting it, a test that patches ``list_expert_skills`` sees the previous
|
|
test's fakes.
|
|
"""
|
|
invalidate_experts_cache()
|
|
yield
|
|
invalidate_experts_cache()
|
|
|
|
|
|
class _FakeUI:
|
|
"""Minimal CommandUI capturing outputs for assertion."""
|
|
|
|
supports_interactive = False
|
|
|
|
def __init__(self) -> None:
|
|
self.lines: list[tuple[str, str]] = []
|
|
self.mounted: list[Any] = []
|
|
|
|
def append_system(self, text: str, style: str = "dim") -> None:
|
|
self.lines.append((text, style))
|
|
|
|
def mount_renderable(self, renderable: Any) -> None:
|
|
self.mounted.append(renderable)
|
|
|
|
|
|
@dataclass
|
|
class _FakeSkillInfo:
|
|
"""Enough of ``SkillInfo`` for the commands to render."""
|
|
|
|
name: str
|
|
description: str = ""
|
|
role: str = ""
|
|
type: str = "expert"
|
|
tags: list[str] = field(default_factory=list)
|
|
source: str = "builtin"
|
|
# Non-empty by default so the fake passes the empty-body filter in
|
|
# ``list_dispatchable_experts``. Tests that specifically want to
|
|
# exercise the empty-body reject path pass ``body=""``.
|
|
body: str = "persona"
|
|
# Legacy-frontmatter expert by default: ``expert_prompt_body`` reads
|
|
# ``body`` for these. Set ``expert_source="agents_md"`` plus
|
|
# ``agents_body`` to fake an expert on the current contract.
|
|
expert_source: str = "frontmatter"
|
|
agents_body: str = ""
|
|
|
|
|
|
def _make_ctx(active_teams: list[str] | None = None) -> tuple[CommandContext, _FakeUI]:
|
|
ui = _FakeUI()
|
|
runtime = ChannelRuntime()
|
|
if active_teams:
|
|
runtime.active_teams = list(active_teams)
|
|
ctx = CommandContext(
|
|
agent=None,
|
|
thread_id="t1",
|
|
ui=ui,
|
|
channel_runtime=runtime,
|
|
)
|
|
return ctx, ui
|
|
|
|
|
|
class TestExpertsList:
|
|
async def test_lists_installed_experts_in_table(self):
|
|
ctx, ui = _make_ctx()
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[
|
|
_FakeSkillInfo(
|
|
name="idea-brainstorm",
|
|
role="Research idea brainstormer",
|
|
),
|
|
],
|
|
):
|
|
await ExpertsCommand().execute(ctx, args=[])
|
|
# A Rich Table was mounted, and the no-experts-invited hint appeared.
|
|
assert len(ui.mounted) == 1
|
|
assert any("No experts invited" in text for text, _ in ui.lines)
|
|
|
|
async def test_empty_list_prints_help_hint(self):
|
|
ctx, ui = _make_ctx()
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[],
|
|
):
|
|
await ExpertsCommand().execute(ctx, args=[])
|
|
assert any("No expert skills installed" in text for text, _ in ui.lines)
|
|
assert not ui.mounted
|
|
|
|
async def test_active_expert_marked_in_table(self):
|
|
ctx, ui = _make_ctx(active_teams=["idea-brainstorm"])
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[
|
|
_FakeSkillInfo(
|
|
name="idea-brainstorm",
|
|
role="Research idea brainstormer",
|
|
),
|
|
],
|
|
):
|
|
await ExpertsCommand().execute(ctx, args=[])
|
|
assert any("Active: idea-brainstorm" in text for text, _ in ui.lines)
|
|
|
|
|
|
class TestExpertToggle:
|
|
async def test_missing_arg_prints_usage(self):
|
|
ctx, ui = _make_ctx()
|
|
await ExpertCommand().execute(ctx, args=[])
|
|
assert any("Usage:" in text for text, _ in ui.lines)
|
|
|
|
async def test_unknown_expert_errors(self):
|
|
ctx, ui = _make_ctx()
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
|
|
):
|
|
await ExpertCommand().execute(ctx, args=["not-an-expert"])
|
|
assert any(
|
|
"No expert skill named 'not-an-expert'" in text for text, _ in ui.lines
|
|
)
|
|
assert ctx.channel_runtime.active_teams == []
|
|
|
|
async def test_async_outage_does_not_block_invite(self):
|
|
"""An async outage must not make an installed expert un-invitable.
|
|
|
|
The old per-skill classification refused here whenever
|
|
``enable_async_subagents`` was off or langgraph dev was unreachable.
|
|
Every expert now keeps its in-turn reach, so the outage degrades the
|
|
reach rather than removing the expert.
|
|
"""
|
|
ctx, _ui = _make_ctx()
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_FakeSkillInfo(name="literature-review")],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
),
|
|
):
|
|
await ExpertCommand().execute(ctx, args=["literature-review"])
|
|
assert ctx.channel_runtime.active_teams == ["literature-review"]
|
|
|
|
async def test_invite_adds_to_active_teams(self):
|
|
ctx, ui = _make_ctx()
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
|
|
):
|
|
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
|
|
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
|
|
assert any("Invited expert: idea-brainstorm" in text for text, _ in ui.lines)
|
|
|
|
async def test_toggle_dismisses_when_already_invited(self):
|
|
ctx, ui = _make_ctx(active_teams=["idea-brainstorm"])
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
|
|
):
|
|
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
|
|
assert ctx.channel_runtime.active_teams == []
|
|
assert any("Dismissed expert: idea-brainstorm" in text for text, _ in ui.lines)
|
|
|
|
async def test_clear_dismisses_all(self):
|
|
ctx, ui = _make_ctx(active_teams=["idea-brainstorm", "second"])
|
|
await ExpertCommand().execute(ctx, args=["clear"])
|
|
assert ctx.channel_runtime.active_teams == []
|
|
assert any(
|
|
"Dismissed experts: idea-brainstorm, second" in text for text, _ in ui.lines
|
|
)
|
|
|
|
async def test_clear_on_empty_list_reports_nothing_to_do(self):
|
|
ctx, ui = _make_ctx()
|
|
await ExpertCommand().execute(ctx, args=["clear"])
|
|
assert ctx.channel_runtime.active_teams == []
|
|
assert any("No experts invited" in text for text, _ in ui.lines)
|
|
|
|
async def test_no_channel_runtime_prints_warning(self):
|
|
ui = _FakeUI()
|
|
ctx = CommandContext(agent=None, thread_id="t1", ui=ui, channel_runtime=None)
|
|
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
|
|
assert any("/expert requires a session runtime" in text for text, _ in ui.lines)
|
|
|
|
|
|
class TestExpertCompletions:
|
|
"""``ExpertCommand.get_completions`` mixes dynamic expert names with the
|
|
static ``clear`` subcommand. Regression coverage for the three fixes on
|
|
PR #371: exact-match suppression, past-first-arg guard, and
|
|
case-insensitive matching.
|
|
"""
|
|
|
|
def _patched_experts(self, *names: str):
|
|
# Patch ``list_dispatchable_experts`` directly (not the underlying
|
|
# ``list_expert_skills``) so the test does not depend on the shipped
|
|
# yaml sub-agent set — the reserved-name filter would otherwise
|
|
# silently reject a fake whose name collides with a future yaml
|
|
# sub-agent.
|
|
return patch(
|
|
"EvoScientist.subagents.expert_container.list_dispatchable_experts",
|
|
return_value=[_FakeSkillInfo(name=n) for n in names],
|
|
)
|
|
|
|
def test_lists_installed_experts_and_clear(self):
|
|
cmd = ExpertCommand()
|
|
with self._patched_experts("smoke-test-sync-expert", "smoke-test-alt-expert"):
|
|
completions = cmd.get_completions([""])
|
|
names = {name for name, _ in completions}
|
|
assert names == {"smoke-test-sync-expert", "smoke-test-alt-expert", "clear"}
|
|
|
|
def test_case_insensitive_prefix_match(self):
|
|
# Skill dir names sometimes have uppercase; completion typed
|
|
# lowercase must still surface them.
|
|
cmd = ExpertCommand()
|
|
with self._patched_experts("Smoke-Test-Case-Expert", "smoke-test-sync-expert"):
|
|
completions = cmd.get_completions(["smoke-test-c"])
|
|
names = {name for name, _ in completions}
|
|
assert names == {"Smoke-Test-Case-Expert"}
|
|
|
|
def test_exact_match_hides_popup_same_case(self):
|
|
cmd = ExpertCommand()
|
|
with self._patched_experts("smoke-test-sync-expert"):
|
|
completions = cmd.get_completions(["smoke-test-sync-expert"])
|
|
assert completions == []
|
|
|
|
def test_exact_match_hides_popup_different_case(self):
|
|
# Case-insensitive exact-match suppression: typing the name in a
|
|
# different case than the skill dir still fully completes it and
|
|
# hides the popup.
|
|
cmd = ExpertCommand()
|
|
with self._patched_experts("Smoke-Test-Case-Expert"):
|
|
completions = cmd.get_completions(["smoke-test-case-expert"])
|
|
assert completions == []
|
|
|
|
def test_past_first_arg_returns_empty(self):
|
|
# /expert takes a single positional. Trailing space -> tokens == ["n", ""].
|
|
cmd = ExpertCommand()
|
|
with self._patched_experts("smoke-test-sync-expert"):
|
|
assert cmd.get_completions(["smoke-test-sync-expert", ""]) == []
|
|
assert cmd.get_completions(["smoke-test-sync-expert", "foo"]) == []
|