From a2fcac087c986f6d83a99b5755b5d485ab45587c Mon Sep 17 00:00:00 2001 From: yflmq001 Date: Sat, 15 Aug 2026 16:04:58 +0800 Subject: [PATCH] fix(cli): chat -c fails loudly on stderr and gains --create-if-missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes chat -c "" -q "<text>"` silently no-oped when no session matched the title under quiet/programmatic use: the not-found message was written to stdout (the channel quiet callers parse as the final response), so a background send to a not-yet-existing named session vanished with no error. Surfaces via Hermes-Bot-Mode bot-to-bot handoffs (#86794). - not-found message now goes to stderr (exit 1 unchanged), so programmatic callers always see it even with -Q/--quiet - new --create-if-missing: with `-c <title>` and no matching session, create a fresh session carrying the title and proceed — the deterministic "send to this named thread, making it if needed" primitive plugins asked for - extract the -c resolution block into _resolve_continue_arg for testability Tests: flag parsing, titled-session creation, stderr routing, source guard. --- hermes_cli/_parser.py | 11 ++ hermes_cli/main.py | 145 +++++++++++----- tests/hermes_cli/test_chat_c_fail_loudly.py | 173 ++++++++++++++++++++ 3 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 tests/hermes_cli/test_chat_c_fail_loudly.py diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 7d13d76caf..88a0eb49f1 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -406,6 +406,17 @@ def build_top_level_parser(): metavar="SESSION_NAME", help="Resume a session by name, or the most recent if no name given", ) + chat_parser.add_argument( + "--create-if-missing", + action="store_true", + default=argparse.SUPPRESS, + help=( + "With -c/--continue <name>: if no session matches the name, " + "create a new session with that title and proceed (instead of " + "failing with a not-found error). Programmatic callers that " + "want 'send to this named thread, making it if needed'." + ), + ) chat_parser.add_argument( "--worktree", "-w", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 76d8519ec1..7941c1b4e7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1779,6 +1779,113 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: return None +def _create_titled_session(title: str) -> Optional[str]: + """Create a fresh session with the given title; return its session id. + + Used by ``chat -c <title> --create-if-missing`` (#86794): programmatic + callers (plugins, scripts) that want "send to this named thread, making + it if needed" get a deterministic outcome instead of a silent no-op. + + The session id follows the same timestamp+uuid shape the CLI uses for a + brand-new session; the title is recorded with user provenance so + auto-titling never overwrites it. + """ + db = None + try: + import uuid as _uuid + + from hermes_state import SessionDB + + now = datetime.now() + timestamp_str = now.strftime("%Y%m%d_%H%M%S") + short_uuid = _uuid.uuid4().hex[:6] + new_session_id = f"{timestamp_str}_{short_uuid}" + + db = SessionDB() + db.create_session(new_session_id, source="cli") + db.set_session_title(new_session_id, title) + return new_session_id + except Exception: + return None + finally: + if db is not None: + try: + db.close() + except Exception: + pass + + +def _resolve_continue_arg(args, *, use_tui: bool) -> None: + """Resolve ``-c/--continue`` into ``args.resume``. + + Handles both forms: + - ``-c <name>``: resolve by title/ID. On miss, fail loudly on **stderr** + (exit 1) so programmatic callers see the error even under quiet mode + (#86794); with ``--create-if-missing``, create a fresh titled session + and resume into it instead. + - bare ``-c``: continue this terminal's breadcrumb session if valid, + else the most recent session (workspace-scoped MRU, then global + fallback). + """ + continue_val = getattr(args, "continue_last", None) + if continue_val and not getattr(args, "resume", None): + if isinstance(continue_val, str): + # -c "session name" — resolve by title or ID + resolved = _resolve_session_by_name_or_id(continue_val) + if resolved: + args.resume = resolved + elif getattr(args, "create_if_missing", False): + # --create-if-missing: no session matches the title — create a + # new session with that title and proceed. This is the + # programmatic-caller primitive ("send to this named thread, + # making it if needed"); without it a background/quiet send to + # a not-yet-existing named session silently no-ops (#86794). + new_sid = _create_titled_session(continue_val) + if new_sid: + args.resume = new_sid + else: + print( + f"No session found matching '{continue_val}' and " + "a new titled session could not be created.", + file=sys.stderr, + ) + sys.exit(1) + else: + print(f"No session found matching '{continue_val}'.", file=sys.stderr) + print( + "Use 'hermes sessions list' to see available sessions, or " + "pass --create-if-missing to start a new session with that title.", + file=sys.stderr, + ) + sys.exit(1) + else: + # -c with no argument — prefer this terminal's own breadcrumb + # (written at session start / rotation) so side-by-side terminals + # each continue their own conversation. Falls back to the + # most-recent session when there is no valid breadcrumb, or when + # session.terminal_continue is false in config.yaml. + try: + from hermes_cli.terminal_breadcrumbs import resolve_breadcrumb_session + + _crumb_id = resolve_breadcrumb_session() + except Exception: + _crumb_id = None + if _crumb_id: + args.resume = _crumb_id + else: + # No valid breadcrumb — continue the most recent session + source = "tui" if use_tui else "cli" + last_id = _resolve_last_session(source=source) + if not last_id and source == "tui": + last_id = _resolve_last_session(source="cli") + if last_id: + args.resume = last_id + else: + kind = "TUI" if use_tui else "CLI" + print(f"No previous {kind} session found to continue.") + sys.exit(1) + + def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: if not path: return None @@ -2842,43 +2949,7 @@ def cmd_chat(args): sys.exit(1) # Resolve --continue into --resume with the latest session or by name - continue_val = getattr(args, "continue_last", None) - if continue_val and not getattr(args, "resume", None): - if isinstance(continue_val, str): - # -c "session name" — resolve by title or ID - resolved = _resolve_session_by_name_or_id(continue_val) - if resolved: - args.resume = resolved - else: - print(f"No session found matching '{continue_val}'.") - print("Use 'hermes sessions list' to see available sessions.") - sys.exit(1) - else: - # -c with no argument — prefer this terminal's own breadcrumb - # (written at session start / rotation) so side-by-side terminals - # each continue their own conversation. Falls back to the - # most-recent session when there is no valid breadcrumb, or when - # session.terminal_continue is false in config.yaml. - try: - from hermes_cli.terminal_breadcrumbs import resolve_breadcrumb_session - - _crumb_id = resolve_breadcrumb_session() - except Exception: - _crumb_id = None - if _crumb_id: - args.resume = _crumb_id - else: - # No valid breadcrumb — continue the most recent session - source = "tui" if use_tui else "cli" - last_id = _resolve_last_session(source=source) - if not last_id and source == "tui": - last_id = _resolve_last_session(source="cli") - if last_id: - args.resume = last_id - else: - kind = "TUI" if use_tui else "CLI" - print(f"No previous {kind} session found to continue.") - sys.exit(1) + _resolve_continue_arg(args, use_tui=use_tui) # --resume @claude / --resume @codex: import a foreign session (Claude # Code / Codex CLI) and resume the newly created Hermes session. diff --git a/tests/hermes_cli/test_chat_c_fail_loudly.py b/tests/hermes_cli/test_chat_c_fail_loudly.py new file mode 100644 index 0000000000..adbf818bb8 --- /dev/null +++ b/tests/hermes_cli/test_chat_c_fail_loudly.py @@ -0,0 +1,173 @@ +"""Tests for `chat -c <title>` failing loudly (stderr) and `--create-if-missing`. + +Regression for #86794: a background/quiet `hermes chat -c "<title>" -q "..."` +against a not-yet-existing titled session silently no-oped — the error message +was written to stdout (which quiet/programmatic callers treat as the "final +response" channel) instead of stderr, and there was no way to create the +titled session from the same invocation. + +Two behaviors are fixed here: + +1. **fail loudly on stderr** — when no session matches `-c <title>`, the error + goes to stderr (exit 1), so programmatic callers always see it. +2. **--create-if-missing** — same invocation with the flag creates a fresh + session carrying the title and proceeds, giving plugins/scripts a + deterministic "send to this named thread, making it if needed" primitive. +""" + +import os + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Point HERMES_HOME at a temp dir so session creation stays isolated.""" + home = tmp_path / "hermes_home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _create_titled_session(title): + from hermes_cli.main import _create_titled_session as fn + + return fn(title) + + +class TestCreateIfMissingFlagParsing: + def test_flag_parses_on_chat_subparser(self): + """--create-if-missing is accepted by the real chat parser.""" + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat_parser = build_top_level_parser() + args = parser.parse_args( + ["chat", "-c", "Bot Chat", "--create-if-missing", "-q", "hi"] + ) + assert args.continue_last == "Bot Chat" + assert getattr(args, "create_if_missing", False) is True + + def test_flag_absent_means_false(self): + """Without the flag, create_if_missing stays unset (SUPPRESS default).""" + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat_parser = build_top_level_parser() + args = parser.parse_args(["chat", "-c", "Bot Chat", "-q", "hi"]) + # SUPPRESS default: attribute absent, and cmd_chat's getattr(..., False) + # must treat it as False. + assert getattr(args, "create_if_missing", False) is False + + +class TestCreateTitledSession: + def test_creates_session_with_title(self, isolated_home): + sid = _create_titled_session("Bot Chat") + assert sid, "should return a session id" + + from hermes_state import SessionDB + + db = SessionDB() + try: + session = db.get_session(sid) + assert session is not None + assert db.get_session_title(sid) == "Bot Chat" + finally: + db.close() + + def test_created_session_resolvable_by_title(self, isolated_home): + """After creation, resolve_session_by_title finds it (idempotent sends).""" + sid = _create_titled_session("Bot Chat") + assert sid + + from hermes_state import SessionDB + + db = SessionDB() + try: + resolved = db.resolve_session_by_title("Bot Chat") + assert resolved == sid + finally: + db.close() + + +class TestChatCFailLoudlyOnStderr: + """Behavior-level: run the real cmd_chat path and inspect channels.""" + + def test_missing_session_fails_on_stderr(self, isolated_home, monkeypatch): + """-c <missing title> → exit 1, message on stderr, stdout untouched.""" + import sys + + import hermes_cli.main as main_mod + + stderr_lines = [] + + class _Exit(Exception): + pass + + def _fake_exit(code): + raise _Exit(code) + + class _Stderr: + def write(self, text): + stderr_lines.append(text) + return len(text) + + class _Stdout: + def write(self, text): + return len(text) + + monkeypatch.setattr(sys, "exit", _fake_exit) + monkeypatch.setattr(sys, "stderr", _Stderr()) + monkeypatch.setattr(sys, "stdout", _Stdout()) + + args = type( + "Args", + (), + { + "continue_last": "Bot Chat", + "resume": None, + "create_if_missing": False, + }, + )() + + with pytest.raises(_Exit) as ei: + main_mod._resolve_continue_arg(args, use_tui=False) + + assert ei.value.args[0] == 1 + assert any("No session found matching 'Bot Chat'" in l for l in stderr_lines) + assert not stderr_lines[0].startswith("Use 'hermes sessions list'") + + def test_create_if_missing_sets_resume(self, isolated_home, monkeypatch): + """--create-if-missing resolves to a new session id on args.resume.""" + import hermes_cli.main as main_mod + + args = type( + "Args", + (), + { + "continue_last": "Bot Chat", + "resume": None, + "create_if_missing": True, + }, + )() + + main_mod._resolve_continue_arg(args, use_tui=False) + + assert args.resume, "resume should be set to the new session id" + from hermes_state import SessionDB + + db = SessionDB() + try: + assert db.get_session_title(args.resume) == "Bot Chat" + finally: + db.close() + + +class TestSourceGuard: + """Source-level guards: the fail-loudly branch must target stderr.""" + + def test_not_found_message_uses_stderr(self): + src = open( + os.path.join(os.path.dirname(__file__), "..", "..", "hermes_cli", "main.py"), + encoding="utf-8", + ).read() + assert "No session found matching '{continue_val}'." in src + assert "file=sys.stderr" in src