diff --git a/tests/test_issue4622_tool_card_restore_guard.py b/tests/test_issue4622_tool_card_restore_guard.py new file mode 100644 index 00000000..299e4450 --- /dev/null +++ b/tests/test_issue4622_tool_card_restore_guard.py @@ -0,0 +1,191 @@ +"""Behavioural regression guard for issue #4622: in the Transparent Stream / +Worklog view, restored (settled / reloaded) tool-call cards must keep showing +the FULL terminal output with a working expand control, and patch/edit cards +must still render their diff body. + +This drives the ACTUAL ``buildToolCard`` and ``_anchorSceneToolCallFromRow`` +from ``static/ui.js`` via node (not a regex over the source). The reload path +rebuilds a tool card from a persisted ``activity_scene_v1`` row through +``_anchorSceneToolCallFromRow`` and then ``buildToolCard``; this pins that the +reconstructed card: + + * produces a "Show more" expand button for output over the 800-char cap, with + the FULL output carried in ``data-full=`` (so the inline ``_toggleToolDiff`` + expand handler can reveal it), and + * renders a ``diff-block`` for patch/edit snippets. + +#4622 was reported as a regression after the v0.51.547-.560 wave (which actually +*restored* this activity via #4539/#4587). Independent reproduction on master was +negative; this test is the permanent guard so the restore path cannot silently +lose full output / expand / diff again. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" + +NODE = shutil.which("node") +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); +} + +// Minimal DOM stub. buildToolCard does createElement('div') then assigns +// row.innerHTML = `...` as a string; we capture that string and assert on it. +function makeEl() { + return { + _class: '', innerHTML: '', _attrs: {}, + get className(){return this._class;}, set className(v){this._class=v;}, + dataset: {}, + setAttribute(k,v){this._attrs[k]=String(v);}, + getAttribute(k){return this._attrs[k];}, + removeAttribute(k){delete this._attrs[k];}, + appendChild(){}, querySelector(){return null;}, closest(){return null;}, + }; +} +global.document = { createElement: () => makeEl() }; +global.window = {}; + +// Escaping + trivial label/icon helpers that buildToolCard references but which +// are orthogonal to the output/diff behaviour under test. +global.esc = (s)=>String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +global.li = (n)=>''; +global.toolIcon = ()=>''; +global._toolActionLabelText = (tc)=>String(tc&&tc.name||'tool'); +global._toolDisplayName = (tc)=>String(tc&&tc.name||'tool'); +global._toolDisclosureIdentity = ()=>''; +global._toolCardPreviewText = ()=>''; +global._formatToolArgPreview = ()=>''; +global._toolDetailLeadText = ()=>''; +global._toolDetailLeadLabel = ()=>'Shell'; +global._isMemorySave = ()=>false; +global._isSkillUpdate = ()=>false; + +// Real functions under test (extracted from the live source): +for (const fn of ['_snippetLooksLikeDiff','_colorDiffLines','_toolActionKind', + '_toolCardAllowsDetail','buildToolCard','_anchorSceneToolCallFromRow']) { + eval(extractFunc(fn)); +} + +const mode = process.argv[3]; +const payload = JSON.parse(process.argv[4]); +let html; +if (mode === 'direct') { + // Build a card directly from a live tool-call object. + html = buildToolCard(payload).innerHTML; +} else if (mode === 'restored') { + // Simulate the reload/settled path: reconstruct the tool call from a persisted + // activity_scene_v1 row, then build the card from THAT. + const tc = _anchorSceneToolCallFromRow(payload, {settled:true}); + const card = buildToolCard(tc); + html = card.innerHTML; + process.stdout.write(JSON.stringify({ + html, + snippet_len: (tc.snippet||'').length, + })); + return; +} else { + throw new Error('unknown mode ' + mode); +} +process.stdout.write(JSON.stringify({ html, snippet_len: (payload.snippet||'').length })); +""" + + +@pytest.fixture(scope="module") +def driver_path(tmp_path_factory): + p = tmp_path_factory.mktemp("tool_card_restore_driver") / "driver.js" + p.write_text(_DRIVER_SRC, encoding="utf-8") + return str(p) + + +def _run(driver_path, mode, payload): + result = subprocess.run( + [NODE, driver_path, str(UI_JS_PATH), mode, json.dumps(payload)], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"node driver failed: {result.stderr}") + return json.loads(result.stdout) + + +# A terminal output well over buildToolCard's 800-char display cap. +LONG_SHELL_OUTPUT = "result line {}\n".format("x" * 20) * 120 # ~> 800 chars +DIFF_SNIPPET = "@@ -1,3 +1,4 @@\n context\n-old line\n+new line\n+added line\n" + + +class TestRestoredToolCardKeepsFullOutputAndExpand: + """#4622 symptom 1: restored terminal cards must keep full output + expand.""" + + def test_long_output_direct_card_has_expand_with_full_snippet(self, driver_path): + tc = {"name": "terminal", "args": {"command": "ls -R /"}, + "snippet": LONG_SHELL_OUTPUT, "done": True} + out = _run(driver_path, "direct", tc) + assert "tool-card-more" in out["html"], ( + "a terminal output over the display cap must render a Show-more expand button" + ) + # The inline _toggleToolDiff handler reveals data-full; it must be + # present (it carries the full output, not the truncated preview). + assert "data-full=" in out["html"] + + def test_restored_scene_terminal_card_stays_expandable(self, driver_path): + """The reload/settled path reconstructs the card from a persisted scene + row; it must still be expandable with the full output recovered.""" + scene_row = { + "role": "tool", "status": "completed", + "tool": {"name": "terminal", "args": {"command": "ls -R /"}}, + "payload": {"output": LONG_SHELL_OUTPUT}, + } + out = _run(driver_path, "restored", scene_row) + assert out["snippet_len"] >= len(LONG_SHELL_OUTPUT) - 4, ( + "restored tool call must recover the FULL output snippet from the " + f"persisted scene row (got {out['snippet_len']} vs {len(LONG_SHELL_OUTPUT)})" + ) + assert "tool-card-more" in out["html"], ( + "restored terminal card must still render the Show-more expand control" + ) + + +class TestRestoredPatchCardRendersDiff: + """#4622 symptom 2: restored patch/edit cards must render the diff body.""" + + def test_direct_patch_card_renders_diff_block(self, driver_path): + tc = {"name": "patch", "args": {"path": "foo.py"}, + "snippet": DIFF_SNIPPET, "is_diff": True, "done": True} + out = _run(driver_path, "direct", tc) + assert "diff-block" in out["html"] + + def test_restored_scene_patch_card_renders_diff_block(self, driver_path): + scene_row = { + "role": "tool", "status": "completed", + "tool": {"name": "patch", "args": {"path": "foo.py"}}, + "payload": {"output": DIFF_SNIPPET}, + } + out = _run(driver_path, "restored", scene_row) + assert out["snippet_len"] >= len(DIFF_SNIPPET) - 4, ( + "restored patch tool call must recover the full diff snippet" + ) + assert "diff-block" in out["html"], ( + "restored patch card must render the diff body (diff-block) from the " + "persisted scene snippet" + )