fix(tui): route pending-input commands via command.dispatch (#48848)

When /goal (and other _PENDING_INPUT_COMMANDS: retry, queue, q, steer,
plan, undo) were typed in the TUI desktop app, slash.exec returned error
4018 instructing the frontend to fall back to command.dispatch. Some
clients failed that client-side fallback, leaving the command empty and
surfacing "empty command" — the user's typed text was silently dropped.

slash.exec now routes pending-input commands to command.dispatch
internally, eliminating the fragile client-side fallback hop. The
response is exactly what command.dispatch would have produced, so the
TUI client behaves identically once the round-trip succeeds.

Salvaged from #48944 — rebased onto current main. The original PR's
source change and test_goal_command.py update are correct, but it missed
the second test surface: tests/tui_gateway/test_protocol.py's
parametrized test_slash_exec_rejects_pending_input_commands still
asserted the old 4018 rejection for retry/queue/q/steer/plan, turning CI
red (5 failures). That test is rewritten here as a behavior contract:
slash.exec for a pending-input command must yield the same payload as a
direct command.dispatch call, and must no longer emit the old
"pending-input command" fallback rejection.

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
This commit is contained in:
kyssta-exe 2026-06-19 14:53:33 +05:30
parent db57a1a035
commit 1699525638
3 changed files with 55 additions and 18 deletions

View file

@ -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):

View file

@ -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):

View file

@ -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: