fix(cli): refuse an unbalanced-quote slash command instead of killing the session (supersedes #43503) - #76887
briandevans wants to merge 2 commits into
Conversation
… the session (supersedes NousResearch#43503) `shlex.split` raises `ValueError: No closing quotation` on an unbalanced quote. Three interactive-CLI slash handlers call it bare on the line the user typed: * hermes_cli/cli_commands_mixin.py:485 `_handle_journey_command` (inside a `try:` whose only handler is `except SystemExit:`) * hermes_cli/cli_commands_mixin.py:1487 `_handle_cron_command` * hermes_cli/cli_commands_mixin.py:1719 `_handle_curator_command` The REPL dispatch wraps `process_command` in `except KeyboardInterrupt` only (cli.py:17307-17322), and the comment there already names the consequence: anything else "unwinds to the outer prompt_toolkit loop and the session dies". So a single stray `"` ends the session and the conversation with it. Route all three through one `_tokenize_slash_command` helper that prints a quoting hint and returns None. This is deliberately not a `cmd.split()` fallback: a naive split keeps the session alive but lets the malformed line execute. `/cron add 30m "partial` splits to `['/cron', 'add', '30m', '"partial']`, `30m` validates as a schedule, and a scheduled job is silently created from input the user never finished typing; `/curator` likewise delegates `'"unterminated'` into `hermes_cli.curator.cli_main`. Refusing before the API call and before the delegation is what keeps the failure inert. The four already-guarded sites in this file (`/diff` :159, `/tools` :658, `/suggestions` :1670, `/blueprint` :1694) are left alone: they are not effectful on the degraded path, and changing them would alter commands that work today. Regression tests assert the cron API is never called and the curator entry point is never invoked for a malformed line, that `/journey` never dispatches, that no exception escapes any of the three handlers, and that correctly quoted commands still group their tokens.
There was a problem hiding this comment.
Pull request overview
This PR hardens the interactive CLI slash-command dispatch against unbalanced quotes by refusing to run malformed /journey, /cron, and /curator inputs (instead of letting a shlex.split() ValueError unwind and kill the prompt_toolkit session). It introduces a shared tokenizer helper and adds targeted regression coverage to ensure malformed input neither crashes the session nor executes partial commands.
Changes:
- Add
CLICommandsMixin._tokenize_slash_command()to centralizeshlex.split()handling and refuse unbalanced-quote input. - Update
/journey,/cron, and/curatorhandlers to use the helper and return early on refusal. - Add
tests/cli/test_cli_slash_quote_refusal.pyto cover refusal (no side effects) and survival (no exception escapes) across the three handlers.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
hermes_cli/cli_commands_mixin.py |
Adds a shared slash tokenizer and routes /journey, /cron, /curator through refusal-on-unbalanced-quote behavior. |
tests/cli/test_cli_slash_quote_refusal.py |
Adds regression tests asserting malformed quoted input is refused, produces a hint, and does not execute side effects. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import shlex | ||
|
|
||
| try: | ||
| return shlex.split(text) | ||
| except ValueError as exc: | ||
| print(f"(._.) {label}: {exc}. Nothing was run.") | ||
| print(f" Close the quote, e.g. {example}") | ||
| return None |
There was a problem hiding this comment.
Good catch — this was a real hole, not a style nit. The return was doing its job (no cron API call, no curator delegation), but patch_stdout would have eaten the hint in exactly the context the guard exists for, so the user would have seen their command do nothing at all with no explanation.
Fixed in a25032b:
_tokenize_slash_commandnow emits both lines throughcli._cli_visible_print, which routes to_cprintwhile a prompt_toolkitApplicationis running and falls back toprintotherwise (cli.py:3200-3217).- Added
test_refusal_hint_uses_the_prompt_toolkit_safe_printer, which patchescli._cli_visible_printand asserts the refusal text goes through it. Verified it is not vacuous: reverting the helper to a bareprintmakes that test fail, restoring it makes it pass. - The docstring now records why, so the next edit does not quietly regress it.
Focused suite: 11 passed.
`patch_stdout` swallows bare `print` while the prompt_toolkit Application owns the terminal (`cli.py:3200-3217`), which is exactly the situation this guard exists for — so the refusal hint was silent in the interactive CLI even though the command was correctly refused. Route both lines through `cli._cli_visible_print`, which falls back to `print` when no Application is running. Adds a test that fails if the helper regresses to a bare `print`.
|
Thanks for carrying the earlier diagnosis forward and changing the unsafe whitespace fallback into a refusal. I verified the premise on current main: unguarded Automated hermes-sweeper review. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two PRs address the unbalanced-quote crash in slash-command parsing. #43503 catches the parsing error for /cron and /curator but falls back to executable whitespace tokenization, whereas #76887 safely refuses malformed input, extends coverage to /journey, and tests both session survival and absence of side effects.
Related pull requests
- #43503
duplicate— (+70/-2) — close as duplicate of #76887: the diff prevents the/cronand/curatorparsing exception from escaping, but itscmd.split()fallback can execute malformed input such as/cron add 30m "partialand create a scheduled job. Despite the keep_open review on #43503, the documented/cronflow requires refusal before dispatch; #76887 implements that correction. - #76887
related— (+216/-7) — keep open with a salvage path: retain the shared refusal tokenizer, early returns for malformed/journey,/cron, and/curatorcommands, no-dispatch/no-cron-side-effect tests, and the prompt_toolkit-visible hint added ina25032bad678. This aligns with the maintainer-bot keep_open verdict while supplying the safe replacement for #43503.
Duplicates
#76887 supersedes #43503 for the same unbalanced-quote failure; #43503 is the unsafe whitespace-fallback variant and can be closed as a duplicate of #76887.
Suggested consolidation
Close #43503 as duplicate of #76887. Keep #76887 open with the concrete salvage path already present in its diff: refuse malformed quoting before dispatch, preserve the shared three-handler tokenizer and no-side-effect regression coverage, and retain the prompt_toolkit-visible feedback from a25032bad678.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup43503 ["PRs duplicating each other"]
P43503["PR #43503 (open)"]
P76887["PR #76887 (open)"]
end
class P43503 open
class P76887 open
class P76887 target
click P43503 "https://github.com/NousResearch/hermes-agent/pull/43503"
click P76887 "https://github.com/NousResearch/hermes-agent/pull/76887"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 15 kB of PR diffs, 13 kB of issue/PR text, 7 kB of discussion (6 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
What does this PR do?
Supersedes #43503. The diagnosis there is @ly-wang19's and it is correct:
shlex.splitraisesValueError: No closing quotationon an unbalanced quote, the interactive REPL dispatch wrapsprocess_commandinexcept KeyboardInterruptonly (cli.py:17307-17322), and the comment on that guard already names the consequence — anything else "unwinds to the outer prompt_toolkit loop and the session dies." One stray"ends the session and the conversation with it. That PR has had no push since the 2026-07-14 review, so this carries it forward with the two changes that review asked for.Delta 1 — refuse instead of degrade (the blocking review point). #43503 catches
ValueErrorand falls back tocmd.split(). That keeps the session alive but lets the malformed line execute, which is exactly what the review flagged: "whitespace fallback can execute malformed input …/cron add 30m "partialcan therefore create a job because30mis valid", and "OnValueError, print an invalid-quoting message and return from both handlers; do not callcmd.split()or delegate the malformed command." Verified on today'smain: a naive split gives['/cron', 'add', '30m', '"partial'],_handle_cron_commandtakespositionals[0]as the schedule (cli_commands_mixin.py:1596) and calls_cron_api(action="create", …)at:1602, so a half-typed line silently creates a real scheduled job./curatorlikewise hands'"unterminated'straight tohermes_cli.curator.cli_main. This PR returns before the API call and before the delegation.Delta 2 — the third live site. #43503 covers 2 of 3.
_handle_journey_command(cli_commands_mixin.py:485) has the identical unguardedshlex.split, sitting inside atry:whose only handler isexcept SystemExit:, dispatched bare fromcli.py:10209. It was not in the handler audit on that PR —/journeystarted usingshlexafter it.Related Issue
No issue is filed for this; the premise is
#43503's and is re-verified against currentmainbelow.Type of Change
Changes Made
hermes_cli/cli_commands_mixin.py— new_tokenize_slash_command(text, *, label, example)helper: returns tokens, or prints a quoting hint and returnsNone. One helper so the three sites cannot drift apart again.hermes_cli/cli_commands_mixin.py:485_handle_journey_command— refuse beforeparser.parse_args;parse_argskeeps its own separateexcept SystemExit.hermes_cli/cli_commands_mixin.py:1487_handle_cron_command— refuse beforesubcommand = tokens[1].lower()and before any_cron_api(...).hermes_cli/cli_commands_mixin.py:1719_handle_curator_command— refuse before thefrom hermes_cli.curator import cli_maindelegation; the bare-/curator→["status"]default is preserved.import shlexlines are removed from all three handlers.cli._cli_visible_print, not bareprint:patch_stdoutswallows bareprintwhile the prompt_toolkitApplicationowns the terminal (cli.py:3200-3217), which is exactly the situation this guard exists for.tests/cli/test_cli_slash_quote_refusal.py— new, 11 tests.Sibling-site sweep
grep -rn 'shlex\.split' --include='*.py' .over production code, then filtered to user-typed input reaching a handler on the CLI REPL slash dispatch. Every site is accounted for:Fixed — unguarded, on the slash-dispatch path,
ValueErrorkills the session:hermes_cli/cli_commands_mixin.py:485_handle_journey_command/journeycli.py:10209hermes_cli/cli_commands_mixin.py:1487_handle_cron_command/croncli.py:10040hermes_cli/cli_commands_mixin.py:1719_handle_curator_command/curatorcli.py:10046Deliberately excluded, with reasons:
:159/diff,:658/tools,:1670/suggestions,:1694/blueprinttry/except ValueError. They use the naive-split fallback, but the degraded parse there is not effectful — no job is created, nothing is delegated. Changing them would alter commands that work today, which is a separate behaviour question and is left out of this PR on purpose.:2607_compose_in_editortry/except Exception, and it splits the$EDITORconfig value, not typed input.hermes_cli/kanban.py:3171_handle_kanban_command(:1747-1750) wrapsrun_slashinexcept Exception.hermes_cli/blueprint_cmd.py:270,hermes_cli/mcp_security.py:96try/except ValueError.hermes_cli/console_engine.py:113ConsoleCommandError, caught by its own REPL at:527/:1136. Different surface.hermes_cli/session_listing.py:23parse_session_listing_argsis only reached fromgateway/slash_commands.py:4521;cli.py:7844importsquery_session_listinginstead. Different dispatch, different blast radius.hermes_cli/main.py:6957desktop.electron_flagsconfig value, not typed input.agent/,gateway/,tools/,plugins/sitesHow to Test
Manually: start
hermes, type/cron add 30m "partialand press enter. Before this change the session exits. After it, you get a hint and the prompt is still there — and/cron listshows no new job.Fails-before / passes-after, all three states
A — clean
main(no guard at all): 6 failed, 4 passed.B —
main+ #43503'scmd.split()fallback: 4 failed, 6 passed. The session survives, but the malformed input runs — this is the review's objection, reproduced:plus
/journeystill dying withValueError: No closing quotationin 2 tests, since that site is not covered there.C — this PR: 11 passed (10 at the time of the run below, plus
test_refusal_hint_uses_the_prompt_toolkit_safe_printeradded ina25032bad; that one fails if the helper regresses to a bareprint).Adjacent surface (every test file that imports the mixin or exercises
/cron,/curator,/journey,/diff,/focus,/reasoning, curator, cron scheduling, write-approval, handoff relay) — 274 passed, serially, on this branch.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the focused file plus the full adjacent surface (274 passed) andtests/cli+tests/hermes_cli; the remaining reds there reproduce identically on cleanmainand are unrelated to this change.Documentation & Housekeeping
docs/, docstrings) — the new helper carries the rationale in its docstring; no user-facing docs describe this pathcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Ashlex.splitraises the sameValueErroron every platform and no platform-specific behaviour is introduced; tested on macOS onlyRelated / Positioning
/journeysite./cron editsemantics,@@ -1588), feat(cli): add clarification-first question mode #75969 (_handle_suggestions_command,@@ -1678), refactor(cli): extract session navigation into CLISessionNavigationMixin #75787 (extraction of704-1245), feat(agent): add per-turn provider request budget #76458 (@@ -1911), fix(moa,desktop): sticky route integrity + interim double-bubble #76191 (@@ -1158), feat(cli): sessions list/search options, columns, pagination #75496 (854-1173), fix(goal): resume standing goal work immediately #75361 (@@ -2332), feat(cli): add width-safe streaming Markdown rendering #75326 (@@ -1970), fix(cli): honor skin colors in inline diffs #75216 (@@ -28 / -2565). None touches 485, 1487 or 1719.cli.py, which no longer defines them (it doesfrom hermes_cli.cli_commands_mixin import CLICommandsMixin).test_cli_slash_quote_refusal.pyrather than fix(cli): don't crash the session on an unbalanced quote in /cron or /curator #43503'stest_cli_slash_unbalanced_quote.pyso the two never collide as an add/add if both are ever applied.