HERMES-GIELIE/tests/test_issue4811_reconnect_chronology.py

335 lines
13 KiB
Python
Raw Normal View History

"""Regression coverage for #4811: reconnect session scrambles message chronology.
_anchorSceneRowsByMessageIndex must emit rows in chronological order
(thinking first, then tools sorted by started_at, then prose) regardless of
whether tools come from the message object or from S.toolCalls.
"""
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"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
# Driver: extracts all helper functions in the _anchorScene* family by scanning
# the source for `function _anchorScene` blocks, then evaluates them with the
# required closure variables (activeSid, streamId, S) in scope. Accepts a JSON
# payload on stdin and writes the byIdx map (as an object keyed by index) on stdout.
_DRIVER_SRC = r"""
'use strict';
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
// Extract every function whose name starts with _anchorScene
// by brace-matching from the `function` keyword.
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found in source');
let i = src.indexOf('{', start);
if (i < 0) throw new Error('No opening brace for ' + name);
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 funcNames = [
'_anchorSceneMessageText',
'_anchorSceneCleanText',
'_anchorSceneTextKey',
'_anchorSceneSafePayload',
'_anchorSceneToolId',
'_anchorSceneToolName',
'_anchorSceneToolArgs',
'_anchorSceneStringPayload',
'_anchorSceneRowBase',
'_anchorSceneProseRow',
'_anchorSceneThinkingRow',
'_anchorSceneToolRowFromCall',
'_anchorSceneMessageReasoningText',
fix(#4622): restore tool-card result body on the settled rebuild path 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.
2026-06-25 06:06:01 +00:00
'_enrichSettledToolRowBodyFromLive',
'_anchorSceneRowsByMessageIndex',
];
// Build a self-contained module: closure variables + all functions + runner
let code = '(function() {\n';
// Closure variables that the functions reference
code += 'var activeSid = "test-session";\n';
code += 'var streamId = "test-stream";\n';
// S is passed in from the test payload
code += 'var S;\n';
for (const name of funcNames) {
code += extractFunc(src, name) + '\n';
}
code += `
var buf = '';
process.stdin.on('data', function(c) { buf += c; });
process.stdin.on('end', function() {
var payload = JSON.parse(buf || '{}');
S = payload.S || { toolCalls: [] };
var messages = payload.messages || [];
var turnStart = payload.turnStart !== undefined ? payload.turnStart : 0;
var lastAsstIndex = payload.lastAsstIndex !== undefined ? payload.lastAsstIndex : messages.length - 1;
var result = _anchorSceneRowsByMessageIndex(messages, turnStart, lastAsstIndex);
// Convert Map to plain object keyed by index
var out = {};
result.forEach(function(rows, idx) { out[idx] = rows; });
process.stdout.write(JSON.stringify(out));
});
})();
`;
process.stdout.write(code);
"""
# Generator driver: write the driver script to a temp file and return its path
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
gen_path = tmp_path_factory.mktemp("gen") / "gen.js"
gen_path.write_text(_DRIVER_SRC, encoding="utf-8")
# Run the generator to produce the actual driver
result = subprocess.run(
[NODE, str(gen_path), str(MESSAGES_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) -> dict:
"""Run the driver with the given payload; return parsed stdout as dict."""
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)
# ---------------------------------------------------------------------------
# Helper builders
# ---------------------------------------------------------------------------
def _tool(tool_id: str, name: str = "terminal", started_at=None, args=None):
t = {"id": tool_id, "name": name, "done": True}
if started_at is not None:
t["started_at"] = started_at
if args is not None:
t["args"] = args
return t
def _s_tool(tool_id: str, assistant_msg_idx: int, name: str = "terminal", started_at=None):
t = {"id": tool_id, "name": name, "done": True, "assistant_msg_idx": assistant_msg_idx}
if started_at is not None:
t["started_at"] = started_at
return t
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_thinking_before_tools_before_prose(driver_path):
"""Phase ordering: thinking (0) < tools (1) < prose (2)."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "I did it",
"reasoning": "thinking first",
"tool_calls": [_tool("t1", started_at=100)],
},
{"role": "assistant", "content": "final answer"},
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}
out = _run(driver_path, payload)
assert "1" in out
rows = out["1"]
roles = [r["role"] for r in rows]
assert roles == ["thinking", "tool", "prose"], f"Expected thinking/tool/prose, got {roles}"
def test_reconnect_tools_interleave_by_started_at(driver_path):
"""S.toolCalls entries interleave chronologically with message-local tools."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "progress",
"tool_calls": [
_tool("t-100", started_at=100),
_tool("t-300", started_at=300),
],
},
{"role": "assistant", "content": "final"},
]
# reconnect tool at started_at=150 should land between t-100 and t-300
s_tool_calls = [_s_tool("t-150", assistant_msg_idx=1, started_at=150)]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": s_tool_calls}}
out = _run(driver_path, payload)
rows = out["1"]
tool_rows = [r for r in rows if r["role"] == "tool"]
tool_ids = [r["tool_call_id"] for r in tool_rows]
assert tool_ids == ["t-100", "t-150", "t-300"], f"Expected t-100/t-150/t-300 order, got {tool_ids}"
def test_no_duplicate_when_s_toolcalls_repeats_message_tool(driver_path):
"""Dedup: S.toolCalls must not add a second row for a tool already in message.tool_calls."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "progress",
"tool_calls": [_tool("dup-id", started_at=100)],
},
{"role": "assistant", "content": "final"},
]
s_tool_calls = [_s_tool("dup-id", assistant_msg_idx=1, started_at=100)]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": s_tool_calls}}
out = _run(driver_path, payload)
rows = out["1"]
tool_rows = [r for r in rows if r["role"] == "tool"]
assert len(tool_rows) == 1, f"Expected 1 tool row (deduped), got {len(tool_rows)}"
assert tool_rows[0]["tool_call_id"] == "dup-id"
def test_deterministic_order_without_started_at(driver_path):
"""When started_at is missing, encounter order (insertion order) is the tiebreaker."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "progress",
"tool_calls": [
_tool("first"),
_tool("second"),
_tool("third"),
],
},
{"role": "assistant", "content": "final"},
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}
out = _run(driver_path, payload)
rows = out["1"]
tool_rows = [r for r in rows if r["role"] == "tool"]
tool_ids = [r["tool_call_id"] for r in tool_rows]
assert tool_ids == ["first", "second", "third"], f"Expected insertion order, got {tool_ids}"
def test_cross_message_index_ordering_preserved(driver_path):
"""Tools from different message indices stay in their own buckets."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "step 1",
"tool_calls": [_tool("t-idx1", started_at=100)],
},
{
"role": "assistant",
"content": "step 2",
"tool_calls": [_tool("t-idx2", started_at=50)],
},
{"role": "assistant", "content": "final"},
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 3, "S": {"toolCalls": []}}
out = _run(driver_path, payload)
assert "1" in out and "2" in out
idx1_tools = [r["tool_call_id"] for r in out["1"] if r["role"] == "tool"]
idx2_tools = [r["tool_call_id"] for r in out["2"] if r["role"] == "tool"]
assert idx1_tools == ["t-idx1"]
assert idx2_tools == ["t-idx2"]
def test_s_toolcalls_outside_turn_range_ignored(driver_path):
"""S.toolCalls entries with assistant_msg_idx outside [turnStart+1, lastAsstIndex) are dropped."""
messages = [
{"role": "user", "content": "go"},
{"role": "assistant", "content": "step"},
{"role": "assistant", "content": "final"},
]
s_tool_calls = [
# idx 0 = turnStart, must be excluded
_s_tool("t-0", assistant_msg_idx=0, started_at=10),
# idx 2 = lastAsstIndex, must be excluded
_s_tool("t-2", assistant_msg_idx=2, started_at=10),
# valid
_s_tool("t-1", assistant_msg_idx=1, started_at=10),
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": s_tool_calls}}
out = _run(driver_path, payload)
all_tool_ids = [r["tool_call_id"] for rows in out.values() for r in rows if r["role"] == "tool"]
assert "t-0" not in all_tool_ids
assert "t-2" not in all_tool_ids
assert "t-1" in all_tool_ids
def test_order_index_sequential_within_bucket(driver_path):
"""order_index is 0-based sequential within each message index bucket."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "prose",
"reasoning": "think",
"tool_calls": [_tool("t1", started_at=100)],
},
{"role": "assistant", "content": "final"},
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}
out = _run(driver_path, payload)
rows = out["1"]
indices = [r["order_index"] for r in rows]
assert indices == list(range(len(rows))), f"order_index not sequential: {indices}"
def test_anonymous_tool_rows_get_distinct_row_ids(driver_path):
"""Two tools WITHOUT an id at the same message index must not collide on the
same row_id (which would let _completeSettledAnchorSceneForTurn dedupe one
away). row_id/seq are regenerated from the final per-bucket order index.
Regression guard for the gate-found SILENT drop: rows are built with
orderIndex=0, so the index-derived row_id/seq must be rewritten on emit."""
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
# two anonymous tools (no 'id') at the same index, distinct timestamps
"tool_calls": [
{"name": "terminal", "done": True, "started_at": 100},
{"name": "read_file", "done": True, "started_at": 200},
],
},
{"role": "assistant", "content": "final"},
]
payload = {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}
out = _run(driver_path, payload)
rows = out["1"]
tool_rows = [r for r in rows if r.get("role") == "tool"]
assert len(tool_rows) == 2, f"both anonymous tool rows must survive, got {len(tool_rows)}"
row_ids = [r["row_id"] for r in tool_rows]
assert len(set(row_ids)) == 2, f"anonymous tool rows collided on row_id: {row_ids}"
seqs = [r.get("seq") for r in tool_rows]
assert len(set(seqs)) == 2, f"anonymous tool rows collided on seq: {seqs}"