feat: enhance expert invitation handling with case-insensitive matching and session management
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user