v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077)
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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>/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
167
tests/test_cmd_idle_fallback.py
Normal file
167
tests/test_cmd_idle_fallback.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user