feat: agent-teams part C - expert selection UX (depends on part B) (#371)
* feat: bias main-agent delegation toward configurable.active_teams * feat: add /experts and /expert TUI commands for expert-skill summoning * feat: align expert-selection wording with WebUI (invite/dismiss) * chore: clear active_teams on /new, cleanup active_team.py comment * fix: use local append_to_system_message in ActiveTeamMiddleware * fix: prevent configurable_extra from overriding thread_id * fix: cache expert-skill lookup for /expert completions * fix: suppress /expert completions past first arg and on exact match * fix: invalidate /expert completion cache on skill install/uninstall * fix: refuse /expert invites for non-dispatchable expert skills * fix: fire /expert cache invalidation on every install_skill / uninstall_skill path * fix: propagate active_teams to Rich CLI and serve dispatch surfaces * fix: keep invited experts across channel shutdown * fix: match /expert completions case-insensitively
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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 = ""
|
||||
default_dispatch: 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"
|
||||
|
||||
|
||||
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",
|
||||
default_dispatch="sync",
|
||||
),
|
||||
],
|
||||
):
|
||||
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",
|
||||
default_dispatch="sync",
|
||||
),
|
||||
],
|
||||
):
|
||||
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_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"]) == []
|
||||
Reference in New Issue
Block a user