"""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 ), 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 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 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"