From 7d7801347ba4173f2f53e4f92a97c4198e7093f4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 18 Jun 2026 13:21:28 +0000 Subject: [PATCH 1/2] fix(kanban): emit blocked event for initial_status="blocked" tasks (fixes NousResearch/hermes-agent#47777) --- hermes_cli/kanban_db.py | 5 ++ .../hermes_cli/test_kanban_blocked_sticky.py | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index b684450e6bb4..517f29a761a7 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2280,6 +2280,11 @@ def create_task( "goal_mode": bool(goal_mode) or None, }, ) + # Emit a "blocked" event so tasks created with + # initial_status="blocked" are treated as sticky-blocked + # by recompute_ready (#47777). + if task_status == "blocked": + _append_event(conn, task_id, "blocked", {"reason": "created with initial_status=blocked"}) return task_id except sqlite3.IntegrityError: if attempt == 1: diff --git a/tests/hermes_cli/test_kanban_blocked_sticky.py b/tests/hermes_cli/test_kanban_blocked_sticky.py index 2d7cafef826f..19b8bf4d2cb1 100644 --- a/tests/hermes_cli/test_kanban_blocked_sticky.py +++ b/tests/hermes_cli/test_kanban_blocked_sticky.py @@ -277,3 +277,59 @@ def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None: # (landed via #28754 / #28781). The original PR shipped a duplicate test # here; dropped during salvage to avoid two assertions of the same contract. # --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Created-blocked tasks must stay blocked (issue #47777) +# --------------------------------------------------------------------------- + + +def test_created_blocked_task_stays_blocked(kanban_home: Path) -> None: + """A task created with initial_status='blocked' must stay blocked + across dispatcher ticks, not be auto-promoted by recompute_ready.""" + with kb.connect() as conn: + tid = kb.create_task(conn, title="parked task", initial_status="blocked") + assert kb.get_task(conn, tid).status == "blocked" + + for _ in range(5): + promoted = kb.recompute_ready(conn) + assert promoted == 0, "created-blocked task must not auto-promote" + assert kb.get_task(conn, tid).status == "blocked" + + +def test_created_blocked_with_done_parents_stays_blocked(kanban_home: Path) -> None: + """Created-blocked must stay blocked even when all parents are done.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child", parents=[parent], initial_status="blocked") + kb.complete_task(conn, parent, result="parent ok") + + assert kb.get_task(conn, child).status == "blocked" + promoted = kb.recompute_ready(conn) + assert promoted == 0 + assert kb.get_task(conn, child).status == "blocked" + + +def test_created_blocked_can_be_unblocked(kanban_home: Path) -> None: + """A created-blocked task can be explicitly unblocked and then + promoted normally (#47777).""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task( + conn, title="child", parents=[parent], initial_status="blocked" + ) + kb.complete_task(conn, parent, result="parent ok") + + # Must stay blocked until explicit unblock. + assert kb.recompute_ready(conn) == 0 + assert kb.get_task(conn, child).status == "blocked" + + # Explicit unblock frees it (status flips to ready because + # all parents are done). + kb.unblock_task(conn, child) + assert kb.get_task(conn, child).status == "ready" + + # recompute_ready sees the task is already ready. + promoted = kb.recompute_ready(conn) + assert promoted == 0 + assert kb.get_task(conn, child).status == "ready" From 5dfd566932986d86cf3cd9d2ecc289afad2b87f5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 23 Jun 2026 04:11:09 +0000 Subject: [PATCH 2/2] fix: add /whoami CLI handler so command no longer shows Unknown (fixes NousResearch/hermes-agent#51009) --- cli.py | 2 + hermes_cli/cli_commands_mixin.py | 22 +++++++ tests/cli/test_whoami_command.py | 106 +++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 tests/cli/test_whoami_command.py diff --git a/cli.py b/cli.py index 39498e696d4a..98e3ae3c2562 100644 --- a/cli.py +++ b/cli.py @@ -7769,6 +7769,8 @@ def process_command(self, command: str) -> bool: self.show_help() elif canonical == "profile": self._handle_profile_command() + elif canonical == "whoami": + self._handle_whoami_command() elif canonical == "tools": self._handle_tools_command(cmd_original) elif canonical == "toolsets": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 95292314c5af..5610d5524eaa 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -477,6 +477,28 @@ def _handle_profile_command(self): print(f" Home: {display}") print() + def _handle_whoami_command(self): + """Show the user's access level in the CLI context. + + In the CLI the operator is always the owner with full access — + there is no platform-mediated slash-access policy. The handler + mirrors the gateway's ``/whoami`` output shape so users see a + familiar format regardless of surface. + """ + from hermes_cli.profiles import get_active_profile_name + + profile_name = get_active_profile_name() + + from hermes_cli.commands import COMMAND_REGISTRY + all_commands = sorted(cmd.name for cmd in COMMAND_REGISTRY if not cmd.gateway_only) + + print() + print(f" **You** — CLI (local)") + print(f" Profile: {profile_name}") + print(f" Tier: owner (full access)") + print(f" Slash commands available: {len(all_commands)}") + print() + def _handle_handoff_command(self, cmd_original: str) -> bool: """Handle ``/handoff `` — transfer this CLI session to a gateway platform. diff --git a/tests/cli/test_whoami_command.py b/tests/cli/test_whoami_command.py new file mode 100644 index 000000000000..3b99b27f1611 --- /dev/null +++ b/tests/cli/test_whoami_command.py @@ -0,0 +1,106 @@ +"""Tests for the /whoami CLI command dispatch and handler. + +The /whoami command is registered in COMMAND_REGISTRY (and advertised by +/help, tab-completion, and the tips system) but had no dispatch branch in +HermesCLI.process_command — so typing it in the CLI/TUI/Desktop printed +"Unknown command: /whoami". These tests lock in the dispatch wiring and +the handler behavior. See issue #51009. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = None + cli_obj._pending_input = MagicMock() + return cli_obj + + +class TestWhoamiDispatch(unittest.TestCase): + """/whoami must route to its handler — not fall through to "Unknown".""" + + def test_whoami_dispatches_to_handler(self): + cli_obj = _make_cli() + with patch.object(cli_obj, "_handle_whoami_command") as mock_handler: + result = cli_obj.process_command("/whoami") + + mock_handler.assert_called_once() + self.assertTrue(result) + + def test_whoami_is_not_unknown_command(self): + cli_obj = _make_cli() + with patch("cli._cprint") as mock_cprint: + result = cli_obj.process_command("/whoami") + + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertNotIn("Unknown command", printed) + self.assertTrue(result) + + +class TestHandleWhoamiCommand(unittest.TestCase): + """Handler should report owner-tier access for the CLI context.""" + + def test_output_contains_owner_tier(self): + cli_obj = _make_cli() + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), + patch("builtins.print") as mock_print, + ): + HermesCLI._handle_whoami_command(cli_obj) + + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("owner", printed) + + def test_output_contains_cli_surface(self): + cli_obj = _make_cli() + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), + patch("builtins.print") as mock_print, + ): + HermesCLI._handle_whoami_command(cli_obj) + + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("CLI", printed) + + def test_output_shows_profile_name(self): + cli_obj = _make_cli() + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="test-profile"), + patch("builtins.print") as mock_print, + ): + HermesCLI._handle_whoami_command(cli_obj) + + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("test-profile", printed) + + +class TestWhoamiRegistry(unittest.TestCase): + def test_whoami_in_registry(self): + from hermes_cli.commands import COMMAND_REGISTRY + + names = [c.name for c in COMMAND_REGISTRY] + self.assertIn("whoami", names) + + def test_whoami_not_gateway_only(self): + from hermes_cli.commands import COMMAND_REGISTRY + + whoami = next(c for c in COMMAND_REGISTRY if c.name == "whoami") + self.assertFalse(whoami.gateway_only, "/whoami should be available on all surfaces") + + def test_whoami_category_is_info(self): + from hermes_cli.commands import COMMAND_REGISTRY + + whoami = next(c for c in COMMAND_REGISTRY if c.name == "whoami") + self.assertEqual(whoami.category, "Info") + + +if __name__ == "__main__": + unittest.main()