From 83c1c201f61c607259f5a5f7af32ddbc9c1cc2cc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:06:27 -0700 Subject: [PATCH 1/7] feat(onboarding): contextual first-touch hints for /busy and /verbose (#16046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a blocking first-run questionnaire, show a one-time hint the first time the user hits each behavior fork: 1. First message while the agent is working — appends a hint to the busy-ack explaining the /busy queue vs /busy interrupt knob, phrased to match the mode that was just applied (don't tell a queue-mode user to switch to queue). 2. First tool that runs for >= 30s in the noisiest progress mode (tool_progress: all) — prints a hint about /verbose to cycle display modes (all -> new -> off -> verbose). Gated on /verbose actually being usable on the surface: always shown on CLI; on gateway only shown when display.tool_progress_command is enabled. Each hint is latched in config.yaml under onboarding.seen., so it fires exactly once per install across CLI, gateway, and cron, then never again. Users can wipe the section to re-see hints. New: - agent/onboarding.py — is_seen / mark_seen / hint strings, shared by both CLI and gateway. - onboarding.seen in DEFAULT_CONFIG (hermes_cli/config.py) and in load_cli_config defaults (cli.py). No _config_version bump — deep merge handles new keys. Wired: - gateway/run.py: _handle_active_session_busy_message appends the hint after building the ack. progress_callback tracks tool.completed duration and queues the tool-progress hint into the progress bubble. - cli.py: CLI input loop appends the busy-input hint on the first busy Enter; _on_tool_progress appends the tool-progress hint on the first >=30s tool completion. In-memory CLI_CONFIG is also updated so subsequent fires in the same process are suppressed immediately. All writes go through atomic_yaml_write and are wrapped in try/except so onboarding can never break the input/busy-ack paths. --- agent/onboarding.py | 144 ++++++++++++++++++ cli.py | 48 ++++++ gateway/run.py | 53 ++++++- hermes_cli/config.py | 7 + tests/agent/test_onboarding.py | 164 +++++++++++++++++++++ tests/gateway/test_busy_session_ack.py | 118 +++++++++++++++ website/docs/user-guide/cli.md | 4 + website/docs/user-guide/messaging/index.md | 11 ++ 8 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 agent/onboarding.py create mode 100644 tests/agent/test_onboarding.py diff --git a/agent/onboarding.py b/agent/onboarding.py new file mode 100644 index 0000000000..eed832ab90 --- /dev/null +++ b/agent/onboarding.py @@ -0,0 +1,144 @@ +""" +Contextual first-touch onboarding hints. + +Instead of blocking first-run questionnaires, show a one-time hint the *first* +time a user hits a behavior fork — message-while-running, first long-running +tool, etc. Each hint is shown once per install (tracked in ``config.yaml`` under +``onboarding.seen.``) and then never again. + +Keep this module tiny and dependency-free so both the CLI and gateway can import +it without pulling in heavy modules. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------------- +# Flag names (stable — used as config.yaml keys under onboarding.seen) +# ------------------------------------------------------------------------- + +BUSY_INPUT_FLAG = "busy_input_prompt" +TOOL_PROGRESS_FLAG = "tool_progress_prompt" + + +# ------------------------------------------------------------------------- +# Hint content +# ------------------------------------------------------------------------- + +def busy_input_hint_gateway(mode: str) -> str: + """Hint shown the first time a user messages while the agent is busy. + + ``mode`` is the effective busy_input_mode that was just applied, so the + message matches reality ("I just interrupted…" vs "I just queued…"). + """ + if mode == "queue": + return ( + "💡 First-time tip — I queued your message instead of interrupting. " + "Send `/busy interrupt` to make new messages stop the current task " + "immediately, or `/busy status` to check. This notice won't appear again." + ) + return ( + "💡 First-time tip — I just interrupted my current task to answer you. " + "Send `/busy queue` to queue follow-ups for after the current task instead, " + "or `/busy status` to check. This notice won't appear again." + ) + + +def busy_input_hint_cli(mode: str) -> str: + """CLI version of the busy-input hint (plain text, no markdown).""" + if mode == "queue": + return ( + "(tip) Your message was queued for the next turn. " + "Use /busy interrupt to make Enter stop the current run instead. " + "This tip only shows once." + ) + return ( + "(tip) Your message interrupted the current run. " + "Use /busy queue to queue messages for the next turn instead. " + "This tip only shows once." + ) + + +def tool_progress_hint_gateway() -> str: + return ( + "💡 First-time tip — that tool took a while and I'm streaming every step. " + "If the progress messages feel noisy, send `/verbose` to cycle modes " + "(all → new → off). This notice won't appear again." + ) + + +def tool_progress_hint_cli() -> str: + return ( + "(tip) That tool ran for a while. Use /verbose to cycle tool-progress " + "display modes (all -> new -> off -> verbose). This tip only shows once." + ) + + +# ------------------------------------------------------------------------- +# State read / write +# ------------------------------------------------------------------------- + +def _get_seen_dict(config: Mapping[str, Any]) -> Mapping[str, Any]: + onboarding = config.get("onboarding") if isinstance(config, Mapping) else None + if not isinstance(onboarding, Mapping): + return {} + seen = onboarding.get("seen") + return seen if isinstance(seen, Mapping) else {} + + +def is_seen(config: Mapping[str, Any], flag: str) -> bool: + """Return True if the user has already been shown this first-touch hint.""" + return bool(_get_seen_dict(config).get(flag)) + + +def mark_seen(config_path: Path, flag: str) -> bool: + """Persist ``onboarding.seen. = True`` to ``config_path``. + + Uses the atomic YAML writer so a concurrent process can't observe a + partially-written file. Returns True on success, False on any error + (including the config file being absent — onboarding is best-effort). + """ + try: + import yaml + from utils import atomic_yaml_write + except Exception as e: # pragma: no cover — dependency issue + logger.debug("onboarding: failed to import yaml/utils: %s", e) + return False + + try: + cfg: dict = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg.get("onboarding"), dict): + cfg["onboarding"] = {} + seen = cfg["onboarding"].get("seen") + if not isinstance(seen, dict): + seen = {} + cfg["onboarding"]["seen"] = seen + if seen.get(flag) is True: + return True # already marked — nothing to do + seen[flag] = True + atomic_yaml_write(config_path, cfg) + return True + except Exception as e: + logger.debug("onboarding: failed to mark flag %s: %s", flag, e) + return False + + +__all__ = [ + "BUSY_INPUT_FLAG", + "TOOL_PROGRESS_FLAG", + "busy_input_hint_gateway", + "busy_input_hint_cli", + "tool_progress_hint_gateway", + "tool_progress_hint_cli", + "is_seen", + "mark_seen", +] diff --git a/cli.py b/cli.py index bc77d4c350..038c83f06f 100644 --- a/cli.py +++ b/cli.py @@ -417,6 +417,11 @@ def load_cli_config() -> Dict[str, Any]: "base_url": "", # Direct OpenAI-compatible endpoint for subagents "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) }, + "onboarding": { + # First-touch hint flags (see agent/onboarding.py). Each hint is + # shown once per install then latched here. + "seen": {}, + }, } # Track whether the config file explicitly set terminal config. @@ -7412,6 +7417,31 @@ class HermesCLI: _cprint(f" {line}") except Exception: pass + # First-touch onboarding: on the first tool in this process + # that takes longer than the threshold while we're in the + # noisiest progress mode, print a one-time hint about + # /verbose. Latched on self so it fires at most once per + # process; persisted to config.yaml so it never fires again + # across processes either. + try: + if ( + not getattr(self, "_long_tool_hint_fired", False) + and self.tool_progress_mode == "all" + and duration >= 30.0 + ): + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_cli, + ) + if not is_seen(CLI_CONFIG, TOOL_PROGRESS_FLAG): + self._long_tool_hint_fired = True + _cprint(f" {_DIM}{tool_progress_hint_cli()}{_RST}") + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[TOOL_PROGRESS_FLAG] = True + except Exception: + pass self._invalidate() return if event_type != "tool.started": @@ -9295,6 +9325,24 @@ class HermesCLI: f"agent_running={self._agent_running}\n") except Exception: pass + # First-touch onboarding: on the very first busy-while-running + # event for this install, print a one-line tip explaining the + # /busy knob. Flag persists to config.yaml and never fires + # again. Guarded for exceptions so onboarding can't break + # the input loop. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_cli, + is_seen, + mark_seen, + ) + if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): + _cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}") + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True + except Exception: + pass else: self._pending_input.put(payload) event.app.current_buffer.reset(append_to_history=True) diff --git a/gateway/run.py b/gateway/run.py index 497d9241c4..d7331bdc75 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1630,6 +1630,27 @@ class GatewayRunner: f"I'll respond to your message shortly." ) + # First-touch onboarding: the very first time a user sends a message + # while the agent is busy, append a one-time hint explaining the + # queue/interrupt knob. Flag is persisted to config.yaml so it never + # fires again on this install. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_gateway, + is_seen, + mark_seen, + ) + _user_cfg = _load_gateway_config() + if not is_seen(_user_cfg, BUSY_INPUT_FLAG): + message = ( + f"{message}\n\n" + f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}" + ) + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + except Exception as _onb_err: + logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err) + thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None try: await adapter._send_with_retry( @@ -9411,12 +9432,42 @@ class GatewayRunner: last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated - + # First-touch onboarding latch: fires at most once per run, even if + # several tools exceed the threshold. + long_tool_hint_fired = [False] + _LONG_TOOL_THRESHOLD_S = 30.0 + def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): """Callback invoked by agent on tool lifecycle events.""" if not progress_queue or not _run_still_current(): return + # First-touch onboarding: the first time a tool takes longer than + # _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool + # (progress_mode == "all"), append a one-time hint suggesting + # /verbose. We only fire when (a) the user hasn't seen the hint + # before and (b) /verbose is actually usable on this platform + # (gateway gate must be open). The CLI has its own trigger. + if event_type == "tool.completed" and not long_tool_hint_fired[0]: + try: + duration = kwargs.get("duration") or 0 + if duration >= _LONG_TOOL_THRESHOLD_S and progress_mode == "all": + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_gateway, + ) + _cfg = _load_gateway_config() + gate_on = bool(_cfg.get("display", {}).get("tool_progress_command", False)) + if gate_on and not is_seen(_cfg, TOOL_PROGRESS_FLAG): + long_tool_hint_fired[0] = True + progress_queue.put(tool_progress_hint_gateway()) + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + except Exception as _hint_err: + logger.debug("tool-progress onboarding hint failed: %s", _hint_err) + return + # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) if event_type not in ("tool.started",): return diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4af2aff1de..72d0232f33 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1016,6 +1016,13 @@ DEFAULT_CONFIG = { "min_interval_hours": 24, }, + # Contextual first-touch onboarding hints (see agent/onboarding.py). + # Each hint is shown once per install and then latched here so it + # never fires again. Users can wipe the section to re-see all hints. + "onboarding": { + "seen": {}, + }, + # Config schema version - bump this when adding new required fields "_config_version": 22, } diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py new file mode 100644 index 0000000000..a14c7d1797 --- /dev/null +++ b/tests/agent/test_onboarding.py @@ -0,0 +1,164 @@ +"""Tests for agent/onboarding.py — contextual first-touch hint helpers.""" + +from __future__ import annotations + +import yaml +import pytest + +from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_cli, + busy_input_hint_gateway, + is_seen, + mark_seen, + tool_progress_hint_cli, + tool_progress_hint_gateway, +) + + +class TestIsSeen: + def test_empty_config_unseen(self): + assert is_seen({}, BUSY_INPUT_FLAG) is False + + def test_missing_onboarding_unseen(self): + assert is_seen({"display": {}}, BUSY_INPUT_FLAG) is False + + def test_onboarding_not_dict_unseen(self): + assert is_seen({"onboarding": "nope"}, BUSY_INPUT_FLAG) is False + + def test_seen_dict_missing_flag(self): + assert is_seen({"onboarding": {"seen": {}}}, BUSY_INPUT_FLAG) is False + + def test_seen_flag_true(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is True + + def test_seen_flag_falsy(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: False}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is False + + def test_other_flags_isolated(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, TOOL_PROGRESS_FLAG) is False + + +class TestMarkSeen: + def test_creates_missing_file_and_sets_flag(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_config(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "model": {"default": "claude-sonnet-4.6"}, + "display": {"skin": "default"}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["model"]["default"] == "claude-sonnet-4.6" + assert loaded["display"]["skin"] == "default" + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_seen_flags(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "onboarding": {"seen": {TOOL_PROGRESS_FLAG: True}}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["onboarding"]["seen"][TOOL_PROGRESS_FLAG] is True + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_idempotent(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + mark_seen(cfg_path, BUSY_INPUT_FLAG) + first = cfg_path.read_text() + + # Second call must be a no-op on-disk content (file may be touched, + # but the YAML contents should be identical). + mark_seen(cfg_path, BUSY_INPUT_FLAG) + second = cfg_path.read_text() + + assert yaml.safe_load(first) == yaml.safe_load(second) + + def test_handles_non_dict_onboarding(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": "corrupted"})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_handles_non_dict_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": {"seen": "corrupted"}})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + +class TestHintMessages: + def test_busy_input_hint_gateway_interrupt(self): + msg = busy_input_hint_gateway("interrupt") + assert "/busy queue" in msg + assert "interrupted" in msg.lower() + + def test_busy_input_hint_gateway_queue(self): + msg = busy_input_hint_gateway("queue") + assert "/busy interrupt" in msg + assert "queued" in msg.lower() + + def test_busy_input_hint_cli_interrupt(self): + msg = busy_input_hint_cli("interrupt") + assert "/busy queue" in msg + + def test_busy_input_hint_cli_queue(self): + msg = busy_input_hint_cli("queue") + assert "/busy interrupt" in msg + + def test_tool_progress_hints_mention_verbose(self): + assert "/verbose" in tool_progress_hint_gateway() + assert "/verbose" in tool_progress_hint_cli() + + def test_hints_are_not_empty(self): + for hint in ( + busy_input_hint_gateway("queue"), + busy_input_hint_gateway("interrupt"), + busy_input_hint_cli("queue"), + busy_input_hint_cli("interrupt"), + tool_progress_hint_gateway(), + tool_progress_hint_cli(), + ): + assert hint.strip() + + +class TestRoundTrip: + """After mark_seen, is_seen on the re-loaded config must return True.""" + + def test_mark_then_is_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is False + + def test_mark_both_flags_independently(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + mark_seen(cfg_path, BUSY_INPUT_FLAG) + mark_seen(cfg_path, TOOL_PROGRESS_FLAG) + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is True diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 290c1a4b89..2d5f30f6d3 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -349,3 +349,121 @@ class TestBusySessionAck: result = await runner._handle_active_session_busy_message(event, sk) assert result is False # not handled, let default path try + + +class TestBusySessionOnboardingHint: + """First-touch hint appended to the busy-ack the first time it fires.""" + + @pytest.mark.asyncio + async def test_first_busy_ack_appends_interrupt_hint(self, tmp_path, monkeypatch): + """First busy-while-running message gets an extra hint about /busy.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # mark_seen imports utils.atomic_yaml_write; make sure it resolves + # against a writable dir by pointing _hermes_home at tmp_path. + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + # Normal ack body + assert "Interrupting" in content + # First-touch hint appended + assert "First-time tip" in content + assert "/busy queue" in content + + # The flag is now persisted to tmp_path/config.yaml + import yaml + cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True + + @pytest.mark.asyncio + async def test_second_busy_ack_omits_hint(self, tmp_path, monkeypatch): + """Once the flag is marked, the hint never appears again.""" + import gateway.run as _gr + import yaml + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # Pre-populate the config so is_seen() returns True from the start. + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "onboarding": {"seen": {"busy_input_prompt": True}}, + })) + monkeypatch.setattr( + _gr, "_load_gateway_config", + lambda: yaml.safe_load((tmp_path / "config.yaml").read_text()), + ) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping again") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + assert "Interrupting" in content + assert "First-time tip" not in content + assert "/busy queue" not in content + + @pytest.mark.asyncio + async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch): + """In queue mode the hint should suggest /busy interrupt, not /busy queue.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "queue" + adapter = _make_adapter() + + event = _make_event(text="queue me") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + runner._running_agents[sk] = agent + + with patch("gateway.run.merge_pending_message_event"): + await runner._handle_active_session_busy_message(event, sk) + + content = adapter._send_with_retry.call_args.kwargs.get("content", "") + assert "Queued for the next turn" in content + assert "First-time tip" in content + assert "/busy interrupt" in content + # Must NOT tell the user to /busy queue when they're already on queue. + assert "/busy queue" not in content diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 90b571aa8b..0ba7245958 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -242,6 +242,10 @@ You can also change it inside the CLI: /busy status ``` +:::tip First-touch hint +The very first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob (`"(tip) Your message interrupted the current run…"`). It only fires once per install — a flag in `config.yaml` under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. +::: + ### Suspending to Background On Unix systems, press **`Ctrl+Z`** to suspend Hermes to the background — just like any terminal process. The shell prints a confirmation: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index dcde46a6b5..2e6fa4f212 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -219,6 +219,17 @@ Send any message while the agent is working to interrupt it. Key behaviors: - **Multiple messages are combined** — messages sent during interruption are joined into one prompt - **`/stop` command** — interrupts without queuing a follow-up message +### Queue vs interrupt (busy-input mode) + +By default, messaging a busy agent interrupts it. To switch the whole install so follow-ups queue behind the current task instead, set: + +```yaml +display: + busy_input_mode: queue # default: interrupt +``` + +The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. + ## Tool Progress Notifications Control how much tool activity is displayed in `~/.hermes/config.yaml`: From 1e37ddc9293cc7b912b1ef85765f4fc93dba7ced Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:19:04 -0700 Subject: [PATCH 2/7] feat(cli): add 'hermes fallback' command to manage fallback providers (#16052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage the fallback_providers chain from the CLI instead of hand-editing config.yaml. The picker reuses select_provider_and_model() from 'hermes model' — same provider list, same credential prompts, same model picker. hermes fallback [list] Show the current chain (primary + fallbacks) hermes fallback add Run the model picker, append selection to chain hermes fallback remove Pick an entry to delete (arrow-key menu) hermes fallback clear Remove all entries (with confirmation) 'add' snapshots config['model'] before calling the picker, extracts the user's selection from the post-picker state, then restores the primary and appends {provider, model, base_url?, api_mode?} to fallback_providers. Auth store's active_provider is snapshot/restored too so OAuth-provider fallbacks don't silently deactivate the user's primary. Duplicates and self-as-fallback are rejected. Legacy single-dict 'fallback_model' entries are auto-migrated to the list format on first write. --- hermes_cli/fallback_cmd.py | 361 +++++++++++++++++++ hermes_cli/main.py | 39 +++ tests/hermes_cli/test_fallback_cmd.py | 486 ++++++++++++++++++++++++++ 3 files changed, 886 insertions(+) create mode 100644 hermes_cli/fallback_cmd.py create mode 100644 tests/hermes_cli/test_fallback_cmd.py diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py new file mode 100644 index 0000000000..02c0a01c39 --- /dev/null +++ b/hermes_cli/fallback_cmd.py @@ -0,0 +1,361 @@ +""" +hermes fallback — manage the fallback provider chain. + +Fallback providers are tried in order when the primary model fails with +rate-limit, overload, or connection errors. See: +https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers + +Subcommands: + hermes fallback [list] Show the current fallback chain (default when no subcommand) + hermes fallback add Pick provider + model via the same picker as `hermes model`, + then append the selection to the chain + hermes fallback remove Pick an entry to delete from the chain + hermes fallback clear Remove all fallback entries + +Storage: ``fallback_providers`` in ``~/.hermes/config.yaml`` (top-level, list of +``{provider, model, base_url?, api_mode?}`` dicts). The legacy single-dict +``fallback_model`` format is migrated to the new list format on first add. +""" +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return the normalized fallback chain as a list of dicts. + + Accepts both the new list format (``fallback_providers``) and the legacy + single-dict format (``fallback_model``). The returned list is always a + fresh copy — callers can mutate without touching the config dict. + """ + chain = config.get("fallback_providers") or [] + if isinstance(chain, list): + result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")] + if result: + return result + legacy = config.get("fallback_model") + if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"): + return [dict(legacy)] + if isinstance(legacy, list): + return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")] + return [] + + +def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None: + """Persist the chain to ``fallback_providers`` and clear legacy key.""" + config["fallback_providers"] = chain + # Drop the legacy single-dict key on write so there's only one source of truth. + if "fallback_model" in config: + config.pop("fallback_model", None) + + +def _format_entry(entry: Dict[str, Any]) -> str: + """One-line human-readable rendering of a fallback entry.""" + provider = entry.get("provider", "?") + model = entry.get("model", "?") + base = entry.get("base_url") + suffix = f" [{base}]" if base else "" + return f"{model} (via {provider}){suffix}" + + +def _extract_fallback_from_model_cfg(model_cfg: Any) -> Optional[Dict[str, Any]]: + """Pull the ``{provider, model, base_url?, api_mode?}`` dict from a ``config["model"]`` snapshot.""" + if not isinstance(model_cfg, dict): + return None + provider = (model_cfg.get("provider") or "").strip() + # The picker writes the selected model to ``model.default``. + model = (model_cfg.get("default") or model_cfg.get("model") or "").strip() + if not provider or not model: + return None + entry: Dict[str, Any] = {"provider": provider, "model": model} + base_url = (model_cfg.get("base_url") or "").strip() + if base_url: + entry["base_url"] = base_url + api_mode = (model_cfg.get("api_mode") or "").strip() + if api_mode: + entry["api_mode"] = api_mode + return entry + + +def _snapshot_auth_active_provider() -> Any: + """Return the current ``active_provider`` in auth.json, or a sentinel if unavailable.""" + try: + from hermes_cli.auth import _load_auth_store + store = _load_auth_store() + return store.get("active_provider") + except Exception: + return None + + +def _restore_auth_active_provider(value: Any) -> None: + """Write back a previously snapshotted ``active_provider`` value.""" + try: + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["active_provider"] = value + _save_auth_store(store) + except Exception: + # Best-effort — if auth.json can't be restored, the user's primary + # provider may have been deactivated by the picker. They can re-run + # `hermes model` to fix it. Don't fail the fallback add. + pass + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + +def cmd_fallback_list(args) -> None: # noqa: ARG001 + """Print the current fallback chain.""" + from hermes_cli.config import load_config + + config = load_config() + chain = _read_chain(config) + + print() + if not chain: + print(" No fallback providers configured.") + print() + print(" Add one with: hermes fallback add") + print() + return + + primary = _describe_primary(config) + if primary: + print(f" Primary: {primary}") + print() + print(f" Fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + print(" Tried in order when the primary fails (rate-limit, 5xx, connection errors).") + print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers") + print() + + +def _describe_primary(config: Dict[str, Any]) -> Optional[str]: + """One-line description of the primary model for display purposes.""" + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + provider = (model_cfg.get("provider") or "?").strip() or "?" + model = (model_cfg.get("default") or model_cfg.get("model") or "?").strip() or "?" + return f"{model} (via {provider})" + if isinstance(model_cfg, str) and model_cfg.strip(): + return model_cfg.strip() + return None + + +def cmd_fallback_add(args) -> None: + """Launch the same picker as `hermes model`, then append the selection to the chain.""" + from hermes_cli.main import _require_tty, select_provider_and_model + from hermes_cli.config import load_config, save_config + + _require_tty("fallback add") + + # Snapshot BEFORE the picker runs so we can distinguish "user actually + # picked something" from "user cancelled" by comparing before/after. + before_cfg = load_config() + model_before = copy.deepcopy(before_cfg.get("model")) + active_provider_before = _snapshot_auth_active_provider() + + print() + print(" Adding a fallback provider. The picker below is the same one used by") + print(" `hermes model` — select the provider + model you want as a fallback.") + print() + + try: + select_provider_and_model(args=args) + except SystemExit: + # Some provider flows exit on auth failure — restore state and re-raise. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + raise + + # Read the post-picker state to see what the user selected. + after_cfg = load_config() + model_after = after_cfg.get("model") + + new_entry = _extract_fallback_from_model_cfg(model_after) + if not new_entry: + # Picker didn't complete (user cancelled or flow bailed). Nothing to do. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(" No fallback added.") + return + + # Picker picked the same thing that's already the primary → nothing changed, + # and there's nothing useful to add as a fallback to itself. + primary_entry = _extract_fallback_from_model_cfg(model_before) + if primary_entry and primary_entry["provider"] == new_entry["provider"] \ + and primary_entry["model"] == new_entry["model"]: + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(f" Selected model matches the current primary ({_format_entry(new_entry)}).") + print(" A provider cannot be a fallback for itself — no change.") + return + + # Reload the config with the primary restored, then append the new entry + # to ``fallback_providers``. We deliberately re-load (rather than mutating + # ``after_cfg``) because the picker may have touched other top-level keys + # (custom_providers, providers credentials) that we want to keep. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + + final_cfg = load_config() + chain = _read_chain(final_cfg) + + # Reject exact-duplicate fallback entries. + for existing in chain: + if existing.get("provider") == new_entry["provider"] \ + and existing.get("model") == new_entry["model"]: + print() + print(f" {_format_entry(new_entry)} is already in the fallback chain — skipped.") + return + + chain.append(new_entry) + _write_chain(final_cfg, chain) + save_config(final_cfg) + + print() + print(f" Added fallback: {_format_entry(new_entry)}") + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + print() + print(" Run `hermes fallback list` to view, or `hermes fallback remove` to delete.") + + +def _restore_model_cfg(model_before: Any) -> None: + """Restore ``config["model"]`` to a previously-captured snapshot.""" + from hermes_cli.config import load_config, save_config + + cfg = load_config() + if model_before is None: + cfg.pop("model", None) + else: + cfg["model"] = copy.deepcopy(model_before) + save_config(cfg) + + +def cmd_fallback_remove(args) -> None: # noqa: ARG001 + """Pick an entry from the chain and remove it.""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured — nothing to remove.") + print() + return + + choices = [_format_entry(e) for e in chain] + choices.append("Cancel") + + try: + from hermes_cli.setup import _curses_prompt_choice + idx = _curses_prompt_choice("Select a fallback to remove:", choices, 0) + except Exception: + idx = _numbered_pick("Select a fallback to remove:", choices) + + if idx is None or idx < 0 or idx >= len(chain): + print() + print(" Cancelled — no change.") + return + + removed = chain.pop(idx) + _write_chain(config, chain) + save_config(config) + + print() + print(f" Removed fallback: {_format_entry(removed)}") + if chain: + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + else: + print(" Fallback chain is now empty.") + print() + + +def cmd_fallback_clear(args) -> None: # noqa: ARG001 + """Remove all fallback entries (with confirmation).""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured — nothing to clear.") + print() + return + + print() + print(f" Current fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + try: + resp = input(" Clear all entries? [y/N]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + print(" Cancelled.") + return + if resp not in ("y", "yes"): + print(" Cancelled — no change.") + return + + _write_chain(config, []) + save_config(config) + print() + print(" Fallback chain cleared.") + print() + + +def _numbered_pick(question: str, choices: List[str]) -> Optional[int]: + """Fallback numbered-list picker when curses is unavailable.""" + print(question) + for i, c in enumerate(choices, 1): + print(f" {i}. {c}") + print() + while True: + try: + val = input(f"Choice [1-{len(choices)}]: ").strip() + if not val: + return None + idx = int(val) - 1 + if 0 <= idx < len(choices): + return idx + print(f"Please enter 1-{len(choices)}") + except ValueError: + print("Please enter a number") + except (KeyboardInterrupt, EOFError): + print() + return None + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +def cmd_fallback(args) -> None: + """Top-level dispatcher for ``hermes fallback [subcommand]``.""" + sub = getattr(args, "fallback_command", None) + if sub in (None, "", "list", "ls"): + cmd_fallback_list(args) + elif sub == "add": + cmd_fallback_add(args) + elif sub in ("remove", "rm"): + cmd_fallback_remove(args) + elif sub == "clear": + cmd_fallback_clear(args) + else: + print(f"Unknown fallback subcommand: {sub}") + print("Use one of: list, add, remove, clear") + raise SystemExit(2) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 30dfee21e2..a53b8d2c5e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7223,6 +7223,9 @@ Examples: hermes auth remove

Remove pooled credential by index, id, or label hermes auth reset Clear exhaustion status for a provider hermes model Select default model + hermes fallback [list] Show fallback provider chain + hermes fallback add Add a fallback provider (same picker as `hermes model`) + hermes fallback remove Remove a fallback provider from the chain hermes config View configuration hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value @@ -7564,6 +7567,42 @@ For more help on a command: ) model_parser.set_defaults(func=cmd_model) + # ========================================================================= + # fallback command — manage the fallback provider chain + # ========================================================================= + from hermes_cli.fallback_cmd import cmd_fallback + + fallback_parser = subparsers.add_parser( + "fallback", + help="Manage fallback providers (tried when the primary model fails)", + description=( + "Manage the fallback provider chain. Fallback providers are tried " + "in order when the primary model fails with rate-limit, overload, or " + "connection errors. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers" + ), + ) + fallback_subparsers = fallback_parser.add_subparsers(dest="fallback_command") + fallback_subparsers.add_parser( + "list", + aliases=["ls"], + help="Show the current fallback chain (default when no subcommand)", + ) + fallback_subparsers.add_parser( + "add", + help="Pick a provider + model (same picker as `hermes model`) and append to the chain", + ) + fallback_subparsers.add_parser( + "remove", + aliases=["rm"], + help="Pick an entry to delete from the chain", + ) + fallback_subparsers.add_parser( + "clear", + help="Remove all fallback entries", + ) + fallback_parser.set_defaults(func=cmd_fallback) + # ========================================================================= # gateway command # ========================================================================= diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py new file mode 100644 index 0000000000..a88c84b3aa --- /dev/null +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -0,0 +1,486 @@ +"""Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration.""" +from __future__ import annotations + +import io +import types +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Shared fixture — isolate HERMES_HOME so save_config writes to tmp_path +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + home = tmp_path / ".hermes" + home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + return tmp_path + + +def _write_config(home: Path, data: dict) -> None: + config_path = home / ".hermes" / "config.yaml" + config_path.write_text(yaml.safe_dump(data), encoding="utf-8") + + +def _read_config(home: Path) -> dict: + config_path = home / ".hermes" / "config.yaml" + return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + +# --------------------------------------------------------------------------- +# _read_chain / _write_chain +# --------------------------------------------------------------------------- + +class TestReadChain: + def test_returns_empty_list_when_unset(self): + from hermes_cli.fallback_cmd import _read_chain + assert _read_chain({}) == [] + + def test_reads_new_list_format(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + + def test_migrates_legacy_single_dict(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}} + assert _read_chain(cfg) == [{"provider": "openrouter", "model": "gpt-5.4"}] + + def test_skips_incomplete_entries(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter"}, # missing model + {"model": "gpt-5.4"}, # missing provider + {"provider": "nous", "model": "foo"}, # valid + "not-a-dict", # noise + ] + } + assert _read_chain(cfg) == [{"provider": "nous", "model": "foo"}] + + def test_returns_copies_not_aliases(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_providers": [{"provider": "nous", "model": "foo"}]} + result = _read_chain(cfg) + result[0]["provider"] = "mutated" + assert cfg["fallback_providers"][0]["provider"] == "nous" + + +# --------------------------------------------------------------------------- +# _extract_fallback_from_model_cfg +# --------------------------------------------------------------------------- + +class TestExtractFallback: + def test_extracts_from_default_field(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"} + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + } + + def test_extracts_optional_base_url_and_api_mode(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = { + "provider": "custom", + "default": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "custom", + "model": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + + def test_returns_none_without_provider(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"default": "foo"}) is None + + def test_returns_none_without_model(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"provider": "openrouter"}) is None + + def test_returns_none_for_non_dict(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg("plain-string") is None + assert _extract_fallback_from_model_cfg(None) is None + + +# --------------------------------------------------------------------------- +# cmd_fallback_list +# --------------------------------------------------------------------------- + +class TestListCommand: + def test_list_empty(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + assert "hermes fallback add" in out + + def test_list_with_entries(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "Fallback chain (2 entries)" in out + assert "anthropic/claude-sonnet-4.6" in out + assert "Hermes-4" in out + # Primary should be shown too + assert "claude-sonnet-4-6" in out + + def test_list_migrates_legacy_for_display(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}, + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "1 entry" in out + assert "gpt-5.4" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_add — mock select_provider_and_model +# --------------------------------------------------------------------------- + +class TestAddCommand: + def test_add_appends_new_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # Simulate what the real picker does: writes the selection to config["model"] + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary is preserved + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + # Fallback was appended + assert cfg["fallback_providers"] == [ + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + ] + out = capsys.readouterr().out + assert "Added fallback" in out + + def test_add_rejects_duplicate(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Should still have exactly one entry + assert len(cfg["fallback_providers"]) == 1 + out = capsys.readouterr().out + assert "already in the fallback chain" in out + + def test_add_rejects_same_as_primary(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "openrouter", "default": "gpt-5.4"}, + }) + + def fake_picker(args=None): + # User picks the same thing that's already the primary + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + assert "matches the current primary" in out + + def test_add_preserves_primary_when_picker_changes_it(self, isolated_home): + """The picker mutates config["model"]; fallback_add must restore the primary.""" + _write_config(isolated_home, { + "model": { + "provider": "anthropic", + "default": "claude-sonnet-4-6", + "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages", + }, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary exactly as it was + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + assert cfg["model"]["base_url"] == "https://api.anthropic.com" + assert cfg["model"]["api_mode"] == "anthropic_messages" + # Fallback added + assert len(cfg["fallback_providers"]) == 1 + assert cfg["fallback_providers"][0]["provider"] == "openrouter" + + def test_add_noop_when_picker_cancelled(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # User cancelled — no change to config + pass + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + # Either "No fallback added" (picker fully cancelled) or "matches the current primary" + # (picker left config untouched) — both indicate a non-add outcome. + assert ("No fallback added" in out) or ("matches the current primary" in out) + + def test_add_noop_when_picker_clears_model(self, isolated_home, capsys): + """Simulate picker explicitly clearing model.default (unusual but possible).""" + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "", "default": ""} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + out = capsys.readouterr().out + assert "No fallback added" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_remove +# --------------------------------------------------------------------------- + +class TestRemoveCommand: + def test_remove_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_remove_selected_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ], + }) + + # Picker returns index 1 (the middle entry, "nous / Hermes-4") + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg["fallback_providers"] == [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ] + out = capsys.readouterr().out + assert "Removed fallback" in out + assert "Hermes-4" in out + + def test_remove_cancel_keeps_chain(self, isolated_home): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + # Cancel = last item (index == len(chain) == 1 in our menu) + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback_clear +# --------------------------------------------------------------------------- + +class TestClearCommand: + def test_clear_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to clear" in out + + def test_clear_with_confirmation(self, isolated_home, capsys, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg.get("fallback_providers") == [] + out = capsys.readouterr().out + assert "Fallback chain cleared" in out + + def test_clear_cancelled(self, isolated_home, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [{"provider": "openrouter", "model": "gpt-5.4"}], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback dispatcher +# --------------------------------------------------------------------------- + +class TestDispatcher: + def test_no_subcommand_lists(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command=None)) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_list_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="ls")) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_remove_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="rm")) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_unknown_subcommand_exits(self, isolated_home): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + with pytest.raises(SystemExit): + cmd_fallback(types.SimpleNamespace(fallback_command="nope")) + + +# --------------------------------------------------------------------------- +# argparse wiring — verify the subparser is registered +# --------------------------------------------------------------------------- + +class TestArgparseWiring: + """Verify `hermes fallback` is wired into main.py's argparse tree. + + main() builds the parser inline, so we invoke main([...]) via subprocess + with --help to introspect registered subcommands without side effects. + """ + + def test_fallback_help_lists_subcommands(self): + import subprocess + import sys + result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "fallback", "--help"], + capture_output=True, + text=True, + timeout=30, + ) + # --help exits 0 + assert result.returncode == 0, f"stderr: {result.stderr}" + out = result.stdout + result.stderr + # All four subcommands should appear in help + assert "list" in out + assert "add" in out + assert "remove" in out + assert "clear" in out From ffd2621039259ee8419549fedc8739bf1a350436 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:24:19 -0700 Subject: [PATCH 3/7] feat(onboarding): port first-touch hints to the TUI (#16054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16046 added /busy and /verbose hints to the classic CLI and the gateway runner but skipped the Ink TUI (and therefore the dashboard /chat page, which embeds the TUI via PTY). This extends the same latch to the TUI with TUI-native wording. The TUI's busy-input model is not the /busy knob from the CLI — single Enter while busy auto-queues, double Enter on an empty line interrupts. The new busy-input hint teaches THAT gesture instead of telling the user to flip a config that does not apply. Changes: - agent/onboarding.py — add busy_input_hint_tui() + tool_progress_hint_tui() - tui_gateway/server.py — onboarding.claim JSON-RPC (Ink triggers busy hint on enqueue) + _maybe_emit_onboarding_hint helper hooked into _on_tool_complete for the 30s/tool_progress=all path. Same config.yaml latch so each hint fires at most once per install across CLI, gateway, and TUI combined. - ui-tui/src/gatewayTypes.ts — OnboardingClaimResponse + onboarding.hint event - ui-tui/src/app/createGatewayEventHandler.ts — render the hint event as sys() - ui-tui/src/app/useSubmission.ts — claim busy_input_prompt on first busy enqueue - tests/agent/test_onboarding.py — +3 cases for TUI hint shape - tests/tui_gateway/test_protocol.py — +4 cases for onboarding.claim - website/docs/user-guide/tui.md — new 'Interrupting and queueing' section explaining the TUI's double-Enter model and the hints Validation: scripts/run_tests.sh tests/agent/test_onboarding.py \ tests/tui_gateway/test_protocol.py \ tests/gateway/test_busy_session_ack.py -> 66 passed npm --prefix ui-tui run type-check -> clean npm --prefix ui-tui run lint -> clean npm --prefix ui-tui run build -> clean --- agent/onboarding.py | 22 ++++ tests/agent/test_onboarding.py | 12 ++ tests/tui_gateway/test_protocol.py | 91 +++++++++++++++ tui_gateway/server.py | 119 ++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 11 ++ ui-tui/src/app/useSubmission.ts | 20 +++- ui-tui/src/gatewayTypes.ts | 6 + website/docs/user-guide/tui.md | 12 ++ 8 files changed, 291 insertions(+), 2 deletions(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index eed832ab90..7b755ef47e 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -80,6 +80,26 @@ def tool_progress_hint_cli() -> str: ) +def busy_input_hint_tui() -> str: + """Hint shown the first time a user sends a message while the TUI is busy. + + The TUI auto-queues messages sent mid-turn and uses double-Enter on empty + input as the interrupt gesture. There is no ``/busy`` knob to flip — this + hint teaches the keybind instead of a command. + """ + return ( + "queued for after the current turn — press Enter twice on an empty " + "line to interrupt the current turn instead. This tip only shows once." + ) + + +def tool_progress_hint_tui() -> str: + return ( + "that tool ran for a while — use /verbose to cycle tool-progress " + "display modes (all → new → off → verbose). This tip only shows once." + ) + + # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -137,8 +157,10 @@ __all__ = [ "TOOL_PROGRESS_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", + "busy_input_hint_tui", "tool_progress_hint_gateway", "tool_progress_hint_cli", + "tool_progress_hint_tui", "is_seen", "mark_seen", ] diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index a14c7d1797..ec88c1cc30 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -10,10 +10,12 @@ from agent.onboarding import ( TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, + busy_input_hint_tui, is_seen, mark_seen, tool_progress_hint_cli, tool_progress_hint_gateway, + tool_progress_hint_tui, ) @@ -128,6 +130,14 @@ class TestHintMessages: def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() + assert "/verbose" in tool_progress_hint_tui() + + def test_busy_input_hint_tui_teaches_double_enter(self): + msg = busy_input_hint_tui() + # TUI uses double-Enter as the interrupt gesture, not /busy. + assert "Enter" in msg + assert "queued" in msg.lower() + assert "/busy" not in msg def test_hints_are_not_empty(self): for hint in ( @@ -135,8 +145,10 @@ class TestHintMessages: busy_input_hint_gateway("interrupt"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), + busy_input_hint_tui(), tool_progress_hint_gateway(), tool_progress_hint_cli(), + tool_progress_hint_tui(), ): assert hint.strip() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 42caaacc58..196e1ee517 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -542,3 +542,94 @@ def test_dispatch_unknown_long_method_still_goes_inline(server): resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) assert resp["result"] == {"ok": True} + + +# ── onboarding.claim ───────────────────────────────────────────────── + + +def test_onboarding_claim_rejects_unknown_flag(server): + resp = server.handle_request({ + "id": "o1", + "method": "onboarding.claim", + "params": {"flag": "bogus_flag"}, + }) + assert "error" in resp + assert resp["error"]["code"] == 4002 + assert "unknown onboarding flag" in resp["error"]["message"] + + +def test_onboarding_claim_busy_input_returns_tui_hint(server, tmp_path, monkeypatch): + """First claim returns the TUI hint text and marks the config.yaml flag.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + # Bust cached cfg so the new _hermes_home is re-read. + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o2", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + + assert "result" in resp + result = resp["result"] + assert result["claimed"] is True + assert isinstance(result["hint"], str) and result["hint"].strip() + # The TUI hint must teach the double-Enter gesture, not the /busy knob. + assert "Enter" in result["hint"] + assert "/busy" not in result["hint"] + + # config.yaml should now be written with the flag set. + cfg_path = tmp_path / "config.yaml" + assert cfg_path.exists() + import yaml + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"]["busy_input_prompt"] is True + + +def test_onboarding_claim_second_call_returns_null_hint(server, tmp_path, monkeypatch): + """Second claim on the same flag reads config.yaml and returns hint=null.""" + import yaml + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"onboarding": {"seen": {"tool_progress_prompt": True}}}) + ) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + resp = server.handle_request({ + "id": "o3", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + + assert "result" in resp + assert resp["result"]["claimed"] is False + assert resp["result"]["hint"] is None + + +def test_onboarding_claim_flags_are_independent(server, tmp_path, monkeypatch): + """Claiming one flag does not affect the other.""" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + server._cfg_cache = None + server._cfg_mtime = None + + # Claim busy_input_prompt first + resp1 = server.handle_request({ + "id": "o4a", + "method": "onboarding.claim", + "params": {"flag": "busy_input_prompt"}, + }) + assert resp1["result"]["claimed"] is True + + # tool_progress_prompt must still be claimable. Cache bust because the + # first claim wrote to disk mid-test. + server._cfg_cache = None + server._cfg_mtime = None + resp2 = server.handle_request({ + "id": "o4b", + "method": "onboarding.claim", + "params": {"flag": "tool_progress_prompt"}, + }) + assert resp2["result"]["claimed"] is True + assert "/verbose" in resp2["result"]["hint"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..419a911e76 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1016,6 +1016,64 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non return f"{text or 'Completed'}{suffix}" if (text or dur) else None +# ── Onboarding hint emission ───────────────────────────────────────── +# First-touch hints are latched to config.yaml (onboarding.seen.) +# and shared with CLI + gateway so each hint fires at most once per +# install across all surfaces. Best-effort — never raises. + +_ONBOARDING_HINTS_EMITTED: set[str] = set() + + +def _maybe_emit_onboarding_hint(sid: str, flag: str) -> bool: + """Atomically claim an onboarding flag and emit its hint to Ink. + + Returns True if a hint was emitted this call, False if the flag was + already seen (or if anything went wrong — onboarding must never + interrupt the normal event flow). Also deduplicates within a single + process run via ``_ONBOARDING_HINTS_EMITTED`` so concurrent callers + can't double-emit before the config.yaml write lands. + """ + if flag in _ONBOARDING_HINTS_EMITTED: + return False + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception: + return False + + try: + cfg = _load_cfg() + except Exception: + cfg = {} + if is_seen(cfg, flag): + _ONBOARDING_HINTS_EMITTED.add(flag) + return False + + if flag == BUSY_INPUT_FLAG: + hint_text = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint_text = tool_progress_hint_tui() + else: + return False + + _ONBOARDING_HINTS_EMITTED.add(flag) + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + try: + _emit("onboarding.hint", sid, {"flag": flag, "text": hint_text}) + except Exception: + return False + return True + + def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -1067,6 +1125,20 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result if _tool_progress_enabled(sid) or payload.get("inline_diff"): _emit("tool.complete", sid, payload) + # First-touch onboarding: the first time a tool runs >= 30s in the + # noisiest progress mode ("all"), emit a one-time hint suggesting + # /verbose. Claim is atomic via config.yaml so the hint fires at + # most once per install across CLI + gateway + TUI. + try: + if ( + duration_s is not None + and duration_s >= 30.0 + and _session_tool_progress_mode(sid) == "all" + ): + _maybe_emit_onboarding_hint(sid, "tool_progress_prompt") + except Exception as _hint_err: # pragma: no cover — onboarding is best-effort + logger.debug("tui onboarding tool-progress hint failed: %s", _hint_err) + def _on_tool_progress( sid: str, @@ -1934,6 +2006,53 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) +# ── Methods: onboarding ────────────────────────────────────────────── +# First-touch hint latch, shared with CLI + gateway via config.yaml +# (``onboarding.seen.``). Ink calls ``onboarding.claim`` the first +# time it hits a behavior fork (busy enqueue, long tool completion); the +# method atomically returns the hint text AND marks the flag seen, so a +# second fast trigger in the same session never double-renders. + +_VALID_ONBOARDING_FLAGS = {"busy_input_prompt", "tool_progress_prompt"} + + +@method("onboarding.claim") +def _(rid, params: dict) -> dict: + flag = str(params.get("flag", "") or "").strip() + if flag not in _VALID_ONBOARDING_FLAGS: + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_tui, + is_seen, + mark_seen, + tool_progress_hint_tui, + ) + except Exception as e: # pragma: no cover — onboarding is best-effort + return _ok(rid, {"hint": None, "claimed": False, "error": str(e)}) + + cfg = _load_cfg() + if is_seen(cfg, flag): + return _ok(rid, {"hint": None, "claimed": False}) + + if flag == BUSY_INPUT_FLAG: + hint = busy_input_hint_tui() + elif flag == TOOL_PROGRESS_FLAG: + hint = tool_progress_hint_tui() + else: # defensive — validated above + return _err(rid, 4002, f"unknown onboarding flag: {flag}") + + # Mark seen atomically before returning. If persistence fails, still + # return the hint so the user sees it at least once this session. + try: + mark_seen(_hermes_home / "config.yaml", flag) + except Exception: + pass + return _ok(rid, {"hint": hint, "claimed": True}) + + # ── Delegation: subagent tree observability + controls ─────────────── # Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). # The registry lives in tools/delegate_tool — these handlers are thin diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd2faecf4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -570,6 +570,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`error: ${message}`) setStatus('ready') } + + return + case 'onboarding.hint': { + const text = String(ev.payload?.text || '').trim() + + if (text) { + sys(`(tip) ${text}`) + } + + return + } } } } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f09dc36340..8414126c32 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { InputDetectDropResponse, OnboardingClaimResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -218,6 +218,22 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { + // First-touch onboarding: teach the TUI's auto-queue + double-Enter + // interrupt pattern the first time the user hits it. Claim is + // atomic server-side (config.yaml latch), shared with CLI + gateway. + gw.request('onboarding.claim', { flag: 'busy_input_prompt' }) + .then(raw => { + const r = asRpcResult(raw) + const text = r?.hint + + if (typeof text === 'string' && text.trim()) { + sys(`(tip) ${text.trim()}`) + } + }) + .catch(() => { + // Onboarding is best-effort — never block the enqueue path. + }) + return composerActions.enqueue(full) } @@ -229,7 +245,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] + [appendMessage, composerActions, composerRefs, gw, interpolate, send, sendQueued, shellExec, slashRef, sys] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ebaa24f2bd 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -174,6 +174,11 @@ export interface PromptSubmitResponse { ok?: boolean } +export interface OnboardingClaimResponse { + claimed?: boolean + hint?: null | string +} + export interface BackgroundStartResponse { task_id?: string } @@ -417,3 +422,4 @@ export type GatewayEvent = type: 'message.complete' } | { payload?: { message?: string }; session_id?: string; type: 'error' } + | { payload: { flag: string; text: string }; session_id?: string; type: 'onboarding.hint' } diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 8c1b179b67..2b936e34e3 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,6 +106,18 @@ The TUI's status line tracks agent state in real time: The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. +## Interrupting and queueing + +The TUI's busy-input model is different from the classic CLI's `display.busy_input_mode` knob. There is no mode to configure — both behaviors are always available: + +- **Single Enter while busy** — message is **queued** and sent as the next turn after the agent finishes. +- **Double Enter on an empty line while busy** — **interrupts** the current turn. +- **Double Enter on an empty line with queued messages and no running turn** — drains the next queued message. + +The first time you send a message while the agent is working, the TUI prints a one-time `(tip)` line explaining the double-Enter gesture. It fires once per install — the same `onboarding.seen.busy_input_prompt` latch used by the classic CLI and the gateway. Delete that key from `~/.hermes/config.yaml` to see the tip again. + +Similarly, the first time a tool runs for 30 seconds or longer while you're in the noisiest `tool_progress: all` mode, the TUI prints a one-time `(tip)` about `/verbose` for cycling display modes. Latched under `onboarding.seen.tool_progress_prompt`. + ## Configuration The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists. From 9a7026049088ef6545053313d71856703ff933f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:31:37 -0700 Subject: [PATCH 4/7] Revert "feat(onboarding): port first-touch hints to the TUI (#16054)" (#16062) This reverts commit ffd2621039259ee8419549fedc8739bf1a350436. --- agent/onboarding.py | 22 ---- tests/agent/test_onboarding.py | 12 -- tests/tui_gateway/test_protocol.py | 91 --------------- tui_gateway/server.py | 119 -------------------- ui-tui/src/app/createGatewayEventHandler.ts | 11 -- ui-tui/src/app/useSubmission.ts | 20 +--- ui-tui/src/gatewayTypes.ts | 6 - website/docs/user-guide/tui.md | 12 -- 8 files changed, 2 insertions(+), 291 deletions(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index 7b755ef47e..eed832ab90 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -80,26 +80,6 @@ def tool_progress_hint_cli() -> str: ) -def busy_input_hint_tui() -> str: - """Hint shown the first time a user sends a message while the TUI is busy. - - The TUI auto-queues messages sent mid-turn and uses double-Enter on empty - input as the interrupt gesture. There is no ``/busy`` knob to flip — this - hint teaches the keybind instead of a command. - """ - return ( - "queued for after the current turn — press Enter twice on an empty " - "line to interrupt the current turn instead. This tip only shows once." - ) - - -def tool_progress_hint_tui() -> str: - return ( - "that tool ran for a while — use /verbose to cycle tool-progress " - "display modes (all → new → off → verbose). This tip only shows once." - ) - - # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -157,10 +137,8 @@ __all__ = [ "TOOL_PROGRESS_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", - "busy_input_hint_tui", "tool_progress_hint_gateway", "tool_progress_hint_cli", - "tool_progress_hint_tui", "is_seen", "mark_seen", ] diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index ec88c1cc30..a14c7d1797 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -10,12 +10,10 @@ from agent.onboarding import ( TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, - busy_input_hint_tui, is_seen, mark_seen, tool_progress_hint_cli, tool_progress_hint_gateway, - tool_progress_hint_tui, ) @@ -130,14 +128,6 @@ class TestHintMessages: def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() - assert "/verbose" in tool_progress_hint_tui() - - def test_busy_input_hint_tui_teaches_double_enter(self): - msg = busy_input_hint_tui() - # TUI uses double-Enter as the interrupt gesture, not /busy. - assert "Enter" in msg - assert "queued" in msg.lower() - assert "/busy" not in msg def test_hints_are_not_empty(self): for hint in ( @@ -145,10 +135,8 @@ class TestHintMessages: busy_input_hint_gateway("interrupt"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), - busy_input_hint_tui(), tool_progress_hint_gateway(), tool_progress_hint_cli(), - tool_progress_hint_tui(), ): assert hint.strip() diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 196e1ee517..42caaacc58 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -542,94 +542,3 @@ def test_dispatch_unknown_long_method_still_goes_inline(server): resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) assert resp["result"] == {"ok": True} - - -# ── onboarding.claim ───────────────────────────────────────────────── - - -def test_onboarding_claim_rejects_unknown_flag(server): - resp = server.handle_request({ - "id": "o1", - "method": "onboarding.claim", - "params": {"flag": "bogus_flag"}, - }) - assert "error" in resp - assert resp["error"]["code"] == 4002 - assert "unknown onboarding flag" in resp["error"]["message"] - - -def test_onboarding_claim_busy_input_returns_tui_hint(server, tmp_path, monkeypatch): - """First claim returns the TUI hint text and marks the config.yaml flag.""" - monkeypatch.setattr(server, "_hermes_home", tmp_path) - # Bust cached cfg so the new _hermes_home is re-read. - server._cfg_cache = None - server._cfg_mtime = None - - resp = server.handle_request({ - "id": "o2", - "method": "onboarding.claim", - "params": {"flag": "busy_input_prompt"}, - }) - - assert "result" in resp - result = resp["result"] - assert result["claimed"] is True - assert isinstance(result["hint"], str) and result["hint"].strip() - # The TUI hint must teach the double-Enter gesture, not the /busy knob. - assert "Enter" in result["hint"] - assert "/busy" not in result["hint"] - - # config.yaml should now be written with the flag set. - cfg_path = tmp_path / "config.yaml" - assert cfg_path.exists() - import yaml - loaded = yaml.safe_load(cfg_path.read_text()) - assert loaded["onboarding"]["seen"]["busy_input_prompt"] is True - - -def test_onboarding_claim_second_call_returns_null_hint(server, tmp_path, monkeypatch): - """Second claim on the same flag reads config.yaml and returns hint=null.""" - import yaml - (tmp_path / "config.yaml").write_text( - yaml.safe_dump({"onboarding": {"seen": {"tool_progress_prompt": True}}}) - ) - monkeypatch.setattr(server, "_hermes_home", tmp_path) - server._cfg_cache = None - server._cfg_mtime = None - - resp = server.handle_request({ - "id": "o3", - "method": "onboarding.claim", - "params": {"flag": "tool_progress_prompt"}, - }) - - assert "result" in resp - assert resp["result"]["claimed"] is False - assert resp["result"]["hint"] is None - - -def test_onboarding_claim_flags_are_independent(server, tmp_path, monkeypatch): - """Claiming one flag does not affect the other.""" - monkeypatch.setattr(server, "_hermes_home", tmp_path) - server._cfg_cache = None - server._cfg_mtime = None - - # Claim busy_input_prompt first - resp1 = server.handle_request({ - "id": "o4a", - "method": "onboarding.claim", - "params": {"flag": "busy_input_prompt"}, - }) - assert resp1["result"]["claimed"] is True - - # tool_progress_prompt must still be claimable. Cache bust because the - # first claim wrote to disk mid-test. - server._cfg_cache = None - server._cfg_mtime = None - resp2 = server.handle_request({ - "id": "o4b", - "method": "onboarding.claim", - "params": {"flag": "tool_progress_prompt"}, - }) - assert resp2["result"]["claimed"] is True - assert "/verbose" in resp2["result"]["hint"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 419a911e76..03631bf174 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1016,64 +1016,6 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non return f"{text or 'Completed'}{suffix}" if (text or dur) else None -# ── Onboarding hint emission ───────────────────────────────────────── -# First-touch hints are latched to config.yaml (onboarding.seen.) -# and shared with CLI + gateway so each hint fires at most once per -# install across all surfaces. Best-effort — never raises. - -_ONBOARDING_HINTS_EMITTED: set[str] = set() - - -def _maybe_emit_onboarding_hint(sid: str, flag: str) -> bool: - """Atomically claim an onboarding flag and emit its hint to Ink. - - Returns True if a hint was emitted this call, False if the flag was - already seen (or if anything went wrong — onboarding must never - interrupt the normal event flow). Also deduplicates within a single - process run via ``_ONBOARDING_HINTS_EMITTED`` so concurrent callers - can't double-emit before the config.yaml write lands. - """ - if flag in _ONBOARDING_HINTS_EMITTED: - return False - try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - TOOL_PROGRESS_FLAG, - busy_input_hint_tui, - is_seen, - mark_seen, - tool_progress_hint_tui, - ) - except Exception: - return False - - try: - cfg = _load_cfg() - except Exception: - cfg = {} - if is_seen(cfg, flag): - _ONBOARDING_HINTS_EMITTED.add(flag) - return False - - if flag == BUSY_INPUT_FLAG: - hint_text = busy_input_hint_tui() - elif flag == TOOL_PROGRESS_FLAG: - hint_text = tool_progress_hint_tui() - else: - return False - - _ONBOARDING_HINTS_EMITTED.add(flag) - try: - mark_seen(_hermes_home / "config.yaml", flag) - except Exception: - pass - try: - _emit("onboarding.hint", sid, {"flag": flag, "text": hint_text}) - except Exception: - return False - return True - - def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -1125,20 +1067,6 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result if _tool_progress_enabled(sid) or payload.get("inline_diff"): _emit("tool.complete", sid, payload) - # First-touch onboarding: the first time a tool runs >= 30s in the - # noisiest progress mode ("all"), emit a one-time hint suggesting - # /verbose. Claim is atomic via config.yaml so the hint fires at - # most once per install across CLI + gateway + TUI. - try: - if ( - duration_s is not None - and duration_s >= 30.0 - and _session_tool_progress_mode(sid) == "all" - ): - _maybe_emit_onboarding_hint(sid, "tool_progress_prompt") - except Exception as _hint_err: # pragma: no cover — onboarding is best-effort - logger.debug("tui onboarding tool-progress hint failed: %s", _hint_err) - def _on_tool_progress( sid: str, @@ -2006,53 +1934,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) -# ── Methods: onboarding ────────────────────────────────────────────── -# First-touch hint latch, shared with CLI + gateway via config.yaml -# (``onboarding.seen.``). Ink calls ``onboarding.claim`` the first -# time it hits a behavior fork (busy enqueue, long tool completion); the -# method atomically returns the hint text AND marks the flag seen, so a -# second fast trigger in the same session never double-renders. - -_VALID_ONBOARDING_FLAGS = {"busy_input_prompt", "tool_progress_prompt"} - - -@method("onboarding.claim") -def _(rid, params: dict) -> dict: - flag = str(params.get("flag", "") or "").strip() - if flag not in _VALID_ONBOARDING_FLAGS: - return _err(rid, 4002, f"unknown onboarding flag: {flag}") - try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - TOOL_PROGRESS_FLAG, - busy_input_hint_tui, - is_seen, - mark_seen, - tool_progress_hint_tui, - ) - except Exception as e: # pragma: no cover — onboarding is best-effort - return _ok(rid, {"hint": None, "claimed": False, "error": str(e)}) - - cfg = _load_cfg() - if is_seen(cfg, flag): - return _ok(rid, {"hint": None, "claimed": False}) - - if flag == BUSY_INPUT_FLAG: - hint = busy_input_hint_tui() - elif flag == TOOL_PROGRESS_FLAG: - hint = tool_progress_hint_tui() - else: # defensive — validated above - return _err(rid, 4002, f"unknown onboarding flag: {flag}") - - # Mark seen atomically before returning. If persistence fails, still - # return the hint so the user sees it at least once this session. - try: - mark_seen(_hermes_home / "config.yaml", flag) - except Exception: - pass - return _ok(rid, {"hint": hint, "claimed": True}) - - # ── Delegation: subagent tree observability + controls ─────────────── # Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). # The registry lives in tools/delegate_tool — these handlers are thin diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 0bd2faecf4..15cf00a5a9 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -570,17 +570,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`error: ${message}`) setStatus('ready') } - - return - case 'onboarding.hint': { - const text = String(ev.payload?.text || '').trim() - - if (text) { - sys(`(tip) ${text}`) - } - - return - } } } } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 8414126c32..f09dc36340 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, OnboardingClaimResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -218,22 +218,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { - // First-touch onboarding: teach the TUI's auto-queue + double-Enter - // interrupt pattern the first time the user hits it. Claim is - // atomic server-side (config.yaml latch), shared with CLI + gateway. - gw.request('onboarding.claim', { flag: 'busy_input_prompt' }) - .then(raw => { - const r = asRpcResult(raw) - const text = r?.hint - - if (typeof text === 'string' && text.trim()) { - sys(`(tip) ${text.trim()}`) - } - }) - .catch(() => { - // Onboarding is best-effort — never block the enqueue path. - }) - return composerActions.enqueue(full) } @@ -245,7 +229,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, gw, interpolate, send, sendQueued, shellExec, slashRef, sys] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ebaa24f2bd..e64d113c22 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -174,11 +174,6 @@ export interface PromptSubmitResponse { ok?: boolean } -export interface OnboardingClaimResponse { - claimed?: boolean - hint?: null | string -} - export interface BackgroundStartResponse { task_id?: string } @@ -422,4 +417,3 @@ export type GatewayEvent = type: 'message.complete' } | { payload?: { message?: string }; session_id?: string; type: 'error' } - | { payload: { flag: string; text: string }; session_id?: string; type: 'onboarding.hint' } diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 2b936e34e3..8c1b179b67 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,18 +106,6 @@ The TUI's status line tracks agent state in real time: The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. -## Interrupting and queueing - -The TUI's busy-input model is different from the classic CLI's `display.busy_input_mode` knob. There is no mode to configure — both behaviors are always available: - -- **Single Enter while busy** — message is **queued** and sent as the next turn after the agent finishes. -- **Double Enter on an empty line while busy** — **interrupts** the current turn. -- **Double Enter on an empty line with queued messages and no running turn** — drains the next queued message. - -The first time you send a message while the agent is working, the TUI prints a one-time `(tip)` line explaining the double-Enter gesture. It fires once per install — the same `onboarding.seen.busy_input_prompt` latch used by the classic CLI and the gateway. Delete that key from `~/.hermes/config.yaml` to see the tip again. - -Similarly, the first time a tool runs for 30 seconds or longer while you're in the noisiest `tool_progress: all` mode, the TUI prints a one-time `(tip)` about `/verbose` for cycling display modes. Latched under `onboarding.seen.tool_progress_prompt`. - ## Configuration The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists. From 7fa70b6c87224543430e4a99c7126f50e4d1190f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:11:08 -0700 Subject: [PATCH 5/7] refactor: /btw is now an alias for /background (#16053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ephemeral no-tools side-question variant of /btw confused users who expected 'by-the-way' to mean 'run this off to the side with tools' — they'd type /btw and get a toolless agent that couldn't do the work. /bg worked because it was /background with full tools. Collapse the two: /btw and /bg both alias to /background. One command, one behavior, no more gotchas about which variant has tools. Removed: - _handle_btw_command in cli.py and gateway/run.py - _run_btw_task + _active_btw_tasks state in gateway/run.py - prompt.btw JSON-RPC method + btw.complete event in tui_gateway - BtwStartResponse type + btw.complete case in ui-tui - Standalone /btw slash tree registration in Discord - Standalone btw CommandDef in hermes_cli/commands.py Updated: - background CommandDef aliases: (bg,) -> (bg, btw) - TUI session.ts: local btw handler merged into background - Docs and tips updated to describe /btw as a /background alias --- cli.py | 118 ------------ gateway/platforms/discord.py | 5 - gateway/run.py | 174 ------------------ hermes_cli/commands.py | 4 +- hermes_cli/tips.py | 3 +- .../hermes-agent/SKILL.md | 1 - tui_gateway/server.py | 42 ----- ui-tui/README.md | 1 - ui-tui/src/app/createGatewayEventHandler.ts | 6 - ui-tui/src/app/slash/commands/session.ts | 20 +- ui-tui/src/gatewayTypes.ts | 5 - web/src/lib/gatewayClient.ts | 1 - website/docs/reference/slash-commands.md | 3 +- .../autonomous-ai-agents-hermes-agent.md | 1 - 14 files changed, 4 insertions(+), 380 deletions(-) diff --git a/cli.py b/cli.py index 038c83f06f..da401e5c18 100644 --- a/cli.py +++ b/cli.py @@ -6129,8 +6129,6 @@ class HermesCLI: self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) - elif canonical == "btw": - self._handle_btw_command(cmd_original) elif canonical == "queue": # Extract prompt after "/queue " or "/q " parts = cmd_original.split(None, 1) @@ -6417,122 +6415,6 @@ class HermesCLI: self._background_tasks[task_id] = thread thread.start() - def _handle_btw_command(self, cmd: str): - """Handle /btw — ephemeral side question using session context. - - Snapshots the current conversation history, spawns a no-tools agent in - a background thread, and prints the answer without persisting anything - to the main session. - """ - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /btw ") - _cprint(" Example: /btw what module owns session title sanitization?") - _cprint(" Answers using session context. No tools, not persisted.") - return - - question = parts[1].strip() - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}" - - if not self._ensure_runtime_credentials(): - _cprint(" (>_<) Cannot start /btw: no valid credentials.") - return - - turn_route = self._resolve_turn_agent_config(question) - history_snapshot = list(self.conversation_history) - - preview = question[:60] + ("..." if len(question) > 60 else "") - _cprint(f' 💬 /btw: "{preview}"') - - def run_btw(): - try: - btw_agent = AIAgent( - model=turn_route["model"], - api_key=turn_route["runtime"].get("api_key"), - base_url=turn_route["runtime"].get("base_url"), - provider=turn_route["runtime"].get("provider"), - api_mode=turn_route["runtime"].get("api_mode"), - acp_command=turn_route["runtime"].get("command"), - acp_args=turn_route["runtime"].get("args"), - max_iterations=8, - enabled_toolsets=[], - quiet_mode=True, - verbose_logging=False, - session_id=task_id, - platform="cli", - reasoning_config=self.reasoning_config, - service_tier=self.service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=self._providers_only, - providers_ignored=self._providers_ignore, - providers_order=self._providers_order, - provider_sort=self._provider_sort, - provider_require_parameters=self._provider_require_params, - provider_data_collection=self._provider_data_collection, - fallback_model=self._fallback_model, - session_db=None, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - result = btw_agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - - # TUI refresh before printing - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - - if response: - try: - from hermes_cli.skin_engine import get_active_skin - _skin = get_active_skin() - _resp_color = _skin.get_color("response_border", "#4F6D4A") - except Exception: - _resp_color = "#4F6D4A" - - ChatConsole().print(Panel( - _render_final_assistant_content(response, mode=self.final_response_markdown), - title=f"[{_resp_color} bold]⚕ /btw[/]", - title_align="left", - border_style=_resp_color, - box=rich_box.HORIZONTALS, - padding=(1, 4), - )) - else: - _cprint(" 💬 /btw: (no response)") - - if self.bell_on_complete: - sys.stdout.write("\a") - sys.stdout.flush() - - except Exception as e: - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - _cprint(f" ❌ /btw failed: {e}") - finally: - if self._app: - self._invalidate(min_interval=0) - - thread = threading.Thread(target=run_btw, daemon=True, name=f"btw-{task_id}") - thread.start() - @staticmethod def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5d30f244e8..b4018c6df6 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2315,11 +2315,6 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_background(interaction: discord.Interaction, prompt: str): await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~") - @tree.command(name="btw", description="Ephemeral side question using session context") - @discord.app_commands.describe(question="Your side question (no tools, not persisted)") - async def slash_btw(interaction: discord.Interaction, question: str): - await self._run_simple_slash(interaction, f"/btw {question}") - # ── Auto-register any gateway-available commands not yet on the tree ── # This ensures new commands added to COMMAND_REGISTRY in # hermes_cli/commands.py automatically appear as Discord slash diff --git a/gateway/run.py b/gateway/run.py index d7331bdc75..6cd1083ba7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3773,9 +3773,6 @@ class GatewayRunner: if canonical == "background": return await self._handle_background_command(event) - if canonical == "btw": - return await self._handle_btw_command(event) - if canonical == "steer": # No active agent — /steer has no tool call to inject into. # Strip the prefix so downstream treats it as a normal user @@ -6673,177 +6670,6 @@ class GatewayRunner: except Exception: pass - async def _handle_btw_command(self, event: MessageEvent) -> str: - """Handle /btw — ephemeral side question in the same chat.""" - question = event.get_command_args().strip() - if not question: - return ( - "Usage: /btw \n" - "Example: /btw what module owns session title sanitization?\n\n" - "Answers using session context. No tools, not persisted." - ) - - source = event.source - session_key = self._session_key_for_source(source) - - # Guard: one /btw at a time per session - existing = getattr(self, "_active_btw_tasks", {}).get(session_key) - if existing and not existing.done(): - return "A /btw is already running for this chat. Wait for it to finish." - - if not hasattr(self, "_active_btw_tasks"): - self._active_btw_tasks: dict = {} - - import uuid as _uuid - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" - _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) - self._background_tasks.add(_task) - self._active_btw_tasks[session_key] = _task - - def _cleanup(task): - self._background_tasks.discard(task) - if self._active_btw_tasks.get(session_key) is task: - self._active_btw_tasks.pop(session_key, None) - - _task.add_done_callback(_cleanup) - - preview = question[:60] + ("..." if len(question) > 60 else "") - return f'💬 /btw: "{preview}"\nReply will appear here shortly.' - - async def _run_btw_task( - self, question: str, source, session_key: str, task_id: str, - ) -> None: - """Execute an ephemeral /btw side question and deliver the answer.""" - from run_agent import AIAgent - - adapter = self.adapters.get(source.platform) - if not adapter: - logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) - return - - _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None - - try: - user_config = _load_gateway_config() - model, runtime_kwargs = self._resolve_session_agent_runtime( - source=source, - session_key=session_key, - user_config=user_config, - ) - if not runtime_kwargs.get("api_key"): - await adapter.send( - source.chat_id, - "❌ /btw failed: no provider credentials configured.", - metadata=_thread_meta, - ) - return - - platform_key = _platform_config_key(source.platform) - reasoning_config = self._resolve_session_reasoning_config( - source=source, - session_key=session_key, - ) - self._service_tier = self._load_service_tier() - turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) - pr = self._provider_routing - - # Snapshot history from running agent or stored transcript - running_agent = self._running_agents.get(session_key) - if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: - history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) - else: - session_entry = self.session_store.get_or_create_session(source) - history_snapshot = self.session_store.load_transcript(session_entry.session_id) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - - def run_sync(): - agent = AIAgent( - model=turn_route["model"], - **turn_route["runtime"], - max_iterations=8, - quiet_mode=True, - verbose_logging=False, - enabled_toolsets=[], - reasoning_config=reasoning_config, - service_tier=self._service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=pr.get("only"), - providers_ignored=pr.get("ignore"), - providers_order=pr.get("order"), - provider_sort=pr.get("sort"), - provider_require_parameters=pr.get("require_parameters", False), - provider_data_collection=pr.get("data_collection"), - session_id=task_id, - platform=platform_key, - session_db=None, - fallback_model=self._fallback_model, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - try: - return agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - finally: - self._cleanup_agent_resources(agent) - - result = await self._run_in_executor_with_context(run_sync) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - if not response: - response = "(No response generated)" - - media_files, response = adapter.extract_media(response) - images, text_content = adapter.extract_images(response) - preview = question[:60] + ("..." if len(question) > 60 else "") - header = f'💬 /btw: "{preview}"\n\n' - - if text_content: - await adapter.send( - chat_id=source.chat_id, - content=header + text_content, - metadata=_thread_meta, - ) - elif not images and not media_files: - await adapter.send( - chat_id=source.chat_id, - content=header + "(No response generated)", - metadata=_thread_meta, - ) - - for image_url, alt_text in (images or []): - try: - await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) - except Exception: - pass - - for media_path, _is_voice in (media_files or []): - try: - await adapter.send_file(chat_id=source.chat_id, file_path=media_path) - except Exception: - pass - - except Exception as e: - logger.exception("/btw task %s failed", task_id) - try: - await adapter.send( - chat_id=source.chat_id, - content=f"❌ /btw failed: {e}", - metadata=_thread_meta, - ) - except Exception: - pass - async def _handle_reasoning_command(self, event: MessageEvent) -> str: """Handle /reasoning command — manage reasoning effort and display toggle. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4d650487b4..614d783d95 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -84,9 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("deny", "Deny a pending dangerous command", "Session", gateway_only=True), CommandDef("background", "Run a prompt in the background", "Session", - aliases=("bg",), args_hint=""), - CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session", - args_hint=""), + aliases=("bg", "btw"), args_hint=""), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index db66e1db1b..a93a31db13 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -10,8 +10,7 @@ import random TIPS = [ # --- Slash Commands --- - "/btw asks a quick side question without tools or history — great for clarifications.", - "/background runs a task in a separate session while your current one stays free.", + "/background (alias /bg or /btw) runs a task in a separate session while your current one stays free.", "/branch forks the current session so you can explore a different direction without losing progress.", "/compress manually compresses conversation context when things get long.", "/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.", diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 4ed03a904c..76a0e51b6c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -281,7 +281,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..30531aab28 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2550,48 +2550,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"task_id": task_id}) -@method("prompt.btw") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - text, sid = params.get("text", ""), params.get("session_id", "") - if not text: - return _err(rid, 4012, "text required") - snapshot = list(session.get("history", [])) - - def run(): - session_tokens = _set_session_context(session["session_key"]) - try: - from run_agent import AIAgent - - result = AIAgent( - model=_resolve_model(), - quiet_mode=True, - platform="tui", - max_iterations=8, - enabled_toolsets=[], - ).run_conversation(text, conversation_history=snapshot) - _emit( - "btw.complete", - sid, - { - "text": ( - result.get("final_response", str(result)) - if isinstance(result, dict) - else str(result) - ) - }, - ) - except Exception as e: - _emit("btw.complete", sid, {"text": f"error: {e}"}) - finally: - _clear_session_context(session_tokens) - - threading.Thread(target=run, daemon=True).start() - return _ok(rid, {"status": "running"}) - - # ── Methods: respond ───────────────────────────────────────────────── diff --git a/ui-tui/README.md b/ui-tui/README.md index 2f95a47aa2..17d57f08af 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -252,7 +252,6 @@ Primary event types the client handles today: | `sudo.request` | `{ request_id }` | | `secret.request` | `{ prompt, env_var, request_id }` | | `background.complete` | `{ task_id, text }` | -| `btw.complete` | `{ text }` | | `error` | `{ message }` | | `gateway.stderr` | synthesized from child stderr | | `gateway.protocol_error` | synthesized from malformed stdout | diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd505078f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -431,12 +431,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'btw.complete': - dropBgTask('btw:x') - sys(`[btw] ${ev.payload.text}`) - - return - case 'subagent.spawn_requested': // Child built but not yet running (waiting on ThreadPoolExecutor slot). // Preserve completed state if a later event races in before this one. diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 1049ee34d8..df106e1d86 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,7 +1,6 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import type { BackgroundStartResponse, - BtwStartResponse, ConfigGetValueResponse, ConfigSetResponse, ImageAttachResponse, @@ -18,7 +17,7 @@ import type { SlashCommand } from '../types.js' export const sessionCommands: SlashCommand[] = [ { - aliases: ['bg'], + aliases: ['bg', 'btw'], help: 'launch a background prompt', name: 'background', run: (arg, ctx) => { @@ -39,23 +38,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - { - help: 'by-the-way follow-up', - name: 'btw', - run: (arg, ctx) => { - if (!arg) { - return ctx.transcript.sys('/btw ') - } - - ctx.gateway.rpc('prompt.btw', { session_id: ctx.sid, text: arg }).then( - ctx.guarded(() => { - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - ctx.transcript.sys('btw running…') - }) - ) - } - }, - { help: 'change or show model', aliases: ['provider'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ce056040c2 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -178,10 +178,6 @@ export interface BackgroundStartResponse { task_id?: string } -export interface BtwStartResponse { - ok?: boolean -} - export interface ClarifyRespondResponse { ok?: boolean } @@ -403,7 +399,6 @@ export type GatewayEvent = | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } - | { payload: { text: string }; session_id?: string; type: 'btw.complete' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 012482b710..fa58841ce1 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -32,7 +32,6 @@ export type GatewayEventName = | "sudo.request" | "secret.request" | "background.complete" - | "btw.complete" | "error" | "skin.changed" | (string & {}); diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6e04bcd010..ed2a2ff2fc 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -36,8 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/resume [name]` | Resume a previously-named session | | `/status` | Show session info | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | -| `/background ` (alias: `/bg`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | -| `/btw ` | Ephemeral side question using session context (no tools, not persisted). Useful for quick clarifications without affecting the conversation history. | +| `/background ` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | ### Configuration diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index efd6326259..10a91f2aae 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -298,7 +298,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI) From 70f56e7605c36885622c0741537e8a9ee5edd68f Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 26 Apr 2026 07:10:52 -0700 Subject: [PATCH 6/7] fix(gateway): let /btw dispatch mid-turn instead of being rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /btw spawns a parallel ephemeral side-question task (self-guarded against concurrent /btw on the same chat) — exactly like /background. But it was missing from the running-agent bypass list in _handle_message(), so it fell through to the catch-all and returned: ⏳ Agent is running — /btw can't run mid-turn. Wait for the current response or /stop first. That's the opposite of what /btw is for — asking a side question while the main turn is still working. Add the bypass next to /background and a regression test covering the mid-turn dispatch path. Reported by @IuriiTiunov on Telegram. --- gateway/run.py | 8 +++++++ .../test_running_agent_session_toggles.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 6cd1083ba7..1ab57984e0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3501,6 +3501,14 @@ class GatewayRunner: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) + # /btw must bypass the running-agent guard for the same reason + # as /background: it spawns a parallel ephemeral side-question + # task (see _handle_btw_command) that doesn't interrupt the + # active conversation and self-guards against concurrent /btw + # on the same chat. + if _cmd_def_inner and _cmd_def_inner.name == "btw": + return await self._handle_btw_command(event) + # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index fbe0d5163c..d60e5b154e 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -165,3 +165,27 @@ async def test_reasoning_rejected_mid_run(): assert result is not None assert "can't run mid-turn" in result assert "/reasoning" in result + + +@pytest.mark.asyncio +async def test_btw_dispatches_mid_run(): + """/btw mid-run must dispatch to its handler, not hit the catch-all. + + /btw spawns a parallel ephemeral side-question task that does NOT + interrupt the active conversation (see _handle_btw_command). It's the + whole point of the command — asking a side question while the main + turn is still working. Before the mid-turn bypass was added, /btw + fell through to the "Agent is running — wait or /stop first" catch-all, + making it useless in exactly the scenario it was designed for. + """ + runner = _make_runner() + runner._handle_btw_command = AsyncMock( + return_value='💬 /btw: "what module owns titles?"\nReply will appear here shortly.' + ) + + result = await runner._handle_message(_make_event("/btw what module owns titles?")) + + runner._handle_btw_command.assert_awaited_once() + assert result is not None + assert "💬 /btw" in result + assert "can't run mid-turn" not in result From 454d883e6977419854cf26138b93b118871d36d7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:15:23 -0700 Subject: [PATCH 7/7] refactor: drop persist_session plumbing + fix broken btw mid-turn bypass (#16075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #16053 (/btw as /background alias). Cleans up the plumbing added exclusively for the old ephemeral /btw handler and repairs a broken btw bypass that landed between my refactor and this follow-up. run_agent.py: - Remove persist_session kwarg, instance attr, and _persist_session short-circuit. Only /btw ever passed persist_session=False; with /btw gone the default (always persist) is the only behavior anyone ever wanted. gateway/run.py: - Remove the unreachable 'if _cmd_def_inner.name == "btw"' block (PR #16059). Canonical name for a /btw message is 'background' after alias resolution — the comparison could never be true, and it called _handle_btw_command which no longer exists. The /background branch above it already dispatches /btw correctly. tests/gateway/test_running_agent_session_toggles.py: - Fix test_btw_dispatches_mid_run to mock _handle_background_command (the real dispatch target for /btw) instead of the deleted _handle_btw_command. --- gateway/run.py | 10 ++------- run_agent.py | 5 ----- .../test_running_agent_session_toggles.py | 21 +++++++++---------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 1ab57984e0..9926920b81 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3498,17 +3498,11 @@ class GatewayRunner: # /background must bypass the running-agent guard — it starts a # parallel task and must never interrupt the active conversation. + # /btw is an alias of /background and resolves to the same canonical + # name, so this branch handles both commands. if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) - # /btw must bypass the running-agent guard for the same reason - # as /background: it spawns a parallel ephemeral side-question - # task (see _handle_btw_command) that doesn't interrupt the - # active conversation and self-guards against concurrent /btw - # on the same chat. - if _cmd_def_inner and _cmd_def_inner.name == "btw": - return await self._handle_btw_command(event) - # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. diff --git a/run_agent.py b/run_agent.py index 7b23b5b41c..43c367e460 100644 --- a/run_agent.py +++ b/run_agent.py @@ -892,7 +892,6 @@ class AIAgent: checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 50, pass_session_id: bool = False, - persist_session: bool = True, ): """ Initialize the AI Agent. @@ -964,7 +963,6 @@ class AIAgent: self.background_review_callback = None # Optional sync callback for gateway delivery self.skip_context_files = skip_context_files self.pass_session_id = pass_session_id - self.persist_session = persist_session self._credential_pool = credential_pool self.log_prefix_chars = log_prefix_chars self.log_prefix = f"{log_prefix} " if log_prefix else "" @@ -3353,10 +3351,7 @@ class AIAgent: """Save session state to both JSON log and SQLite on any exit path. Ensures conversations are never lost, even on errors or early returns. - Skipped when ``persist_session=False`` (ephemeral helper flows). """ - if not self.persist_session: - return self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index d60e5b154e..6bf8be9973 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -169,23 +169,22 @@ async def test_reasoning_rejected_mid_run(): @pytest.mark.asyncio async def test_btw_dispatches_mid_run(): - """/btw mid-run must dispatch to its handler, not hit the catch-all. + """/btw mid-run must dispatch to /background's handler, not hit the catch-all. - /btw spawns a parallel ephemeral side-question task that does NOT - interrupt the active conversation (see _handle_btw_command). It's the - whole point of the command — asking a side question while the main - turn is still working. Before the mid-turn bypass was added, /btw - fell through to the "Agent is running — wait or /stop first" catch-all, - making it useless in exactly the scenario it was designed for. + /btw is an alias of /background (see hermes_cli/commands.py). Typing + /btw mid-turn must spawn a parallel background task — that's the whole + point of the command. Before the mid-turn bypass was added for + /background, /btw fell through to the "Agent is running — wait or + /stop first" catch-all, making it useless in exactly the scenario it + was designed for. The alias and the bypass together make it work. """ runner = _make_runner() - runner._handle_btw_command = AsyncMock( - return_value='💬 /btw: "what module owns titles?"\nReply will appear here shortly.' + runner._handle_background_command = AsyncMock( + return_value='🚀 Background task started: "what module owns titles?"' ) result = await runner._handle_message(_make_event("/btw what module owns titles?")) - runner._handle_btw_command.assert_awaited_once() + runner._handle_background_command.assert_awaited_once() assert result is not None - assert "💬 /btw" in result assert "can't run mid-turn" not in result