diff --git a/CHANGELOG.md b/CHANGELOG.md index efda435..8797e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Hermes Web UI -- Changelog +## [v0.50.146] — 2026-04-22 + +### Fixed +- **Slash command input now shown as user message in chat** — commands like `/help`, + `/skills`, `/status` previously produced a response with no visible user input above + it, making the conversation appear to start from nowhere. Added a `noEcho` flag to + action-only commands (`/clear`, `/new`, `/stop`, etc.) and echo the user's input as + a message bubble for commands that produce a chat response. User message is pushed + BEFORE the handler runs to ensure correct ordering in `S.messages`. Closes #840. (#841) + ## [v0.50.145] — 2026-04-22 ### Fixed diff --git a/static/commands.js b/static/commands.js index a9d939c..66d4db1 100644 --- a/static/commands.js +++ b/static/commands.js @@ -3,24 +3,26 @@ // (no round-trip to the agent) and shows feedback via toast or local message. const COMMANDS=[ + // noEcho:true = action-only commands that don't produce a chat response. + // Commands without noEcho get a user message echoed to the chat (#840). {name:'help', desc:t('cmd_help'), fn:cmdHelp}, - {name:'clear', desc:t('cmd_clear'), fn:cmdClear}, - {name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]'}, - {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact}, - {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models'}, - {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'}, - {name:'new', desc:t('cmd_new'), fn:cmdNew}, - {name:'usage', desc:t('cmd_usage'), fn:cmdUsage}, - {name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'}, + {name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true}, + {name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true}, + {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true}, + {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true}, + {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true}, + {name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true}, + {name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true}, + {name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true}, {name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'}, {name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'}, - {name:'stop', desc:t('cmd_stop'), fn:cmdStop}, + {name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true}, {name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'}, - {name:'retry', desc:t('cmd_retry'), fn:cmdRetry}, - {name:'undo', desc:t('cmd_undo'), fn:cmdUndo}, + {name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true}, + {name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true}, {name:'status', desc:t('cmd_status'), fn:cmdStatus}, - {name:'voice', desc:t('cmd_voice'), fn:cmdVoice}, - {name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh']}, + {name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true}, + {name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true}, ]; const SLASH_SUBARG_SOURCES={ @@ -38,13 +40,15 @@ function parseCommand(text){ function executeCommand(text){ const parsed=parseCommand(text); - if(!parsed)return false; + if(!parsed)return null; const cmd=COMMANDS.find(c=>c.name===parsed.name); - if(!cmd)return false; + if(!cmd)return null; // A handler may return `false` to opt out of interception — e.g. /reasoning // with an effort level falls through so the agent's own handler sees it, // preserving the pre-existing pass-through behaviour for that subcommand. - return cmd.fn(parsed.args) !== false; + if(cmd.fn(parsed.args)===false)return null; + // Return noEcho flag so send() knows whether to echo the command as a user message (#840). + return {noEcho:!!cmd.noEcho}; } function getMatchingCommands(prefix){ @@ -513,6 +517,8 @@ async function cmdPersonality(args){ } try{ const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})}); + S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`}); + renderMessages(); showToast(t('personality_set')+name); }catch(e){showToast(t('failed_colon')+e.message);} } @@ -537,6 +543,8 @@ async function cmdTitle(args){ if(typeof syncTopbar==='function')syncTopbar(); if(typeof renderSessionList==='function')renderSessionList(); showToast(`${t('title_set')} "${S.session.title}"`); + S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`}); + renderMessages(); }catch(e){showToast(t('failed_colon')+e.message);} } async function cmdRetry(){ diff --git a/static/messages.js b/static/messages.js index 109f0f5..4d8fa74 100644 --- a/static/messages.js +++ b/static/messages.js @@ -16,9 +16,34 @@ async function send(){ } return; } - // Slash command intercept -- local commands handled without agent round-trip - if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){ - $('msg').value='';autoResize();hideCmdDropdown();return; + // Slash command intercept -- local commands handled without agent round-trip. + // We push the user message BEFORE running the handler for echo-worthy + // commands so chat order is correct: some handlers (e.g. cmdHelp) push + // their assistant response synchronously. If we pushed AFTER, S.messages + // would be [assistant, user] and the chat would show the response above + // the user's own input — reverse chronological order (#840 ordering bug). + if(text.startsWith('/')&&!S.pendingFiles.length){ + const _parsedCmd=parseCommand(text); + const _cmd=_parsedCmd?COMMANDS.find(c=>c.name===_parsedCmd.name):null; + if(_cmd){ + let _pushedUser=false; + if(!_cmd.noEcho){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + _pushedUser=true; + renderMessages(); + } + // Run the handler directly (we already looked it up). If it returns + // false it's opting out — e.g. /reasoning falls through so the + // agent sees the raw text. Roll back the echo push in that case so + // the normal send path doesn't duplicate it. + if(_cmd.fn(_parsedCmd.args)===false){ + if(_pushedUser){S.messages.pop();renderMessages();} + // Fall through to normal send path + } else { + $('msg').value='';autoResize();hideCmdDropdown();return; + } + } } if(!S.session){await newSession();await renderSessionList();} diff --git a/tests/test_issue840_slash_echo.py b/tests/test_issue840_slash_echo.py new file mode 100644 index 0000000..f8a282a --- /dev/null +++ b/tests/test_issue840_slash_echo.py @@ -0,0 +1,202 @@ +"""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"