Files
hermes-webui/tests/test_issue4811_reconnect_chronology.py
T
nesquena-hermesandfranksong2702 e2ade27bfe fix(anchor): preserve settled mixed content order (#4958)
When a settled assistant turn carries _anchor_activity_scene and has mixed
content[] (text interleaved with tool_use), promote that ordering into anchor
scene rows (prose / tool / thinking rows in sequence) instead of only the raw
content[] fallback, and mirror the same scene model in the backend hydration
path so a cold reload reconstructs an identical transcript.

Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was
silently dropped — only the turn-final assistant's post-last-tool text is the
"final answer" tail; earlier assistant messages keep post-tool content as
activity rows. Tail thinking is excluded from the final-answer text but still
emitted as a thinking row (verified on both JS and Python paths).

Tool-row de-duplication is conservative — ID-equal always merges; name/
invocation matches are cardinality-gated and same-message; body-prefix is only
a secondary confirmation inside the id-flexible branch (also requires matching
started_at + compatible name + invocation). It biases toward an extra visible
tool card over ever silently merging two distinct calls.

Gate: Codex SAFE (tail-thinking edge verified already-handled both sides),
Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS,
non-mixed regression all clean), full suite 10778 passed (only the 4 known
pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking
peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row).

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-27 22:54:29 +00:00

341 lines
13 KiB
Python

"""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',
'_anchorSceneContentText',
'_anchorSceneMessageHasContentToolUse',
'_anchorSceneSafePayload',
'_anchorSceneToolId',
'_anchorSceneToolName',
'_anchorSceneToolArgs',
'_anchorSceneContentTool',
'_anchorSceneStringPayload',
'_anchorSceneRowBase',
'_anchorSceneProseRow',
'_anchorSceneThinkingRow',
'_anchorSceneToolRowFromCall',
'_anchorSceneToolRowName',
'_anchorSceneToolRowsHaveCompatibleNames',
'_anchorSceneMatchingContentToolRow',
'_anchorSceneMessageReasoningText',
'_anchorSceneRowsFromContentParts',
'_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}"