diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index fa804e2092..d94370a1f3 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1222,6 +1222,39 @@ def test_command_dispatch_scopes_skill_lookup_to_session_profile(server, tmp_pat assert resp["result"]["name"] == "b-only" +def test_slash_exec_routes_a_secondary_only_bundle_to_dispatch(server, tmp_path, monkeypatch): + """A skill bundle that exists only under the session profile's ``skill-bundles/`` must be + resolved (and routed to command.dispatch) against that profile, not the launch home (#110695).""" + import agent.skill_bundles as sb_mod + import agent.skill_commands as sc_mod + + monkeypatch.delenv("HERMES_BUNDLES_DIR", raising=False) + profile_b = tmp_path / "profile_b" + external_b = tmp_path / "external_b" + for name in ("one", "two"): + d = external_b / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {name}.\n---\n\n# {name}\n") + (profile_b / "skill-bundles").mkdir(parents=True) + (profile_b / "skill-bundles" / "b-pack.yaml").write_text("name: b-pack\nskills: [one, two]\n") + (profile_b / "config.yaml").write_text(f"skills:\n external_dirs:\n - {external_b}\n") + sid = "test-session-profile-b-bundle" + server._sessions[sid] = {"session_key": sid, "agent": None, "profile_home": str(profile_b)} + + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "no-local-skills"), + patch.object(sb_mod, "_bundles_cache", {}), + patch.object(sb_mod, "_bundles_cache_mtime", None), + patch.object(sc_mod, "_skill_commands", {}), + patch.object(sc_mod, "_skill_commands_home", None), + ): + resp = server.handle_request({ + "id": "r1", "method": "slash.exec", "params": {"command": "/b-pack go", "session_id": sid}}) + + assert "error" not in resp, resp + assert resp["result"]["type"] == "send" and "b-pack" in resp["result"]["notice"] + + def test_command_dispatch_queue_sends_message(server): """command.dispatch /queue returns {type: 'send', message: ...} for the TUI.""" sid = "test-session" diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 87440b5c11..ce052d64eb 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -514,18 +514,28 @@ def _run_plugin_command(handler, arg: str) -> str: return str(_tools_mod("hermes_cli.plugins").resolve_plugin_command_result(handler(arg)) or "") -def _is_profile_skill_command(session: dict, base: str) -> bool: - """True when ``/base`` is a skill command of the session's profile (HERMES_HOME bound to it so - get_skill_commands() sees its skills.external_dirs; nothing upstream binds it). False on failure.""" +@contextlib.contextmanager +def _session_home_scope(session): + """Bind HERMES_HOME to the session's profile for the block (no-op for the launch profile). + + Skill/bundle/quick-command resolution is home-keyed (``skills.external_dirs``, ``skill-bundles/``, + ``quick_commands`` all live in the profile's config/home); nothing upstream of these RPC handlers + binds it, so an unscoped call resolves against the launch profile (#110695).""" + hc = _tools_mod("hermes_constants") + profile_home = session.get("profile_home") if session else None + token = hc.set_hermes_home_override(profile_home) if profile_home else None try: - hc = _tools_mod("hermes_constants") - profile_home = session.get("profile_home") - token = hc.set_hermes_home_override(profile_home) if profile_home else None - try: + yield + finally: + if token is not None: + hc.reset_hermes_home_override(token) + + +def _is_profile_skill_command(session: dict, base: str) -> bool: + """True when ``/base`` is a skill command of the session's profile. False on failure.""" + try: + with _session_home_scope(session): return f"/{base}" in _tools_mod("agent.skill_commands").get_skill_commands() - finally: - if token is not None: - hc.reset_hermes_home_override(token) except Exception: return False @@ -571,20 +581,13 @@ def _dispatch_bundle(rid, params, session, name, arg): def _dispatch_skill(rid, params, session, name, arg): with contextlib.suppress(Exception): sc = _tools_mod("agent.skill_commands") - hc = _tools_mod("hermes_constants") - profile_home = session.get("profile_home") if session else None - token = hc.set_hermes_home_override(profile_home) if profile_home else None - try: - cmds, key = sc.scan_skill_commands(), f"/{name}" - if key in cmds: - msg = sc.build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") - if msg: # UIs render `display`, never `message`. - return _ok(rid, { - "type": "skill", "message": msg, "name": cmds[key].get("name", name), - "display": _skill_scaffold_projection(msg)}) - finally: - if token is not None: - hc.reset_hermes_home_override(token) + cmds, key = sc.get_skill_commands(), f"/{name}" + if key in cmds: + msg = sc.build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") + if msg: # UIs render `display`, never `message`. + return _ok(rid, { + "type": "skill", "message": msg, "name": cmds[key].get("name", name), + "display": _skill_scaffold_projection(msg)}) return None @@ -832,14 +835,18 @@ def _(rid, params: dict) -> dict: name, arg = _resolve_name(params.get("name", "").lstrip("/")), params.get("arg", "") session = _sessions.get(params.get("session_id", "")) - # Stage order is load-bearing: quick > plugin > bundle > skill > built-in. + # Stage order is load-bearing: quick > plugin > bundle > skill > built-in. One home binding + # around the whole loop: the routing guard (``_is_profile_skill_command``) and the stages + # must resolve against the SAME profile or a secondary-only skill is routed here and then + # not found (#110695). stages = (_dispatch_quick, _dispatch_plugin, _dispatch_bundle, _dispatch_skill, _SLASH_BUILTINS.get(name)) - for stage in filter(None, stages): - res = stage(rid, params, session, name, arg) - if res is not None: - if name in _SESSION_CONTROL_SLASHES and "error" not in res: - _publish_session_control_snapshot(params.get("session_id", ""), session) - return res + with _session_home_scope(session): + for stage in filter(None, stages): + res = stage(rid, params, session, name, arg) + if res is not None: + if name in _SESSION_CONTROL_SLASHES and "error" not in res: + _publish_session_control_snapshot(params.get("session_id", ""), session) + return res return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}") @@ -864,7 +871,8 @@ def _(rid, params: dict) -> dict: return _err(rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore") # Pending-input built-ins route straight to command.dispatch (some clients fail the # error-then-retry fallback); bundles go the same way under their resolved key. - target = base if base in _PENDING_INPUT_COMMANDS else _bundle_key_for(base) + with _session_home_scope(session): # a secondary-only bundle must route too (#110695) + target = base if base in _PENDING_INPUT_COMMANDS else _bundle_key_for(base) if target is not None: return _methods["command.dispatch"](rid, {"name": target.lstrip("/"), "arg": arg, "session_id": sid}) if _is_profile_skill_command(session, base):