diff --git a/api/streaming.py b/api/streaming.py index c861f01..6bd7201 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -74,6 +74,25 @@ def _strip_thinking_markup(text: str) -> str: return s +def _strip_xml_tool_calls(text: str) -> str: + """Strip XML-style function_calls blocks that DeepSeek and similar models + emit in their raw response text. These blocks are processed separately as + tool calls; leaving them in the assistant content causes them to render + visibly in the chat bubble. + + Handles both complete blocks () and + partial/orphaned opening tags that may appear at the tail of a stream. + """ + if not text or '' not in text.lower(): + return text + s = str(text) + # Strip complete blocks (possibly multiple) + s = re.sub(r'.*?', '', s, flags=re.IGNORECASE | re.DOTALL) + # Strip orphaned opening tags (stream cut off before closing tag) + s = re.sub(r'.*$', '', s, flags=re.IGNORECASE | re.DOTALL) + return s.strip() + + def _sanitize_generated_title(text: str) -> str: """Sanitize LLM-generated title text before persisting to session.""" s = _strip_thinking_markup(text or '') @@ -1137,6 +1156,21 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta _previous_messages, result.get('messages') or s.messages, ) + # Strip XML tool-call blocks from assistant message content. + # DeepSeek and some other providers emit ... + # in the raw response text; this must be removed before the content is + # saved to the session and displayed in the chat bubble. (#702) + for _m in s.messages: + if isinstance(_m, dict) and _m.get('role') == 'assistant': + _raw_content = _m.get('content') + if isinstance(_raw_content, str): + _cleaned = _strip_xml_tool_calls(_raw_content) + if _cleaned != _raw_content: + _m['content'] = _cleaned + elif isinstance(_raw_content, list): + for _part in _raw_content: + if isinstance(_part, dict) and isinstance(_part.get('text'), str): + _part['text'] = _strip_xml_tool_calls(_part['text']) # ── Detect silent agent failure (no assistant reply produced) ── # When the agent catches an auth/network error internally it may return diff --git a/static/i18n.js b/static/i18n.js index f888e22..a6bb51c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -147,6 +147,8 @@ const LOCALES = { failed_colon: 'Failed: ', // ui.js no_workspace: 'No workspace', + workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', + workspace_empty_dir: 'This workspace is empty.', dialog_confirm_title: 'Confirm action', dialog_prompt_title: 'Enter a value', dialog_confirm_btn: 'Confirm', @@ -255,7 +257,7 @@ const LOCALES = { settings_label_sound: 'Notification sound', settings_desc_sound: 'Play a sound when the assistant finishes a response.', settings_label_notifications: 'Browser notifications', - settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.', + settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.', settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.', settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.', @@ -572,6 +574,8 @@ const LOCALES = { failed_colon: 'Error: ', // ui.js no_workspace: 'Sin espacio de trabajo', + workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.', + workspace_empty_dir: 'Este espacio de trabajo está vacío.', // workspace.js unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?', save: 'Guardar', @@ -985,6 +989,8 @@ const LOCALES = { failed_colon: 'Fehlgeschlagen: ', // ui.js no_workspace: 'Kein Workspace', + workspace_empty_no_path: 'Kein Workspace ausgewählt. Wähle einen Workspace unter Einstellungen \u2192 Workspace, um Dateien zu durchsuchen.', + workspace_empty_dir: 'Dieser Workspace ist leer.', dialog_confirm_title: 'Aktion bestätigen', dialog_prompt_title: 'Wert eingeben', dialog_confirm_btn: 'Bestätigen', @@ -1199,6 +1205,9 @@ const LOCALES = { theme_usage: '\u7528\u6cd5\uff1a/theme ', theme_set: '\u4e3b\u9898\uff1a', no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd', + + workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。', + workspace_empty_dir: '此工作区为空。', no_personalities: '\u6ca1\u6709\u627e\u5230\u4eba\u8bbe\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09', available_personalities: '\u53ef\u7528\u4eba\u8bbe\uff1a', personality_switch_hint: '\n\n\u4f7f\u7528 `/personality ` \u5207\u6362\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002', @@ -1611,6 +1620,9 @@ const LOCALES = { theme_usage: '\u7528\u6cd5\uff1a/theme ', theme_set: '\u4e3b\u984c\uff1a', no_active_session: '\u7576\u524d\u6c92\u6709\u6d3b\u52d5\u6703\u8a71', + + workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。', + workspace_empty_dir: '此工作區為空。', no_personalities: '\u6c92\u6709\u627e\u5230\u4eba\u8a2d\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09', available_personalities: '\u53ef\u7528\u4eba\u8a2d\uff1a', personality_switch_hint: '\n\n\u4f7f\u7528 `/personality ` \u5207\u63db\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002', diff --git a/static/index.html b/static/index.html index 620df4d..6877e70 100644 --- a/static/index.html +++ b/static/index.html @@ -390,6 +390,7 @@
+
diff --git a/static/messages.js b/static/messages.js index fb5d4f6..3c3597a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -229,8 +229,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _renderPending=false; // Extract display text from assistantText, stripping completed thinking blocks // and hiding content still inside an open thinking block. + function _stripXmlToolCalls(s){ + // Strip ... blocks (DeepSeek XML tool syntax). + // These are processed as tool calls server-side; showing them raw in the bubble + // looks broken. Also handles orphaned opening tags mid-stream. (#702) + if(!s||s.toLowerCase().indexOf('')===-1) return s; + s=s.replace(/[\s\S]*?<\/function_calls>/gi,''); + s=s.replace(/[\s\S]*$/i,''); + return s.trim(); + } function _streamDisplay(){ - const raw=assistantText; + const raw=_stripXmlToolCalls(assistantText); if(reasoningText) return raw; for(const {open,close} of _thinkPairs){ // Trim leading whitespace before checking for the open tag — some models @@ -252,7 +261,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return raw; } function _parseStreamState(){ - const raw=assistantText; + const raw=_stripXmlToolCalls(assistantText); if(reasoningText){ return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false}; } diff --git a/static/ui.js b/static/ui.js index 7affad7..4394df9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -463,6 +463,17 @@ function getModelLabel(modelId){ return modelId.split('/').pop()||'Unknown'; } +function _stripXmlToolCallsDisplay(s){ + // Strip ... blocks emitted by DeepSeek and + // similar models in their raw response text. These are processed separately + // as tool calls; leaving them in the content causes them to render visibly + // in the settled chat bubble. (#702) + if(!s||s.toLowerCase().indexOf('')===-1) return s; + s=s.replace(/[\s\S]*?<\/function_calls>/gi,''); + s=s.replace(/[\s\S]*$/i,''); + return s.trim(); +} + function renderMd(raw){ let s=raw||''; // ── MEDIA: token stash (must run first, before any other processing) ─────── @@ -1459,7 +1470,7 @@ function renderMessages(){ if(m.attachments&&m.attachments.length){ filesHtml=`
${m.attachments.map(f=>`
${li('paperclip',12)} ${esc(f)}
`).join('')}
`; } - const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(String(content)); + const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(_stripXmlToolCallsDisplay(String(content))); const editBtn = isUser ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; const copyBtn = ``; @@ -2135,6 +2146,20 @@ function renderFileTree(){ const box=$('fileTree');box.innerHTML=''; // Cache current dir entries S._dirCache[S.currentDir||'.']=S.entries; + // Show empty-state when no workspace is set or the directory is empty (#703) + const emptyEl=$('wsEmptyState'); + const hasWorkspace=!!(S.session&&S.session.workspace); + if(!hasWorkspace){ + if(emptyEl){emptyEl.textContent=t('workspace_empty_no_path');emptyEl.style.display='flex';} + box.style.display='none'; + return; + } + if(emptyEl) emptyEl.style.display='none'; + box.style.display=''; + if(!S.entries||!S.entries.length){ + if(emptyEl){emptyEl.textContent=t('workspace_empty_dir');emptyEl.style.display='flex';} + return; + } _renderTreeItems(box, S.entries, 0); } diff --git a/tests/test_sprint48.py b/tests/test_sprint48.py new file mode 100644 index 0000000..c015a08 --- /dev/null +++ b/tests/test_sprint48.py @@ -0,0 +1,209 @@ +"""Tests for sprint 48 UX bug fixes — v0.50.92. + +Covers: + - #702: XML tool-call syntax () stripped from assistant + message content before rendering (server-side + client-side). + - #703: Workspace file panel shows an empty-state message when no workspace + is configured or the directory is empty. + - #704: Notification settings description uses "app" instead of "tab". +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel): + return (REPO / rel).read_text() + + +# ── Bug #702 — XML tool-call leak on DeepSeek ──────────────────────────────── + +class TestXmlToolCallStrip: + """_strip_xml_tool_calls() is defined in api/streaming.py and must remove + ... blocks from assistant content.""" + + def _load_fn(self): + """Import the helper from streaming.py without triggering full server + initialisation (which would fail in unit-test contexts).""" + import importlib, sys, types + + # Stub heavy transitive imports so we can import the module cleanly. + for mod in ('api.config', 'api.helpers', 'api.models', 'api.workspace'): + if mod not in sys.modules: + sys.modules[mod] = types.ModuleType(mod) + + # Provide minimal symbols that streaming.py needs at import time. + cfg = sys.modules.setdefault('api.config', types.ModuleType('api.config')) + for attr in ('STREAMS', 'STREAMS_LOCK', 'CANCEL_FLAGS', 'AGENT_INSTANCES', + 'LOCK', 'SESSIONS', 'SESSION_DIR', + '_get_session_agent_lock', '_set_thread_env', + '_clear_thread_env', 'resolve_model_provider'): + if not hasattr(cfg, attr): + setattr(cfg, attr, None) + + # Fall back to reading the source and exec-ing just the function. + src = read('api/streaming.py') + ns: dict = {} + # Extract the function definition with regex so we don't need to import + # the whole module (avoids all the heavy deps). + match = re.search( + r'(def _strip_xml_tool_calls\(.*?)\n(?=\ndef |\nclass )', + src, re.DOTALL + ) + assert match, "_strip_xml_tool_calls not found in api/streaming.py" + exec(compile('import re\n' + match.group(1), '', 'exec'), ns) + return ns['_strip_xml_tool_calls'] + + def test_complete_block_removed(self): + fn = self._load_fn() + text = "Hello foo world" + result = fn(text) + assert '' not in result + assert 'Hello' in result + assert 'world' in result + + def test_orphaned_opening_tag_removed(self): + fn = self._load_fn() + text = "Some answer text\n\ntool" + result = fn(text) + assert '' not in result + assert 'Some answer text' in result + + def test_no_tag_unchanged(self): + fn = self._load_fn() + text = "This is a normal response with no tool calls." + assert fn(text) == text + + def test_multiple_blocks_removed(self): + fn = self._load_fn() + text = ( + "Part one a " + "middle b end" + ) + result = fn(text) + assert '' not in result + assert 'Part one' in result + assert 'middle' in result + assert 'end' in result + + def test_function_defined_in_streaming_py(self): + src = read('api/streaming.py') + assert 'def _strip_xml_tool_calls(' in src, ( + "_strip_xml_tool_calls must be defined in api/streaming.py" + ) + + def test_strip_applied_to_assistant_messages(self): + """Verify the strip call is applied to assistant message content after + the agent run completes (server-side persistence fix).""" + src = read('api/streaming.py') + assert '_strip_xml_tool_calls' in src, ( + "_strip_xml_tool_calls must be referenced in api/streaming.py" + ) + # Confirm it is called on message content, not just defined + assert src.count('_strip_xml_tool_calls') >= 2, ( + "_strip_xml_tool_calls must be both defined and called" + ) + + def test_client_side_strip_in_messages_js(self): + src = read('static/messages.js') + assert '_stripXmlToolCalls' in src, ( + "Client-side _stripXmlToolCalls must exist in static/messages.js" + ) + assert 'function_calls' in src.lower(), ( + "Client-side strip must reference 'function_calls'" + ) + + def test_client_side_strip_in_ui_js(self): + src = read('static/ui.js') + assert '_stripXmlToolCallsDisplay' in src, ( + "_stripXmlToolCallsDisplay must exist in static/ui.js" + ) + + +# ── Bug #703 — Workspace file panel empty state ─────────────────────────────── + +class TestWorkspaceEmptyState: + + def test_i18n_no_path_string_present(self): + src = read('static/i18n.js') + assert 'workspace_empty_no_path' in src, ( + "i18n key workspace_empty_no_path must be defined in i18n.js" + ) + + def test_i18n_no_path_mentions_settings(self): + src = read('static/i18n.js') + # Extract the value of the key + m = re.search(r"workspace_empty_no_path:\s*'([^']+)'", src) + assert m, "workspace_empty_no_path value not found in i18n.js" + assert 'Settings' in m.group(1), ( + "workspace_empty_no_path should mention Settings" + ) + + def test_i18n_empty_dir_string_present(self): + src = read('static/i18n.js') + assert 'workspace_empty_dir' in src, ( + "i18n key workspace_empty_dir must be defined in i18n.js" + ) + + def test_empty_state_element_in_html(self): + src = read('static/index.html') + assert 'wsEmptyState' in src, ( + "id=\"wsEmptyState\" empty-state element must exist in index.html" + ) + + def test_render_file_tree_shows_empty_state(self): + src = read('static/ui.js') + assert 'wsEmptyState' in src, ( + "renderFileTree in ui.js must reference wsEmptyState" + ) + assert 'workspace_empty_no_path' in src, ( + "renderFileTree must use workspace_empty_no_path i18n key" + ) + assert 'workspace_empty_dir' in src, ( + "renderFileTree must use workspace_empty_dir i18n key" + ) + + +# ── Bug #704 — Notification description says "tab" ─────────────────────────── + +class TestNotificationDescriptionText: + + def test_english_uses_app_not_tab(self): + src = read('static/i18n.js') + # Find the English locale block (appears before other locales) + # The English block starts at line 1 (it's the first locale object). + # We look for the settings_desc_notifications in the English section. + # English block ends before the Spanish (es) block. + es_marker = "settings_desc_notifications: 'Muestra" + en_end = src.index(es_marker) if es_marker in src else len(src) + en_section = src[:en_end] + + m = re.search(r"settings_desc_notifications:\s*'([^']+)'", en_section) + assert m, "English settings_desc_notifications not found" + desc = m.group(1) + assert 'tab' not in desc.lower(), ( + f"English notification description must not say 'tab', got: {desc!r}" + ) + assert 'app' in desc.lower(), ( + f"English notification description must say 'app', got: {desc!r}" + ) + + def test_new_wording_exact(self): + src = read('static/i18n.js') + expected = 'while the app is in the background' + assert expected in src, ( + f"Exact phrase {expected!r} must appear in i18n.js" + ) + + def test_old_wording_removed_from_english(self): + src = read('static/i18n.js') + old_phrase = 'while the tab is in the background' + # The old phrase must not appear in the English locale section + es_marker = "settings_desc_notifications: 'Muestra" + en_end = src.index(es_marker) if es_marker in src else len(src) + en_section = src[:en_end] + assert old_phrase not in en_section, ( + "Old English notification description with 'tab' must be removed" + )