diff --git a/tests/tui_gateway/test_goal_command.py b/tests/tui_gateway/test_goal_command.py index d06f5b8fbbd..cfff285f1ef 100644 --- a/tests/tui_gateway/test_goal_command.py +++ b/tests/tui_gateway/test_goal_command.py @@ -185,15 +185,17 @@ def test_goal_requires_session(server): # ── slash.exec /goal routing ────────────────────────────────────────── -def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session): - """slash.exec must reject /goal with 4018 so the TUI client falls through - to command.dispatch. Without this, the HermesCLI slash-worker subprocess - would set the goal but silently drop the kickoff — the queue is in-proc.""" +def test_slash_exec_routes_goal_to_command_dispatch(server, session): + """slash.exec must route /goal directly to command.dispatch internally + instead of returning an error. Previously the 4018 error required the + TUI client to retry via command.dispatch, but some clients failed the + fallback, leaving the command empty ("empty command").""" sid, _, _ = session r = _call(server, "slash.exec", command="goal status", session_id=sid) - assert "error" in r - assert r["error"]["code"] == 4018 - assert "command.dispatch" in r["error"]["message"] + # Should succeed by routing to command.dispatch internally + assert "result" in r + assert r["result"]["type"] == "exec" + assert "No active goal" in r["result"]["output"] def test_pending_input_commands_includes_goal(server): diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 60d3c7a5c4f..775a07cb317 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1121,20 +1121,45 @@ def test_slash_exec_plugin_handler_error_returns_output(server): @pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) -def test_slash_exec_rejects_pending_input_commands(server, cmd): - """slash.exec must reject commands that use _pending_input in the CLI.""" - sid = "test-session" - server._sessions[sid] = {"session_key": sid, "agent": None} +def test_slash_exec_routes_pending_input_commands_to_dispatch(server, cmd): + """slash.exec must route _pending_input commands to command.dispatch + internally instead of returning the old 4018 "use command.dispatch" + fallback error (#48848). Some TUI clients failed that client-side + fallback, dropping the input and surfacing "empty command". - resp = server.handle_request({ + The contract is that slash.exec produces exactly the response + command.dispatch would for the same command — no fragile retry hop. + """ + base, _, arg = cmd.partition(" ") + + def fresh_session(): + return {"session_key": "test-session", "agent": None} + + sid = "test-session" + + # Response from the (new) internal routing in slash.exec. + server._sessions[sid] = fresh_session() + routed = server.handle_request({ "id": "r1", "method": "slash.exec", "params": {"command": cmd, "session_id": sid}, }) - assert "error" in resp - assert resp["error"]["code"] == 4018 - assert "pending-input command" in resp["error"]["message"] + # Response from calling command.dispatch directly with the parsed parts. + server._sessions[sid] = fresh_session() + direct = server.handle_request({ + "id": "r1", + "method": "command.dispatch", + "params": {"name": base, "arg": arg, "session_id": sid}, + }) + + # slash.exec must no longer emit the old client-fallback rejection. + if "error" in routed: + assert "pending-input command" not in routed["error"]["message"] + + # Internal routing must yield the same payload as command.dispatch. + assert routed.get("result") == direct.get("result") + assert routed.get("error") == direct.get("error") def test_command_dispatch_queue_sends_message(server): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1b92831df3d..d65cdf49343 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8462,7 +8462,9 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [ # Commands that queue messages onto _pending_input in the CLI. # In the TUI the slash worker subprocess has no reader for that queue, -# so slash.exec rejects them → TUI falls through to command.dispatch. +# so slash.exec routes them to command.dispatch internally (which handles +# them and returns a structured payload) instead of erroring out and +# relying on a client-side fallback. See #48848. _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( { "retry", @@ -9729,8 +9731,16 @@ def _(rid, params: dict) -> dict: _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" if _cmd_base in _PENDING_INPUT_COMMANDS: - return _err( - rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}" + # Route directly to command.dispatch instead of returning an error + # that requires the frontend to retry. Some TUI clients fail the + # fallback, leaving the command empty and showing "empty command". + return _methods["command.dispatch"]( + rid, + { + "name": _cmd_base, + "arg": _cmd_arg, + "session_id": params.get("session_id", ""), + }, ) if _cmd_base in _WORKER_BLOCKED_COMMANDS: