In the Transparent Stream / Worklog view, terminal tool cards lost their stdout and patch/edit cards lost their diff AFTER a turn settled (live cards were fine). Root cause (proven via a node harness driving the real settled chain _anchorSceneRowsByMessageIndex -> _anchorSceneToolRowFromCall -> _anchorSceneToolCallFromRow): the settled rebuild builds each tool row from the persisted messages[].tool_calls (state.db/sidecar), which can carry only a short preview — or, on a cold/paginated load, nothing — for the result body. The full output lives on the live S.toolCalls entry at settle time, but the dedup loop in _anchorSceneRowsByMessageIndex SKIPPED the matching live entry (continue) instead of restoring the missing body onto the surviving settled row. So buildToolCard got tc.snippet='' -> empty output region, and the patch/edit diff (detected from the snippet) never rendered. This also blanks the transparent 'Output' tab and the 'Full' (detailed-input) tab. Fix: new _enrichSettledToolRowBodyFromLive() restores snippet/result/output, command, and args onto a settled row from the matched live call, ONLY when the settled value is empty (never clobbers a genuinely persisted body). Wired into the dedup loop: on a tid match that would have been skipped, enrich the existing pooled row instead of dropping the live data. The prior #4625 guard only exercised the LIVE buildToolCard path, which is why it stayed green while the settled path regressed. New tests/test_issue4622_settled_toolcard_body.py drives the full SETTLED chain and asserts: terminal stdout restored + full output in data-full + transparent Output tab non-empty; patch diff body restored + renders as a diff + Full-tab args kept; genuine persisted body not clobbered; args restored when dropped; no-live-match leaves the row untouched; no duplicate row after enrich. Verified to FAIL on clean master (assertions) and pass with the fix. Also registered the new helper in the and now references the new symbol). Reusable proof harness saved to ~/WebUI/qa/harnesses/. Closes #4622.
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""Regression coverage for #4622: settled tool cards lose their output body.
|
|
|
|
In the Transparent Stream / Worklog view, a terminal tool card stopped showing
|
|
its stdout and a patch/edit card stopped rendering its diff *after the stream
|
|
settled* (live cards were fine). The settled rebuild
|
|
(`_anchorSceneRowsByMessageIndex`) builds each tool row from
|
|
`messages[].tool_calls` (state.db / sidecar), which can carry only a short
|
|
preview — or, on a cold/paginated load, nothing — for the result body. The full
|
|
output lives on the live `S.toolCalls` entry at settle time, but the dedup loop
|
|
*skipped* the matching live entry instead of restoring the missing body onto the
|
|
surviving settled row.
|
|
|
|
This drives the REAL settled render chain end-to-end:
|
|
_anchorSceneRowsByMessageIndex (messages.js)
|
|
-> _anchorSceneToolRowFromCall (messages.js) # settled row
|
|
-> _enrichSettledToolRowBodyFromLive (messages.js) # the fix
|
|
-> _anchorSceneToolCallFromRow (ui.js, settled) # the `tc` buildToolCard consumes
|
|
|
|
`tc.snippet` is what buildToolCard renders as the output body (full copy in the
|
|
Show-more `data-full`) and what the transparent "Output" tab shows; `tc.args` is
|
|
the detailed-input "Full" tab. Asserting on `tc` pins all three views without a
|
|
DOM.
|
|
|
|
The prior #4625 guard only exercised the LIVE `buildToolCard` path, which is why
|
|
it stayed green while the settled path regressed — these tests MUST drive the
|
|
settled rebuild.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
MESSAGES_JS = REPO_ROOT / "static" / "messages.js"
|
|
UI_JS = REPO_ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
# Driver: extract the real settled-render functions from messages.js + ui.js by
|
|
# brace-matching, run them in node with the closure vars they reference, drive
|
|
# _anchorSceneRowsByMessageIndex -> _anchorSceneToolCallFromRow, and emit the
|
|
# `tc` object each settled tool card would be built from.
|
|
_DRIVER_SRC = r"""
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const mSrc = fs.readFileSync(process.argv[2], 'utf8');
|
|
const uSrc = fs.readFileSync(process.argv[3], 'utf8');
|
|
function extractFunc(src, name) {
|
|
const re = new RegExp('function\\s+' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{', start), 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 messagesFns = [
|
|
'_anchorSceneMessageText','_anchorSceneCleanText','_anchorSceneTextKey',
|
|
'_anchorSceneSafePayload','_anchorSceneToolId','_anchorSceneToolName',
|
|
'_anchorSceneToolArgs','_anchorSceneStringPayload','_anchorSceneRowBase',
|
|
'_anchorSceneProseRow','_anchorSceneThinkingRow','_anchorSceneToolRowFromCall',
|
|
'_anchorSceneMessageReasoningText','_enrichSettledToolRowBodyFromLive',
|
|
'_anchorSceneRowsByMessageIndex',
|
|
];
|
|
const uiFns = ['_anchorSceneToolCallFromRow'];
|
|
let code = '(function(){\n';
|
|
code += 'var activeSid="test-session"; var streamId="test-stream"; var S;\n';
|
|
for (const n of messagesFns) code += extractFunc(mSrc, n) + '\n';
|
|
for (const n of uiFns) code += extractFunc(uSrc, n) + '\n';
|
|
code += `
|
|
var buf='';
|
|
process.stdin.on('data',c=>buf+=c);
|
|
process.stdin.on('end',()=>{
|
|
var p=JSON.parse(buf||'{}');
|
|
S=p.S||{toolCalls:[]};
|
|
var messages=p.messages||[];
|
|
var turnStart=p.turnStart!==undefined?p.turnStart:0;
|
|
var lastAsstIndex=p.lastAsstIndex!==undefined?p.lastAsstIndex:messages.length-1;
|
|
var byIdx=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex);
|
|
var cards=[];
|
|
byIdx.forEach(function(rows,idx){
|
|
rows.forEach(function(row){
|
|
if(row.role!=='tool') return;
|
|
var tc=_anchorSceneToolCallFromRow(row,{settled:true});
|
|
var outputTab=String((tc.snippet||tc.preview||tc.result||tc.output)||'').trim();
|
|
cards.push({
|
|
idx:idx, name:tc.name, tid:tc.tid,
|
|
snippet:tc.snippet||'', snippetLen:(tc.snippet||'').length,
|
|
args:tc.args||{}, command:tc.command||'',
|
|
rendersOutputBody:!!tc.snippet,
|
|
rendersDiff:/^@@\\s/.test(tc.snippet||'') && (tc.snippet||'').split('\\n').filter(l=>l[0]==='+'||l[0]==='-').length>=2,
|
|
transparentOutputTabNonEmpty:!!outputTab,
|
|
transparentFullTabArgKeys:Object.keys((tc.args&&typeof tc.args==='object')?tc.args:{}),
|
|
});
|
|
});
|
|
});
|
|
process.stdout.write(JSON.stringify(cards));
|
|
});
|
|
})();
|
|
`;
|
|
process.stdout.write(code);
|
|
"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def driver_path(tmp_path_factory):
|
|
gen = tmp_path_factory.mktemp("gen") / "gen.js"
|
|
gen.write_text(_DRIVER_SRC, encoding="utf-8")
|
|
result = subprocess.run(
|
|
[NODE, str(gen), str(MESSAGES_JS), str(UI_JS)],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if result.returncode != 0:
|
|
pytest.fail(f"Driver generation failed:\n{result.stderr}")
|
|
driver = tmp_path_factory.mktemp("driver") / "driver.js"
|
|
driver.write_text(result.stdout, encoding="utf-8")
|
|
return str(driver)
|
|
|
|
|
|
def _run(driver_path: str, payload: dict) -> list:
|
|
assert NODE is not None
|
|
result = subprocess.run(
|
|
[NODE, driver_path], input=json.dumps(payload),
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Node error:\n{result.stderr}")
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Builders for the reporter's scenario: a settled turn where the persisted
|
|
# state.db tool_calls lack the body, but the live S.toolCalls carries the full
|
|
# output + diff. This is the reconnect/settle reconciliation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TERM_OUTPUT = (
|
|
"total 48\n"
|
|
"drwxr-xr-x 6 user staff 192 .\n"
|
|
"-rw-r--r-- 1 user staff 1024 app.py\n"
|
|
"-rw-r--r-- 1 user staff 2048 README.md\n"
|
|
"-rw-r--r-- 1 user staff 512 config.yaml"
|
|
)
|
|
_DIFF = "@@ -1,4 +1,5 @@\n def main():\n- print('old')\n+ print('new')\n+ return 0\n pass"
|
|
|
|
|
|
def _settled_then_live(term_persisted="", patch_persisted="", patch_args=None):
|
|
"""Turn with a terminal + edit_file call; persisted rows carry `*_persisted`
|
|
bodies, live S.toolCalls carries the full output + diff."""
|
|
messages = [
|
|
{"role": "user", "content": "run ls and patch a file"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Done.",
|
|
"tool_calls": [
|
|
{"id": "term-1", "name": "terminal", "started_at": 100,
|
|
"command": "ls -la", "snippet": term_persisted},
|
|
{"id": "patch-1", "name": "edit_file", "started_at": 200,
|
|
"args": patch_args or {"path": "x.py"}, "snippet": patch_persisted},
|
|
],
|
|
},
|
|
{"role": "assistant", "content": "final answer"},
|
|
]
|
|
S = {"toolCalls": [
|
|
{"id": "term-1", "name": "terminal", "assistant_msg_idx": 1, "started_at": 100,
|
|
"command": "ls -la", "snippet": _TERM_OUTPUT},
|
|
{"id": "patch-1", "name": "edit_file", "assistant_msg_idx": 1, "started_at": 200,
|
|
"args": {"path": "x.py"}, "snippet": _DIFF},
|
|
]}
|
|
return {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": S}
|
|
|
|
|
|
def _card(cards, name):
|
|
matches = [c for c in cards if c["name"] == name]
|
|
assert matches, f"no settled card for {name}: {[c['name'] for c in cards]}"
|
|
return matches[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_settled_terminal_output_restored_when_persisted_body_empty(driver_path):
|
|
"""Empty persisted snippet -> full terminal stdout restored from live S.toolCalls."""
|
|
cards = _run(driver_path, _settled_then_live(term_persisted="", patch_persisted=""))
|
|
term = _card(cards, "terminal")
|
|
assert term["rendersOutputBody"] is True, "terminal output body must render after settle"
|
|
assert term["snippet"] == _TERM_OUTPUT, "settled card must carry the FULL stdout (Show-more data-full)"
|
|
assert term["transparentOutputTabNonEmpty"] is True, "transparent 'Output' tab must be non-empty"
|
|
|
|
|
|
def test_settled_patch_diff_restored_when_persisted_body_empty(driver_path):
|
|
"""Empty persisted snippet -> patch/edit diff body restored + renders as a diff."""
|
|
cards = _run(driver_path, _settled_then_live(term_persisted="", patch_persisted=""))
|
|
patch = _card(cards, "edit_file")
|
|
assert patch["rendersOutputBody"] is True, "patch card body must render after settle"
|
|
assert patch["rendersDiff"] is True, "patch/edit card must render the diff body after settle"
|
|
assert patch["snippet"] == _DIFF
|
|
# "Full" tab keeps the detailed input args.
|
|
assert "path" in patch["transparentFullTabArgKeys"]
|
|
|
|
|
|
def test_settled_body_not_clobbered_when_persisted_value_genuine(driver_path):
|
|
"""A genuine persisted body must WIN over the live value (no clobber)."""
|
|
payload = _settled_then_live(
|
|
term_persisted="GENUINE PERSISTED OUTPUT",
|
|
patch_persisted="@@ -1 +1 @@\n-persisted old\n+persisted new",
|
|
)
|
|
cards = _run(driver_path, payload)
|
|
assert _card(cards, "terminal")["snippet"] == "GENUINE PERSISTED OUTPUT"
|
|
assert _card(cards, "edit_file")["snippet"].startswith("@@ -1 +1 @@")
|
|
|
|
|
|
def test_settled_args_restored_when_persisted_args_empty(driver_path):
|
|
"""A patch card whose persisted args were dropped gets them back from live."""
|
|
payload = _settled_then_live(patch_args={}) # persisted row has no args
|
|
cards = _run(driver_path, payload)
|
|
patch = _card(cards, "edit_file")
|
|
# live S.toolCalls carried args={"path":"x.py"} -> restored onto the Full tab.
|
|
assert "path" in patch["transparentFullTabArgKeys"]
|
|
|
|
|
|
def test_no_live_match_leaves_settled_row_unchanged(driver_path):
|
|
"""With no matching live entry, the settled row is untouched (graceful)."""
|
|
payload = _settled_then_live(term_persisted="kept", patch_persisted="kept-diff")
|
|
payload["S"] = {"toolCalls": []} # nothing live to restore from
|
|
cards = _run(driver_path, payload)
|
|
assert _card(cards, "terminal")["snippet"] == "kept"
|
|
assert _card(cards, "edit_file")["snippet"] == "kept-diff"
|
|
|
|
|
|
def test_single_row_no_duplicate_after_enrich(driver_path):
|
|
"""Enriching must not also add a second (live) row — exactly one row per tid."""
|
|
cards = _run(driver_path, _settled_then_live())
|
|
assert len([c for c in cards if c["tid"] == "term-1"]) == 1
|
|
assert len([c for c in cards if c["tid"] == "patch-1"]) == 1
|