HERMES-GIELIE/tests/test_1062_busy_input_modes.py
nesquena-hermes 520034c071
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands

- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720

* test: 17 regression tests for busy_input_mode + slash commands

PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:

  Backend (api/config.py):
    - default 'queue' is set in _DEFAULT_SETTINGS
    - enum validator restricts to {queue, interrupt, steer}

  Slash commands (static/commands.js):
    - /queue, /interrupt, /steer all registered with correct fns
    - /interrupt and /steer set noEcho:true (the queued payload
      becomes the visible turn, not the slash invocation)
    - cmdQueue requires S.busy
    - cmdInterrupt + cmdSteer call queueSessionMessage before
      cancelStream (otherwise the drain has nothing to pick up)

  send() busy branch (static/messages.js):
    - reads window._busyInputMode
    - calls cancelStream on interrupt/steer
    - queues before cancelling (ordering invariant)

  Boot init + panels.js wiring (static/boot.js, static/panels.js):
    - both success and fallback paths set window._busyInputMode
    - load/save/apply path threads busy_input_mode through

  i18n (static/i18n.js):
    - all 17 new keys present in each of the 6 locale blocks

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

* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers

1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
   as a user bubble, then the drain would send the queued message, causing a
   double-bubble in the conversation (#840 pattern).

2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
   payload but never cleared S.pendingFiles or called renderTray(). Staged files
   would remain in the tray and be re-attached on the next send(), duplicating
   attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().

3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
   interrupt + steer), now documents that all three must set noEcho:true.

4. test_slash_commands_clear_pending_files: new test that all three handlers clear
   S.pendingFiles and call renderTray() after enqueuing.

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>

* docs: v0.50.214 release notes and version bump

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 18:51:06 -07:00

220 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Regression tests for busy_input_mode (PR #1062, closes #720).
Pins the wiring for the three modes (queue / interrupt / steer):
- The setting key + default + enum validation in api/config.py
- Three slash commands registered in static/commands.js
- send()'s busy branch reads window._busyInputMode and dispatches
- Boot initializes window._busyInputMode from settings
- 17 new i18n keys present in all 6 locale blocks
Issue: #720 (configurable busy-input behaviour)
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
# ── Backend: setting registration + enum validation ─────────────────────
class TestBusyInputModeSetting:
"""The new setting key must be registered with a default and enum validator."""
def test_default_is_queue(self):
"""Default value preserves existing queue behaviour for users who don't touch the setting."""
assert '"busy_input_mode": "queue"' in CONFIG_PY, (
"_DEFAULT_SETTINGS must include busy_input_mode='queue' so existing users see no change"
)
def test_enum_validator_present(self):
"""_SETTINGS_ENUM_KEYS must validate busy_input_mode against {queue, interrupt, steer}."""
# Find the entry inside the enum dict (a set literal as the value)
idx = CONFIG_PY.find('"busy_input_mode": {')
assert idx >= 0, "busy_input_mode entry missing from _SETTINGS_ENUM_KEYS"
block = CONFIG_PY[idx:idx + 200]
assert '"queue"' in block and '"interrupt"' in block and '"steer"' in block, (
"busy_input_mode enum must contain {queue, interrupt, steer}"
)
# ── Frontend: slash commands ─────────────────────────────────────────────
class TestSlashCommandRegistration:
"""The three new slash commands must be registered in COMMANDS array."""
def test_queue_command_registered(self):
assert "name:'queue'" in COMMANDS_JS and "fn:cmdQueue" in COMMANDS_JS
def test_interrupt_command_registered(self):
assert "name:'interrupt'" in COMMANDS_JS and "fn:cmdInterrupt" in COMMANDS_JS
def test_steer_command_registered(self):
assert "name:'steer'" in COMMANDS_JS and "fn:cmdSteer" in COMMANDS_JS
def test_all_three_busy_commands_are_no_echo(self):
"""All three busy commands must set noEcho:true so the slash invocation
is not echoed as a visible user bubble. Without noEcho, /queue causes a
double-bubble: the raw slash text appears, then the queued message appears
again when the drain fires.
"""
for name in ("queue", "interrupt", "steer"):
idx = COMMANDS_JS.find(f"name:'{name}'")
assert idx >= 0, f"{name} not registered"
block = COMMANDS_JS[idx:idx + 250]
assert "noEcho:true" in block, (
f"/{name} registration must set noEcho:true — "
"without it the command text is echoed as a user bubble, causing duplicates"
)
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."""
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"
def test_cmd_interrupt_calls_cancel_stream(self):
idx = COMMANDS_JS.find("async function cmdInterrupt(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 800]
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"
def test_cmd_steer_falls_back_to_interrupt(self):
"""Steer is a placeholder — currently uses interrupt semantics with a different toast."""
idx = COMMANDS_JS.find("async function cmdSteer(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 800]
assert "queueSessionMessage" in body
assert "cancelStream" in body
# Toast should differ from interrupt to signal the placeholder
assert "cmd_steer_fallback" in body or "steer_fallback" in body
# ── send() busy branch ───────────────────────────────────────────────────
def test_slash_commands_clear_pending_files(self):
"""All three slash command handlers must clear S.pendingFiles and call
renderTray() after enqueuing, so staged files are not duplicated when
the next send() fires.
"""
for fn_name in ("cmdQueue", "cmdInterrupt", "cmdSteer"):
idx = COMMANDS_JS.find(f"function {fn_name}(")
assert idx >= 0, f"{fn_name} not found"
body = COMMANDS_JS[idx:idx + 800]
assert "S.pendingFiles=[]" in body, (
f"{fn_name} must clear S.pendingFiles after queueSessionMessage — "
"otherwise staged files are re-attached on the next send()"
)
assert "renderTray()" in body, (
f"{fn_name} must call renderTray() after clearing pendingFiles"
)
class TestSendBusyBranchDispatch:
"""send()'s busy block must read window._busyInputMode and branch accordingly."""
def test_send_reads_busy_input_mode(self):
# The send() function should read window._busyInputMode in the busy block
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0
# Look in the first ~3000 chars of send() for the busy mode read
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
assert "_busyInputMode" in send_body, (
"send() must read window._busyInputMode in the S.busy branch"
)
def test_send_calls_cancel_stream_on_interrupt(self):
send_idx = MESSAGES_JS.find("async function send(")
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
# The interrupt branch must call cancelStream
assert "cancelStream" in send_body
# And queue before cancel (otherwise the drain has nothing to pick up)
# Verify the order textually: queueSessionMessage appears before cancelStream
# within the busy block's interrupt branch
cancel_idx = send_body.find("cancelStream")
queue_idx = send_body.find("queueSessionMessage")
assert queue_idx >= 0 and cancel_idx >= 0
assert queue_idx < cancel_idx, (
"queueSessionMessage must run before cancelStream so the drain "
"after setBusy(false) picks up the queued message"
)
# ── Boot init + settings panel wiring ───────────────────────────────────
class TestBootAndPanelsWiring:
def test_boot_init_default_path(self):
"""Boot success path initialises window._busyInputMode from settings."""
assert "window._busyInputMode=(s.busy_input_mode||'queue')" in BOOT_JS
def test_boot_init_fallback_path(self):
"""Boot fallback path (settings load failed) initialises to safe default."""
# The fallback should set window._busyInputMode='queue'
assert "window._busyInputMode='queue'" in BOOT_JS
def test_panels_load_save_apply(self):
assert "settingsBusyInputMode" in PANELS_JS, "panels.js must load the setting"
assert "body.busy_input_mode" in PANELS_JS, "saveSettings must include busy_input_mode in body"
assert "window._busyInputMode=body.busy_input_mode" in PANELS_JS, (
"_applySavedSettingsUi must propagate busy_input_mode to the global"
)
def test_index_html_dropdown_has_three_options(self):
idx = INDEX_HTML.find('id="settingsBusyInputMode"')
assert idx >= 0
block = INDEX_HTML[idx:idx + 800]
assert 'value="queue"' in block
assert 'value="interrupt"' in block
assert 'value="steer"' in block
# ── i18n locale coverage ─────────────────────────────────────────────────
class TestI18nKeys:
"""All 17 new keys must appear in each of the 6 locale blocks."""
REQUIRED_KEYS = [
"cmd_queue",
"cmd_interrupt",
"cmd_steer",
"cmd_queue_no_msg",
"cmd_queue_not_busy",
"cmd_queue_confirm",
"cmd_interrupt_no_msg",
"cmd_interrupt_confirm",
"cmd_steer_no_msg",
"cmd_steer_fallback",
"busy_steer_fallback",
"busy_interrupt_confirm",
"settings_label_busy_input_mode",
"settings_desc_busy_input_mode",
"settings_busy_input_mode_queue",
"settings_busy_input_mode_interrupt",
"settings_busy_input_mode_steer",
]
def test_each_key_appears_at_least_six_times(self):
"""Each key should appear once per locale (en, ru, es, de, zh, zh-Hant) = 6 occurrences minimum."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count >= 6, (
f"i18n key {key!r} appears {count} times; expected ≥6 (one per locale block)"
)
def test_key_count_total(self):
"""17 keys × 6 locales = 102 minimum occurrences across the file."""
total = sum(I18N_JS.count(f"{key}:") for key in self.REQUIRED_KEYS)
assert total >= 17 * 6, (
f"Total i18n occurrences = {total}; expected ≥ {17*6}"
)