fix(cli): chat -c fails loudly on stderr and gains --create-if-missing
`hermes chat -c "<title>" -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.
This commit is contained in:
@@ -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",
|
||||
|
||||
+108
-37
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user