diff --git a/static/js/chat.js b/static/js/chat.js index 60149d005..7ecefdb7d 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1082,7 +1082,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _lastToolName = ''; const _searchIcon = ''; const _toolLabels = { - 'web_search': _searchIcon + 'Searching', + 'web_search': 'Searching', 'bash': 'Running', 'python': 'Running', 'create_document': 'Writing', @@ -1102,6 +1102,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 'list_models': 'Browsing', 'ui_control': 'Adjusting', }; + const _toolIcons = { + 'web_search': _searchIcon, + }; function _thinkingLabel() { if (!_lastToolName) { return 'Thinking'; @@ -2049,10 +2052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } threadWrap.classList.add('streaming'); const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool; + const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6'; const node = document.createElement('div') node.className = 'agent-thread-node running'; const cmdHtml = cmd ? `
${esc(cmd)}
` : ''; - node.innerHTML = `
\u25B6${esc(toolLabel)}▁▂▃
${cmdHtml}
`; + node.innerHTML = `
${toolIcon}${esc(toolLabel)}▁▂▃
${cmdHtml}
`; // Expand/collapse via delegated click handler (init at module bottom). threadWrap.appendChild(node); currentToolBubble = node; diff --git a/tests/test_web_search_tool_icon_js.py b/tests/test_web_search_tool_icon_js.py new file mode 100644 index 000000000..6e855df40 --- /dev/null +++ b/tests/test_web_search_tool_icon_js.py @@ -0,0 +1,119 @@ +"""Pin the web_search tool-icon rendering in the agent thread (PR #??). + +Verifies: +- web_search renders an icon instead of raw markup +- Other tools get the default ▶ icon +- Hostile tool names are HTML-escaped in the label + +Pure JS via node --input-type=module (same approach as +test_composer_arrow_up_recall_js.py). Skips when node is not installed. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HAS_NODE = shutil.which("node") is not None + +_CHECK_JS = r""" +function esc(s) { + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return (s || '').replace(/[&<>"']/g, (m) => map[m]); +} + +const _searchIcon = ''; + +const _toolLabels = { + web_search: 'Searching', + bash: 'Running', +}; + +const _toolIcons = { + web_search: _searchIcon, +}; + +function renderIcon(toolName) { + return _toolIcons[toolName.toLowerCase()] || '\u25B6'; +} + +function renderLabel(toolName) { + return _toolLabels[toolName.toLowerCase()] || toolName; +} + +function renderThreadHTML(toolName, cmd) { + const label = renderLabel(toolName); + const icon = renderIcon(toolName); + const cmdHtml = cmd ? `
${esc(cmd)}
` : ''; + return `
${icon}${esc(label)}\u2581\u2582\u2583
${cmdHtml}
`; +} + +const cases = CASES_JSON; +const results = cases.map(c => { + const html = renderThreadHTML(c.tool, c.cmd || ''); + return { tool: c.tool, html }; +}); +console.log(JSON.stringify(results)); +""" + + +def _run(cases: list) -> list: + js = _CHECK_JS.replace("CASES_JSON", json.dumps(cases)) + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_web_search_icon_contains_svg(): + out = _run([{"tool": "web_search"}])[0] + assert " in agent-thread-icon for web_search" + assert "Searching" in out["html"], "Expected 'Searching' label for web_search" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_default_tool_icon_is_triangle(): + out = _run([{"tool": "bash"}])[0] + assert "▶" in out["html"], "Expected ▶ icon for tools without custom icon" + assert " for bash" + assert "Running" in out["html"], "Expected 'Running' label for bash" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_unknown_tool_falls_back_to_name(): + out = _run([{"tool": "my_custom_tool"}])[0] + assert "▶" in out["html"], "Expected ▶ for unknown tool" + assert "my_custom_tool" in out["html"], "Expected tool name as label" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_hostile_tool_name_is_escaped(): + out = _run([{"tool": ''}])[0] + assert "<img" in out["html"], "Expected < to be HTML-escaped" + assert ">" in out["html"], "Expected > to be HTML-escaped" + assert " must not appear" + assert "onerror" not in out["html"] or """ in out["html"], "onerror must not be executable" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_unknown_tool_case_insensitive_matches_icons(): + out = _run([{"tool": "WEB_SEARCH"}, {"tool": "Web_Search"}]) + for r in out: + assert "