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
+8
View File
@@ -788,6 +788,7 @@ def _get_default_middleware(
ModelFallbackMiddleware,
ToolErrorHandlerMiddleware,
ToolHistoryRepairMiddleware,
create_active_team_middleware,
create_code_interpreter_middleware,
create_context_editing_middleware,
create_memory_lifecycle_middleware,
@@ -862,6 +863,13 @@ def _get_default_middleware(
ErrorNormalizationMiddleware(),
ToolHistoryRepairMiddleware(),
ConfigurableModelMiddleware(),
# Team-binding cue for the main agent only. Reads
# `configurable.active_teams: list[str]` and appends a cue biasing
# the main agent to consult the invited expert(s). Skipped for
# async subagents (a running expert graph shouldn't inject a
# "prefer expert X" hint into its own system prompt — the persona
# is already baked in). See agent-teams-design.md.
*([] if for_async_subagent else [create_active_team_middleware()]),
create_context_editing_middleware(model),
ModelFallbackMiddleware(events=events),
ContextOverflowMapperMiddleware(),
+10 -1
View File
@@ -16,7 +16,12 @@ import typer
from rich.markup import escape
from rich.table import Table
from ..commands.base import ChannelRuntime, Command, CommandContext
from ..commands.base import (
ChannelRuntime,
Command,
CommandContext,
active_teams_configurable_extra,
)
from ..gateway import (
GraphGateway,
GraphTarget,
@@ -1321,6 +1326,7 @@ def _serve_process_message(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
configurable_extra=active_teams_configurable_extra(channel_runtime),
on_thinking=_send_thinking,
on_todo=_send_todo,
on_file_write=_send_media,
@@ -1352,6 +1358,7 @@ def _serve_drain_notifications(
model: str | None,
workspace_dir: str,
show_thinking: bool,
channel_runtime: ChannelRuntime | None = None,
) -> None:
"""Drain the async-task notification queue in headless serve mode.
@@ -1383,6 +1390,7 @@ def _serve_drain_notifications(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
configurable_extra=active_teams_configurable_extra(channel_runtime),
gateway=runtime_state.runtime_gateways.graph_gateway,
runtime=runtime_state.async_runtime,
)
@@ -1659,6 +1667,7 @@ def serve(
model=config.model,
workspace_dir=ws,
show_thinking=effective_channel_thinking,
channel_runtime=channel_runtime,
)
finally:
active_cancel_scope = no_active_cancel_scope
+8 -1
View File
@@ -481,7 +481,7 @@ def cmd_interactive(
width = console.size.width
console.print(Text("\u2500" * width, style="dim"))
from ..commands.base import ChannelRuntime
from ..commands.base import ChannelRuntime, active_teams_configurable_extra
channel_runtime = ChannelRuntime()
@@ -1116,6 +1116,9 @@ def cmd_interactive(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
configurable_extra=active_teams_configurable_extra(
channel_runtime
),
on_thinking=_send_thinking_to_channel,
on_todo=_send_todo_to_channel,
on_file_write=_send_media_to_channel,
@@ -1181,6 +1184,7 @@ def cmd_interactive(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
configurable_extra=active_teams_configurable_extra(channel_runtime),
on_stream_event=_handle_stream_status_event,
status_footer_builder=_stream_status_footer,
gateway=runtime_gateways.graph_gateway,
@@ -1495,6 +1499,9 @@ def cmd_interactive(
show_thinking=show_thinking,
interactive=True,
metadata=_meta,
configurable_extra=active_teams_configurable_extra(
channel_runtime
),
on_stream_event=_handle_stream_status_event,
status_footer_builder=_stream_status_footer,
gateway=runtime_gateways.graph_gateway,
+3
View File
@@ -32,6 +32,7 @@ class StreamingTUIBackend(Protocol):
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
@@ -61,6 +62,7 @@ class RichStreamingBackend:
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
@@ -79,6 +81,7 @@ class RichStreamingBackend:
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
+5
View File
@@ -1793,6 +1793,10 @@ def run_textual_interactive(
summarization_w = None
try:
_anchor_engaged = False
_active_teams = list(self._channel_runtime.active_teams)
_configurable_extra = (
{"active_teams": _active_teams} if _active_teams else None
)
async for event in iter_with_stream_cancel(
graph_gateway.stream_events(
RunRequest(
@@ -1805,6 +1809,7 @@ def run_textual_interactive(
local_graph=agent,
workspace_dir=self._workspace_dir,
),
configurable_extra=_configurable_extra,
)
),
cancel_scope,
+3
View File
@@ -118,6 +118,7 @@ def run_streaming(
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
@@ -139,6 +140,7 @@ def run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
@@ -165,6 +167,7 @@ def run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
+28 -1
View File
@@ -66,20 +66,47 @@ class CommandUI(Protocol):
@dataclass
class ChannelRuntime:
"""Mutable handle to the agent + thread bound to running channels."""
"""Mutable handle to the agent + thread bound to running channels.
Also holds session-scoped bindings mutated by slash commands — the
``active_teams`` list backs the ``/expert`` command, feeding into
``RunRequest.configurable_extra`` at stream call time.
"""
agent: Any = None
thread_id: str | None = None
active_teams: list[str] = field(default_factory=list)
def bind(self, agent: Any, thread_id: str) -> None:
self.agent = agent
self.thread_id = thread_id
def clear(self) -> None:
# ``active_teams`` is session-scoped and reset explicitly by ``/new``
# (session.py) and ``/expert clear`` — not tied to channel lifecycle.
# Clearing here on channel shutdown would silently dismiss the user's
# invited experts, which they never asked for.
self.agent = None
self.thread_id = None
def active_teams_configurable_extra(
runtime: ChannelRuntime | None,
) -> dict[str, Any] | None:
"""Build ``RunRequest.configurable_extra`` from a channel runtime.
Returns ``{"active_teams": [...]}`` when the runtime has invited
experts, or ``None`` when there is no runtime or no active invites —
lets stream call sites forward the field unconditionally without
each duplicating the "read runtime slot, build dict, drop when
empty" three-liner.
"""
if runtime is None:
return None
invited = list(runtime.active_teams)
return {"active_teams": invited} if invited else None
@dataclass
class CommandContext:
"""Context passed to commands during execution."""
@@ -3,6 +3,7 @@ from __future__ import annotations
from . import (
autoskills,
channel,
experts,
general,
mcp,
model,
@@ -15,6 +16,7 @@ from . import (
__all__ = [
"autoskills",
"channel",
"experts",
"general",
"mcp",
"model",
@@ -0,0 +1,247 @@
"""Slash commands for TUI expert-skill selection.
``/experts`` — list installed expert skills.
``/expert <name>`` — toggle an expert into the current session's
``active_teams`` list; the next turn's ``configurable.active_teams`` picks
this up and ``ActiveTeamMiddleware`` biases the main-agent's delegation
toward the invited expert(s).
``/expert clear`` — reset the list.
User-facing verbs match the WebUI gallery: **invite** to add an expert,
**dismiss** to remove one. Internal state field stays ``active_teams``
for wire compatibility.
Backing store is ``ChannelRuntime.active_teams`` (see
``EvoScientist/commands/base.py``). WebUI users get the same effect via
its gallery + langgraph-sdk ``config.configurable``; these commands are
the TUI-side equivalent.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from rich.table import Table
from ..base import Argument, Command, CommandContext, SubCommand
from ..manager import manager
if TYPE_CHECKING:
from ...tools.skills_manager import SkillInfo
_dispatchable_experts_cache: list[SkillInfo] | None = None
def invalidate_experts_cache() -> None:
"""Reset the /expert dispatchable-experts cache.
Called after ``install_skill`` / ``uninstall_skill`` mutations so a
freshly installed expert shows up in the /expert popup on the next
keystroke.
"""
global _dispatchable_experts_cache
_dispatchable_experts_cache = None
def _subscribe_cache_invalidation() -> None:
"""Register with ``skills_manager`` so every install/uninstall path
(slash commands, agent ``skill_manager`` @tool, onboarding) busts
the /expert popup — no caller has to remember.
"""
try:
from ...tools.skills_manager import register_skills_changed_callback
register_skills_changed_callback(invalidate_experts_cache)
except Exception:
# ``skills_manager`` not importable in some early-init contexts;
# cache staleness is a UX inconvenience, not a correctness bug.
pass
_subscribe_cache_invalidation()
def _dispatchable_experts() -> list[SkillInfo]:
"""Cached list of experts that /expert can safely invite.
Filters ``list_expert_skills`` down to those that pass the same
empty-body + name-collision guards ``build_expert_subagent_specs``
and ``_fold_expert_subagents`` apply at agent-construction time, so
the /expert popup and invite-accept path only ever surface names
that will actually reach ``ActiveTeamMiddleware``'s cue.
"""
global _dispatchable_experts_cache
if _dispatchable_experts_cache is None:
try:
from ...subagents.expert_container import list_dispatchable_experts
_dispatchable_experts_cache = list_dispatchable_experts(include_system=True)
except Exception:
return []
return _dispatchable_experts_cache
class ExpertsCommand(Command):
"""List installed expert skills."""
name: ClassVar[str] = "/experts"
description: ClassVar[str] = "List installed expert skills"
category: ClassVar[str] = "Experts"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...tools.skills_manager import list_expert_skills
experts = list_expert_skills(include_system=True)
active = _current_active_teams(ctx)
if not experts:
ctx.ui.append_system("No expert skills installed.", style="dim")
ctx.ui.append_system(
"Install with: /install-skill <path-or-url>", style="dim"
)
return
table = Table(title=f"Expert Skills ({len(experts)})", show_header=True)
table.add_column("Name", style="cyan")
table.add_column("Role", style="dim")
table.add_column("Dispatch", style="dim")
table.add_column("Active", style="green")
for skill in experts:
marker = "*" if skill.name in active else ""
table.add_row(
skill.name,
skill.role or skill.description,
skill.default_dispatch or "sync",
marker,
)
ctx.ui.mount_renderable(table)
if active:
ctx.ui.append_system(
f"Active: {', '.join(active)}. Toggle with `/expert <name>`, "
"clear with `/expert clear`.",
style="dim",
)
else:
ctx.ui.append_system(
"No experts invited. `/expert <name>` to invite one.",
style="dim",
)
class ExpertCommand(Command):
"""Invite, dismiss, or clear expert skills for the current thread."""
name: ClassVar[str] = "/expert"
description: ClassVar[str] = "Invite or dismiss an expert skill"
category: ClassVar[str] = "Experts"
arguments: ClassVar[list[Argument]] = [
Argument(
name="name_or_clear",
type=str,
description="Expert skill name to toggle, or 'clear' to reset",
required=True,
)
]
subcommands: ClassVar[list[SubCommand]] = [
SubCommand("clear", "Dismiss all invited experts"),
]
def _get_expert_candidates(self) -> list[tuple[str, str]]:
return [(s.name, s.role or s.description) for s in _dispatchable_experts()]
def get_completions(self, tokens: list[str]) -> list[tuple[str, str]]:
"""Complete expert names + the ``clear`` subcommand."""
# /expert takes a single positional arg; anything past it (including a
# trailing space that turns tokens into ["name", ""]) has nothing to offer.
if len(tokens) > 1:
return []
prefix = tokens[0].lower() if tokens else ""
candidates = [
*self._get_expert_candidates(),
("clear", "Dismiss all invited experts"),
]
matches = [
(name, desc) for name, desc in candidates if name.lower().startswith(prefix)
]
# Exact match — argument already complete, hide the popup.
if len(matches) == 1 and matches[0][0].lower() == prefix:
return []
return matches
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
runtime = ctx.channel_runtime
if runtime is None:
ctx.ui.append_system(
"/expert requires a session runtime; not available in this context.",
style="yellow",
)
return
if not args:
ctx.ui.append_system(
"Usage: /expert <name> toggle an expert into the invited list",
style="yellow",
)
ctx.ui.append_system(
" /expert clear dismiss all invited experts",
style="dim",
)
return
target = args[0].strip()
if target.lower() == "clear":
if not runtime.active_teams:
ctx.ui.append_system("No experts invited.", style="dim")
return
dismissed = list(runtime.active_teams)
runtime.active_teams = []
ctx.ui.append_system(
f"Dismissed experts: {', '.join(dismissed)}", style="dim"
)
return
dispatchable = {s.name for s in _dispatchable_experts()}
if target not in dispatchable:
from ...tools.skills_manager import list_expert_skills
installed = {s.name for s in list_expert_skills(include_system=True)}
if target in installed:
ctx.ui.append_system(
f"Expert '{target}' can't be dispatched (empty SKILL.md body "
"or name collision with a built-in sub-agent).",
style="red",
)
else:
ctx.ui.append_system(
f"No expert skill named '{target}'. `/experts` lists "
"installed ones.",
style="red",
)
return
if target in runtime.active_teams:
runtime.active_teams = [n for n in runtime.active_teams if n != target]
ctx.ui.append_system(f"Dismissed expert: {target}", style="dim")
else:
runtime.active_teams = [*runtime.active_teams, target]
ctx.ui.append_system(f"Invited expert: {target}", style="green")
# Case (c): expert was installed after agent construction, so it
# will not reach ``task()`` until the graph is rebuilt. Cheap
# always-print hint mirrors the /install-skill success message.
ctx.ui.append_system(
"If just installed, run /new to activate it.", style="dim"
)
if runtime.active_teams:
ctx.ui.append_system(
f"Active: {', '.join(runtime.active_teams)}", style="dim"
)
def _current_active_teams(ctx: CommandContext) -> list[str]:
runtime = ctx.channel_runtime
return list(runtime.active_teams) if runtime is not None else []
manager.register(ExpertsCommand())
manager.register(ExpertCommand())
@@ -214,7 +214,22 @@ class NewCommand(Command):
category = "Session"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
# ``/new`` means fresh state — release any invited experts before
# starting the new session. Uniform with the ``ChannelRuntime.clear``
# path on channel shutdown; avoids the "why is idea-brainstorm still
# active in my new thread?" surprise. Users who want to reuse an
# invite in the next thread can re-invite explicitly.
runtime = ctx.channel_runtime
dismissed: list[str] = []
if runtime is not None and runtime.active_teams:
dismissed = list(runtime.active_teams)
runtime.active_teams = []
await ctx.ui.start_new_session()
if dismissed:
ctx.ui.append_system(
f"Dismissed experts on new session: {', '.join(dismissed)}",
style="dim",
)
class ClearCommand(Command):
+1
View File
@@ -165,6 +165,7 @@ class LocalGraphGateway:
metadata=request.metadata,
media=request.media,
events=self.events,
configurable_extra=request.configurable_extra,
)
try:
async for event in inner:
+7
View File
@@ -41,6 +41,13 @@ class RunRequest:
metadata: dict[str, Any] | None = None
media: list[str] | None = None
target: GraphTarget | None = None
configurable_extra: dict[str, Any] | None = None
"""Extra keys to merge into the LangGraph ``configurable`` dict alongside
``thread_id`` — e.g. ``{"active_teams": [...]}`` from the TUI
``/expert`` command. WebUI callers achieve the same effect via
``langgraph_sdk``'s ``config.configurable`` on their own; this field is
the local-gateway equivalent so CLI / TUI / headless serve can bias
the run identically."""
@dataclass(frozen=True, slots=True)
+3
View File
@@ -4,6 +4,7 @@ Re-exports middleware classes and factory functions so that existing
``from EvoScientist.middleware import X`` imports continue to work.
"""
from .active_team import ActiveTeamMiddleware, create_active_team_middleware
from .ask_user import (
AskUserMiddleware,
AskUserRequest,
@@ -40,6 +41,7 @@ from .tool_selector import create_tool_selector_middleware
from .utils import disable_thinking
__all__ = [
"ActiveTeamMiddleware",
"AskUserMiddleware",
"AskUserRequest",
"AskUserWidgetResult",
@@ -56,6 +58,7 @@ __all__ = [
"ToolErrorHandlerMiddleware",
"ToolHistoryRepairMiddleware",
"compute_context_editing_trigger",
"create_active_team_middleware",
"create_code_interpreter_middleware",
"create_context_editing_middleware",
"create_memory_lifecycle_middleware",
+129
View File
@@ -0,0 +1,129 @@
"""ActiveTeamMiddleware for EvoScientist agent-teams v1.
Reads ``configurable.active_teams: list[str]`` on every model call and
appends a system-prompt cue biasing the main agent to consult the
user-invited expert(s) via ``task({subagent_type: ...})``.
Backend-stateless team binding: WebUI sends ``active_teams`` on every
``stream.submit()`` for as long as the invited expert is active; this
middleware reads it fresh per turn via ``langgraph.config.get_config()``.
Matches the plan's decision to reach for the ``configurable`` primitive
rather than a server-side thread-state store (CLAUDE.md #5).
Naming note: the WIRE FORMAT is ``configurable.active_teams`` (plural,
legacy from the earlier "teams" framing that survived the pivot per the
WebUI section of the design note). Under the current expert-skill
mechanism the semantic content is a list of expert names, but the
wire key stays ``active_teams`` for WebUI compatibility. Internal
system-prompt tags use ``<active_expert>`` / ``<active_experts>``
because that matches what the LLM sees as the semantic target.
No-op when:
- ``configurable.active_teams`` is absent, empty, non-list, or contains
no non-empty string entries.
- The middleware is invoked outside a runnable context (``get_config``
raises).
Not included in the async-subagent middleware stack: an expert running
as its own graph would otherwise inject a "prefer expert X" cue into
its own system prompt, where the persona is already baked in. See
``EvoScientist.py::_get_default_middleware``.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from langchain.agents.middleware.types import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
_TEMPLATE_SINGLE = (
"<active_expert>\n"
"The user has invited the expert `{expert}` to this thread. "
"Consult it via `task({{subagent_type: '{expert}', ...}})` for "
"requests within its scope. It stays available for the whole session "
"until the user dismisses it.\n"
"</active_expert>"
)
_TEMPLATE_MULTI = (
"<active_experts>\n"
"The user has invited the following experts to this thread: "
"{experts}. Consult any of them via "
"`task({{subagent_type: '<expert_name>', ...}})` based on which fits "
"the current request. Do not consult an expert if the request is "
"clearly outside its scope.\n"
"</active_experts>"
)
def _read_active_teams() -> list[str]:
"""Read ``configurable.active_teams`` from the current RunnableConfig.
Returns an empty list when the config is absent, malformed, or the
call happens outside a runnable context.
"""
try:
from langgraph.config import get_config
cfg = get_config()
except Exception:
# Outside a runnable context (most common in tests) or
# langgraph not importable — nothing to inject.
return []
if not isinstance(cfg, dict):
return []
configurable = cfg.get("configurable") or {}
if not isinstance(configurable, dict):
return []
raw = configurable.get("active_teams")
if not isinstance(raw, list):
return []
return [t for t in raw if isinstance(t, str) and t]
class ActiveTeamMiddleware(AgentMiddleware):
"""Bias delegation toward the user's active expert(s) on every turn."""
name = "active_team"
def _cue_for(self, experts: list[str]) -> str:
if len(experts) == 1:
return _TEMPLATE_SINGLE.format(expert=experts[0])
experts_str = ", ".join(f"`{e}`" for e in experts)
return _TEMPLATE_MULTI.format(experts=experts_str)
def modify_request(self, request: ModelRequest) -> ModelRequest:
"""Append the active-expert cue to the request's system message."""
experts = _read_active_teams()
if not experts:
return request
from .utils import append_to_system_message
new_system = append_to_system_message(
request.system_message,
self._cue_for(experts),
)
return request.override(system_message=new_system)
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
return handler(self.modify_request(request))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
return await handler(self.modify_request(request))
def create_active_team_middleware() -> ActiveTeamMiddleware:
"""Build ActiveTeamMiddleware."""
return ActiveTeamMiddleware()
+3
View File
@@ -1440,6 +1440,7 @@ def _run_streaming(
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict[str, object] | None = None,
configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
@@ -1526,6 +1527,7 @@ def _run_streaming(
message=message,
thread_id=thread_id,
metadata=metadata,
configurable_extra=configurable_extra,
target=_graph_target_for_local_agent(agent, metadata),
)
)
@@ -1768,6 +1770,7 @@ def _run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
+10 -1
View File
@@ -860,6 +860,7 @@ async def stream_agent_events(
metadata: dict[str, Any] | None = None,
media: list[str] | None = None,
events: "ToolSelectionView | None" = None,
configurable_extra: dict[str, Any] | None = None,
) -> AsyncGenerator[dict[str, Any], None]:
"""Stream events from a DeepAgents/LangGraph v3 run.
@@ -875,6 +876,10 @@ async def stream_agent_events(
metadata: Optional metadata dict merged into the LangGraph config
(e.g. agent_name, updated_at for checkpoint persistence).
media: Optional list of local file paths for attachments.
configurable_extra: Optional extra keys merged into ``configurable``
alongside ``thread_id`` — e.g. ``{"active_teams": [...]}`` from
TUI ``/expert`` bindings, mirroring what WebUI writes via
langgraph-sdk. Ignored if ``None`` or empty.
Yields:
Event dicts: thinking, text, tool_call, tool_result,
@@ -886,7 +891,11 @@ async def stream_agent_events(
events = SessionEventSink()
config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
configurable: dict[str, Any] = {
**(configurable_extra or {}),
"thread_id": thread_id,
}
config: dict[str, Any] = {"configurable": configurable}
if metadata:
config["metadata"] = metadata
emitter = StreamEventEmitter()
@@ -126,6 +126,75 @@ def build_expert_subagent_spec(
}
_reserved_subagent_names_cache: frozenset[str] | None = None
def _reserved_subagent_names() -> frozenset[str]:
"""Names ``_fold_expert_subagents`` refuses for expert registration.
Union of every static yaml sub-agent name (from ``subagents/*.yaml``)
plus deepagents' ``general-purpose``. Mirrors the ``taken`` set built
inline in ``EvoScientist.py::_fold_expert_subagents`` so callers that
need to know "which names will be rejected at fold time" don't have
to replay it.
Cached on first call — yaml sub-agent files are static per process.
"""
global _reserved_subagent_names_cache
if _reserved_subagent_names_cache is not None:
return _reserved_subagent_names_cache
from pathlib import Path
import yaml
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
from .. import subagents as _subagents_pkg
names: set[str] = {GENERAL_PURPOSE_SUBAGENT["name"]}
for pkg_dir in _subagents_pkg.__path__:
for yml_path in Path(pkg_dir).glob("*.yaml"):
if yml_path.name.startswith(("_", ".")):
continue
try:
data = yaml.safe_load(yml_path.read_text(encoding="utf-8"))
except Exception:
continue
if isinstance(data, dict):
names.update(str(k) for k in data)
_reserved_subagent_names_cache = frozenset(names)
return _reserved_subagent_names_cache
def list_dispatchable_experts(*, include_system: bool = True) -> list[SkillInfo]:
"""Experts that will actually be dispatchable via ``task()``.
Combines ``list_expert_skills`` with the same filters
``build_expert_subagent_specs`` (empty body) and
``_fold_expert_subagents`` (name collision with yaml sub-agents or
``general-purpose``) apply at construction time. Callers surfacing
experts to the user (e.g. the ``/expert`` slash command) should use
this instead of ``list_expert_skills`` directly, otherwise they can
accept a name that will silently misroute or per-turn error at
dispatch time.
Read-only filter — construction-time warnings for empty-body /
colliding experts are emitted by ``build_expert_subagent_specs`` and
``_fold_expert_subagents`` respectively, so nothing is logged here.
"""
from ..tools.skills_manager import list_expert_skills
reserved = _reserved_subagent_names()
dispatchable: list[SkillInfo] = []
for info in list_expert_skills(include_system=include_system):
if not _body_of(info).strip():
continue
if info.name in reserved:
continue
dispatchable.append(info)
return dispatchable
def build_expert_subagent_specs(
tool_registry: dict[str, Any] | None = None,
*,
+71
View File
@@ -38,6 +38,7 @@ import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
@@ -48,6 +49,58 @@ from .. import paths
_logger = logging.getLogger(__name__)
# --- skills-changed publish primitive ---------------------------------------
#
# ``install_skill`` and ``uninstall_skill`` are called from six places today:
# three slash commands, two paths in the agent's ``skill_manager`` @tool, and
# the onboarding step. Consumers that cache skill data (currently the
# /expert completion popup) need to invalidate on every mutation, and wiring
# that at each caller site was the "vibes and hope" pattern reviewers rightly
# called out on PR #371 — the next caller silently reintroduces staleness.
#
# The invariant lives here instead: any subscriber registered via
# ``register_skills_changed_callback`` is fired after every install / uninstall
# call, so no caller has to remember.
_skills_changed_callbacks: list[Callable[[], None]] = []
def register_skills_changed_callback(callback: Callable[[], None]) -> None:
"""Register a callback fired after every ``install_skill`` /
``uninstall_skill`` call.
Consumers that cache skill data — e.g. the ``/expert`` completion
popup — subscribe once at import time so every mutation path
(commands, agent ``skill_manager`` @tool, onboarding) invalidates
their view. Callbacks are process-scoped and never unregistered
automatically; tests should call ``_reset_skills_changed_callbacks``
from a fixture to isolate state.
"""
_skills_changed_callbacks.append(callback)
def _reset_skills_changed_callbacks() -> None:
"""Test hook: clear the callback list."""
_skills_changed_callbacks.clear()
def _notify_skills_changed() -> None:
"""Fire every registered callback.
Exceptions are logged but swallowed so a misbehaving subscriber can't
break the install/uninstall return path.
"""
for cb in _skills_changed_callbacks:
try:
cb()
except Exception:
_logger.warning(
"skills-changed callback %r raised; continuing.",
getattr(cb, "__qualname__", cb),
exc_info=True,
)
@dataclass
class SkillInfo:
"""Information about an installed skill."""
@@ -609,6 +662,17 @@ def install_skill(
- path: installed path (if successful)
- error: error message (if failed)
"""
try:
return _install_skill_impl(source, dest_dir, global_install)
finally:
_notify_skills_changed()
def _install_skill_impl(
source: str,
dest_dir: str | None = None,
global_install: bool = True,
) -> dict:
dest_dir = dest_dir or (
str(paths.GLOBAL_SKILLS_DIR) if global_install else str(paths.USER_SKILLS_DIR)
)
@@ -924,6 +988,13 @@ def uninstall_skill(name: str) -> dict:
- success: bool
- error: error message (if failed)
"""
try:
return _uninstall_skill_impl(name)
finally:
_notify_skills_changed()
def _uninstall_skill_impl(name: str) -> dict:
# Validate name to prevent path traversal
clean_name = _sanitize_name(name)
if not clean_name:
+4
View File
@@ -22,11 +22,14 @@ async def collect_events(
thread_id: str = "t1",
*,
events=None,
configurable_extra: dict[str, Any] | None = None,
):
"""Collect stream_agent_events output for tests.
``events`` is the frontend tool-selection sink to drive suppression /
selection rendering (defaults to the silent NoOpSink inside the stream).
``configurable_extra`` is forwarded verbatim to ``stream_agent_events``
for tests that assert plumbing into the LangGraph ``configurable`` dict.
"""
collected = []
async for ev in stream_agent_events(
@@ -34,6 +37,7 @@ async def collect_events(
message,
thread_id,
events=events,
configurable_extra=configurable_extra,
):
collected.append(ev)
return collected
+217
View File
@@ -0,0 +1,217 @@
"""Tests for EvoScientist.middleware.active_team."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from langchain_core.messages import SystemMessage
from EvoScientist.middleware.active_team import (
ActiveTeamMiddleware,
_read_active_teams,
create_active_team_middleware,
)
def _request():
"""A minimal ModelRequest stand-in supporting the fields the middleware
reads (`system_message`) and the `.override(**kwargs)` mutator."""
request = SimpleNamespace(
state={},
runtime=object(),
system_message=SystemMessage(content="base system"),
)
request.override = lambda **kwargs: SimpleNamespace(
**{
"state": request.state,
"runtime": request.runtime,
"system_message": kwargs.get("system_message", request.system_message),
}
)
return request
def _system_text(modified) -> str:
system_message = modified.system_message
assert system_message is not None
return str(system_message.content)
def _mock_config():
cfg = MagicMock()
cfg.enable_ask_user = False
cfg.auto_mode = False
cfg.auto_approve = False
cfg.model_fallbacks = None
cfg.auxiliary_model = ""
cfg.auxiliary_provider = ""
cfg.code_interpreter_timeout = 60
cfg.code_interpreter_max_result_chars = 6000
return cfg
# ---- unit tests: _read_active_teams behavior --------------------------------
@patch("langgraph.config.get_config")
def test_read_active_teams_returns_list_when_present(mock_get_config):
mock_get_config.return_value = {
"configurable": {"active_teams": ["idea-brainstorm"]},
}
assert _read_active_teams() == ["idea-brainstorm"]
@patch("langgraph.config.get_config")
def test_read_active_teams_returns_empty_when_configurable_missing(mock_get_config):
mock_get_config.return_value = {}
assert _read_active_teams() == []
@patch("langgraph.config.get_config")
def test_read_active_teams_returns_empty_when_active_teams_missing(mock_get_config):
mock_get_config.return_value = {"configurable": {"other_field": "x"}}
assert _read_active_teams() == []
@patch("langgraph.config.get_config")
def test_read_active_teams_returns_empty_when_value_not_list(mock_get_config):
"""WebUI mistakenly sends a scalar instead of a list; must not crash."""
mock_get_config.return_value = {
"configurable": {"active_teams": "idea-brainstorm"},
}
assert _read_active_teams() == []
@patch("langgraph.config.get_config")
def test_read_active_teams_filters_non_string_entries(mock_get_config):
mock_get_config.return_value = {
"configurable": {
"active_teams": ["idea-brainstorm", None, 42, "", "lit-review"]
},
}
assert _read_active_teams() == ["idea-brainstorm", "lit-review"]
@patch("langgraph.config.get_config", side_effect=RuntimeError("outside context"))
def test_read_active_teams_returns_empty_outside_runnable_context(mock_get_config):
assert _read_active_teams() == []
# ---- unit tests: middleware behavior ---------------------------------------
@patch("langgraph.config.get_config")
def test_middleware_no_op_when_active_teams_absent(mock_get_config):
mock_get_config.return_value = {"configurable": {}}
middleware = ActiveTeamMiddleware()
request = _request()
modified = middleware.modify_request(request)
# No override applied: original request returned as-is.
assert modified is request
@patch("langgraph.config.get_config")
def test_middleware_no_op_when_active_teams_empty_list(mock_get_config):
mock_get_config.return_value = {"configurable": {"active_teams": []}}
middleware = ActiveTeamMiddleware()
request = _request()
modified = middleware.modify_request(request)
assert modified is request
@patch("langgraph.config.get_config")
def test_middleware_appends_single_expert_cue(mock_get_config):
mock_get_config.return_value = {
"configurable": {"active_teams": ["idea-brainstorm"]},
}
middleware = ActiveTeamMiddleware()
modified = middleware.modify_request(_request())
text = _system_text(modified)
assert "<active_expert>" in text
assert "`idea-brainstorm`" in text
assert "Consult it via `task(" in text
assert "base system" in text # original preserved
@patch("langgraph.config.get_config")
def test_middleware_appends_multi_expert_cue(mock_get_config):
mock_get_config.return_value = {
"configurable": {"active_teams": ["idea-brainstorm", "literature-review"]},
}
middleware = ActiveTeamMiddleware()
modified = middleware.modify_request(_request())
text = _system_text(modified)
assert "<active_experts>" in text
assert "`idea-brainstorm`" in text
assert "`literature-review`" in text
assert "Consult any of them" in text
assert "base system" in text
@patch("langgraph.config.get_config")
def test_middleware_appends_cue_for_unknown_expert_names(mock_get_config):
"""Middleware doesn't validate names against the registry; main decides."""
mock_get_config.return_value = {
"configurable": {"active_teams": ["nonexistent-expert"]},
}
middleware = ActiveTeamMiddleware()
modified = middleware.modify_request(_request())
text = _system_text(modified)
assert "`nonexistent-expert`" in text
@patch("langgraph.config.get_config", side_effect=RuntimeError("outside context"))
def test_middleware_no_op_outside_runnable_context(mock_get_config):
middleware = ActiveTeamMiddleware()
request = _request()
modified = middleware.modify_request(request)
assert modified is request
# ---- composition tests: _get_default_middleware ----------------------------
@patch(
"EvoScientist.middleware.create_tool_selector_middleware",
return_value=[MagicMock(), MagicMock()],
)
@patch("EvoScientist.EvoScientist._ensure_chat_model")
@patch("EvoScientist.EvoScientist._ensure_config")
def test_default_middleware_includes_active_team_for_main_agent(
mock_config, mock_model, mock_tool_selector
):
mock_config.return_value = _mock_config()
mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
from EvoScientist.EvoScientist import _get_default_middleware
middleware = _get_default_middleware()
assert any(isinstance(m, ActiveTeamMiddleware) for m in middleware)
@patch(
"EvoScientist.middleware.create_tool_selector_middleware",
return_value=[MagicMock(), MagicMock()],
)
@patch("EvoScientist.EvoScientist._ensure_chat_model")
@patch("EvoScientist.EvoScientist._ensure_config")
def test_default_middleware_excludes_active_team_for_async_subagent(
mock_config, mock_model, mock_tool_selector
):
mock_config.return_value = _mock_config()
mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
from EvoScientist.EvoScientist import _get_default_middleware
middleware = _get_default_middleware(for_async_subagent=True)
assert not any(isinstance(m, ActiveTeamMiddleware) for m in middleware)
# ---- factory --------------------------------------------------------------
def test_factory_returns_middleware_instance():
assert isinstance(create_active_team_middleware(), ActiveTeamMiddleware)
+50
View File
@@ -0,0 +1,50 @@
"""Unit tests for helpers in ``EvoScientist.commands.base``."""
from __future__ import annotations
from EvoScientist.commands.base import ChannelRuntime, active_teams_configurable_extra
class TestActiveTeamsConfigurableExtra:
"""``active_teams_configurable_extra`` is used at every stream-call site
that needs to forward /expert invites into ``RunRequest.configurable_extra``.
"""
def test_none_runtime_returns_none(self):
assert active_teams_configurable_extra(None) is None
def test_runtime_without_invites_returns_none(self):
# Empty list must produce ``None`` so callers can pass the result
# unconditionally without polluting ``configurable`` with an empty
# ``active_teams: []`` (which ``ActiveTeamMiddleware`` would treat
# as no-op anyway, but the wire stays cleaner without it).
runtime = ChannelRuntime()
assert active_teams_configurable_extra(runtime) is None
def test_runtime_with_invites_returns_dict_copy(self):
runtime = ChannelRuntime()
runtime.active_teams = ["idea-brainstorm", "paper-review"]
result = active_teams_configurable_extra(runtime)
assert result == {"active_teams": ["idea-brainstorm", "paper-review"]}
# Must be a *copy* — mutating the returned list may not leak back
# to the runtime's session-scoped invite list.
result["active_teams"].append("mutated")
assert runtime.active_teams == ["idea-brainstorm", "paper-review"]
class TestChannelRuntimeClear:
"""``ChannelRuntime.clear`` runs on channel shutdown; it must leave the
session-scoped ``active_teams`` list intact so stopping a channel does
not silently dismiss the user's invited experts. ``/new`` and
``/expert clear`` handle invite reset explicitly.
"""
def test_clear_preserves_active_teams(self):
runtime = ChannelRuntime()
runtime.agent = object()
runtime.thread_id = "t-42"
runtime.active_teams = ["idea-brainstorm"]
runtime.clear()
assert runtime.agent is None
assert runtime.thread_id is None
assert runtime.active_teams == ["idea-brainstorm"]
+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"]) == []
+51
View File
@@ -34,3 +34,54 @@ class TestNewCommand:
ctx = CommandContext(agent=None, thread_id="tid", ui=ui)
# No AttributeError even though ctx.agent is None
await NewCommand().execute(ctx, [])
async def test_clears_invited_experts_and_announces(self):
"""/new dismisses invited experts uniformly with channel-shutdown clear."""
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from EvoScientist.commands.implementation.session import NewCommand
ui = MagicMock()
ui.start_new_session = AsyncMock()
runtime = ChannelRuntime()
runtime.active_teams = ["idea-brainstorm"]
ctx = CommandContext(
agent=None,
thread_id="tid",
ui=ui,
channel_runtime=runtime,
)
await NewCommand().execute(ctx, [])
assert runtime.active_teams == []
messages = [call.args[0] for call in ui.append_system.call_args_list]
assert any(
"Dismissed experts on new session: idea-brainstorm" in msg
for msg in messages
)
async def test_no_announcement_when_no_experts_invited(self):
"""No noise on ``/new`` when the invite list is already empty."""
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from EvoScientist.commands.implementation.session import NewCommand
ui = MagicMock()
ui.start_new_session = AsyncMock()
runtime = ChannelRuntime()
ctx = CommandContext(
agent=None,
thread_id="tid",
ui=ui,
channel_runtime=runtime,
)
await NewCommand().execute(ctx, [])
ui.append_system.assert_not_called()
async def test_no_announcement_without_channel_runtime(self):
"""Runs cleanly when no ChannelRuntime is attached."""
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand
ui = MagicMock()
ui.start_new_session = AsyncMock()
ctx = CommandContext(agent=None, thread_id="tid", ui=ui, channel_runtime=None)
await NewCommand().execute(ctx, [])
ui.append_system.assert_not_called()
+67
View File
@@ -12,6 +12,7 @@ from EvoScientist.tools.skills_manager import (
_parse_github_url,
_parse_skill_md,
_record_install,
_reset_skills_changed_callbacks,
_validate_skill_dir,
fetch_remote_skill_index,
get_all_tags,
@@ -21,6 +22,7 @@ from EvoScientist.tools.skills_manager import (
list_expert_skills,
list_skills,
list_skills_by_tag,
register_skills_changed_callback,
resolve_remote_head,
uninstall_skill,
)
@@ -1589,3 +1591,68 @@ class TestSkillManagerInstall:
assert "Path: /skills/worked" in result
assert "broken" in result
assert "corrupt frontmatter" in result
# =============================================================================
# Tests for the skills-changed publish primitive
# =============================================================================
@pytest.fixture
def isolated_skills_changed_callbacks():
"""Clear the module-level callback list before and after each test so
subscribers registered elsewhere (e.g. by importing ``experts.py``) do
not leak in or out of these tests.
"""
_reset_skills_changed_callbacks()
yield
_reset_skills_changed_callbacks()
class TestSkillsChangedCallback:
"""Verifies install_skill / uninstall_skill fire subscribers on every
return path — success, early error return, and success-with-real-mutation.
"""
def test_install_skill_fires_callback_on_error_return(
self, isolated_skills_changed_callbacks, temp_skills_dir
):
fired: list[bool] = []
register_skills_changed_callback(lambda: fired.append(True))
result = install_skill("/nonexistent/path", str(temp_skills_dir))
assert result["success"] is False
assert fired == [True]
def test_install_skill_fires_callback_on_success(
self, isolated_skills_changed_callbacks, sample_skill_dir, temp_skills_dir
):
fired: list[bool] = []
register_skills_changed_callback(lambda: fired.append(True))
result = install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert result["success"] is True
assert fired == [True]
def test_uninstall_skill_fires_callback_on_error_return(
self, isolated_skills_changed_callbacks
):
fired: list[bool] = []
register_skills_changed_callback(lambda: fired.append(True))
result = uninstall_skill("nonexistent-skill")
assert result["success"] is False
assert fired == [True]
def test_misbehaving_callback_does_not_break_return(
self, isolated_skills_changed_callbacks, temp_skills_dir
):
good: list[bool] = []
def bad_callback() -> None:
raise RuntimeError("subscriber intentionally raising")
register_skills_changed_callback(bad_callback)
register_skills_changed_callback(lambda: good.append(True))
# Bad callback runs first; the good one still fires; the install
# return value is unaffected.
result = install_skill("/nonexistent/path", str(temp_skills_dir))
assert result["success"] is False
assert good == [True]
+20
View File
@@ -158,6 +158,26 @@ class TestV3ProtocolStreaming:
assert "stream_mode" not in kwargs
assert "subgraphs" not in kwargs
async def test_configurable_extra_merged_into_config(self):
"""``configurable_extra`` from RunRequest lands next to thread_id."""
agent = FakeV3Agent([message_delta("hi")])
await collect_events(
agent,
thread_id="t1",
configurable_extra={"active_teams": ["idea-brainstorm"]},
)
_, kwargs = agent.astream_events.call_args
configurable = kwargs["config"]["configurable"]
assert configurable["thread_id"] == "t1"
assert configurable["active_teams"] == ["idea-brainstorm"]
async def test_configurable_extra_none_leaves_thread_id_only(self):
"""When no extras are passed, only ``thread_id`` sits under configurable."""
agent = FakeV3Agent([message_delta("hi")])
await collect_events(agent, thread_id="t1")
_, kwargs = agent.astream_events.call_args
assert kwargs["config"]["configurable"] == {"thread_id": "t1"}
async def test_streamed_non_selector_json_is_replayed(self):
"""Normal JSON answers are not swallowed by selector JSON buffering."""
agent = FakeV3Agent(