mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-22 10:20:14 +00:00
62adc0c00d
* 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>
168 lines
8.3 KiB
Python
168 lines
8.3 KiB
Python
"""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"
|
|
)
|