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:
jfilipiuk
2026-07-27 14:34:06 +02:00
committed by Xi Zhang
parent 3a1dbf0a0f
commit 3cda9894c7
25 changed files with 1265 additions and 4 deletions
+234
View File
@@ -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"]) == []