fix(tui): dispatch custom skill bundles as agent turns (#62859)

This commit is contained in:
Adolanium 2026-07-12 09:34:12 +03:00 committed by GitHub
parent f8054601a8
commit 964ecef401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 204 additions and 5 deletions

View file

@ -1266,6 +1266,58 @@ def test_slash_exec_rejects_skill_commands(server):
assert "skill command" in resp["error"]["message"]
def test_slash_exec_routes_custom_skill_bundle_away_from_worker(server):
"""slash.exec expands any custom bundle through command.dispatch."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
worker = Worker()
server._sessions[sid] = {
"session_key": sid,
"agent": None,
"slash_worker": worker,
}
fake_bundles = {
"/analysis-pack": {
"name": "analysis-pack",
"skills": ["source-check", "claim-audit"],
}
}
fake_msg = (
'[IMPORTANT: The user has invoked the "analysis-pack" skill bundle.]\n\n'
"User instruction: compare vector databases"
)
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
patch(
"agent.skill_bundles.build_bundle_invocation_message",
return_value=(fake_msg, ["source-check", "claim-audit"], []),
):
resp = server.handle_request({
"id": "r-bundle-slash",
"method": "slash.exec",
"params": {
"command": "analysis-pack compare vector databases",
"session_id": sid,
},
})
assert "error" not in resp
assert resp["result"] == {
"type": "send",
"message": fake_msg,
"notice": "⚡ Loading bundle: analysis-pack (2 skills)",
}
assert worker.calls == []
def test_slash_exec_handles_plugin_commands_in_live_gateway(server):
"""Plugin slash commands return normal slash.exec output without using the worker."""
sid = "test-session"
@ -1418,6 +1470,37 @@ def test_command_dispatch_queue_sends_message(server):
assert result["message"] == "tell me about quantum computing"
def test_command_dispatch_builtin_queue_wins_over_colliding_bundle(server):
"""A custom /queue bundle must not shadow the built-in /queue command."""
sid = "test-session"
server._sessions[sid] = {"session_key": sid}
fake_bundles = {
"/queue": {
"name": "queue",
"skills": ["source-check", "claim-audit"],
}
}
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
patch("agent.skill_bundles.build_bundle_invocation_message") as build_bundle:
resp = server.handle_request({
"id": "r-queue-collision",
"method": "command.dispatch",
"params": {
"name": "queue",
"arg": "tell me about quantum computing",
"session_id": sid,
},
})
assert "error" not in resp
assert resp["result"] == {
"type": "send",
"message": "tell me about quantum computing",
}
build_bundle.assert_not_called()
def test_command_dispatch_queue_requires_arg(server):
"""command.dispatch /queue without an argument returns an error."""
sid = "test-session"
@ -1619,6 +1702,54 @@ def test_command_dispatch_returns_skill_payload(server):
assert result["name"] == "hermes-agent-dev"
def test_command_dispatch_returns_custom_bundle_payload(server):
"""command.dispatch preserves bundle arguments in a sendable agent turn."""
sid = "test-session"
server._sessions[sid] = {"session_key": sid}
fake_bundles = {
"/review-suite": {
"name": "review-suite",
"skills": ["source-check", "claim-audit", "enough-research"],
}
}
arg = "audit the migration plan"
fake_msg = (
'[IMPORTANT: The user has invoked the "review-suite" skill bundle.]\n\n'
f"User instruction: {arg}"
)
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
patch(
"agent.skill_bundles.build_bundle_invocation_message",
return_value=(
fake_msg,
["source-check", "claim-audit", "enough-research"],
[],
),
) as build_bundle, \
patch("agent.skill_commands.build_skill_invocation_message") as build_skill, \
patch.object(server, "_resolve_session_platform", return_value="tui"):
resp = server.handle_request({
"id": "r-bundle-dispatch",
"method": "command.dispatch",
"params": {"name": "review-suite", "arg": arg, "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {
"type": "send",
"message": fake_msg,
"notice": "⚡ Loading bundle: review-suite (3 skills)",
}
build_bundle.assert_called_once_with(
"/review-suite",
arg,
task_id=sid,
platform="tui",
)
build_skill.assert_not_called()
def test_command_dispatch_awaits_async_plugin_handler(server):
async def _handler(arg):
return f"async:{arg}"

View file

@ -11866,6 +11866,52 @@ def _(rid, params: dict) -> dict:
except Exception:
pass
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
get_skill_bundles,
resolve_bundle_command_key,
)
from hermes_cli.commands import resolve_command
bundle_key = (
resolve_bundle_command_key(name)
if resolve_command(name) is None
else None
)
except Exception:
bundle_key = None
if bundle_key is not None:
try:
bundle_result = build_bundle_invocation_message(
bundle_key,
arg,
task_id=session.get("session_key", "") if session else "",
platform=_resolve_session_platform(),
)
except Exception as exc:
return _err(rid, 4018, f"bundle dispatch failed: {exc}")
if not bundle_result:
return _err(rid, 4018, f"failed to load bundle: {bundle_key}")
msg, loaded_names, missing = bundle_result
bundle_info = get_skill_bundles().get(bundle_key, {})
bundle_name = bundle_info.get("name", bundle_key.lstrip("/"))
notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)"
if missing:
notice += f"\nSkipped missing skills: {', '.join(missing)}"
return _ok(
rid,
{
"type": "send",
"message": msg,
"notice": notice,
},
)
try:
from agent.skill_commands import (
scan_skill_commands,
@ -12277,7 +12323,7 @@ def _(rid, params: dict) -> dict:
except Exception as exc:
return _err(rid, 5009, f"compress failed: {exc}")
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}")
# ── Methods: paste ────────────────────────────────────────────────────
@ -13069,10 +13115,11 @@ def _(rid, params: dict) -> dict:
if not cmd:
return _err(rid, 4004, "empty command")
# Skill slash commands and _pending_input commands must NOT go through the
# slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin
# commands must also avoid the worker, but unlike skills/pending-input they
# still return normal slash.exec output so the TUI keeps the pager path.
# Skill and bundle slash commands plus _pending_input commands must NOT go
# through the slash worker — see _PENDING_INPUT_COMMANDS definition above.
# Plugin commands must also avoid the worker, but unlike skills and
# pending-input commands they still return normal slash.exec output so the
# TUI keeps the pager path.
_cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd
_cmd_parts = _cmd_text.split(maxsplit=1)
_cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower()
@ -13100,6 +13147,27 @@ def _(rid, params: dict) -> dict:
"snapshot restore mutates live config/state; use command.dispatch for /snapshot restore",
)
try:
from agent.skill_bundles import resolve_bundle_command_key
from hermes_cli.commands import resolve_command
_bundle_key = (
resolve_bundle_command_key(_cmd_base)
if resolve_command(_cmd_base) is None
else None
)
if _bundle_key is not None:
return _methods["command.dispatch"](
rid,
{
"name": _bundle_key.lstrip("/"),
"arg": _cmd_arg,
"session_id": params.get("session_id", ""),
},
)
except Exception:
pass
try:
from agent.skill_commands import get_skill_commands