diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b27059..95553115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.633] — 2026-06-24 — Release WN (collapsed tool cards preview the result, not the call args) + +### Fixed + +- **A completed tool card's collapsed header now previews the tool's result instead of its call arguments.** For a finished tool, the one-line header showed the call args (e.g. `path=src/`) rather than what actually happened (e.g. `Found 3 matches`). Completed tools now surface a one-line result preview (from the tool's preview/snippet), suppressing JSON object/array blobs and capping length, while running tools and the args-preview fallback are unchanged. The preview is escaped on render (no XSS). Thanks @rodboev. (#4851, closes #4752) + ## [v0.51.632] — 2026-06-24 — Release WM (new conversations use your configured default model) ### Fixed diff --git a/static/ui.js b/static/ui.js index 56f9f888..45252c98 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12903,9 +12903,21 @@ function _formatToolArgPreview(args){ const out=parts.join(' · '); return out.length>180?`${out.slice(0,177)}…`:out; } +function _toolResultOneLiner(preview){ + if(!preview) return ''; + const first=preview.split('\n').find(l=>l.trim())||''; + const trimmed=first.trim(); + if(!trimmed) return ''; + if(trimmed[0]==='{') return ''; + if(trimmed[0]==='['){try{JSON.parse(trimmed);return '';}catch(e){/* not JSON */}} + return trimmed.length>180?trimmed.slice(0,177)+'…':trimmed; +} function _toolCardPreviewText(tc, displaySnippet){ const explicitPreview=String(tc&&tc.preview||'').trim(); if(tc&&tc.done===false&&explicitPreview) return explicitPreview; + const resultSource=explicitPreview||String(tc&&tc.snippet||'').trim(); + const resultLine=_toolResultOneLiner(resultSource); + if(tc&&tc.done!==false&&resultLine) return resultLine; const argPreview=_formatToolArgPreview(tc&&tc.args); if(argPreview) return argPreview; if(tc&&tc.done===false) return 'Running'; diff --git a/tests/test_issue1824_cli_patch_diff_rendering.py b/tests/test_issue1824_cli_patch_diff_rendering.py index 7e51d750..ad6051a2 100644 --- a/tests/test_issue1824_cli_patch_diff_rendering.py +++ b/tests/test_issue1824_cli_patch_diff_rendering.py @@ -112,6 +112,7 @@ def test_rendered_apply_patch_tool_card_html_contains_diff_lines(): "_toolArgPreviewValue", "_toolArgPreviewKeyIsHidden", "_formatToolArgPreview", + "_toolResultOneLiner", "_toolCardPreviewText", # #3336: buildToolCard now wraps diff snippets via these helpers. "_snippetLooksLikeDiff", diff --git a/tests/test_issue4658_transparent_collapsed_preview.py b/tests/test_issue4658_transparent_collapsed_preview.py index c0aa224c..5401ef50 100644 --- a/tests/test_issue4658_transparent_collapsed_preview.py +++ b/tests/test_issue4658_transparent_collapsed_preview.py @@ -23,6 +23,7 @@ shims, and runs the same render path live streaming and persisted reload share (both go through _decorateTransparentEventRow(buildToolCard(tc))). """ import json +import re import shutil import subprocess from pathlib import Path @@ -35,11 +36,77 @@ UI_JS_PATH = REPO_ROOT / "static" / "ui.js" NODE = shutil.which("node") pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") +_FN_NAMES = [ + '_toolDisplayName', '_toolActionKind', '_toolKindIcon', '_toolPathBasename', + '_decodeToolLabelEntities', '_redactToolTargetLabel', '_shortToolLabel', '_toolI18n', + '_toolTargetLabel', '_toolVisibleTargetLabel', '_toolCommandTitle', '_toolQueryTitle', + '_toolActionLabelText', '_toolArgPreviewValue', '_toolArgPreviewKeyIsHidden', + '_formatToolArgPreview', '_toolResultOneLiner', '_toolCardPreviewText', '_toolCardAllowsDetail', + '_toolDetailLeadLabel', '_toolDetailLeadText', '_toolShortName', '_transparentEventPreview', + '_transparentToolStatus', '_transparentToolSummary', + '_isMemorySave', '_isSkillUpdate', '_tcAction', + 'buildToolCard', '_decorateTransparentEventRow', +] -_DRIVER_SRC = r""" -const fs = require('fs'); -const src = fs.readFileSync(process.argv[2], 'utf8'); +def _function_source(src: str, name: str) -> str: + match = re.search(rf"function\s+{re.escape(name)}\s*\(", src) + if not match: + return "" + brace = src.find("{", match.end()) + assert brace != -1, f"{name}() has no body" + depth = 1 + i = brace + 1 + in_string = None + escaped = False + in_line_comment = False + in_block_comment = False + while i < len(src) and depth: + ch = src[i] + nxt = src[i + 1] if i + 1 < len(src) else "" + if in_line_comment: + if ch == "\n": + in_line_comment = False + i += 1 + continue + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + i += 2 + continue + i += 1 + continue + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == in_string: + in_string = None + i += 1 + continue + if ch == "/" and nxt == "/": + in_line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + in_block_comment = True + i += 2 + continue + if ch in "'\"`": + in_string = ch + i += 1 + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{name}() body did not close" + return src[match.start():i] + + +_DRIVER_TEMPLATE = r""" // ── Minimal DOM shims (enough for buildToolCard + _decorateTransparentEventRow) function makeEl(tag){ const el = { @@ -61,7 +128,6 @@ function makeEl(tag){ get innerHTML(){return el._html;}, set innerHTML(v){ el._html=String(v); - // Parse just the spans we assert on out of the template buildToolCard emits. el.children=[]; const mk=(cls,text)=>{const c=makeEl('span'); c._classes=new Set(cls.split(' ').filter(Boolean)); c.textContent=text; c.parentNode=el; el.children.push(c);}; const grab=(cls)=>{const re=new RegExp('([\\s\\S]*?)<\\/span>'); const m=el._html.match(re); return m?m[1]:null;}; @@ -70,7 +136,6 @@ function makeEl(tag){ const preview=grab('tool-card-preview'); if(preview!==null) mk('tool-card-preview', preview); const header=makeEl('div'); header._classes=new Set(['tool-card-header']); - // re-home the parsed spans under a header element so querySelector('.tool-card-header') works header.children=el.children.slice(); header.children.forEach(c=>c.parentNode=header); const card=makeEl('div'); card._classes=new Set(['tool-card']); card.appendChild(header); el.children=[card]; el.firstChild=card; @@ -87,8 +152,8 @@ function makeEl(tag){ return el; } -global.document = { createElement: (t)=>makeEl(t), querySelectorAll:()=>[], querySelector:()=>null }; -global.window = {}; +global.document = { createElement: (t)=>makeEl(t), querySelectorAll:()=>[], querySelector:()=>null, addEventListener:()=>{}, removeEventListener:()=>{} }; +global.window = { addEventListener:()=>{}, removeEventListener:()=>{} }; global.CSS = { escape: s=>s }; global.t = undefined; global.li = () => ''; @@ -100,36 +165,10 @@ global._attachCopyButton = () => {}; global._attachProgressBar = () => {}; global._wireTransparentHeaderToggle = () => {}; global._transparentToolDetailHtml = () => '
'; -// _toolDisclosureIdentity pulls in worklog hashing helpers irrelevant to the -// collapsed-preview assertion; stub it (only feeds a data- attribute). global._toolDisclosureIdentity = () => ''; -function extractFunc(name){ - const re=new RegExp('function\\s+'+name+'\\s*\\('); - const start=src.search(re); if(start<0) throw new Error(name+' not found'); - let i=src.indexOf('{',start); let depth=1; i++; - while(depth>0&&i180 chars is truncated with ellipsis.""" + long_preview = "x" * 200 + tc = {"done": True, "preview": long_preview, "args": {}} + result = _preview_text(tc) + assert result.endswith("…") + assert len(result) == 178 # 177 chars + ellipsis + + def test_no_args_no_preview_returns_completed(self): + """Completed tool with no args and no preview returns 'Completed'.""" + tc = {"done": True, "preview": "", "args": {}} + assert _preview_text(tc) == "Completed" + + def test_error_state_returns_failed(self): + """Error tool with no preview or args returns 'Failed'.""" + tc = {"done": True, "is_error": True, "preview": "", "args": {}} + assert _preview_text(tc) == "Failed" + + def test_running_no_preview_returns_running(self): + """Running tool with no preview returns 'Running'.""" + tc = {"done": False, "preview": "", "args": {}} + assert _preview_text(tc) == "Running" + + def test_snippet_fallback_on_cold_load(self): + """Cold-loaded tool with result in snippet (no preview) uses snippet.""" + tc = {"done": True, "preview": "", "snippet": "Found 3 matches", "args": {"path": "src/"}} + assert _preview_text(tc) == "Found 3 matches" + + def test_error_with_text_snippet_shows_message(self): + """Error tool with a plain-text snippet shows the error message, not 'Failed'.""" + tc = {"done": True, "is_error": True, "preview": "", "snippet": "Permission denied", "args": {}} + assert _preview_text(tc) == "Permission denied" + + def test_snippet_json_suppressed(self): + """Cold-loaded tool with JSON in snippet falls through to args.""" + tc = {"done": True, "preview": "", "snippet": '{"key":"val"}', "args": {"path": "src/"}} + result = _preview_text(tc) + assert result != '{"key":"val"}' + assert "path" in result or "src" in result diff --git a/tests/test_tool_card_preview_summary.py b/tests/test_tool_card_preview_summary.py index ca9010b8..bb2aa307 100644 --- a/tests/test_tool_card_preview_summary.py +++ b/tests/test_tool_card_preview_summary.py @@ -21,24 +21,14 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") _DRIVER_SRC = r""" const fs = require('fs'); -const src = fs.readFileSync(process.argv[2], 'utf8'); -function extractFunc(name) { - const re = new RegExp('function\\s+' + name + '\\s*\\('); - const start = src.search(re); - if (start < 0) throw new Error(name + ' not found'); - let i = src.indexOf('{', start); - let depth = 1; i++; - while (depth > 0 && i < src.length) { - if (src[i] === '{') depth++; - else if (src[i] === '}') depth--; - i++; - } - return src.slice(start, i); +const lines = fs.readFileSync(process.argv[2], 'utf8').split('\n'); +let startIdx = -1, endIdx = lines.length; +for (let i = 0; i < lines.length; i++) { + if (/^function _toolArgPreviewValue\(/.test(lines[i]) && startIdx < 0) startIdx = i; + if (/^function _toolCardAllowsDetail\(/.test(lines[i])) { endIdx = i; break; } } -eval(extractFunc('_toolArgPreviewValue')); -eval(extractFunc('_toolArgPreviewKeyIsHidden')); -eval(extractFunc('_formatToolArgPreview')); -eval(extractFunc('_toolCardPreviewText')); +if (startIdx < 0) throw new Error('_toolArgPreviewValue not found'); +eval(lines.slice(startIdx, endIdx).join('\n')); let buf = ''; process.stdin.on('data', c => { buf += c; }); process.stdin.on('end', () => {