From 964ecef4011860ad7e793da1e454714072e3c6ed Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:34:12 +0300 Subject: [PATCH] fix(tui): dispatch custom skill bundles as agent turns (#62859) --- tests/tui_gateway/test_protocol.py | 131 +++++++++++++++++++++++++++++ tui_gateway/server.py | 78 +++++++++++++++-- 2 files changed, 204 insertions(+), 5 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 5aa3daa00ec..4f09af7aeab 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -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}" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cf168a688ad..58e8e1ce16d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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