{"repo":"NousResearch/hermes-agent","public_id":"48848","display_label":"TUI /goal command dispatch and empty-command input handling","summary":"The members appear to center on the TUI /goal slash command, including a bug report of an \"empty command\" message with swallowed input text in the desktop app. Several titles describe changes to route pending-input commands through command.dispatch, but the titles alone do not show their outcome.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":48953,"merged_at":"2026-06-19T09:29:17+00:00"},"resolution":{"state":"resolved","by":"pr-48953"},"freshness":{"state":"verified","last_verified_at":"2026-06-19T09:29:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":48848,"pr_number":48953,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-19T09:29:17+00:00"}],"members":[{"kind":"issue","number":48848,"title":"Bug: /goal command returns \"empty command\" and swallows all input text in TUI desktop app","url":null,"state":"closed"},{"kind":"pr","number":48856,"title":"Fix TUI /goal slash dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1699525638ed4feba3fd35f0be5c6d4d2d326a49","number":0},{"kind":"pr","value":"#48848","number":48848}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":48944,"title":"fix(tui): route pending-input commands via command.dispatch (#48848)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48953","number":48953}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48953,"title":"fix(tui): route pending-input commands via command.dispatch (#48848)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_goal_command.py","tests/tui_gateway/test_protocol.py","tui_gateway/server.py"],"fix_hunks":[{"file":"tests/tui_gateway/test_goal_command.py","hunks":["@@ -185,15 +185,17 @@ def test_goal_requires_session(server):\n # ── slash.exec /goal routing ──────────────────────────────────────────\n \n \n-def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session):\n-    \"\"\"slash.exec must reject /goal with 4018 so the TUI client falls through\n-    to command.dispatch. Without this, the HermesCLI slash-worker subprocess\n-    would set the goal but silently drop the kickoff — the queue is in-proc.\"\"\"\n+def test_slash_exec_routes_goal_to_command_dispatch(server, session):\n+    \"\"\"slash.exec must route /goal directly to command.dispatch internally\n+    instead of returning an error.  Previously the 4018 error required the\n+    TUI client to retry via command.dispatch, but some clients failed the\n+    fallback, leaving the command empty (\"empty command\").\"\"\"\n     sid, _, _ = session\n     r = _call(server, \"slash.exec\", command=\"goal status\", session_id=sid)\n-    assert \"error\" in r\n-    assert r[\"error\"][\"code\"] == 4018\n-    assert \"command.dispatch\" in r[\"error\"][\"message\"]\n+    # Should succeed by routing to command.dispatch internally\n+    assert \"result\" in r\n+    assert r[\"result\"][\"type\"] == \"exec\"\n+    assert \"No active goal\" in r[\"result\"][\"output\"]\n \n \n def test_pending_input_commands_includes_goal(server):"]},{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -1121,20 +1121,45 @@ def handler(arg):\n \n \n @pytest.mark.parametrize(\"cmd\", [\"retry\", \"queue hello\", \"q hello\", \"steer fix the test\", \"plan\"])\n-def test_slash_exec_rejects_pending_input_commands(server, cmd):\n-    \"\"\"slash.exec must reject commands that use _pending_input in the CLI.\"\"\"\n+def test_slash_exec_routes_pending_input_commands_to_dispatch(server, cmd):\n+    \"\"\"slash.exec must route _pending_input commands to command.dispatch\n+    internally instead of returning the old 4018 \"use command.dispatch\"\n+    fallback error (#48848). Some TUI clients failed that client-side\n+    fallback, dropping the input and surfacing \"empty command\".\n+\n+    The contract is that slash.exec produces exactly the response\n+    command.dispatch would for the same command — no fragile retry hop.\n+    \"\"\"\n+    base, _, arg = cmd.partition(\" \")\n+\n+    def fresh_session():\n+        return {\"session_key\": \"test-session\", \"agent\": None}\n+\n     sid = \"test-session\"\n-    server._sessions[sid] = {\"session_key\": sid, \"agent\": None}\n \n-    resp = server.handle_request({\n+    # Response from the (new) internal routing in slash.exec.\n+    server._sessions[sid] = fresh_session()\n+    routed = server.handle_request({\n         \"id\": \"r1\",\n         \"method\": \"slash.exec\",\n         \"params\": {\"command\": cmd, \"session_id\": sid},\n     })\n \n-    assert \"error\" in resp\n-    assert resp[\"error\"][\"code\"] == 4018\n-    assert \"pending-input command\" in resp[\"error\"][\"message\"]\n+    # Response from calling command.dispatch directly with the parsed parts.\n+    server._sessions[sid] = fresh_session()\n+    direct = server.handle_request({\n+        \"id\": \"r1\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": base, \"arg\": arg, \"session_id\": sid},\n+    })\n+\n+    # slash.exec must no longer emit the old client-fallback rejection.\n+    if \"error\" in routed:\n+        assert \"pending-input command\" not in routed[\"error\"][\"message\"]\n+\n+    # Internal routing must yield the same payload as command.dispatch.\n+    assert routed.get(\"result\") == direct.get(\"result\")\n+    assert routed.get(\"error\") == direct.get(\"error\")\n \n \n def test_command_dispatch_queue_sends_message(server):"]},{"file":"tui_gateway/server.py","hunks":["@@ -8462,7 +8462,9 @@ def _(rid, params: dict) -> dict:\n \n # Commands that queue messages onto _pending_input in the CLI.\n # In the TUI the slash worker subprocess has no reader for that queue,\n-# so slash.exec rejects them → TUI falls through to command.dispatch.\n+# so slash.exec routes them to command.dispatch internally (which handles\n+# them and returns a structured payload) instead of erroring out and\n+# relying on a client-side fallback. See #48848.\n _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(\n     {\n         \"retry\",","@@ -9729,8 +9731,16 @@ def _(rid, params: dict) -> dict:\n     _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else \"\"\n \n     if _cmd_base in _PENDING_INPUT_COMMANDS:\n-        return _err(\n-            rid, 4018, f\"pending-input command: use command.dispatch for /{_cmd_base}\"\n+        # Route directly to command.dispatch instead of returning an error\n+        # that requires the frontend to retry.  Some TUI clients fail the\n+        # fallback, leaving the command empty and showing \"empty command\".\n+        return _methods[\"command.dispatch\"](\n+            rid,\n+            {\n+                \"name\": _cmd_base,\n+                \"arg\": _cmd_arg,\n+                \"session_id\": params.get(\"session_id\", \"\"),\n+            },\n         )\n \n     if _cmd_base in _WORKER_BLOCKED_COMMANDS:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}