Files
hermes-webui/tests/test_issue5224_terminal_error_transcript_preserve.py
T
happy5318 f4b28346e5 fix(session): keep bounded-tail recovery suffix and paging offset
Bounded settle/cancel reloads return the latest 30 messages, not a
prefix of the visible window. The shorter-terminal preserve path only
compared prefixes, so a long session dropped the Connection-interrupted
marker. Anchor persist also ran before _oldestIdx was refreshed from
the bounded response, writing the scene against the wrong transcript
row.

- Treat a suffix match like a prefix match when preserving the terminal
  recovery marker
- Refresh _messagesTruncated / _oldestIdx before attaching/persisting
  the projected scene on settle, cancel, and apperror
- Document the msg_limit=30 / msg_limit=all contract
- Add runtime + source-order regression tests
2026-09-18 22:00:45 +08:00

546 lines
23 KiB
Python

"""Regression tests for #5224: preserve terminal-visible transcript on recovery."""
import json
import subprocess
import shutil
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 is required to execute message runtime tests")
_DRIVER = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
const scenario = JSON.parse(process.argv[3] || '{}');
function extractFunction(source, name) {
const markers = [`async function ${name}(`, `function ${name}(`];
let start = -1;
for (const marker of markers) {
start = source.indexOf(marker);
if (start >= 0) break;
}
if (start < 0) {
throw new Error(`missing ${name}`);
}
let i = source.indexOf('{', start);
if (i < 0) {
throw new Error(`missing function body for ${name}`);
}
let depth = 1;
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
i++;
}
throw new Error(`unterminated function body for ${name}`);
}
function extractFunctionByName(name) {
return extractFunction(src, name);
}
function installRuntimeHelpers() {
const helpers = [
"_isMarkerOnlyAssistantMessage",
"_streamRecoveryControlMessageText",
"_streamRecoveryControlMessage",
"_filterRecoveryControlMessages",
"_replaceMarkerOnlyAssistantWithStreamError",
"_messageIdentityKey",
"_isHistoricalAnchorActivityScene",
"_carryForwardEphemeralTurnFields",
"_isTerminalStreamErrorMarkerMessage",
"_ensureSingleTerminalStreamErrorMarker",
"_restoreSettledSession",
"_handleStreamError",
];
for (const helper of helpers) {
const body = extractFunctionByName(helper);
const factory = new Function(`${body}; return ${helper};`);
globalThis[helper] = factory();
}
}
function buildRuntime() {
const activeSid = scenario.activeSid || 'session-5224';
const streamId = scenario.streamId || 'stream-5224';
const calls = [];
globalThis.activeSid = activeSid;
globalThis.streamId = streamId;
globalThis.assistantText = false;
globalThis.S = JSON.parse(JSON.stringify(scenario.state || {}));
if (!globalThis.S.session) {
globalThis.S.session = { session_id: activeSid };
}
if (!Object.prototype.hasOwnProperty.call(globalThis.S, 'activeStreamId')) {
globalThis.S.activeStreamId = streamId;
}
globalThis.INFLIGHT = {};
globalThis._EPHEMERAL_TURN_FIELDS = [
'_turnUsage',
'_turnDuration',
'_turnTps',
'_gatewayRouting',
'_statusCard',
'_anchor_stream_id',
'_anchor_activity_scene',
];
globalThis._isActiveSession = () => scenario.isActiveSession !== false;
globalThis._isSessionCurrentPane = () => scenario.isSessionCurrentPane !== false;
globalThis._isSessionActivelyViewed = () => !!scenario.isSessionActivelyViewed;
globalThis._closeSource = () => calls.push('closeSource');
globalThis._clearStreamEndRecovery = () => calls.push('clearStreamEndRecovery');
globalThis._clearOwnerInflightState = () => calls.push('clearOwnerInflight');
globalThis.clearLiveToolCards = () => calls.push('clearLiveToolCards');
globalThis.removeThinking = () => calls.push('removeThinking');
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
globalThis._smdEndParser = () => calls.push('smdEndParser');
globalThis._markSessionCompletionUnread = () => calls.push('markCompletionUnread');
globalThis._markSessionViewed = () => calls.push('markSessionViewed');
globalThis.localStorage = {
setItem: () => calls.push('setLocalStorageItem'),
getItem: () => null,
removeItem: () => calls.push('removeLocalStorageItem'),
};
globalThis._setActiveSessionUrl = () => calls.push('setActiveSessionUrl');
globalThis.showToast = () => calls.push('showToast');
globalThis._clearApprovalForOwner = () => calls.push('clearApprovalForOwner');
globalThis._clearClarifyForOwner = () => calls.push('clearClarifyForOwner');
globalThis._streamFadeCleanupReduceMotionListener = () => calls.push('streamFadeCleanup');
globalThis._cancelThrottledSnapshotTimer = () => calls.push('cancelThrottledSnapshot');
globalThis._clearAnchorProseIncrementalNode = () => calls.push('clearAnchorProse');
globalThis._cancelAnimationFramePendingStreamRender = () => calls.push('cancelRaf');
globalThis.finalizeThinkingCard = () => calls.push('finalizeThinkingCard');
globalThis.syncTopbar = () => calls.push('syncTopbar');
globalThis.renderMessages = () => calls.push('renderMessages');
globalThis.renderSessionList = () => calls.push('renderSessionList');
globalThis._setActivePaneIdleIfOwner = () => calls.push('setActivePaneIdle');
globalThis.setBusy = () => calls.push('setBusy');
globalThis.setComposerStatus = () => calls.push('setComposerStatus');
globalThis.setStatus = () => calls.push('setStatus');
globalThis._messageRenderableMessageCount = () => scenario.messageRenderableCount || 50;
globalThis._currentMessageRenderWindowSize = () => scenario.currentWindowSize || 12;
globalThis._messageRenderWindowSize = 20;
globalThis._streamFinalized = !!scenario.streamFinalized;
globalThis._persistTimer = null;
globalThis._oldestIdx = Number.isFinite(scenario.oldestIdx) ? scenario.oldestIdx : 0;
globalThis._messagesTruncated = !!scenario.messagesTruncated;
globalThis.oldestIdxAtAttach = null;
globalThis.messagesTruncatedAtAttach = null;
globalThis._attachProjectedAnchorSceneToLastAssistant = () => {
calls.push('attachProjected');
globalThis.oldestIdxAtAttach = globalThis._oldestIdx;
globalThis.messagesTruncatedAtAttach = globalThis._messagesTruncated;
};
globalThis.api = async () => scenario.apiPayload || { session: null };
globalThis.msgContent = undefined;
globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false;
return calls;
}
(async () => {
installRuntimeHelpers();
const calls = buildRuntime();
if (scenario.action === 'restore_shorter_terminal') {
const status = await _restoreSettledSession({}, {
status: true,
preserveVisibleOnShorterTerminalSnapshot: true,
});
const messages = Array.isArray(S.messages) ? S.messages : [];
const terminalMarkerCount = messages.filter(_isTerminalStreamErrorMarkerMessage).length;
console.log(JSON.stringify({
action: scenario.action,
status,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
terminalMarkerCount,
oldestIdx: globalThis._oldestIdx,
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
messagesTruncated: !!globalThis._messagesTruncated,
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
calls,
}));
return;
}
if (scenario.action === 'restore_fuller_terminal') {
const status = await _restoreSettledSession({}, {
status: true,
preserveVisibleOnShorterTerminalSnapshot: true,
});
const messages = Array.isArray(S.messages) ? S.messages : [];
const terminalMarkerCount = messages.filter(_isTerminalStreamErrorMarkerMessage).length;
console.log(JSON.stringify({
action: scenario.action,
status,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
terminalMarkerCount,
oldestIdx: globalThis._oldestIdx,
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
messagesTruncated: !!globalThis._messagesTruncated,
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
calls,
}));
return;
}
if (scenario.action === 'terminal_marker_idempotent') {
const messages = JSON.parse(JSON.stringify(scenario.messages || []));
_ensureSingleTerminalStreamErrorMarker(messages);
_ensureSingleTerminalStreamErrorMarker(messages);
const terminalMarkerCount = messages.filter(_isTerminalStreamErrorMarkerMessage).length;
console.log(JSON.stringify({
action: scenario.action,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
terminalMarkerCount,
calls,
}));
return;
}
throw new Error(`unknown action: ${scenario.action}`);
})().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
"""
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
driver = tmp_path_factory.mktemp("issue5224_driver") / "driver.js"
driver.write_text(_DRIVER, encoding="utf-8")
return str(driver)
def _run_scenario(driver_path: str, scenario: dict) -> dict:
command = [NODE, driver_path, str(MESSAGES_JS), json.dumps(scenario)]
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
check=False,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed: {result.stderr}")
return json.loads(result.stdout.strip())
def test_terminal_error_restore_preserves_visible_transcript_when_server_snapshot_shorter(driver_path):
"""Terminal recovery must keep richer visible transcript when restored snapshot is shorter."""
outcome = _run_scenario(driver_path, {
"action": "restore_shorter_terminal",
"state": {
"session": {"session_id": "session-5224", "message_count": 5},
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Visible assistant answer segment one", "_ts": "a1"},
{"role": "assistant", "content": "Visible assistant answer segment two", "_ts": "a2"},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "err"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Visible assistant answer segment one", "_ts": "a1"},
],
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
assert outcome["status"] == "restored"
roles_and_contents = [(item["role"], item["content"]) for item in outcome["messages"]]
assert ("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished.") in roles_and_contents, (
f"terminal error marker missing: {roles_and_contents}"
)
assert outcome["terminalMarkerCount"] == 1, f"expected exactly one terminal marker, got {outcome['terminalMarkerCount']}"
assert any("Visible assistant answer segment two" in content for _, content in roles_and_contents), (
"visible transcript tail was dropped after stale shorter restore"
)
def test_terminal_error_restore_replaces_when_snapshots_are_fuller(driver_path):
"""A fuller settled snapshot must replace the visible transcript to keep the pane current."""
outcome = _run_scenario(driver_path, {
"action": "restore_fuller_terminal",
"state": {
"session": {"session_id": "session-5224", "message_count": 1},
"messages": [
{"role": "assistant", "content": "Old interrupted shell", "_ts": "old"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Settled first segment", "_ts": "a1"},
{"role": "assistant", "content": "Settled second segment", "_ts": "a2"},
{"role": "assistant", "content": "Settled third segment", "_ts": "a3"},
],
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
assert outcome["status"] == "restored"
expected = [
("user", "Question about data?"),
("assistant", "Settled first segment"),
("assistant", "Settled second segment"),
("assistant", "Settled third segment"),
]
observed = [(item["role"], item["content"]) for item in outcome["messages"]]
assert observed == expected, f"fuller settled snapshot should replace visible messages: {observed}"
def test_terminal_error_restore_replaces_shorter_authoritative_snapshot_when_prefix_does_not_match(driver_path):
"""A shorter settled snapshot that changes the assistant turn must replace stale live fragments."""
outcome = _run_scenario(driver_path, {
"action": "restore_shorter_terminal",
"state": {
"session": {"session_id": "session-5224", "message_count": 5},
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Visible assistant fragment one", "_ts": "a1"},
{"role": "assistant", "content": "Visible assistant fragment two", "_ts": "a2"},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "err"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Settled final answer", "_ts": "final"},
],
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
observed = [(item["role"], item["content"]) for item in outcome["messages"]]
assert observed == [
("user", "Question about data?"),
("assistant", "Settled final answer"),
], f"authoritative settled snapshot should replace stale live tail: {observed}"
assert outcome["terminalMarkerCount"] == 0, (
f"terminal marker should not survive authoritative settled replacement, got {outcome['terminalMarkerCount']}"
)
def test_terminal_error_restore_does_not_preserve_from_historical_marker_on_older_turn(driver_path):
"""An old terminal marker earlier in the session must not preserve a later unrelated live tail."""
outcome = _run_scenario(driver_path, {
"action": "restore_shorter_terminal",
"state": {
"session": {"session_id": "session-5224", "message_count": 7},
"messages": [
{"role": "user", "content": "Earlier question", "_ts": "u0"},
{"role": "assistant", "content": "Earlier answer", "_ts": "a0"},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "err0"},
{"role": "user", "content": "Current question", "_ts": "u1"},
{"role": "assistant", "content": "Current fragment one", "_ts": "a1"},
{"role": "assistant", "content": "Current fragment two", "_ts": "a2"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Earlier question", "_ts": "u0"},
{"role": "assistant", "content": "Earlier answer", "_ts": "a0"},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "err0"},
{"role": "user", "content": "Current question", "_ts": "u1"},
{"role": "assistant", "content": "Current fragment one", "_ts": "a1"},
],
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
observed = [(item["role"], item["content"]) for item in outcome["messages"]]
assert observed == [
("user", "Earlier question"),
("assistant", "Earlier answer"),
("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished."),
("user", "Current question"),
("assistant", "Current fragment one"),
], f"historical marker should not preserve later unrelated tail: {observed}"
assert outcome["terminalMarkerCount"] == 1, (
f"historical marker count should stay unchanged, got {outcome['terminalMarkerCount']}"
)
def test_terminal_error_marker_is_single_instance_and_not_duplicated(driver_path):
"""Appending terminal error marker repeatedly should keep one marker and remove duplicates."""
outcome = _run_scenario(driver_path, {
"action": "terminal_marker_idempotent",
"messages": [
{"role": "user", "content": "Question", "_ts": "u1"},
{"role": "assistant", "content": "Answer", "_ts": "a1"},
],
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
})
assert outcome["terminalMarkerCount"] == 1, f"terminal marker should be deduped to one, got {outcome['terminalMarkerCount']}"
assert outcome["messages"][-1]["content"].startswith("**Connection interrupted:**")
def test_bounded_tail_preserves_terminal_marker_when_server_returns_suffix(driver_path):
"""A bounded settle tail is a suffix of the visible window, not a prefix.
Long-session recovery used to compare the 30-row tail against the start of
S.messages. The prefix check failed, so preserveVisibleOnShorterTerminalSnapshot
dropped the client-side Connection-interrupted marker (#7628).
"""
historical = []
for i in range(2):
historical.append({"role": "user", "content": f"Earlier question {i}", "_ts": f"u{i}"})
historical.append({"role": "assistant", "content": f"Earlier answer {i}", "_ts": f"a{i}"})
tail = [
{"role": "user", "content": "Current question", "_ts": "u-cur"},
{"role": "assistant", "content": "Current fragment one", "_ts": "a-cur"},
]
terminal = {
"role": "assistant",
"content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.",
"_ts": "err",
}
outcome = _run_scenario(driver_path, {
"action": "restore_shorter_terminal",
"oldestIdx": 0,
"messagesTruncated": False,
"state": {
"session": {"session_id": "session-5224", "message_count": 7},
"messages": historical + tail + [terminal],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": tail,
"_messages_truncated": True,
"_messages_offset": 4,
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
observed = [(item["role"], item["content"]) for item in outcome["messages"]]
assert observed == [
("user", "Current question"),
("assistant", "Current fragment one"),
("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished."),
], f"bounded tail must keep the terminal recovery suffix: {observed}"
assert outcome["terminalMarkerCount"] == 1, (
f"expected the terminal marker to survive a suffix-only tail, got {outcome['terminalMarkerCount']}"
)
assert outcome["oldestIdx"] == 4, f"paging offset should come from the bounded response, got {outcome['oldestIdx']}"
assert outcome["oldestIdxAtAttach"] == 4, (
f"anchor persist must see the new offset, got {outcome['oldestIdxAtAttach']}"
)
assert outcome["messagesTruncated"] is True
assert outcome["messagesTruncatedAtAttach"] is True
def test_settle_refreshes_paging_offset_before_anchor_persist(driver_path):
"""_persistSettledAnchorScene reads _oldestIdx; a stale 0 would write the scene to the wrong row."""
outcome = _run_scenario(driver_path, {
"action": "restore_fuller_terminal",
"oldestIdx": 0,
"messagesTruncated": False,
"state": {
"session": {"session_id": "session-5224", "message_count": 1},
"messages": [
{"role": "assistant", "content": "Old interrupted shell", "_ts": "old"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Settled first segment", "_ts": "a1"},
{"role": "assistant", "content": "Settled second segment", "_ts": "a2"},
{"role": "assistant", "content": "Settled third segment", "_ts": "a3"},
],
"_messages_truncated": True,
"_messages_offset": 40,
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
assert outcome["status"] == "restored"
assert outcome["oldestIdx"] == 40
assert outcome["oldestIdxAtAttach"] == 40, (
f"stale offset 0 must not leak into anchor persist, got {outcome['oldestIdxAtAttach']}"
)
assert outcome["messagesTruncatedAtAttach"] is True