v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077)
Some checks failed
Release & Docker / release (push) Has been cancelled

* fix(commands): /queue /interrupt /steer send normally when agent is idle

When the agent is not running, these three commands now fall through to a
direct send() call (setting the input value and invoking send()) instead
of showing an error toast.  This matches CLI behaviour — the commands are
mode-sensitive: they operate as queue/interrupt/steer when busy, and as
normal sends when idle.

Before:
  /queue hello   → "No active task — just send normally" (toast, nothing sent)
  /steer hello   → "No active task to stop." (misleading + nothing sent)
  /interrupt hi  → "No active task to stop." (nothing sent)

After:
  /queue hello   → message sent immediately (same as typing and pressing Enter)
  /steer hello   → message sent immediately
  /interrupt hi  → message sent immediately

Note: /stop when idle still shows "No active task" — that one is correct
since stopping nothing is always an error.

15 new tests in test_cmd_idle_fallback.py covering the idle path for all
three commands and verifying the active-session paths are unchanged.

* test(commands): update stale test doc — /queue idle now sends, not rejects

test_cmd_queue_requires_busy was written before the idle-send fallback
existed. Its docstring said "/queue while not busy is a usage error"
and the assertion message said "reject if idle" — both accurate for the
old toast-and-return behaviour but wrong after this PR.

The test assertion itself (`"if(!S.busy)" in body`) still passes because
the idle guard still exists; it just routes to send() instead of a toast.
Updating the name and copy to accurately describe what the code now does,
so the test reads as documentation rather than as a contradiction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.50.217 release notes and version bump

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-25 21:35:51 -07:00
committed by GitHub
parent 58ad315dca
commit 62adc0c00d
6 changed files with 201 additions and 9 deletions

View File

@@ -3,6 +3,11 @@
## [Unreleased]
## v0.50.217 — 2026-04-26
### Fixed
- **`/queue`, `/interrupt`, `/steer` send normally when agent is idle** — typing any of these commands while nothing is running now sends the message as a normal turn instead of showing an error toast. Matches CLI behaviour: commands are mode-sensitive (queue/interrupt/steer when busy, plain send when idle). `/stop` when idle still shows the error — stopping nothing is always an error. (`static/commands.js`) [#1076]
## v0.50.216 — 2026-04-26
### Added

View File

@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.216 (April 26, 2026) — 2428 tests collected
> Last updated: v0.50.217 (April 26, 2026) — 2442 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/

View File

@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated coverage: 2428 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Automated coverage: 2442 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.

View File

@@ -546,7 +546,13 @@ async function cmdStop(){
async function cmdQueue(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_queue_no_msg'));return;}
if(!S.busy){showToast(t('cmd_queue_not_busy'));return;}
// If nothing is running, /queue <msg> just sends like a normal message
if(!S.busy){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
@@ -561,7 +567,13 @@ async function cmdQueue(args){
async function cmdInterrupt(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_interrupt_no_msg'));return;}
if(!S.busy||!S.activeStreamId){showToast(t('no_active_task'));return;}
// If nothing is running, /interrupt <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
// Queue the message first (before cancel sets busy=false and drains)
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
@@ -586,7 +598,13 @@ async function cmdInterrupt(args){
async function cmdSteer(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_steer_no_msg'));return;}
if(!S.busy||!S.activeStreamId){showToast(t('no_active_task'));return;}
// If nothing is running, /steer <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
await _trySteer(msg, /*explicitSteer=*/true);
}

View File

@@ -76,17 +76,19 @@ class TestSlashCommandRegistration:
class TestSlashCommandHandlers:
"""The three handler functions must guard properly and call cancelStream where appropriate."""
def test_cmd_queue_requires_busy(self):
"""/queue while not busy is a usage error — user should send normally."""
def test_cmd_queue_handles_idle_state(self):
"""/queue when idle now sends the message normally instead of showing an
error toast. The if(!S.busy) guard must still exist — it routes to the
idle-send path rather than the queue path."""
idx = COMMANDS_JS.find("async function cmdQueue(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 600]
assert "if(!S.busy)" in body, "/queue must check S.busy and reject if idle"
assert "if(!S.busy)" in body, "/queue must have an if(!S.busy) guard that routes to send()"
def test_cmd_interrupt_calls_cancel_stream(self):
idx = COMMANDS_JS.find("async function cmdInterrupt(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 800]
body = COMMANDS_JS[idx:idx + 1300] # expanded: idle-fallback block added before the busy path
assert "queueSessionMessage" in body, "/interrupt must queue the new message before cancelling"
assert "cancelStream" in body, "/interrupt must call cancelStream() so the drain re-sends"

View File

@@ -0,0 +1,167 @@
"""Regression tests for idle-state fallback on /queue, /interrupt, /steer.
When the agent is idle (S.busy=false, S.activeStreamId=null), these commands
previously showed an error toast instead of sending the message. They now
fall through to a direct send() call, matching CLI behaviour:
- /queue msg → send when idle, queue when busy
- /interrupt msg → send when idle, cancel+requeue when busy+streaming
- /steer msg → send when idle, inject mid-turn when busy+streaming
"""
import re
import pathlib
COMMANDS_JS = (pathlib.Path(__file__).parent.parent / "static" / "commands.js").read_text(encoding="utf-8")
# ─────────────────────────────────────────────────────────────────────────────
# Source-level structural checks
# ─────────────────────────────────────────────────────────────────────────────
class TestIdleFallbackStructure:
"""Each handler must contain an idle-path that calls send() instead of
showing an error toast."""
def _get_function_body(self, fn_name: str, window: int = 800) -> str:
idx = COMMANDS_JS.find(f"async function {fn_name}(")
assert idx >= 0, f"{fn_name} not found in commands.js"
return COMMANDS_JS[idx: idx + window]
# /queue ──────────────────────────────────────────────────────────────────
def test_queue_idle_path_calls_send(self):
"""cmdQueue must call send() when !S.busy (idle)."""
body = self._get_function_body("cmdQueue")
assert "send()" in body, (
"cmdQueue must call send() when idle instead of showing an error toast"
)
def test_queue_idle_path_sets_input_value(self):
"""cmdQueue must populate the message input before calling send()."""
body = self._get_function_body("cmdQueue")
assert "inp.value=msg" in body or "inp.value = msg" in body, (
"cmdQueue must set input value before calling send()"
)
def test_queue_idle_path_not_toast_only(self):
"""cmdQueue must NOT show cmd_queue_not_busy toast as its only idle action."""
body = self._get_function_body("cmdQueue")
# The old code returned after a toast; new code returns after send().
# The toast key may still be in the file for other reasons but must not
# be the only thing that happens when !S.busy.
idle_branch_start = body.find("if(!S.busy)")
assert idle_branch_start >= 0, "cmdQueue must have an if(!S.busy) branch"
idle_branch = body[idle_branch_start: idle_branch_start + 300]
assert "send()" in idle_branch, (
"cmdQueue's idle branch must call send(), not just show a toast"
)
# /interrupt ──────────────────────────────────────────────────────────────
def test_interrupt_idle_path_calls_send(self):
"""cmdInterrupt must call send() when idle (!S.busy || !S.activeStreamId)."""
body = self._get_function_body("cmdInterrupt")
assert "send()" in body, (
"cmdInterrupt must call send() when idle instead of showing an error toast"
)
def test_interrupt_idle_path_sets_input_value(self):
body = self._get_function_body("cmdInterrupt")
assert "inp.value=msg" in body or "inp.value = msg" in body
def test_interrupt_idle_branch_exists(self):
body = self._get_function_body("cmdInterrupt")
# Either !S.busy||!S.activeStreamId or !S.busy block with send()
has_idle = "!S.busy||!S.activeStreamId" in body or "!S.busy" in body
assert has_idle, "cmdInterrupt must have an idle guard"
idle_start = body.find("!S.busy")
assert "send()" in body[idle_start: idle_start + 350], (
"cmdInterrupt idle branch must call send()"
)
# /steer ──────────────────────────────────────────────────────────────────
def test_steer_idle_path_calls_send(self):
"""cmdSteer must call send() when idle (!S.busy || !S.activeStreamId)."""
body = self._get_function_body("cmdSteer")
assert "send()" in body, (
"cmdSteer must call send() when idle instead of showing an error toast"
)
def test_steer_idle_path_sets_input_value(self):
body = self._get_function_body("cmdSteer")
assert "inp.value=msg" in body or "inp.value = msg" in body
def test_steer_idle_branch_before_trySteer(self):
"""The idle fallback must appear BEFORE the _trySteer call so steer text
is sent normally when there is nothing to steer."""
body = self._get_function_body("cmdSteer")
idle_idx = body.find("!S.busy")
steer_idx = body.find("_trySteer")
assert idle_idx >= 0, "cmdSteer must have an idle guard"
assert steer_idx >= 0, "cmdSteer must call _trySteer for active sessions"
assert idle_idx < steer_idx, (
"Idle fallback must come before _trySteer — otherwise steer text is "
"sent to the endpoint even when there is no active stream"
)
# Old error paths removed ─────────────────────────────────────────────────
def test_queue_no_longer_toasts_only_when_idle(self):
"""The old `showToast(t('cmd_queue_not_busy')); return` should not be the
sole handler for the idle case — that was the bug."""
body = self._get_function_body("cmdQueue")
idle_idx = body.find("if(!S.busy)")
assert idle_idx >= 0
idle_block = body[idle_idx: idle_idx + 250]
# send() must appear in the idle block
assert "send()" in idle_block, (
"cmdQueue idle block must contain send(), not just a toast+return"
)
def test_steer_no_longer_calls_no_active_task_toast_when_idle(self):
"""cmdSteer must not show 'no_active_task' toast as its response to being
called while idle — that wording implied steer cancels a task, which it
does not."""
body = self._get_function_body("cmdSteer")
idle_idx = body.find("!S.busy")
assert idle_idx >= 0
idle_block = body[idle_idx: idle_idx + 250]
assert "no_active_task" not in idle_block, (
"cmdSteer idle path must not show 'no_active_task' — steer doesn't "
"stop tasks, and when idle it should send normally"
)
class TestBusyPathsStillWork:
"""Active-session paths must be unchanged — guards are preserved."""
def _get_function_body(self, fn_name: str, window: int = 800) -> str:
idx = COMMANDS_JS.find(f"async function {fn_name}(")
assert idx >= 0
return COMMANDS_JS[idx: idx + window]
def test_queue_still_queues_when_busy(self):
"""When S.busy, cmdQueue must still call queueSessionMessage."""
body = self._get_function_body("cmdQueue")
assert "queueSessionMessage" in body, (
"cmdQueue must still queue messages when S.busy is true"
)
def test_interrupt_still_cancels_when_busy(self):
"""When S.busy && S.activeStreamId, cmdInterrupt must still call cancelStream."""
body = self._get_function_body("cmdInterrupt", window=1200)
assert "cancelStream" in body
def test_steer_still_calls_trySteer_when_busy(self):
"""When S.busy && S.activeStreamId, cmdSteer must still call _trySteer."""
body = self._get_function_body("cmdSteer")
assert "_trySteer" in body
def test_stop_command_unchanged(self):
"""cmdStop still uses no_active_task toast — that's correct for /stop."""
idx = COMMANDS_JS.find("async function cmdStop(")
body = COMMANDS_JS[idx: idx + 400]
assert "no_active_task" in body, (
"/stop should still show 'no active task' when idle — stopping nothing is an error"
)