mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
fix(ui): raw SVG markup displayed instead of search icon for web_search tool label (#3601)
* fix(ui): escaped SVG renders as raw markup during web_search tool label The _toolLabels['web_search'] entry embedded an SVG HTML string concatenated with label text. At render time the entire value was passed through esc(), HTML-escaping <svg> tags so the icon displayed as raw text instead of rendering visually. Fix: separate icon from label text via a _toolIcons map. The SVG is injected as raw innerHTML (unescaped) in .agent-thread-icon, while the label text remains safely escaped. * test: add behavioral test for web_search tool icon rendering Co-authored-by: TheDragonTail <jakeoldfield2@gmail.com> --------- Co-authored-by: TheDragonTail <jakeoldfield2@gmail.com>
This commit is contained in:
+6
-2
@@ -1082,7 +1082,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
let _lastToolName = '';
|
let _lastToolName = '';
|
||||||
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||||
const _toolLabels = {
|
const _toolLabels = {
|
||||||
'web_search': _searchIcon + 'Searching',
|
'web_search': 'Searching',
|
||||||
'bash': 'Running',
|
'bash': 'Running',
|
||||||
'python': 'Running',
|
'python': 'Running',
|
||||||
'create_document': 'Writing',
|
'create_document': 'Writing',
|
||||||
@@ -1102,6 +1102,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
'list_models': 'Browsing',
|
'list_models': 'Browsing',
|
||||||
'ui_control': 'Adjusting',
|
'ui_control': 'Adjusting',
|
||||||
};
|
};
|
||||||
|
const _toolIcons = {
|
||||||
|
'web_search': _searchIcon,
|
||||||
|
};
|
||||||
function _thinkingLabel() {
|
function _thinkingLabel() {
|
||||||
if (!_lastToolName) {
|
if (!_lastToolName) {
|
||||||
return 'Thinking';
|
return 'Thinking';
|
||||||
@@ -2049,10 +2052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
}
|
}
|
||||||
threadWrap.classList.add('streaming');
|
threadWrap.classList.add('streaming');
|
||||||
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
||||||
|
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
|
||||||
const node = document.createElement('div')
|
const node = document.createElement('div')
|
||||||
node.className = 'agent-thread-node running';
|
node.className = 'agent-thread-node running';
|
||||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${toolIcon}</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||||
// Expand/collapse via delegated click handler (init at module bottom).
|
// Expand/collapse via delegated click handler (init at module bottom).
|
||||||
threadWrap.appendChild(node);
|
threadWrap.appendChild(node);
|
||||||
currentToolBubble = node;
|
currentToolBubble = node;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Pin the web_search tool-icon rendering in the agent thread (PR #??).
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- web_search renders an <svg> 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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||||
|
|
||||||
|
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 ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||||
|
return `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${icon}</span><span class="agent-thread-tool">${esc(label)}</span><span class="agent-thread-wave">\u2581\u2582\u2583</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "<svg" in out["html"], "Expected <svg> 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 "<svg" not in out["html"], "Expected no <svg> 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": '<img src=x onerror="alert(1)">'}])[0]
|
||||||
|
assert "<img" in out["html"], "Expected < to be HTML-escaped"
|
||||||
|
assert ">" in out["html"], "Expected > to be HTML-escaped"
|
||||||
|
assert "<img" not in out["html"], "Raw <img> 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 "<svg" in r["html"], f"Expected SVG for case-variant '{r['tool']}'"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_command_is_escaped():
|
||||||
|
out = _run([{"tool": "bash", "cmd": "echo $HOME && ls"}])[0]
|
||||||
|
assert "echo $HOME" in out["html"], "Expected command text in output"
|
||||||
Reference in New Issue
Block a user