feat: enhance expert invitation handling with case-insensitive matching and session management

This commit is contained in:
X-iZhang
2026-08-07 16:42:40 +01:00
committed by Xi Zhang
parent ab1a6b0062
commit aff63ccd65
10 changed files with 159 additions and 23 deletions
+11
View File
@@ -170,6 +170,17 @@ class TestExpertToggle:
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
assert any("Invited expert: idea-brainstorm" in text for text, _ in ui.lines)
async def test_invite_matches_name_case_insensitively(self):
"""Execute honours the same case-insensitive match as completion."""
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(
+21
View File
@@ -85,3 +85,24 @@ class TestNewCommand:
ctx = CommandContext(agent=None, thread_id="tid", ui=ui, channel_runtime=None)
await NewCommand().execute(ctx, [])
ui.append_system.assert_not_called()
async def test_failed_session_start_keeps_invitations(self):
"""A raising start_new_session leaves the current session's invites."""
import pytest
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from EvoScientist.commands.implementation.session import NewCommand
ui = MagicMock()
ui.start_new_session = AsyncMock(side_effect=RuntimeError("gateway down"))
runtime = ChannelRuntime()
runtime.active_teams = ["idea-brainstorm"]
ctx = CommandContext(
agent=None,
thread_id="tid",
ui=ui,
channel_runtime=runtime,
)
with pytest.raises(RuntimeError):
await NewCommand().execute(ctx, [])
assert runtime.active_teams == ["idea-brainstorm"]
+47
View File
@@ -117,3 +117,50 @@ class TestResumeCommand:
assert ctx.workspace_dir == "/keep"
# Callback still fires with the metadata value (empty string)
ui.handle_session_resume.assert_awaited_once_with("tid", "")
class TestResumeClearsInvitedExperts:
"""Invitations are session-scoped: switching threads dismisses them."""
def _runtime(self, invited):
from EvoScientist.commands.base import ChannelRuntime
runtime = ChannelRuntime()
runtime.active_teams = list(invited)
return runtime
async def test_switching_thread_dismisses_and_announces(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
thread_id="current",
thread_store=FakeThreadStore(resolved_thread_id="other-tid"),
)
ctx.channel_runtime = self._runtime(["idea-brainstorm"])
await ResumeCommand().execute(ctx, ["other-tid"])
assert ctx.channel_runtime.active_teams == []
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any(
"Dismissed experts on session switch: idea-brainstorm" in m for m in msgs
)
async def test_resuming_current_thread_keeps_invitations(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
thread_id="current",
thread_store=FakeThreadStore(resolved_thread_id="current"),
)
ctx.channel_runtime = self._runtime(["idea-brainstorm"])
await ResumeCommand().execute(ctx, ["current"])
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert not any("Dismissed experts" in m for m in msgs)
async def test_failed_resolution_keeps_invitations(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, _ui = _ctx(thread_store=FakeThreadStore())
ctx.channel_runtime = self._runtime(["idea-brainstorm"])
await ResumeCommand().execute(ctx, ["nope"])
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
+16
View File
@@ -101,6 +101,22 @@ class TestParseSkillMd:
assert result.name == "no-frontmatter-skill"
assert result.description == "(no description)"
def test_parse_with_empty_description_value(self, tmp_path):
"""A present-but-empty ``description:`` parses to YAML None; guard it."""
skill_dir = tmp_path / "empty-desc-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: empty-desc-skill
description:
---
# Body
"""
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.description == "(no description)"
def test_parse_with_partial_frontmatter(self, tmp_path):
skill_dir = tmp_path / "partial-skill"
skill_dir.mkdir()
+19
View File
@@ -1321,6 +1321,25 @@ class TestPanelDispatchEvents:
assert completes[0]["id"] == "ptc_task_abc12345"
assert completes[0]["duration_ms"] == 1234
async def test_complete_event_with_null_duration_normalizes_to_zero(self):
agent = FakeV3Agent(
[
custom_subagent_event(
{
"type": "subagent",
"phase": "complete",
"id": "ptc_task_abc12345",
"eval_id": "ci_eval_1",
"duration_ms": None,
}
),
]
)
events = await collect_events(agent)
completes = [e for e in events if e.get("type") == "panel_dispatch_complete"]
assert len(completes) == 1
assert completes[0]["duration_ms"] == 0
async def test_error_event_becomes_panel_dispatch_error(self):
agent = FakeV3Agent(
[