203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
|
|
"""Tests for slash command echo (#840) — user message shown in chat after /skills, /help, etc."""
|
||
|
|
import os
|
||
|
|
|
||
|
|
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||
|
|
|
||
|
|
|
||
|
|
def _read(name):
|
||
|
|
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||
|
|
|
||
|
|
|
||
|
|
class TestExecuteCommandReturnValue:
|
||
|
|
"""executeCommand() now returns null or {noEcho:bool} instead of true/false."""
|
||
|
|
|
||
|
|
def test_execute_command_returns_null_on_no_match(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("function executeCommand(")
|
||
|
|
block = src[idx:idx + 400]
|
||
|
|
# Must return null (not false) when no command matched
|
||
|
|
assert "return null;" in block, (
|
||
|
|
"executeCommand must return null when no command found (not false)"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_execute_command_returns_noecho_object(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
assert "return {noEcho:" in src, (
|
||
|
|
"executeCommand must return {noEcho:...} when a command runs"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_clear(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
# Find the clear command entry
|
||
|
|
idx = src.find("name:'clear'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 100]
|
||
|
|
assert "noEcho:true" in entry, "/clear must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_new(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'new'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/new must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_stop(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'stop'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/stop must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_retry(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'retry'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/retry must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_undo(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'undo'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/undo must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_voice(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'voice'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/voice must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_theme(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'theme'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho:true" in entry, "/theme must have noEcho:true"
|
||
|
|
|
||
|
|
def test_no_echo_flag_on_model(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'model'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 130]
|
||
|
|
assert "noEcho:true" in entry, "/model must have noEcho:true"
|
||
|
|
|
||
|
|
def test_skills_has_no_noecho(self):
|
||
|
|
"""Commands that produce chat responses must NOT have noEcho."""
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'skills'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 100]
|
||
|
|
assert "noEcho" not in entry, "/skills must echo — no noEcho flag"
|
||
|
|
|
||
|
|
def test_help_has_no_noecho(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'help'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho" not in entry, "/help must echo — no noEcho flag"
|
||
|
|
|
||
|
|
def test_status_has_no_noecho(self):
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
idx = src.find("name:'status'")
|
||
|
|
assert idx >= 0
|
||
|
|
entry = src[idx:idx + 80]
|
||
|
|
assert "noEcho" not in entry, "/status must echo — no noEcho flag"
|
||
|
|
|
||
|
|
|
||
|
|
class TestSendSlashIntercept:
|
||
|
|
"""send() in messages.js must push user message for echo-worthy commands."""
|
||
|
|
|
||
|
|
def test_send_checks_noecho_flag(self):
|
||
|
|
src = _read("static/messages.js")
|
||
|
|
idx = src.find("Slash command intercept")
|
||
|
|
block = src[idx:idx + 1400]
|
||
|
|
assert "_cmd.noEcho" in block or "cmd.noEcho" in block, (
|
||
|
|
"send() must check the command's noEcho flag before pushing user message (#840)"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_send_pushes_user_message_for_echo_commands(self):
|
||
|
|
src = _read("static/messages.js")
|
||
|
|
idx = src.find("Slash command intercept")
|
||
|
|
block = src[idx:idx + 1400]
|
||
|
|
assert "role:'user'" in block and "content:text" in block, (
|
||
|
|
"send() must push {role:'user', content:text} for echo-worthy slash commands (#840)"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_send_pushes_user_message_before_running_handler(self):
|
||
|
|
"""Ordering fix: cmdHelp-style handlers push their assistant response
|
||
|
|
synchronously. The user message must be pushed BEFORE the handler
|
||
|
|
runs so S.messages ends up [user, assistant] — not [assistant, user]
|
||
|
|
which would display in reverse chronological order."""
|
||
|
|
src = _read("static/messages.js")
|
||
|
|
idx = src.find("Slash command intercept")
|
||
|
|
block = src[idx:idx + 1400]
|
||
|
|
user_push_pos = block.find("role:'user'")
|
||
|
|
handler_call_pos = block.find("_cmd.fn(")
|
||
|
|
if handler_call_pos == -1:
|
||
|
|
handler_call_pos = block.find("cmd.fn(")
|
||
|
|
assert user_push_pos != -1, "user message push not found in intercept block"
|
||
|
|
assert handler_call_pos != -1, "handler invocation not found in intercept block"
|
||
|
|
assert user_push_pos < handler_call_pos, (
|
||
|
|
"User message must be pushed BEFORE the handler runs — otherwise "
|
||
|
|
"sync handlers like cmdHelp push the assistant response first and "
|
||
|
|
"the chat displays in reverse chronological order."
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_send_rolls_back_user_push_on_handler_optout(self):
|
||
|
|
"""If a handler returns false (opt-out — e.g. /reasoning <level>),
|
||
|
|
the pre-pushed user message must be popped so the normal send path
|
||
|
|
can add it cleanly for forwarding to the agent."""
|
||
|
|
src = _read("static/messages.js")
|
||
|
|
idx = src.find("Slash command intercept")
|
||
|
|
block = src[idx:idx + 1400]
|
||
|
|
assert "S.messages.pop()" in block, (
|
||
|
|
"send() must S.messages.pop() the user message on handler opt-out "
|
||
|
|
"to avoid duplicating the user turn when falling through to "
|
||
|
|
"the normal send path."
|
||
|
|
)
|
||
|
|
assert "===false" in block or "=== false" in block, (
|
||
|
|
"opt-out must be detected by handler returning === false"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_compress_has_no_echo_flag():
|
||
|
|
"""compress is action-only — it resets S.messages internally; echo would flicker."""
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
import re
|
||
|
|
m = re.search(r"\{name:'compress'[^}]+\}", src)
|
||
|
|
assert m, "compress entry not found in COMMANDS"
|
||
|
|
assert 'noEcho:true' in m.group(), f"compress missing noEcho:true: {m.group()}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_compact_has_no_echo_flag():
|
||
|
|
"""compact is an alias for compress — same noEcho requirement."""
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
import re
|
||
|
|
m = re.search(r"\{name:'compact'[^}]+\}", src)
|
||
|
|
assert m, "compact entry not found in COMMANDS"
|
||
|
|
assert 'noEcho:true' in m.group(), f"compact missing noEcho:true: {m.group()}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_title_with_args_pushes_confirmation_message():
|
||
|
|
"""When /title <name> succeeds, cmdTitle pushes an assistant confirmation bubble."""
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
# After the rename API call succeeds, an assistant message is pushed
|
||
|
|
idx = src.find("title_set")
|
||
|
|
segment = src[idx: idx + 300]
|
||
|
|
assert 'S.messages.push' in segment, "cmdTitle success path must push an assistant message"
|
||
|
|
assert "role:'assistant'" in segment, "cmdTitle confirmation must have role:assistant"
|
||
|
|
|
||
|
|
|
||
|
|
def test_personality_with_args_pushes_confirmation_message():
|
||
|
|
"""When /personality <name> succeeds, cmdPersonality pushes an assistant confirmation bubble."""
|
||
|
|
src = _read("static/commands.js")
|
||
|
|
# Find the set-personality success path (after the clear/none/default branch)
|
||
|
|
# S.messages.push comes BEFORE the personality_set toast
|
||
|
|
idx = src.find("personality_set')+`**${name}**`")
|
||
|
|
assert idx != -1, "cmdPersonality confirmation push not found in source"
|
||
|
|
segment = src[max(0, idx-100): idx + 200]
|
||
|
|
assert 'S.messages.push' in segment, "cmdPersonality success path must push an assistant message"
|
||
|
|
assert "role:'assistant'" in segment, "cmdPersonality confirmation must have role:assistant"
|