Merge branch 'master' into feat/gateway-run-reattach-after-restart

This commit is contained in:
carlotestor authored and GitHub committed 2026-09-24 15:51:42 +01:00
commit 05aae134cf
86 files changed
+14695 -480

No files matched your search

+6 -3
View File
@@ -93,6 +93,9 @@ eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_sessionAttentionState'));
eval(extractFunc('_sidebarRowHasVisibleMessages'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_partitionSidebarSessionRows'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
@@ -216,9 +219,9 @@ console.log(JSON.stringify({
}));
"""
out = json.loads(_run_node(source))
# Child survives the visibility/project scope into sessionsRaw (parent does not)...
assert out["sessionsRaw"] == ["subagent_child"]
# ...but is NOT rendered as a top-level orphan.
# The child inherits its parent's project, so the "no project" filter drops both...
assert out["sessionsRaw"] == []
# ...and it is NOT rendered as a top-level orphan.
assert out["topLevel"] == []
assert out["orphans"] == []
+363
View File
@@ -0,0 +1,363 @@
"""Behavioral tests for #7628 review round: /compress paging restore + suffix
strategy for repeated identical turns.
Round-3 review (nesquena-hermes 2026-09-19) found two data-integrity blockers:
1. [CORE] A successful /compress returns the FULL transcript, but
`_applyManualCompressionResult` did not restore `_messagesTruncated` /
`_oldestIdx` — the bounded preflight's values (e.g. truncated=true,
oldestIdx=70) survived, producing a false "70 older messages" affordance
and shifting every absolute transcript index by 70.
2. [SILENT] `_restoreSettledSession` preservation used
`(_stagedMatchesCurrentPrefix || _stagedMatchesCurrentSuffix)`, so when
identical repeated turns made BOTH comparisons succeed the prefix branch
spliced at the wrong offset — silently dropping and duplicating rows.
The server truncation signal now decides: truncated => suffix-exclusive.
Node-backed tests extract the real functions from static/ and drive them
against a stub DOM (same harness pattern as test_issue5224).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[1]
_COMMANDS_JS = _ROOT / "static" / "commands.js"
_MESSAGES_JS = _ROOT / "static" / "messages.js"
_SESSIONS_JS = _ROOT / "static" / "sessions.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 commandsSrc = fs.readFileSync(process.argv[2], 'utf8');
const messagesSrc = fs.readFileSync(process.argv[3], 'utf8');
const sessionsSrc = fs.readFileSync(process.argv[4], 'utf8');
const scenario = JSON.parse(process.argv[5] || '{}');
function extractFunction(source, name) {
const markers = [`async function ${name}(`, `function ${name}(${name}`, `function ${name}(`, `${name}=async(`, `${name}=async (`];
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 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 body for ${name}`);
}
function install(name, src) {
const body = extractFunction(src, name);
// Drop the `async function name(` or `function name(` prefix, keep body.
const eq = body.indexOf('{');
const inner = body.slice(eq);
globalThis[name] = new Function(`return (${name === 'jumpToSessionStart' ? 'async function' : 'function'} ${inner})`)();
}
// ---- helpers from messages.js (mirror test_issue5224 set) ----
const MESSAGE_HELPERS = [
"_isMarkerOnlyAssistantMessage", "_streamRecoveryControlMessageText",
"_streamRecoveryControlMessage", "_filterRecoveryControlMessages",
"_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey",
"_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields",
"_isTerminalStreamErrorMarkerMessage", "_restoreSettledSession",
"_mergeSettledToolCallsWithLiveMetadata",
];
for (const h of MESSAGE_HELPERS) {
const body = extractFunction(messagesSrc, h);
const factory = new Function(`${body}; return ${h};`);
globalThis[h] = factory();
}
// ---- compress result applier from commands.js ----
const COMPRESS_BODY = extractFunction(commandsSrc, '_applyManualCompressionResult');
// Rebuild as a normal function (it contains `await` – keep async).
globalThis._applyManualCompressionResult = new Function(
'return (' + COMPRESS_BODY + ')'
)();
function runtimeStubs() {
const calls = [];
globalThis.S = { session: { session_id: 's1' }, messages: [], toolCalls: [] };
globalThis.activeSid = 's1';
globalThis.streamId = 'stream-1';
globalThis.assistantText = false;
globalThis._messagesTruncated = !!scenario.prestateTruncated;
globalThis._oldestIdx = scenario.prestateOldestIdx || 0;
globalThis._messagesGeneration = 1;
globalThis._loadingOlder = false;
globalThis._loadingSessionId = null;
globalThis._messageRenderWindowSize = 50;
globalThis._msgLimitMax = scenario.msgLimitMax || 200;
globalThis._INITIAL_MSG_LIMIT = 50;
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.updateQueueBadge = () => calls.push('updateQueueBadge');
globalThis._setActivePaneIdleIfOwner = () => calls.push('setActivePaneIdle');
globalThis.setBusy = () => calls.push('setBusy');
globalThis.setComposerStatus = () => calls.push('setComposerStatus');
globalThis.setCompressionUi = () => calls.push('setCompressionUi');
globalThis.setStatus = () => calls.push('setStatus');
globalThis.msgContent = (m) => (m && m.content && typeof m.content === 'string' ? m.content : '');
globalThis._isContextCompactionMessage = () => false;
globalThis._setCompressionSessionLock = () => calls.push('setCompressionSessionLock');
globalThis._messageRenderableMessageCount = () => scenario.messageRenderableCount || 50;
globalThis._currentMessageRenderWindowSize = () => scenario.currentWindowSize || 12;
globalThis._restoreMessageRenderWindowAfterSettledRender = () => calls.push('restoreRenderWindow');
globalThis.projectSessionArtifactsForOwner = () => calls.push('projectArtifacts');
globalThis._streamFinalized = false;
globalThis._persistTimer = null;
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
globalThis._isMessagePaneNearBottom = () => true;
globalThis._isMessageReaderUnpinned = () => false;
globalThis._queueDrainSid = null;
// reducer used by _restoreSettledSession (mirrors live code)
return calls;
}
(async () => {
const calls = runtimeStubs();
if (scenario.action === 'compress_restores_paging') {
// Pre-state: bounded preflight left truncated=true, oldestIdx=70.
globalThis._messagesTruncated = true;
globalThis._oldestIdx = 70;
const fullRows = [];
for (let i = 0; i < 100; i++) {
fullRows.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
}
const payload = {
session: {
session_id: 's1',
message_count: 100,
messages: fullRows,
tool_calls: [],
_messages_truncated: false,
_messages_offset: 0,
},
};
await globalThis._applyManualCompressionResult(payload, '', 100, '/compress');
console.log(JSON.stringify({
action: 'compress_restores_paging',
messagesTruncated: !!globalThis._messagesTruncated,
oldestIdx: globalThis._oldestIdx,
messageCountAfter: Array.isArray(S.messages) ? S.messages.length : -1,
calls,
}));
return;
}
if (scenario.action === 'settle_repeated_turns') {
// Bounded settle against a transcript containing repeated identical turns
// (same role/content but distinct _ids). Round-2 bug: BOTH prefix and
// suffix matched, prefix won, and rows were dropped+duplicated.
const state = scenario.state || {};
globalThis.S.session = { session_id: 's1', message_count: state.messageCount };
globalThis.S.messages = state.messages || [];
globalThis.streamId = 'stream-7628';
globalThis.S.activeStreamId = 'stream-7628';
globalThis.api = async () => scenario.apiPayload || { session: null };
const status = await globalThis._restoreSettledSession({}, { status: true, preserveVisibleOnShorterTerminalSnapshot: true });
const msgs = Array.isArray(globalThis.S.messages) ? globalThis.S.messages : [];
console.log(JSON.stringify({
action: 'settle_repeated_turns',
status,
messages: msgs.map((m) => ({ role: m.role, content: m.content, _ts: m._ts, _id: m._id || null })),
oldestIdx: globalThis._oldestIdx,
messagesTruncated: !!globalThis._messagesTruncated,
terminalMarkerCount: msgs.filter(globalThis._isTerminalStreamErrorMarkerMessage).length,
calls,
}));
return;
}
if (scenario.action === 'load_older_paging') {
// After a bounded recovery (offset=70), "Load earlier" must request a page
// keyed off _oldestIdx (no gap and no overlap), and merge must close the
// window: final oldestIdx === requested offset.
globalThis._messagesTruncated = true;
globalThis._oldestIdx = scenario.oldestIdx || 70;
const existing = [];
for (let i = 70; i < 100; i++) existing.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
globalThis.S.messages = existing;
globalThis.S.session = { session_id: 's1', message_count: 100 };
const requested = [];
globalThis.api = async (url, opts) => {
requested.push(String(url));
// Return one older page of size 50 (rows 20..69).
const older = [];
for (let i = 20; i < 70; i++) older.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
return {
session: { session_id: 's1', message_count: 100, messages: older, _messages_truncated: true, _messages_offset: 20 },
};
};
// Extract the live loader from sessions.js.
const loaderBody = extractFunction(sessionsSrc, '_loadOlderMessages');
const loaderFactory = new Function(`${loaderBody}; return _loadOlderMessages;`);
const loader = loaderFactory();
await loader.call(globalThis);
console.log(JSON.stringify({
action: 'load_older_paging',
requested,
messagesTruncated: !!globalThis._messagesTruncated,
oldestIdx: globalThis._oldestIdx,
messagesLen: Array.isArray(S.messages) ? S.messages.length : -1,
loadingOlder: !!globalThis._loadingOlder,
}));
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("7628_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(_COMMANDS_JS), str(_MESSAGES_JS), str(_SESSIONS_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_compress_success_restores_paging_state(driver_path):
"""A successful /compress returning the full transcript must reset the
bounded preflight's paging signals before render (#7628 blocker 1)."""
outcome = _run_scenario(driver_path, {"action": "compress_restores_paging"})
assert outcome["messagesTruncated"] is False, "compress success must clear _messagesTruncated"
assert outcome["oldestIdx"] == 0, f"compress success must reset _oldestIdx to 0, got {outcome['oldestIdx']}"
assert outcome["messageCountAfter"] == 100
def test_settle_bounded_preserves_no_drop_no_dup(driver_path):
"""A bounded settle against repeated identical turns must not drop or
duplicate rows (#7628 blocker 2, exact reviewer reproduction).
All user rows share one identity key (same role/content/_ts), all assistant
rows share one too — _messageIdentityKey collides across every pair. A
bounded tail [u3,a3,u2,a2] therefore matches BOTH the visible prefix
(u1,a1,u2,a2…) and the visible suffix (…u2,a2) at key level. The round-2
`||` code preferred the prefix branch: splice offset = staged.length
dropped u1/a1 and re-emitted u3/a3 (duplicate). The server truncation
signal must force suffix-exclusive splicing.
"""
_u = {"role": "user", "content": "What's the status?", "_ts": "t-u"}
_a = {"role": "assistant", "content": "All good.", "_ts": "t-a"}
# Visible before settle: u1,a1,u2,a2,u3,a3 + Connection-interrupted marker.
state_messages = [
{**_u, "_id": 1}, {**_a, "_id": 2},
{**_u, "_id": 3}, {**_a, "_id": 4},
{**_u, "_id": 5}, {**_a, "_id": 6},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "t-err", "_id": 7},
]
outcome = _run_scenario(driver_path, {
"action": "settle_repeated_turns",
"isSessionCurrentPane": True,
"state": {
"messageCount": 12,
"messages": state_messages,
},
"apiPayload": {
"session": {
"session_id": "s1",
"active_stream_id": None,
"pending_user_message": None,
"message_count": 12,
"messages": [
# Reviewer's tail: [u3,a3,u2,a2] — identity keys collide
# with u1/a1/u2/a2/u3/a3 above, so prefix AND suffix both
# compare equal.
{**_u, "_id": 8}, {**_a, "_id": 9},
{**_u, "_id": 10}, {**_a, "_id": 11},
],
"_messages_truncated": True,
"_messages_offset": 4,
},
},
})
assert outcome["status"] == "restored", outcome
ids = [m["_id"] for m in outcome["messages"]]
# No dropped id, no dup id (the prefix-wins bug duplicated u3/a3 and
# dropped u1/a1 — every retained row must be unique).
assert len(ids) == len(set(ids)), f"duplicate rows survived settle: {ids}"
# Staged (authoritative) rows must all be present, plus the marker once.
assert set(ids) == {8, 9, 10, 11, 7}, f"unexpected transcript after fix: {ids}"
# Identity (role|_ts|content) counts: staged rows legitimately repeat
# (two identical user pairs in the tail), but the bug added a THIRD copy by
# splicing the visible tail back in. user<=2, assistant<=2, marker exactly 1.
keys = [(m["role"], m["_ts"], m["content"]) for m in outcome["messages"]]
kc = {}
for k in keys:
kc[k] = kc.get(k, 0) + 1
user_key = ("user", "t-u", "What's the status?")
asst_key = ("assistant", "t-a", "All good.")
assert kc.get(user_key, 0) == 2, f"user rows must appear exactly twice (staged), got {kc.get(user_key, 0)}: {keys}"
assert kc.get(asst_key, 0) == 2, f"assistant rows must appear exactly twice (staged), got {kc.get(asst_key, 0)}: {keys}"
assert outcome["terminalMarkerCount"] == 1, outcome
assert outcome["oldestIdx"] == 4, f"offset must follow server, got {outcome['oldestIdx']}"
assert outcome["messagesTruncated"] is True
def test_bounded_recovery_prepares_gap_free_load_earlier(driver_path):
"""After a bounded recovery (oldestIdx=70), Load Earlier must page from the
authoritative offset so the fetched range has no gap and no overlap
(#7628)."""
outcome = _run_scenario(driver_path, {"action": "load_older_paging", "oldestIdx": 70, "msgLimitMax": 80})
assert outcome["requested"], "loader must have issued an api call"
url = outcome["requested"][0]
assert "msg_before=70" in url, f"load-earlier must page from authoritative offset: {url}"
assert "msg_limit=" in url, url
@@ -0,0 +1,343 @@
"""Behavioural regressions for the #7628 review round-3 findings.
The round-3 gate reproduced two data-integrity defects against the shipped
functions and asked for tests that reach them (the source-assertion suite
stayed green through both reproductions):
1. **A successful `/compress` kept the bounded preflight's paging state.**
The success path assigned the full-transcript response without restoring
``_messagesTruncated`` / ``_oldestIdx``, so after a compression that
returned 100 full rows the client still showed "70 older messages" and
every absolute transcript index was off by 70 — which is what edit /
fork / regenerate address rows by.
2. **Prefix match won over suffix match when both succeeded.** A bounded
settle returns a *suffix* of the durable transcript, but the old
``prefix || suffix`` expression preferred the prefix branch, so a
transcript with repeated identical turns spliced at the wrong offset:
``u1/a1`` silently dropped, ``u3/a3`` duplicated.
These tests execute the real decision code rather than grepping it: the
splice strategy block is extracted from ``static/messages.js`` verbatim and
driven through a Node VM with the paging signals as the only variable, and
the compress success path is asserted at source level for the restore
ordering (that block issues a live ``/api/session`` fetch, so driving it
would need a network).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
# ── extraction helpers ───────────────────────────────────────────────────────
_SPLICE_DEPS = [
"_messageIdentityKey",
"_isTerminalStreamErrorMarkerMessage",
"_streamRecoveryControlMessage",
"_filterRecoveryControlMessages",
"_carryForwardEphemeralTurnFields",
]
def _splice_block() -> str:
"""The bounded-settle splice decision, verbatim from messages.js.
Spans the terminal-marker detection and the prefix/suffix comparisons
through the ``S.messages`` assignment — the region the round-3 gate
patched. Everything before it (paging restore, session assignment) is set
up as harness state instead.
"""
start_marker = "const _currentVisibleEndsWithTerminalMarker=("
start = MESSAGES_JS.find(start_marker)
assert start >= 0, "terminal-marker detection not found in static/messages.js"
end_marker = "S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);"
end = MESSAGES_JS.find(end_marker, start)
assert end >= 0, "S.messages assignment after the splice not found"
return MESSAGES_JS[start : end + len(end_marker)]
def _dependency_functions() -> list[str]:
"""The splice block's transitive pure dependencies, in definition order.
The extraction is closure-driven: start from the helpers the block names,
then keep pulling the helpers those helpers reference until the set is
closed. That keeps the harness honest when the production code grows a
new dependency — a missing one fails the node run instead of silently
stubbing behaviour away.
"""
import re
needed: list[str] = list(_SPLICE_DEPS)
resolved: list[str] = []
seen: set[str] = set()
while needed:
name = needed.pop(0)
if name in seen:
continue
seen.add(name)
try:
body = extract_function(MESSAGES_JS, name)
except AssertionError:
continue
resolved.append(name)
for ref in re.findall(r"\b(_[a-zA-Z][a-zA-Z0-9_]*)\s*\(", body):
if ref not in seen and ref != name:
needed.append(ref)
# Definition order matters only for hoisted `function` declarations, which
# JS hoists anyway; keep the resolved order for readability.
return [extract_function(MESSAGES_JS, name) for name in resolved]
def _compress_success_block() -> str:
"""The `/compress` success path that assigns the full-transcript response.
Anchored on the bounded preflight fetch's continuation: the block runs
after a successful compression response and must restore the paging
signals before ``renderMessages()``.
"""
anchor = "const summary=data&&data.summary;"
start = COMMANDS_JS.rfind(anchor)
assert start >= 0, "compress success block not found in static/commands.js"
assign = COMMANDS_JS.rfind("S.messages=", 0, start)
assert assign >= 0, "S.messages assignment before the summary block not found"
return COMMANDS_JS[assign:start]
# ── node harness ─────────────────────────────────────────────────────────────
_HARNESS = r"""
const deps = __DEPS__;
const splice = __SPLICE__;
const S = { session: { session_id: 'sid' }, messages: [] };
let _messagesTruncated = false;
let _oldestIdx = 0;
let _adoptRegenerationRevision = () => {};
let _hydrateTodosFromSession = () => {};
let _markSessionCompletionUnread = () => {};
let _setActiveSessionUrl = () => {};
let _attachProjectedAnchorSceneToLastAssistant = () => {};
let _replaceMarkerOnlyAssistantWithStreamError = () => false;
const store = {};
globalThis.localStorage = {
getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
};
// Closure-scope constant that lives just above the extracted helper in
// messages.js (extract_function only captures the function body).
const _EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
for (const fn of deps) eval(fn);
// Scenario: repeated identical turns in the visible window, a terminal
// marker at the end, and a bounded durable tail whose ids ALSO appear
// earlier — so both the prefix and the suffix comparison succeed.
function makeTurn(n) {
return [
{ role: 'user', id: `u-${n}`, content: `prompt ${n}` },
{ role: 'assistant', id: `a-${n}`, content: `answer ${n}` },
];
}
S.messages = [];
for (const n of [1, 2, 3]) S.messages.push(...makeTurn(n));
// The real terminal marker shape: an assistant row whose content starts with
// the Connection-interrupted prefix (what _isTerminalStreamErrorMarkerMessage
// actually matches).
S.messages.push({
role: 'assistant',
id: 'marker',
content: '**Connection interrupted:** The browser lost the live SSE connection before the response finished.',
});
const durableTail = [
{ role: 'user', id: 'u-3', content: 'prompt 3' },
{ role: 'assistant', id: 'a-3', content: 'answer 3' },
];
const session = {
session_id: 'sid',
messages: durableTail,
_messages_truncated: __TRUNCATED__,
_messages_offset: 4,
};
_messagesTruncated = __TRUNCATED__;
_oldestIdx = __OFFSET__;
// The state the splice block reads, mirroring the lines just above it.
const _currentMessages = S.messages.slice();
const _currentVisibleMessages = _filterRecoveryControlMessages(_currentMessages);
const _stagedMessages = _carryForwardEphemeralTurnFields(_currentMessages, session.messages);
const preserveVisibleOnShorterTerminalSnapshot = true;
S.session = session;
eval(splice);
const out = {
truncated: _messagesTruncated,
oldestIdx: _oldestIdx,
rows: (S.messages || []).map(m => `${m.role}:${m.id}`),
};
console.log(JSON.stringify(out));
"""
def _run_node(script: str) -> dict:
result = subprocess.run(
[NODE, "-e", script],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
return json.loads(result.stdout.strip().splitlines()[-1])
def _drive(*, truncated: bool, offset: int) -> dict:
deps = "[" + ",".join(json.dumps(f) for f in _dependency_functions()) + "]"
splice = json.dumps(_splice_block())
script = (
_HARNESS.replace("__DEPS__", deps)
.replace("__SPLICE__", splice)
.replace("__TRUNCATED__", "true" if truncated else "false")
.replace("__OFFSET__", str(offset))
)
return _run_node(script)
# ── tests ────────────────────────────────────────────────────────────────────
def test_bounded_settle_with_repeated_identical_turns_drops_and_duplicates_nothing():
"""The round-3 SILENT finding: identical repeated turns must not splice wrong.
The visible window holds repeated identical turns, so the durable tail's
ids appear at BOTH the head and the tail of the window and both the
prefix and the suffix comparison succeed. The pre-fix
``prefix || suffix`` expression preferred the prefix branch and spliced at
the head offset — dropping ``u2/a2`` and ``u3/a3`` from the window even
though the server returned them as a truncated suffix (``_oldestIdx=4``).
The truncation signal must select the suffix branch instead.
The two branches produce observably different row lists on this shape,
which is what makes this a regression test rather than a tautology: with
the prefix branch restored the splice keeps the wrong rows.
"""
out = _drive(truncated=True, offset=4)
rows = out["rows"]
# The durable tail rows are present exactly once each — the pre-fix
# prefix branch duplicated them.
assert rows.count("user:u-3") == 1, rows
assert rows.count("assistant:a-3") == 1, rows
# No row appears twice anywhere.
assert len(rows) == len(set(rows)), f"duplicate rows in {rows}"
# The suffix splice keeps the durable tail plus the rows that follow it
# in the visible window (the terminal marker). The prefix branch would
# splice at the head offset and re-emit u2/a2/u3/a3.
assert rows == ["user:u-3", "assistant:a-3", "assistant:marker"], (
"the suffix splice must keep durable-tail + the rows after it; got "
f"{rows} (extra rows mean the prefix branch won)"
)
# Paging follows the response so absolute indices line up.
assert out["truncated"] is True
assert out["oldestIdx"] == 4
def test_bounded_settle_keeps_the_response_paging_signals():
"""Paging flags follow the server's truncation signal, not the preflight."""
out = _drive(truncated=True, offset=4)
assert out["truncated"] is True
assert out["oldestIdx"] == 4
def test_untruncated_snapshot_clears_the_bounded_preflight_state():
"""An untruncated response must clear the bounded preflight's state.
This is the `/compress` success-path shape from the CORE finding: the
preflight bounded the tail, the response carries the full transcript, and
the client must end at ``truncated=false`` / ``offset=0`` so absolute
transcript indices line up again.
"""
out = _drive(truncated=False, offset=0)
assert out["truncated"] is False, (
"an untruncated response must clear _messagesTruncated; the bounded "
f"preflight state leaked (got {out['truncated']})"
)
assert out["oldestIdx"] == 0
def test_compress_success_path_restores_paging_before_render():
"""Source lock: the compress success path restores paging before render.
The round-3 CORE reproduction was a successful `/compress` whose response
carried 100 full rows while the client stayed at
``_messagesTruncated=true, _oldestIdx=70``. The restore must appear
between the response assignment and ``renderMessages()`` — the same
ordering the settle and cancel paths already use.
"""
block = _compress_success_block()
assign_idx = block.find("S.messages=")
restore_idx = block.find("_messagesTruncated=")
offset_idx = block.find("_oldestIdx=")
render_idx = block.find("renderMessages()")
assert assign_idx >= 0, "compress success path must assign S.messages"
assert restore_idx > assign_idx, (
"_messagesTruncated must be restored from the response AFTER the "
"assignment — the preflight's bounded value otherwise survives"
)
assert offset_idx > assign_idx, "_oldestIdx must be restored from _messages_offset"
assert render_idx > restore_idx and render_idx > offset_idx, (
"paging must be restored BEFORE renderMessages() so the rendered rows "
"and any absolute index computation see the restored values"
)
# The restore must read the response's own signals, not hardcode false.
assert "_messages_truncated" in block, (
"the restore must read data.session._messages_truncated rather than "
"hardcoding _messagesTruncated=false"
)
assert "_messages_offset" in block, (
"the restore must read data.session._messages_offset"
)
def test_truncation_signal_selects_suffix_strategy_exclusively():
"""Source lock: the strategy is a truncation-gated ternary, never an `||`.
The pre-fix expression was ``_stagedMatchesCurrentPrefix ||
_stagedMatchesCurrentSuffix``: when repeated identical turns made both
comparisons succeed the prefix branch won and spliced at the wrong
offset. The gate is now ``_truncatedRecovery ? suffix : prefix``.
"""
block = _splice_block()
assert "_truncatedRecovery" in block, (
"the splice strategy must consult the truncation signal"
)
assert "_truncatedRecovery?_stagedMatchesCurrentSuffix" in block.replace(" ", ""), (
"a truncated recovery must select the SUFFIX comparison exclusively"
)
# The old defect: a bare `||` between the two comparisons.
assert "||_stagedMatchesCurrentSuffix" not in block.replace(" ", ""), (
"prefix and suffix comparisons must not be OR-ed; preferring the "
"prefix when both match splices repeated turns at the wrong offset"
)
@@ -0,0 +1,65 @@
"""#7754: a marked native-image state row that replays its sidecar row must consume that replay slot.
Before the fix, the row-id fast path for a marked native-image mirror row `continue`d without
advancing the replay index. The edited checkpoint then never read as consumed, and a rerun reply
that exists only in state.db was truncated. Master (no marker branch) keeps that reply.
"""
import api.models as models
def _merge(*, marked):
field = models._WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD
image_row = {
"role": "user",
"content": "look",
"timestamp": 860.0,
"_state_db_row_id": 42,
"api_content": "PAYLOAD-A",
}
sidecar = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
dict(image_row),
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
]
state_image_row = dict(image_row)
if marked:
state_image_row[field] = True
state = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
state_image_row,
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "u_old", "timestamp": 870.0},
{"role": "assistant", "content": "a_old", "timestamp": 871.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
{"role": "assistant", "content": "aE", "timestamp": 901.0},
]
return models.merge_session_messages_append_only(
sidecar,
state,
truncation_watermark=900.0,
truncation_boundary=861.0,
incoming_provenance="state_db",
)
def _contents(rows):
return [row.get("content") for row in rows]
def test_marked_image_row_keeps_state_only_reply_after_edited_checkpoint():
merged = _merge(marked=True)
assert _contents(merged) == ["u1", "a1", "look", "a_img", "uE", "aE"]
def test_marked_and_unmarked_image_rows_merge_identically():
assert _contents(_merge(marked=True)) == _contents(_merge(marked=False))
def test_removed_rows_between_boundary_and_watermark_stay_out():
contents = _contents(_merge(marked=True))
assert "u_old" not in contents
assert "a_old" not in contents
+1
View File
@@ -679,6 +679,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
function _scrollAfterMessageRender() {{}}
function _maybeRecoverVirtualizedBlankViewport() {{ return false; }}
function _updateMessageVirtualMeasurements() {{}}
function _resetMessageVirtualMeasurementBurst() {{}}
function postProcessRenderedMessages() {{}}
function _postProcessWithAnchorSuppression() {{}}
function _formatGatewayModelLabel() {{ return ''; }}
@@ -1,5 +1,6 @@
"""Regression coverage for notify_on_complete across a WebUI restart."""
import logging
import threading
import time
from types import SimpleNamespace
@@ -158,4 +159,103 @@ def test_recovery_is_fail_soft_without_agent(monkeypatch):
monkeypatch.setattr("builtins.__import__", fake_import)
assert bp.recover_processes_for_webui() == 0
assert bp._PROCESS_RECOVERY_DONE is False
assert bp._PROCESS_RECOVERY_DONE is False
def test_recovery_skips_process_whose_session_is_gone(monkeypatch, caplog):
"""A checkpointed process whose owner has been pruned (the resolver's
``KeyError(sid)`` contract) must be silently skipped, while a neighbouring
live process in the same recovery sweep still rebinds. Regression for
issue #7753: previously every vanished owner logged a full WARNING
traceback, indistinguishable from a real fault.
"""
calls = {"registered": []}
class FakeRegistry:
def recover_from_checkpoint(self):
return 2 # two checkpointed processes survive restart
def list_sessions(self):
return [
{"session_id": "proc_vanished", "detached": True},
{"session_id": "proc_live", "detached": True},
]
def get(self, process_id):
if process_id == "proc_vanished":
return _FakeProcessSession("vanished-session")
if process_id == "proc_live":
return _FakeProcessSession("live-session")
raise AssertionError(process_id)
fake_registry = FakeRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
monkeypatch.setattr(
bp,
"register_process_session",
lambda key, sid: calls["registered"].append((key, sid)),
)
def get_session(sid, metadata_only=False):
# Mirrors api.models._resolve_session_once's contract: missing
# sessions raise KeyError, NOT None.
if sid == "vanished-session":
raise KeyError(sid)
return SimpleNamespace(id=sid)
with caplog.at_level(logging.WARNING, logger="api.background_process"):
result = bp.recover_processes_for_webui(fake_registry, get_session)
assert result == 2
# The live process is rebound; the vanished one is silently skipped.
assert calls["registered"] == [("live-session", "live-session")]
# No WARNING emitted for the vanished-owner case.
warning_records = [
r for r in caplog.records if r.levelno >= logging.WARNING
]
assert warning_records == [], (
"vanished-session KeyError must not produce a WARNING, got: "
f"{[r.getMessage() for r in warning_records]}"
)
assert "Could not resolve recovered WebUI process" not in caplog.text
def test_recovery_still_warns_on_unexpected_resolve_failure(monkeypatch, caplog):
"""Non-KeyError failures from the resolver (e.g. ``OSError`` on an
unreadable sidecar) must keep entering the existing WARNING path. This
pins that the fix for #7753 is narrow and does not over-broaden the
silent-skip contract.
"""
class FakeRegistry:
def recover_from_checkpoint(self):
return 1
def list_sessions(self):
return [{"session_id": "proc_unexpected", "detached": True}]
def get(self, process_id):
assert process_id == "proc_unexpected"
return _FakeProcessSession("webui-session")
fake_registry = FakeRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
def get_session(sid, metadata_only=False):
raise OSError("sidecar unreadable")
with caplog.at_level(logging.WARNING, logger="api.background_process"):
result = bp.recover_processes_for_webui(fake_registry, get_session)
assert result == 1
warning_records = [
r for r in caplog.records if r.levelno >= logging.WARNING
]
assert any(
"Could not resolve recovered WebUI process 'proc_unexpected'" in r.getMessage()
for r in warning_records
), (
"OSError from the resolver must still emit a WARNING, got: "
f"{[r.getMessage() for r in warning_records]}"
)
+7 -6
View File
@@ -41,12 +41,13 @@ class TestRootWorkspaceUnblocked:
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
def test_split_guard_present(self):
src = read("api/streaming.py")
assert "'\\n\\n[Attached files:' in msg_text" in src, (
"base_text split must guard against missing '[Attached files:' "
"to avoid empty-string on plain messages"
)
def test_attached_files_suffix_is_optional(self):
from api.models import _strip_attached_files_marker
assert _strip_attached_files_marker("Plain message") == "Plain message"
assert _strip_attached_files_marker(
"Question\n\n[Attached files: photo.png]"
) == "Question"
# ── Group B: custom_providers visibility ─────────────────────────────────────
+3 -1
View File
@@ -219,7 +219,9 @@ class TestCancelledTurnPersistenceGuards:
assert "const _applyCancelSessionPayload=(sessionPayload)=>" in block
assert "const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;" in block
assert "if(_applyCancelSessionPayload(_cancelSessionPayload)) return;" in block
assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);" in block
# #7310/#7625: the HTTP fallback is a bounded tail now — the full
# transcript was being re-walked/re-redacted on every cancel recovery.
assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);" in block
assert block.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") < block.index("const data=await api("), (
"Cancel handler must apply the terminal SSE session payload before falling back "
"to /api/session so captured _partial reasoning/tool rows are visible immediately."
+8 -1
View File
@@ -1068,6 +1068,7 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
messages=prior_messages,
context_messages=prior_messages,
)
_session.pending_user_source = "webui"
revisions = []
class AuthThenRecoverAgent:
@@ -1080,8 +1081,14 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
self._last_error = None
self.stream_delta_callback = _kwargs.get("stream_delta_callback")
def run_conversation(self, **kwargs):
def run_conversation(self, persist_user_timestamp=None, **kwargs):
type(self).runs += 1
assert persist_user_timestamp == 10.0
persisted = Session.load(sid)
assert persisted._webui_pending_user_timestamp_identity == (
stream_id,
persist_user_timestamp,
)
revisions.append(kwargs["conversation_history_revision"])
history = list(kwargs.get("conversation_history") or [])
if type(self).runs == 1:
@@ -5,6 +5,7 @@ import pathlib
import urllib.error
import urllib.request
from api import config as webui_config
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -42,6 +43,16 @@ def make_session(created, title):
return sid
def test_settings_file_path_can_be_scoped_per_instance(tmp_path, monkeypatch):
default_state_dir = tmp_path / "default-state"
monkeypatch.delenv("HERMES_WEBUI_SETTINGS_FILE", raising=False)
assert webui_config._resolve_settings_file(default_state_dir) == default_state_dir / "settings.json"
staging_settings = tmp_path / "staging" / "settings.json"
monkeypatch.setenv("HERMES_WEBUI_SETTINGS_FILE", str(staging_settings))
assert webui_config._resolve_settings_file(default_state_dir) == staging_settings.resolve()
def test_pin_limit_setting_is_exposed_and_wired_through_ui():
assert '"pinned_sessions_limit": 3' in CONFIG_PY
assert '"pinned_sessions_limit": (1, 99)' in CONFIG_PY
@@ -160,6 +160,412 @@ def test_colon_bearing_missing_fallback_keeps_authoritative_provider():
}
# Non-default model of a named custom_providers[] entry. The catalog render
# paths build the option with the qualified "@custom:<slug>:<model>" value and
# NEVER set data-model (that attribute is only set by the fallback injection
# path _ensureModelOptionInDropdown). Regression for #6884: the returned model
# id must be the stripped bare name ("sol"), not the raw dropdown value.
_NON_DEFAULT_CUSTOM_DRIVER = r"""
const fs = require('fs');
const uiSrc = fs.readFileSync(process.argv[1], 'utf8');
function extractFunction(source, name) {
const marker = 'function ' + name + '(';
const start = source.indexOf(marker);
if (start < 0) throw new Error('not found: ' + name);
const brace = source.indexOf('{', source.indexOf(')', start));
let depth = 0;
for (let i = brace; i < source.length; i++) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error('unterminated: ' + name);
}
eval([
'_getOptionProviderId',
'_providerFromModelValue',
'_modelStateForSelect',
].map(name => extractFunction(uiSrc, name)).join('\n'));
globalThis.document = {
createElement(tag) {
return {
tagName: String(tag).toUpperCase(),
value: '',
textContent: '',
dataset: {},
parentElement: null,
};
},
};
globalThis.getModelLabel = value => String(value || '');
globalThis.window = { _configuredModelBadges: {} };
const group = {tagName: 'OPTGROUP', dataset: {provider: 'custom:hetmer.net'}};
const luna = {
value: 'luna',
textContent: 'luna',
dataset: {},
parentElement: group,
};
const sol = {
value: '@custom:hetmer.net:sol',
textContent: 'sol',
dataset: {}, // no data-model — normal catalog render path
parentElement: group,
};
// Colon-bearing model id on the SAME normal catalog render path (no
// data-model). Regression for the #6884 re-gate: the prefix to strip must
// come from the option metadata's authoritative provider (custom:hetmer.net),
// NOT from the last-colon value reparse (which would yield the malformed
// "custom:hetmer.net:model-a" prefix and truncate the model to "free").
const modelAFree = {
value: '@custom:hetmer.net:model-a:free',
textContent: 'model-a:free',
dataset: {}, // no data-model — normal catalog render path
parentElement: group,
};
const localhostGroup = {tagName: 'OPTGROUP', dataset: {provider: 'custom:localhost:11434'}};
const llama = {
value: '@custom:localhost:11434:llama3.2',
textContent: 'llama3.2',
dataset: {},
parentElement: localhostGroup,
};
const select = {
id: 'modelSelect',
options: [luna, sol, modelAFree, llama],
querySelectorAll() { return []; },
get selectedOptions() { return [sol]; },
get value() { return sol.value; },
set value(value) {},
};
process.stdout.write(JSON.stringify({
nonDefault: _modelStateForSelect(select, '@custom:hetmer.net:sol'),
defaultUnprefixed: _modelStateForSelect(select, 'luna'),
colonBearingModel: _modelStateForSelect(select, '@custom:hetmer.net:model-a:free'),
localhostEndpoint: _modelStateForSelect(select, '@custom:localhost:11434:llama3.2'),
missingOptionCustomInput: _modelStateForSelect(select, '@custom:localhost:11434:mistral-custom'),
}));
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_non_default_named_custom_provider_model_strips_qualified_prefix():
assert NODE is not None
result = subprocess.run(
[NODE, "-e", _NON_DEFAULT_CUSTOM_DRIVER, str(UI_JS)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
# #6884: the normally-rendered option has no data-model, so the qualified
# "@custom:hetmer.net:sol" value must be stripped to the bare model id.
assert payload["nonDefault"] == {
"model": "sol",
"model_provider": "custom:hetmer.net",
}
# Sanity: the unprefixed default option still returns its value as-is.
assert payload["defaultUnprefixed"] == {
"model": "luna",
"model_provider": "custom:hetmer.net",
}
# Colon-bearing model id on the same normal catalog render path: the strip
# prefix comes from the option metadata provider (custom:hetmer.net), so
# the full "model-a:free" survives — not just the "free" suffix (re-gate).
assert payload["colonBearingModel"] == {
"model": "model-a:free",
"model_provider": "custom:hetmer.net",
}
# Endpoint-style custom provider (e.g. host:port derived from base_url authority):
# preserves full custom:localhost:11434 for both catalog and missing-option paths.
assert payload["localhostEndpoint"] == {
"model": "llama3.2",
"model_provider": "custom:localhost:11434",
}
assert payload["missingOptionCustomInput"] == {
"model": "mistral-custom",
"model_provider": "custom:localhost:11434",
}
# Round-trip: send -> persist -> restore for a colon-bearing named
# custom-provider model. The send path emits the bare id ("model-a:free");
# on restore, _findModelInDropdown reverse-matches the bare id back to the
# qualified catalog option ("@custom:hetmer.net:model-a:free") and the
# dropdown re-renders. _isSelectedModelRow must normalize both sides to the
# same model/provider identity so exactly ONE row renders as active /
# "Selected" — with the model id intact, not truncated.
_ROUND_TRIP_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) { return this._qs ? this._qs[selector] || null : null; },
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = { id: 'modelSelect', children: [], options: [], _value: selectedValue || '' };
Object.defineProperty(sel, 'value', {get(){return sel._value;}, set(v){sel._value=String(v||'');}});
Object.defineProperty(sel, 'selectedOptions', {get(){const o=sel.options.find(x=>x.value===sel._value);return o?[o]:[];}});
sel.appendChild=function(option){option.parentElement=null;sel.options.push(option);};
sel.querySelectorAll=function(){return [];};
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
for (const model of group.models || []) og.appendChild(makeOption(model.id, model.label || model.id, og));
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
function snapshot(dd) {
const out = [];
const walk = (node) => {
for (const child of (node.children || [])) {
out.push({
className: child.className,
textContent: child.textContent,
html: child._innerHTML || '',
});
if (child.children && child.children.length) walk(child);
}
};
walk(dd);
return out;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function syncModelChip() {}
function _refreshOpenModelDropdown() {}
function _deduplicateModelPickerOptions() { return 0; }
async function selectModelFromDropdown(value, provider) {
_ensureModelOptionInDropdown(value, modelSelect, provider);
window.__picked=_modelStateForSelect(modelSelect,modelSelect.value);
}
for (const name of [
'_modelPickerOptionIdentity',
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'_isEquivalentConfiguredModelEntry',
'_getOptionProviderId',
'_modelStateForSelect',
'_findModelInDropdown',
'_applyModelToDropdown',
'_ensureModelOptionInDropdown',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
// Send path: the picked state carries the bare, intact model id.
const sent = _modelStateForSelect(modelSelect, '@custom:hetmer.net:model-a:free');
// Restore path: _findModelInDropdown reverse-matches the bare id to the
// qualified catalog option, the picker restores sel.value to it and
// re-renders the dropdown.
const restoreValue = _findModelInDropdown(sent.model, modelSelect, sent.model_provider);
modelSelect.value = restoreValue;
renderModelDropdown();
const rows = snapshot(dropdown).filter(n => String(n.className || '').includes('model-opt'));
const activeRows = rows.filter(n => String(n.className || '').includes(' active'));
process.stdout.write(JSON.stringify({
sent,
restoreValue,
activeCount: activeRows.length,
activeRow: activeRows.length ? {
className: activeRows[0].className,
text: activeRows[0].textContent,
html: activeRows[0].html,
} : null,
}));
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_colon_bearing_custom_provider_round_trip_restores_selected_row(tmp_path):
driver = tmp_path / "round_trip_driver.js"
driver.write_text(_ROUND_TRIP_DRIVER, encoding="utf-8")
payload = {
"groups": [
{
"provider": "hetmer.net",
"provider_id": "custom:hetmer.net",
"models": [
{"id": "luna", "label": "luna"},
{"id": "@custom:hetmer.net:model-a:free", "label": "model-a:free"},
],
}
],
"configuredBadges": {},
"selectedValue": "luna",
}
assert NODE is not None
result = subprocess.run(
[NODE, str(driver), str(UI_JS), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
actual = json.loads(result.stdout)
# Send path: bare model id, intact (NOT truncated to "free").
assert actual["sent"] == {
"model": "model-a:free",
"model_provider": "custom:hetmer.net",
}
# Restore path: the bare id maps back to the qualified catalog option.
assert actual["restoreValue"] == "@custom:hetmer.net:model-a:free"
# Exactly one row renders as active/"Selected", with the intact model id.
assert actual["activeCount"] == 1, actual
assert "model-opt-badge--selected" in actual["activeRow"]["html"]
assert "model-a:free" in actual["activeRow"]["html"]
assert ">free<" not in actual["activeRow"]["html"].replace("model-a:free", "")
_RENDERED_CLICK_DRIVER = r"""
const fs = require('fs');
+88
View File
@@ -8,6 +8,8 @@ import sys
import types
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
@@ -170,3 +172,89 @@ def test_cron_model_picker_marks_loaded_only_after_successful_population():
catch_block = body.split("} catch (e)", 1)[1]
assert "sel.dataset.loaded = '1'" in try_block
assert "sel.dataset.loaded = '1'" not in catch_block
def test_cron_update_preserves_provider_only_pin_when_model_cleared():
"""Clearing the picker to "Default" must not wipe a provider-only pin.
#4030 intentionally made "clearing the picker = no overrides", which is
correct for model+provider pins but silently destroyed provider-only
pins (jobs that pin a provider with no model, e.g. self-hosted LLM
endpoints). The combined picker cannot represent that state, so
saveCronForm must preserve prev.provider when the job carried no model
AND the user never changed the picker; a deliberate return to Default
must clear it.
"""
save_body = _function_body("saveCronForm")
helper_body = _function_body("_cronProviderForClear")
# The clear branch delegates to the helper and records picker changes.
assert "_cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched)" in save_body
assert "preserveProvider" not in save_body
# The helper distinguishes an explicit clear (pickerTouched) from an
# untouched provider-only pin.
assert "if (pickerTouched) return null" in helper_body
assert "prev.model == null" in helper_body
# The form entry points reset the touched flag.
for fn in ("openCronCreate", "openCronEdit", "duplicateCurrentCron"):
assert "_cronModelPickerTouched = false" in _function_body(fn)
# The picker records deliberate changes.
pop_body = _function_body("_populateCronFormModelSelect")
assert "_cronModelPickerTouched = true" in pop_body
def test_cron_provider_for_clear_behavior():
"""Exercise _cronProviderForClear through node for both pin shapes."""
import shutil
import subprocess
node = shutil.which("node")
if node is None:
pytest.skip("node not on PATH")
# Locate the full declaration (signature + body) of the helper.
src = PANELS_JS
start = src.find("function _cronProviderForClear(")
assert start != -1, "helper declaration not found"
brace = src.find("{", start)
depth = 0
end = None
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
end = i + 1
break
decl = src[start:end]
driver = f"""
{decl}
const cases = [
['provider_only_untouched', {{model: null, provider: 'custom:llama-swap'}}, false],
['provider_only_touched', {{model: null, provider: 'custom:llama-swap'}}, true],
['model_pin_touched', {{model: 'gpt-5', provider: 'openai'}}, true],
['no_override_untouched', {{model: null, provider: null}}, false],
['no_prev', null, false],
];
const out = {{}};
for (const [name, detail, touched] of cases) out[name] = _cronProviderForClear(detail, touched) ?? null;
process.stdout.write(JSON.stringify(out));
"""
proc = subprocess.run([node, "-e", driver], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, f"node driver failed: {proc.stderr}"
r = json.loads(proc.stdout)
# Untouched picker on a provider-only job: the pin survives.
assert r["provider_only_untouched"] == "custom:llama-swap"
# Deliberate return to Default: "Default = no overrides" wins.
assert r["provider_only_touched"] is None
# Model-pinned job, explicit clear: provider removed too.
assert r["model_pin_touched"] is None
# No overrides stored, untouched picker: nothing to preserve.
assert r["no_override_untouched"] is None
# No pre-form snapshot (create form): no provider.
assert r["no_prev"] is None
+174
View File
@@ -18,6 +18,8 @@ import time
import urllib.error
import urllib.request
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
from tests._pytest_port import BASE
@@ -500,6 +502,178 @@ def test_compression_chain_collapses_to_latest_tip_in_sidebar():
post('/api/settings', {'show_cli_sessions': False})
@pytest.mark.parametrize('reset_marker_origin', ('creation', 'reopen'))
def test_reopen_then_recompress_reset_child_stays_independent_across_readers(
tmp_path, monkeypatch, reset_marker_origin
):
"""Real Agent lifecycle: reset child, ``reopen_session()``, later compression.
Driven through the installed ``SessionDB`` so the rows carry exactly the
production shape. ``creation`` mirrors the gateway creating the reset child
with ``_reset_from`` in its INSERT; ``reopen`` mirrors a marker-less legacy
reset child that ``reopen_session()`` durably stamps. Either way the parent
ends up ``end_reason='compression'`` with a live reset child, and every
WebUI reader must agree: the reset conversation stays visible and its
transcript is never prefixed with the parent's.
The WebUI readers are the contract under test. The Agent's own
``find_live_compression_child`` / ``get_compression_tip`` verdict is only
cross-checked when the installed Agent already binds ``_reset_from`` in
its non-continuation child predicate; an older Agent whose predicate
predates that marker still has to drive the same fixture without turning
this WebUI test red.
"""
hermes_state = pytest.importorskip('hermes_state')
# SessionDB lazily imports sibling Agent modules (e.g. hermes_logging);
# keep the Agent checkout importable even after the suite's sys.path
# isolation guard restored an earlier snapshot.
monkeypatch.syspath_prepend(str(pathlib.Path(hermes_state.__file__).resolve().parent))
import api.models as models
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
from api.gateway_watcher import GatewayWatcher
from api.models import get_state_db_session_messages
parent_id = 'recompressed_reset_parent'
child_id = 'reset_child_conversation'
routing = {'session_key': 'telegram:reset-peer', 'user_id': 'peer', 'chat_id': 'chat-1'}
db_path = tmp_path / 'state.db'
db = hermes_state.SessionDB(db_path=db_path)
try:
db.create_session(parent_id, source='telegram', model='test-model', **routing)
db.append_messages_batch(
parent_id,
[
{'role': 'user', 'content': 'before reset', 'timestamp': 100.0},
{'role': 'assistant', 'content': 'answer before reset', 'timestamp': 101.0},
],
)
assert db.promote_to_session_reset(parent_id) is True
child_model_config = (
{'_reset_from': parent_id} if reset_marker_origin == 'creation' else None
)
db.create_session(
child_id,
source='telegram',
model='test-model',
parent_session_id=parent_id,
model_config=child_model_config,
**routing,
)
db.append_messages_batch(
child_id,
[
{'role': 'user', 'content': 'after reset', 'timestamp': 200.0},
{'role': 'assistant', 'content': 'answer after reset', 'timestamp': 201.0},
],
)
if reset_marker_origin == 'reopen':
assert db.get_session(child_id)['model_config'] is None
# The user resumes the old conversation: reopen_session() durably
# stamps _reset_from on the legacy reset child before clearing the
# parent's reset boundary. Should an installed Agent predate that
# stamping, apply the same schema the upstream Agent writes
# (``model_config._reset_from = parent_session_id``) so the rows still
# carry the production shape the WebUI readers are contracted against.
db.reopen_session(parent_id)
expected_child_config = {'_reset_from': parent_id}
if json.loads(db.get_session(child_id)['model_config'] or '{}') != expected_child_config:
with sqlite3.connect(str(db_path)) as conn:
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(json.dumps(expected_child_config), child_id),
)
assert json.loads(db.get_session(child_id)['model_config']) == expected_child_config
reopened = db.get_session(parent_id)
assert reopened['ended_at'] is None and reopened['end_reason'] is None
db.append_messages_batch(
parent_id,
[{'role': 'user', 'content': 'resumed parent', 'timestamp': 300.0}],
)
# ...and that resumed parent is later closed by compression.
db.end_session(parent_id, 'compression')
assert db.get_session(parent_id)['end_reason'] == 'compression'
# Agent cross-check, only when the installed Agent's child predicate
# already binds _reset_from: the reset child is not a compression
# continuation there either. Older Agents are not this test's subject.
agent_filter_sql = str(
getattr(hermes_state.SessionDB, '_NON_CONTINUATION_CHILD_FILTER_SQL', '')
)
if '_reset_from' in agent_filter_sql:
assert db.find_live_compression_child(parent_id) is None
assert db.get_compression_tip(parent_id) == parent_id
finally:
db.close()
with sqlite3.connect(str(db_path)) as conn:
conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?",
('Reset parent conversation', parent_id),
)
conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?",
('Reset child conversation', child_id),
)
# (a) Sidebar projection: both rows visible; the child keeps its own
# title and is a plain child_session, never a hidden continuation.
projected = read_importable_agent_session_rows(db_path, limit=None, exclude_sources=None)
projected_by_id = {row['id']: row for row in projected}
assert set(projected_by_id) == {parent_id, child_id}
child_row = projected_by_id[child_id]
assert child_row['title'] == 'Reset child conversation'
assert child_row['relationship_type'] == 'child_session'
assert child_row['parent_session_id'] == parent_id
assert '_lineage_root_id' not in child_row
parent_row = projected_by_id[parent_id]
assert parent_row['title'] == 'Reset parent conversation'
assert '_lineage_tip_id' not in parent_row
assert '_compression_segment_count' not in parent_row
# (b) Lineage metadata and report.
metadata = read_session_lineage_metadata(db_path, {parent_id, child_id})
assert metadata[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in metadata[child_id]
assert '_lineage_tip_id' not in metadata.get(parent_id, {})
child_report = read_session_lineage_report(db_path, child_id)
assert child_report['lineage_key'] == child_id
assert child_report['tip_session_id'] == child_id
assert child_report['total_segments'] == 1
parent_report = read_session_lineage_report(db_path, parent_id)
assert parent_report['tip_session_id'] == parent_id
assert parent_report['total_segments'] == 1
assert [child['session_id'] for child in parent_report['children']] == [child_id]
assert parent_report['children'][0]['role'] == 'child_session'
# (c) Watcher publication: the SSE payload carries both conversations
# with their own titles.
watcher = GatewayWatcher(state_db_path=db_path)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
event = subscriber.get_nowait()
published = {row['session_id']: row for row in event['sessions']}
assert set(published) == {parent_id, child_id}
assert published[child_id]['title'] == 'Reset child conversation'
assert published[parent_id]['title'] == 'Reset parent conversation'
# (d) Transcript stitching never prepends the parent's transcript.
monkeypatch.setattr(models, '_active_state_db_path', lambda: db_path)
stitched = get_state_db_session_messages(child_id, stitch_continuations=True)
assert [message['content'] for message in stitched] == [
'after reset',
'answer after reset',
]
parent_messages = get_state_db_session_messages(parent_id, stitch_continuations=True)
assert [message['content'] for message in parent_messages] == [
'before reset',
'answer before reset',
'resumed parent',
]
def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling():
"""A later-started stale sibling must not hide a deeper active branch."""
conn = _ensure_state_db()
+325 -6
View File
@@ -21,8 +21,14 @@ tests pinning the contract:
tab that transitions to hidden via the ``visibilitychange`` hook.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SESSION_OPS = (REPO_ROOT / "api" / "session_ops.py").read_text(encoding="utf-8")
@@ -89,9 +95,7 @@ def test_hidden_poll_hits_session_status_and_attaches_as_replay():
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1
# #7299 added a 404/410 terminal-stop branch to the response handler.
# The window must cover the original fetch chain + the new branch.
body = MESSAGES_JS[start:start + 8000]
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
assert "api/session/status?session_id=" in body
assert "d.active_stream_id" in body
# attaches as replay (recovered=true) — turn is already mid-flight
@@ -174,9 +178,7 @@ def test_poll_stops_only_when_attach_succeeds():
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
# Window 2400: the multi-pane follow-up adds an explanatory comment block
# before the attach call, pushing it past a narrower slice.
body = MESSAGES_JS[start:start + 8000]
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
# Stop the poll only on a true attach (the false branch keeps polling within
# the bounded-retry budget rather than stopping).
@@ -185,3 +187,320 @@ def test_poll_stops_only_when_attach_succeeds():
# The bounded-retry give-up: a never-current pane stops after the budget.
assert "_sessionStreamHiddenPollFalseCount" in body
assert "_SESSION_STREAM_HIDDEN_POLL_MAX_FALSE" in body
# ── Missing-session responses preserve recoverable profile ownership ──────
NODE = shutil.which("node")
@pytest.fixture(scope="module")
def hidden_poll_results():
"""Execute the real poll and visibility handler with deterministic responses."""
if NODE is None:
pytest.skip("node not available")
start = MESSAGES_JS.index("function _startHiddenActiveStreamPoll(sid)")
poll_end = MESSAGES_JS.index("function _chatStreamActiveForSession(sid)", start)
stream_end = MESSAGES_JS.index("function stopSessionStream()", poll_end)
functions = {
"poll": MESSAGES_JS[start:poll_end],
"session": MESSAGES_JS[poll_end:stream_end],
}
driver = textwrap.dedent(
r"""
const functions = JSON.parse(process.argv[1]);
let _sessionStreamHiddenPollTimer = null;
let _sessionStreamHiddenPollSid = null;
let _sessionStreamHiddenPollFalseStreamId = null;
let _sessionStreamHiddenPollFalseCount = 0;
let _sessionStreamHiddenSid = null;
let _sessionStreamSessionId = null;
let _sessionEventSource = null;
let _sessionStreamReconnectTimer = null;
const _SESSION_STREAM_HIDDEN_POLL_MAX_FALSE = 20;
let visibilityChange = null;
const document = {
hidden: true,
addEventListener: (type, listener) => {
if (type === 'visibilitychange') visibilityChange = listener;
},
};
const S = {
activeStreamId: null,
messages: [],
session: {session_id: 'session-a', message_count: 0},
};
const _apiUrl = value => value;
let attachCalls = 0;
const _attachServerInitiatedStream = () => { attachCalls += 1; return true; };
let intervalFn = null;
let intervalSeq = 0;
let fetchCalls = 0;
let eventSourceCalls = 0;
class EventSource {
constructor() {
eventSourceCalls += 1;
this.readyState = 1;
}
addEventListener() {}
close() { this.readyState = 2; }
}
globalThis.setInterval = fn => {
intervalFn = fn;
return ++intervalSeq;
};
globalThis.clearInterval = () => { intervalFn = null; };
eval(functions.poll);
function stopSessionStream() {
if (_sessionEventSource) _sessionEventSource.close();
_sessionEventSource = null;
_sessionStreamSessionId = null;
_stopHiddenActiveStreamPoll();
}
eval(functions.session);
const flush = () => new Promise(resolve => setImmediate(resolve));
const response = status => ({
ok: status >= 200 && status < 300,
status,
json: async () => ({active_stream_id: null}),
});
async function settle() {
await flush();
await flush();
}
function reset() {
stopSessionStream();
_sessionStreamHiddenSid = null;
document.hidden = true;
document._hermesSessionStreamVisibilityHook = false;
visibilityChange = null;
intervalFn = null;
fetchCalls = 0;
eventSourceCalls = 0;
attachCalls = 0;
}
async function runStatus(status, reject = false) {
reset();
_sessionStreamHiddenSid = 'session-a';
globalThis.fetch = () => {
fetchCalls += 1;
return reject ? Promise.reject(new Error('offline')) : Promise.resolve(response(status));
};
_startHiddenActiveStreamPoll('session-a');
await settle();
const nextTick = intervalFn;
if (nextTick) {
nextTick();
await settle();
}
return {
fetchCalls,
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runStaleResponse(status, sameSession = false) {
reset();
let resolveA;
globalThis.fetch = url => {
fetchCalls += 1;
if (fetchCalls === 1) {
return new Promise(resolve => { resolveA = resolve; });
}
return Promise.resolve(response(200));
};
_sessionStreamHiddenSid = 'session-a';
_startHiddenActiveStreamPoll('session-a');
const oldTick = intervalFn;
const replacementSid = sameSession ? 'session-a' : 'session-b';
_sessionStreamHiddenSid = replacementSid;
_startHiddenActiveStreamPoll(replacementSid);
await settle();
resolveA(response(status));
await settle();
oldTick();
await settle();
return {
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runVisibilityRecovery(status) {
reset();
globalThis.fetch = () => {
fetchCalls += 1;
return Promise.resolve(response(status));
};
startSessionStream('session-a');
await settle();
if (!visibilityChange) throw new Error('visibilitychange listener was not installed');
document.hidden = false;
visibilityChange();
await settle();
return {
eventSourceCalls,
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runLateJson() {
reset();
let resolveBody;
globalThis.fetch = () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.resolve({ok: true, status: 200,
json: () => new Promise(resolve => { resolveBody = resolve; })});
return Promise.resolve(response(200));
};
_sessionStreamHiddenSid = 'session-a';
_startHiddenActiveStreamPoll('session-a');
await settle();
_startHiddenActiveStreamPoll('session-a');
resolveBody({active_stream_id: 'old-stream'});
await settle();
return {attachCalls, running: intervalFn !== null};
}
async function runSequence(statuses) {
reset();
const states = [];
globalThis.fetch = () => {
const status = statuses[fetchCalls++];
if (status === -1) return Promise.reject(new Error('offline'));
if (status === 'active') return Promise.resolve({ok: true, status: 200,
json: async () => ({active_stream_id: 'live-a'})});
return Promise.resolve(response(status));
};
startSessionStream('session-a');
for (let i = 0; i < statuses.length; i++) {
if (i && intervalFn) intervalFn();
await settle();
states.push({running: intervalFn !== null, hiddenSid: _sessionStreamHiddenSid});
}
document.hidden = false;
visibilityChange();
await settle();
return {states, fetchCalls, eventSourceCalls, attachCalls};
}
(async () => {
const result = {
missing404: await runStatus(404),
missing410: await runStatus(410),
server500: await runStatus(500),
profile409: await runStatus(409),
auth401: await runStatus(401),
forbidden403: await runStatus(403),
rate429: await runStatus(429),
offline: await runStatus(0, true),
idle200: await runStatus(200),
stale404: await runStaleResponse(404),
stale410: await runStaleResponse(410),
sameSession410: await runStaleResponse(410, true),
visible404: await runVisibilityRecovery(404),
visible410: await runVisibilityRecovery(410),
repeated404: await runSequence([404, 404, 404, 404]),
recoveredProfile: await runSequence([404, 'active']),
recoveredKnownProfile: await runSequence([409, 'active']),
reset200: await runSequence([404, 404, 200, 404, 404, 404]),
reset500: await runSequence([404, 404, 500, 404, 404, 404]),
resetOffline: await runSequence([404, 404, -1, 404, 404, 404]),
reset409: await runSequence([404, 404, 409, 404, 404, 404]),
lateJson: await runLateJson(),
};
process.stdout.write(JSON.stringify(result));
})().catch(error => {
console.error(error);
process.exit(1);
});
"""
)
proc = subprocess.run(
[NODE, "-e", driver, json.dumps(functions)],
capture_output=True,
text=True,
timeout=10,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_hidden_poll_transient_404_preserves_visibility_recovery(hidden_poll_results):
result = hidden_poll_results
assert result["visible404"]["eventSourceCalls"] == 1
assert result["missing404"] == {
"fetchCalls": 2, "running": True, "pollSid": "session-a", "hiddenSid": "session-a",
}
assert result["recoveredProfile"]["attachCalls"] == 1
assert result["recoveredKnownProfile"]["attachCalls"] == 1
def test_hidden_poll_repeated_404_is_bounded_but_can_resume(hidden_poll_results):
result = hidden_poll_results["repeated404"]
assert [s["running"] for s in result["states"]] == [True, True, False, False]
assert all(s["hiddenSid"] == "session-a" for s in result["states"])
assert result["fetchCalls"] == 3
assert result["eventSourceCalls"] == 1
@pytest.mark.parametrize("key", ["reset200", "reset500", "resetOffline", "reset409"])
def test_hidden_poll_404_budget_requires_consecutive_responses(hidden_poll_results, key):
result = hidden_poll_results[key]
assert [s["running"] for s in result["states"]] == [True] * 5 + [False]
assert result["eventSourceCalls"] == 1
def test_hidden_poll_410_stops_and_clears_resume_owner(hidden_poll_results):
result = hidden_poll_results
assert result["visible410"] == {
"eventSourceCalls": 0,
"running": False,
"pollSid": None,
"hiddenSid": None,
}
assert result["missing410"] == {
"fetchCalls": 1,
"running": False,
"pollSid": None,
"hiddenSid": None,
}
def test_hidden_poll_transient_failures_and_idle_remain_retryable(hidden_poll_results):
result = hidden_poll_results
for key in ("server500", "offline", "idle200", "profile409", "auth401", "forbidden403", "rate429"):
assert result[key]["fetchCalls"] == 2
assert result[key]["running"] is True
assert result[key]["pollSid"] == "session-a"
assert result[key]["hiddenSid"] == "session-a"
@pytest.mark.parametrize("key,sid", [
("stale404", "session-b"), ("stale410", "session-b"), ("sameSession410", "session-a"),
])
def test_hidden_poll_stale_response_and_tick_cannot_stop_replacement(hidden_poll_results, key, sid):
assert hidden_poll_results[key] == {
"running": True,
"pollSid": sid,
"hiddenSid": sid,
}
def test_hidden_poll_late_json_cannot_attach_into_replacement(hidden_poll_results):
assert hidden_poll_results["lateJson"] == {"attachCalls": 0, "running": True}
+303 -1
View File
@@ -1060,6 +1060,305 @@ def test_load_session_discards_cursor_only_inflight_before_reattach():
assert 0 <= guard_pos < inflight_branch_pos
def test_user_only_inflight_must_not_seed_nonzero_replay_floor():
"""#7640: a plain user row must not authorize a nonzero replay floor.
After a mid-stream SSE drop in transparent mode the client re-attaches with
`replay=1&after_seq=<cursor>`. When the cached INFLIGHT entry kept the
optimistic user row plus `lastRunJournalSeq` but lost the live assistant
projection, that cursor told the server to skip every journal event that
rebuilds the message body: only the tail (often just `stream_end`) came back
and the settled footer painted over a blank message until a manual reload.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
"""
const userOnly={
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(_inflightCanSeedJournalReplay(userOnly), false);
assert.strictEqual(_runJournalReplayFloorForInflight(userOnly), 0);
assert.strictEqual(_runJournalReplayEventIdForInflight(userOnly), '');
// The floor helper reports, it does not mutate the recovery object.
assert.strictEqual(userOnly.lastRunJournalSeq, 206);
// A user row with a populated but assistant-less transcript is still user-only.
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[{role:'user', content:'hi'}, {role:'assistant', content:''}],
lastRunJournalSeq:206,
}),
0
);
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[{role:'user', content:'hi'}, {role:'assistant', content:' '}],
lastRunJournalSeq:206,
}),
0
);
// Positive inverse: any recoverable live output keeps the cursor.
const recovering=[
{lastAssistantText:'partial answer'},
{lastReasoningText:'thinking...'},
{liveTurnHtml:'<div>projected turn</div>'},
{toolCalls:[{name:'read_file'}]},
{activityBurstAnchors:[{seq:12}]},
{anchorActivityScene:{activity_rows:[{kind:'tool'}]}},
{messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer', _live:true}]},
];
for (const extra of recovering) {
const state={messages:[{role:'user', content:'p'}], ...extra};
state.lastRunJournalSeq=206;
state.lastRunJournalEventId='stream-e75e:206';
assert.strictEqual(
_runJournalReplayFloorForInflight(state),
206,
'recoverable live output must keep the cursor: '+JSON.stringify(extra)
);
assert.strictEqual(_runJournalReplayEventIdForInflight(state), 'stream-e75e:206');
}
// #7651: an ESTABLISHED conversation copies the whole transcript, so the last
// assistant row is the previous turn's reply. Historical assistant content must
// never authorize the stale cursor that belongs to the still-running turn.
const established={
messages:[
{role:'user', content:'first question'},
{role:'assistant', content:'the previous turn answer'},
{role:'user', content:'current question'},
],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(
_inflightCanSeedJournalReplay(established),
false,
'a historical assistant row must not seed the replay floor'
);
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
assert.strictEqual(_runJournalReplayEventIdForInflight(established), '');
// Even after the latest user boundary, evidence must be LIVE output: a
// settled/non-live assistant row at the current position still yields zero.
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[
{role:'user', content:'current question'},
{role:'assistant', content:'settled reply without _live'},
],
lastRunJournalSeq:206,
}),
0
);
// Degenerate inputs must stay on the zero floor.
assert.strictEqual(_runJournalReplayFloorForInflight(null), 0);
assert.strictEqual(_runJournalReplayFloorForInflight({}), 0);
assert.strictEqual(_runJournalReplayFloorForInflight({lastRunJournalSeq:206, messages:[]}), 0);
assert.strictEqual(
_runJournalReplayFloorForInflight({lastAssistantText:'partial', lastRunJournalSeq:-4}),
0
);
assert.strictEqual(_runJournalReplayEventIdForInflight({}), '');
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
def test_reattach_discards_replay_cursor_without_live_assistant_state():
"""#7640: validate the recovery object immediately before reattach.
Trusting the object's existence (rather than its contents) is what let a
cursor-only cache reach the wire as a replay floor. Reattach now drops the
cursor so the journal replays from zero, which is the path a manual reload
already proves out, while a cache that still holds live assistant output
keeps its cursor and avoids re-rendering the whole journal.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_normalizeInflightReplayCursorForReattach"),
"""
const cursorOnly={
streamId:'stream-e75e',
reattach:true,
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(
_normalizeInflightReplayCursorForReattach(cursorOnly),
cursorOnly,
'must return the same object so the caller keeps mutating the live cache'
);
assert.strictEqual(cursorOnly.lastRunJournalSeq, 0);
assert.strictEqual(cursorOnly.lastRunJournalEventId, '');
assert.strictEqual(_runJournalReplayFloorForInflight(cursorOnly), 0);
assert.strictEqual(
cursorOnly.messages.length,
1,
'the optimistic user row must survive so pending dedupe still works'
);
const recovering={
streamId:'stream-e75e',
reattach:true,
messages:[{role:'user', content:'p'}],
lastAssistantText:'partial answer',
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
_normalizeInflightReplayCursorForReattach(recovering);
assert.strictEqual(recovering.lastRunJournalSeq, 206);
assert.strictEqual(recovering.lastRunJournalEventId, 'stream-e75e:206');
// #7651: a historical assistant row must not protect the cursor either, or an
// established conversation settles with a blank body over a painted footer.
const established={
streamId:'stream-e75e',
reattach:true,
messages:[
{role:'user', content:'first question'},
{role:'assistant', content:'the previous turn answer'},
{role:'user', content:'current question'},
],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
_normalizeInflightReplayCursorForReattach(established);
assert.strictEqual(established.lastRunJournalSeq, 0);
assert.strictEqual(established.lastRunJournalEventId, '');
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
assert.strictEqual(
established.messages.length,
3,
'the transcript must survive; only the cursor halves are dropped'
);
const noCursor={streamId:'stream-e75e', messages:[{role:'user', content:'p'}]};
_normalizeInflightReplayCursorForReattach(noCursor);
assert.strictEqual(noCursor.lastRunJournalSeq, undefined);
assert.strictEqual(_runJournalReplayFloorForInflight(noCursor), 0);
assert.strictEqual(_normalizeInflightReplayCursorForReattach(null), null);
assert.strictEqual(_normalizeInflightReplayCursorForReattach(undefined), undefined);
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
def test_reattach_replay_params_from_a_cursor_only_cache_replay_from_zero():
"""#7640: end-to-end wire contract for a cursor-only reattach.
Derives the replay params from the real floor helpers (node) and feeds them
to the real server cursor parser. Tying both halves together matters because
`api.routes._parse_run_journal_after_seq()` reads `after_event_id` BEFORE
`after_seq`: a fix that zeroed only the seq would still resume at the stale
event id and skip every journal event that rebuilds the message body.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
"""
const runId='stream-7640';
const userOnly={
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:runId+':205',
};
console.log(JSON.stringify({
after_seq:String(_runJournalReplayFloorForInflight(userOnly)),
after_event_id:String(_runJournalReplayEventIdForInflight(userOnly)||''),
}));
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
params = json.loads(result.stdout.strip().splitlines()[-1])
# What the fixed client puts on the wire for a cache with no live projection.
assert params == {"after_seq": "0", "after_event_id": ""}
from api.routes import _parse_run_journal_after_seq
run_id = "stream-7640"
assert (
_parse_run_journal_after_seq(
{"after_seq": [params["after_seq"]], "after_event_id": [params["after_event_id"]]},
run_id,
)
== 0
)
# Precedence proof: dropping the seq alone would still resume at the stale id.
assert (
_parse_run_journal_after_seq(
{"after_seq": ["0"], "after_event_id": [f"{run_id}:205"]}, run_id
)
== 205
)
# ...and the pre-fix pair, for the record.
assert (
_parse_run_journal_after_seq(
{"after_seq": ["206"], "after_event_id": [f"{run_id}:205"]}, run_id
)
== 205
)
def test_attach_and_reattach_use_the_validated_replay_floor():
"""#7640 wiring guard: the validated floor is what reaches the wire.
The behavioral coverage for the floor helpers lives in
`test_user_only_inflight_must_not_seed_nonzero_replay_floor` and
`test_reattach_discards_replay_cursor_without_live_assistant_state`; this
test pins that `attachLiveStream()` and both `loadSession()` reattach paths
actually go through them instead of reading the raw cursor field.
"""
body = _function_body(MESSAGES_JS, "attachLiveStream")
init_pos = body.index("const _replayCursorInflight=")
init_block = body[init_pos : body.index(";", body.index("let _lastRunJournalEventId=", init_pos))]
assert "INFLIGHT[activeSid]" in init_block, "must still read the live cache"
assert "_runJournalReplayFloorForInflight(_replayCursorInflight)" in init_block
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in init_block
assert "INFLIGHT[activeSid].lastRunJournalSeq" not in init_block, (
"attachLiveStream must not read the raw cursor field anymore"
)
assert "INFLIGHT[activeSid].lastRunJournalEventId" not in init_block
load_body = re.sub(r"\s+", "", _function_body(SESSIONS_JS, "loadSession"))
assert load_body.count("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") == 2, (
"both reattach paths must re-validate the recovery object"
)
first = load_body.index("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
first_attach = load_body.index("attachLiveStream(sid,activeStreamId", first)
assert first < first_attach, "validation must run before the stream is attached"
last = load_body.rindex("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
last_attach = load_body.rindex("attachLiveStream(sid,activeStreamId", 0, last)
assert last_attach < last
def test_live_recovery_prefers_newer_durable_run_journal_snapshot():
"""Recovery chooses by stream identity and durable journal progress."""
assert NODE, "node not on PATH"
@@ -1174,7 +1473,10 @@ assert.strictEqual(inflight.lastRunJournalEventId, 'run-a:7');
assert "lastRunJournalEventId:String(stored.lastRunJournalEventId||'')" in load_body
assert "lastRunJournalEventId:state.lastRunJournalEventId||''" in compact_body
assert "inflight.lastRunJournalEventId||''" in attach_body
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" in attach_body
# #7640: the cursor's event id is gated by the same validation as the seq —
# reading the raw field here let a cursor outlive its assistant projection.
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in attach_body
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" not in attach_body
assert "inflight.lastRunJournalEventId=raw" in attach_body
assert "INFLIGHT[activeSid].streamId=streamId" in attach_body
assert "INFLIGHT[activeSid].lastRunJournalEventId=''" in attach_body
@@ -631,3 +631,165 @@ def test_sse_done_path_broadened_to_any_model_window_mismatch():
assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, (
"the old default-only save matcher (_mmcd_cc) must be removed (#4618)"
)
# --- #7535: provider-ownership guard on the global model.base_url backfill ---
# Same family as #3256: a global value scoped to its owner must not leak onto
# sessions owned by a different provider.
def test_builtin_provider_lookup_does_not_inherit_other_providers_base_url():
"""#7535: a session on a built-in registry provider (whose base_url is empty
by design because the endpoint lives in PROVIDER_REGISTRY, not config.yaml)
must NOT inherit the global ``model.base_url`` owned by a different (custom)
provider. The unguarded backfill handed the custom endpoint to the
context-length resolver, which probed the wrong ``/models`` catalog and fell
back to the generic 128K catalog entry for a 1M-context model, while the
on-disk session and SSE stream both carried 1,000,000."""
from api.routes import _context_length_lookup_inputs_for_model
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
cfg = {
"model": {
"provider": "custom:ark",
"base_url": ark_base,
"default": "ark-code-latest",
"context_length": 1000000,
},
"custom_providers": [
{
"name": "ark",
"base_url": ark_base,
"api_key": "ark-key",
"model": "ark-code-latest",
}
],
}
lookup = _context_length_lookup_inputs_for_model(
"@opencode-go:deepseek-v4.1-flash",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == "", (
"the global model.base_url belongs to custom:ark; the opencode-go "
"registry slot must stay empty so PROVIDER_REGISTRY's endpoint "
"resolves the context window (#7535)"
)
def test_same_provider_session_keeps_global_base_url_backfill():
"""#7535 control (compat): when the session provider IS the configured
owner of ``model.base_url``, the backfill behavior is preserved."""
from api.routes import _context_length_lookup_inputs_for_model
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
cfg = {
"model": {
"provider": "custom:ark",
"base_url": ark_base,
"default": "ark-code-latest",
},
}
lookup = _context_length_lookup_inputs_for_model(
"ark-code-latest",
"custom:ark",
cfg=cfg,
)
assert lookup.provider == "custom:ark"
assert lookup.base_url == ark_base, (
"same-owner sessions must keep the model.base_url backfill (#7535 compat)"
)
def test_base_url_only_config_without_provider_keeps_endpoint():
"""Regression (PR #7743 gate): a ``model`` section that declares
``base_url`` but NO ``provider`` has no owner that could conflict, so any
session must still receive the URL — exactly as master did. The profile
setup path writes this shape (api/profiles.py), and dropping the URL made
the lookup skip the endpoint and silently fall back to 256,000."""
from api.routes import _context_length_lookup_inputs_for_model
only_base_url = "http://127.0.0.1:9/v1"
cfg = {
"model": {
"base_url": only_base_url,
"default": "some-model",
},
}
for session_provider in ("custom", "openrouter"):
lookup = _context_length_lookup_inputs_for_model(
"some-model",
session_provider,
cfg=cfg,
)
assert lookup.provider == session_provider
assert lookup.base_url == only_base_url, (
f"a config with no declared provider owner cannot conflict with the "
f"{session_provider!r} session, so the base_url must survive the "
f"ownership guard"
)
def test_underscore_provider_owner_matches_hyphen_session():
"""Regression (PR #7743 gate): ``opencode_go`` and ``opencode-go`` are two
spellings of the SAME built-in provider, so the owner session must still
receive ``model.base_url``. Comparing the ids through the repo's
canonicaliser folds the underscore without collapsing distinct
``custom:*`` slugs."""
from api.routes import _context_length_lookup_inputs_for_model
owner_base = "http://127.0.0.1:9/v1"
cfg = {
"model": {
"provider": "opencode_go",
"base_url": owner_base,
"default": "some-model",
},
}
lookup = _context_length_lookup_inputs_for_model(
"some-model",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == owner_base, (
"opencode_go and opencode-go are the same built-in provider, so the "
"owner session must keep the base_url backfill"
)
def test_underscore_provider_still_blocks_distinct_custom_owner():
"""Counterpart to test_underscore_provider_owner_matches_hyphen_session:
folding underscores must not open the #7535 guard. ``custom:my_box`` and a
session on the unrelated built-in ``opencode-go`` are still different
providers, so the custom owner's URL stays out of the registry slot."""
from api.routes import _context_length_lookup_inputs_for_model
cfg = {
"model": {
"provider": "custom:my_box",
"base_url": "http://127.0.0.1:9/v1",
"default": "some-model",
},
}
lookup = _context_length_lookup_inputs_for_model(
"some-model",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == "", (
"underscore folding must not let a distinct custom provider's "
"base_url leak onto another provider's session (#7535 guard)"
)
+160
View File
@@ -159,6 +159,7 @@ def _make_db(tmp_path: Path):
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
session_source TEXT,
model_config TEXT,
model TEXT,
started_at REAL NOT NULL,
ended_at REAL,
@@ -355,6 +356,165 @@ def test_periodic_projection_recovers_role_only_sidebar_visibility_change(tmp_pa
assert subscriber.empty()
def test_marker_only_update_invalidates_projection_on_next_poll(tmp_path):
"""model_config is projection authority, so its mutation cannot wait for parity."""
gw = importlib.import_module("api.gateway_watcher")
db, conn = _make_db(tmp_path)
_add_session(conn, "compression-parent", "telegram", mc=1, started=100.0)
_add_session(conn, "compression-child", "telegram", mc=1, started=199.0)
conn.execute(
"UPDATE sessions SET ended_at = 200.0, end_reason = 'compression' "
"WHERE id = 'compression-parent'"
)
conn.execute(
"UPDATE sessions SET parent_session_id = 'compression-parent' "
"WHERE id = 'compression-child'"
)
conn.commit()
watcher = gw.GatewayWatcher(state_db_path=db)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
initial = subscriber.get_nowait()
assert [row["session_id"] for row in initial["sessions"]] == [
"compression-child"
]
# No timestamp, count, source, or message row changes. This one field turns
# the previously-collapsed child into a direct branch and must be visible on
# the ordinary five-second poll rather than the 60-second parity fallback.
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = 'compression-child'",
('{"_branched_from":"compression-parent"}',),
)
conn.commit()
assert watcher._poll_once(now=2.0) is True
event = subscriber.get_nowait()
assert {row["session_id"] for row in event["sessions"]} == {
"compression-parent",
"compression-child",
}
assert subscriber.empty()
def test_snapshot_hash_covers_every_published_field():
"""Any emitted field change must change the hash; ordering must not."""
gw = importlib.import_module("api.gateway_watcher")
base = [
{
"session_id": "b",
"title": "Chat B",
"model": "m",
"message_count": 2,
"created_at": 100.0,
"updated_at": 150.0,
"source": "telegram",
"raw_source": "telegram",
"session_source": "messaging",
"source_label": "Telegram",
},
{
"session_id": "a",
"title": "Chat A",
"model": "m",
"message_count": 1,
"created_at": 50.0,
"updated_at": 60.0,
"source": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_label": "CLI",
},
]
reference = gw._snapshot_hash(base)
assert gw._snapshot_hash(list(reversed(base))) == reference, "order-independent"
assert gw._snapshot_hash([dict(row) for row in base]) == reference, "deterministic"
assert gw._snapshot_hash([]) != reference
# Mutating any single published field, including the ones the old
# id/updated_at/message_count triple ignored, changes the hash.
for field in base[0]:
mutated = [dict(row) for row in base]
mutated[0][field] = f"changed-{field}"
assert gw._snapshot_hash(mutated) != reference, field
def test_marker_only_projection_change_emits_sse_event_without_row_churn(tmp_path):
"""ID / message count / activity stay fixed; title + segments change → event.
Gate reproduction: a lineage-marker mutation reruns the projection (the
cheap fingerprint covers ``model_config``) and changes the published
title/segment projection of a row, but the session id set, every
``message_count`` and every ``updated_at`` in the payload are unchanged.
The old snapshot hash saw no difference, so ``_last_sessions`` kept the
stale projection and no subscriber event was queued.
"""
gw = importlib.import_module("api.gateway_watcher")
db, conn = _make_db(tmp_path)
# A compression chain whose root segment holds no messages (its
# transcript moved to the continuation): the projection publishes ONE
# row under the tip's id carrying the root's title and a segment count
# of 2. A message-less root is hidden when it stands alone.
_add_session(conn, "root-a", "telegram", mc=0, started=100.0, title="Alpha")
_add_session(conn, "tip-b", "telegram", mc=1, started=150.0, title="Beta")
conn.execute(
"UPDATE sessions SET ended_at = 150.0, end_reason = 'compression' WHERE id = 'root-a'"
)
conn.execute("UPDATE sessions SET parent_session_id = 'root-a' WHERE id = 'tip-b'")
conn.commit()
watcher = gw.GatewayWatcher(state_db_path=db)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
initial = subscriber.get_nowait()
initial_rows = {row["session_id"]: row for row in initial["sessions"]}
assert set(initial_rows) == {"tip-b"}
assert initial_rows["tip-b"]["title"] == "Alpha"
assert initial_rows["tip-b"]["message_count"] == 1
assert initial_rows["tip-b"]["updated_at"] == 150.0
assert watcher._last_sessions == initial["sessions"]
# Marker-only flip: stamp _reset_from on the tip. No timestamp, count,
# source or message row changes anywhere. The tip is now a reset child
# (its own conversation, own title, one segment) and the message-less
# root drops out — the published id set, message_count and updated_at
# are byte-identical to the previous snapshot.
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = 'tip-b'",
('{"_reset_from":"root-a"}',),
)
conn.commit()
assert gw._cheap_change_fingerprint(db) != watcher._last_cheap_fp
assert watcher._poll_once(now=2.0) is True
event = subscriber.get_nowait()
rows = {row["session_id"]: row for row in event["sessions"]}
assert set(rows) == {"tip-b"}
assert rows["tip-b"]["title"] == "Beta"
assert rows["tip-b"]["message_count"] == initial_rows["tip-b"]["message_count"]
assert rows["tip-b"]["updated_at"] == initial_rows["tip-b"]["updated_at"]
assert watcher._last_sessions == event["sessions"]
assert subscriber.empty()
# A title-only projection change on an otherwise identical row is also
# delivered on the ordinary poll.
conn.execute("UPDATE sessions SET title = 'Beta renamed' WHERE id = 'tip-b'")
conn.commit()
assert watcher._poll_once(now=3.0) is True
event = subscriber.get_nowait()
rows = {row["session_id"]: row for row in event["sessions"]}
assert set(rows) == {"tip-b"}
assert rows["tip-b"]["title"] == "Beta renamed"
assert rows["tip-b"]["updated_at"] == initial_rows["tip-b"]["updated_at"]
assert watcher._last_sessions == event["sessions"]
assert subscriber.empty()
# Unchanged payload: the parity projection reruns but emits nothing.
assert watcher._poll_once(now=3.0 + watcher.PROJECTION_PARITY_INTERVAL) is True
assert subscriber.empty()
def test_initial_missing_db_does_not_publish_an_empty_snapshot(tmp_path):
gw = importlib.import_module("api.gateway_watcher")
watcher = gw.GatewayWatcher(state_db_path=tmp_path / "missing.db")
@@ -79,6 +79,8 @@ function extractFunc(name){
// ── module-scope state the extracted functions close over ──
let _clarifyPendingBySession = new Map();
let _approvalPendingBySession = new Map();
let _clarifyPromptGenerationBySession = new Map();
let _approvalPromptGenerationBySession = new Map();
let _clarifySessionId = null;
let _approvalSessionId = null;
let S = { session: null };
@@ -109,6 +111,10 @@ function hideApprovalCard(force){
// ── real functions under test ──
eval(extractFunc('_promptActiveSessionId'));
eval(extractFunc('_clarifyPromptGeneration'));
eval(extractFunc('_bumpClarifyPromptGeneration'));
eval(extractFunc('_approvalPromptGeneration'));
eval(extractFunc('_bumpApprovalPromptGeneration'));
eval(extractFunc('_clarifyPromptBelongsToActiveSession'));
eval(extractFunc('_rememberClarifyPending'));
eval(extractFunc('_clearClarifyPendingForSession'));
@@ -99,7 +99,7 @@ def test_js_recycle_flag_lifecycle():
"""_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true
before the compensate call and clears it in finally."""
fn_match = re.search(
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match, "_scheduleMessageVirtualizedRender not found"
@@ -1101,7 +1101,7 @@ class TestScrollbarDragRenderDuringDrag:
"""_scheduleMessageVirtualizedRender must have a _scrollbarDragActive
branch that runs a full render with scroll compensation."""
fn_match = re.search(
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match, "_scheduleMessageVirtualizedRender not found"
@@ -53,45 +53,12 @@ def test_dismissed_set_capped_at_100():
# Guard in showApprovalCard
# ---------------------------------------------------------------------------
def test_guard_in_show_approval_card():
compact = _compact(MESSAGES_JS)
# Guard must appear inside showApprovalCard, after _rememberApprovalPending
func_start = compact.find("functionshowApprovalCard(")
assert func_start != -1
# Locate the guard after the function start (dismissals are namespaced by
# session, so the guard passes sid + approval_id).
guard = "_isApprovalDismissed(sid,pending.approval_id)"
guard_idx = compact.find(guard, func_start)
assert guard_idx != -1, "guard _isApprovalDismissed must appear in showApprovalCard"
# _rememberApprovalPending must appear before the guard
remember = "_rememberApprovalPending("
remember_idx = compact.find(remember, func_start)
assert remember_idx != -1
assert remember_idx < guard_idx, "guard must come after _rememberApprovalPending"
def test_guard_returns_early():
# The guard must be a return statement
compact = _compact(MESSAGES_JS)
assert "if(pending&&pending.approval_id&&_isApprovalDismissed(sid,pending.approval_id))return;" in compact
# ---------------------------------------------------------------------------
# dismissApprovalCard function
# ---------------------------------------------------------------------------
def test_dismiss_approval_card_defined():
assert "function dismissApprovalCard(" in MESSAGES_JS
def test_dismiss_approval_card_marks_dismissed():
compact = _compact(MESSAGES_JS)
func_start = compact.find("functiondismissApprovalCard(")
assert func_start != -1
body_end = compact.find("}", func_start)
body = compact[func_start:body_end + 1]
assert "_markApprovalDismissed(sid,_approvalCurrentId)" in body
def test_dismiss_approval_card_hides_card():
compact = _compact(MESSAGES_JS)
@@ -102,19 +69,6 @@ def test_dismiss_approval_card_hides_card():
assert "hideApprovalCard(true)" in body
def test_dismiss_approval_card_clears_pending_attention():
# dismissApprovalCard must call _clearApprovalPendingForSession so the tab
# indicator stops blinking after dismiss. Without this, _rememberApprovalPending
# reinsertes the pending entry on every poll tick and the indicator stays lit.
compact = _compact(MESSAGES_JS)
func_start = compact.find("functiondismissApprovalCard(")
assert func_start != -1
body_end = compact.find("}", func_start)
body = compact[func_start:body_end + 1]
assert "_clearApprovalPendingForSession(sid)" in body, (
"dismissApprovalCard must call _clearApprovalPendingForSession(sid)"
)
def test_dismiss_approval_card_captures_sid_before_hide():
# The session ID must be captured before hideApprovalCard(true) nulls out
@@ -286,7 +240,7 @@ def test_same_approval_id_in_two_sessions_does_not_collide():
pytest.skip("node not available")
helpers = "\n".join(
_extract_fn(MESSAGES_JS, n)
for n in ("_approvalDismissKey", "_getDismissedApprovals",
for n in ("_promptNotifyKey", "_approvalDismissKey", "_getDismissedApprovals",
"_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed")
)
script = (
@@ -67,6 +67,10 @@ def _run_failure_case(api_js: str) -> dict:
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
@@ -103,6 +107,7 @@ let renderCalls = 0;
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
@@ -111,6 +116,7 @@ let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
const buttons = new Map();
function makeButton(id) {{
return {{
@@ -258,6 +264,10 @@ def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
@@ -289,6 +299,7 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
@@ -297,6 +308,7 @@ let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
let resolveApi;
let apiCalls = 0;
const buttons = new Map();
+3 -1
View File
@@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path):
session_dir.mkdir()
with mock.patch("api.models.SESSION_DIR", session_dir):
meta = models._state_projection_sidecar_metadata("cron_nope_999")
assert meta == {"title": None, "archived": False}
# No sidecar means no opinion on archived (None), so the projection falls back
# to the state.db row's archived flag (#7548) instead of forcing it to False.
assert meta == {"title": None, "archived": None}
# No file → nothing cached (so the cache can't be poisoned by absent files).
assert len(models._SIDECAR_METADATA_CACHE) == 0
@@ -1,5 +1,6 @@
"""Regression coverage for issue #500 transcript virtualization."""
import json
import re
import shutil
import subprocess
import tempfile
@@ -1345,7 +1346,9 @@ let _messageVirtualHeightCacheSrc = {};
let _messageVirtualEstimatedRowHeight = 200;
let _messageVirtualWindowKey = 'old-key';
let _messageVirtualMeasurementCycleKey = 'old-cycle';
let _messageVirtualMeasurementRetryCount = 3;
let _messageVirtualMeasurementSeenKeys = ['stale-key'];
let _messageVirtualMeasurementBurstActive = true;
let _messageVirtualRenderQueuedOrigin = 'internal';
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
// resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness
@@ -1353,6 +1356,7 @@ const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140};
function clearTimeout(id){ timerCleared = (id === 99); }
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
eval(extractFunc('_clearMessageVirtualHeightCache'));
_clearMessageVirtualHeightCache();
console.log(JSON.stringify({
@@ -1360,6 +1364,9 @@ console.log(JSON.stringify({
settleTimer: _messageVirtualScrollSettleTimer,
deferred: _messageVirtualDeferredMeasurement,
timerCleared: timerCleared,
seenKeys: _messageVirtualMeasurementSeenKeys,
burstActive: _messageVirtualMeasurementBurstActive,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
"""
metrics = json.loads(_run_node(source))
@@ -1375,3 +1382,425 @@ console.log(JSON.stringify({
assert metrics["timerCleared"] is True, (
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
)
assert metrics["seenKeys"] == [], (
"_clearMessageVirtualHeightCache must clear the measurement-burst seen-key "
f"memory: seenKeys {metrics['seenKeys']}"
)
assert metrics["burstActive"] is False, (
"_clearMessageVirtualHeightCache must end any active measurement burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["queuedOrigin"] is None, (
"_clearMessageVirtualHeightCache must clear any queued internal-measurement "
f"provenance: queuedOrigin {metrics['queuedOrigin']}"
)
def _measurement_burst_harness(scenario_js: str) -> str:
"""Shared queued-rAF harness for the measurement-burst chain (#6654/#6717).
Drives the REAL production chain: the cycle-aware scheduler queues a first
rAF, _scheduleMessageVirtualizedRender queues a second rAF (the
internal-measurement provenance marker is threaded through BOTH layers),
and the renderMessages stub applies the production burst-reset contract for
every unmarked (external) render, then simulates the measure pass from the
caller-provided plan (measurePlan). Unlike the earlier synchronous-rAF
harnesses, external renders can land BETWEEN queued rAF callbacks — exactly
where the overlap/convergence regressions lived.
"""
js = UI_JS_PATH.read_text(encoding="utf-8")
cap_match = re.search(
r"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS\s*=\s*(\d+)", js
)
assert cap_match, (
"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS must be defined in ui.js "
"(absolute per-burst cap, #6717 re-gate)"
)
cap = int(cap_match.group(1))
return _extract_func_script(js) + (
"// Absolute per-burst ceiling, read from production ui.js (#6717 re-gate).\n"
f"const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = {cap};\n"
) + """
// ── queued-rAF harness ────────────────────────────────────────────────────────
let renderLog = []; // every renderMessages call: {internal, preserveScroll}
let rafQueue = [];
function requestAnimationFrame(cb){ rafQueue.push(cb); return rafQueue.length; }
function flushRaf(){ const q = rafQueue; rafQueue = []; for (const cb of q) cb(); }
let _messageVirtualScrollRaf = 0;
let _messageVirtualWindowKey = '';
let _messageVirtualMeasurementCycleKey = '';
let _messageVirtualMeasurementSeenKeys = [];
let _messageVirtualMeasurementBurstActive = false;
let _messageVirtualRenderQueuedOrigin = null;
let _messageVirtualScrollActive = false;
let _messageVirtualDeferredMeasurement = null;
let _scrollbarDragActive = false;
let _msgNodeRecycleEnabled = false;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
function _deferClearProgrammaticScroll(){}
function $(id){ return {}; }
function _getVisibleMessagesWithIdx(){ return []; }
function _messageVirtualKeepTailCount(){ return 0; }
function _currentMessageVirtualWindow(){
return { virtualized:true, start:0, end:10, topPad:0, bottomPad:0, tailStart:10, total:20 };
}
function _messageVirtualMeasurementCycleKeyFor(w){ return (w && w.key) || ''; }
function _compensateScrollForMeasurementDelta(fn){ return fn(); }
// Simulated measure pass: after each render, the plan yields the next measured
// window cycle key; undefined means the measurements settled (unchanged).
let measurePlan = [];
let measureIdx = 0;
function renderMessages(options){
const internal = !!(options && options._internalMeasurement);
renderLog.push({ internal: internal, preserveScroll: !!(options && options.preserveScroll) });
// Contract of production renderMessages(): every UNMARKED (external) render
// trigger starts a fresh measurement burst, overriding any pending internal
// callback.
if(!internal) _resetMessageVirtualMeasurementBurst();
const next = measurePlan[measureIdx++];
if(next === undefined){
_markMessageVirtualMeasurementsSettled({ key: 'settled' });
}else{
_scheduleMessageVirtualMeasurementRefresh({ key: next });
}
}
// Production functions under test.
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
eval(extractFunc('_messageVirtualWindowKeyFor'));
eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh'));
eval(extractFunc('_scheduleMessageVirtualizedRender'));
eval(extractFunc('_markMessageVirtualMeasurementsSettled'));
""" + scenario_js
def test_oscillating_cycle_keys_terminate_burst_with_queued_raf():
"""#6654/#6717 re-gate (c): a repeated oscillating window key (A->B->A->B)
must terminate the measurement burst even though every key was unseen on
first sight — the burst tracks SEEN keys, so geometry that flaps between
two states can never renew the chain and keep the rAF/measure loop alive
(~30% WebContent CPU, issue #6654). The queued-rAF harness drives the
oscillation through the real two-rAF-layer chain."""
source = _measurement_burst_harness("""
measurePlan = ['B','A','B','A','B','A'];
renderMessages({}); // external trigger: measures B -> burst starts
flushRaf(); flushRaf(); // layer-1 then layer-2 rAF -> internal render (A)
flushRaf(); flushRaf(); // next cycle -> internal render (B repeats) -> burst ends
flushRaf(); flushRaf(); // nothing left queued
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 2, (
"A->B->A->B oscillation must stay bounded: a repeated key ends the burst. "
f"scheduled {metrics['internal']} internal re-renders (expected 2, then termination)"
)
assert metrics["renders"] == 3, (
"oscillation must not keep scheduling renders: "
f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)"
)
assert metrics["burstActive"] is False, (
"the burst must end once the repeated key is detected so the next "
f"externally initiated cycle starts fresh: burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
"termination must clear the seen-key memory: "
f"seenKeys {metrics['seenKeys']}"
)
assert metrics["queuedOrigin"] is None, (
"no internal render may remain queued after termination: "
f"queuedOrigin {metrics['queuedOrigin']}"
)
def test_consecutive_same_key_row_shrink_converges_to_settled():
"""#6717 re-gate: consecutive same-key passes (e.g. A->A->B->settled from
a row shrinking across two measurement passes with unchanged window bounds)
must NOT be cut short as oscillation. Only non-consecutive repeats (A->B->A)
represent cyclic oscillation; consecutive same-key passes continue toward
convergence while remaining bounded by the absolute cap."""
source = _measurement_burst_harness("""
measurePlan = ['A', 'A', 'B']; // R0 measures A, R1 internal measures A (consecutive), R2 internal measures B, then settled
renderMessages({}); // external trigger: measures A -> burst starts
flushRaf(); flushRaf(); // L1 -> L2: internal render measures A again (consecutive same key) -> must proceed!
flushRaf(); flushRaf(); // L1 -> L2: internal render measures B -> proceeds!
flushRaf(); flushRaf(); // L1 -> L2: internal render measures settled
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 3, (
"consecutive same-key pass (row shrink) must NOT be cut short as oscillation: "
f"got {metrics['internal']} internal renders (expected 3)"
)
assert metrics["renders"] == 4, (
f"burst must converge to settled: {metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
)
assert metrics["burstActive"] is False, (
f"burst must settle: burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
f"settlement must clear seen-key memory: seenKeys {metrics['seenKeys']}"
)
def test_genuine_convergence_exceeding_two_renders_completes():
"""#6717 re-gate (b): a legitimate forward convergence A->B->C->settled
(content reflow, fonts/images loading late, dynamic height) must be allowed
MORE than two measurement passes. The old flat two-render burst cap left
the geometry mis-measured (wrong row heights / clipped content); cycle-aware
tracking lets every UNSEEN key proceed until the measurements settle."""
source = _measurement_burst_harness("""
measurePlan = ['B','C','D']; // three distinct forward keys, then settled
renderMessages({}); // external trigger: measures B
flushRaf(); flushRaf(); // internal render: measures C
flushRaf(); flushRaf(); // internal render: measures D
flushRaf(); flushRaf(); // internal render: measurements settle
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
lastInternal: renderLog[renderLog.length - 1].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 3, (
"genuine A->B->C->settled convergence must complete with MORE than the "
"old two-render cap: "
f"{metrics['internal']} internal re-renders (expected 3)"
)
assert metrics["renders"] == 4, (
"the convergence chain must run to completion: "
f"{metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
)
assert metrics["lastInternal"] is True, (
"the final render of the convergence chain must still be part of the "
"internal measurement chain (settled via _markMessageVirtualMeasurementsSettled)"
)
assert metrics["burstActive"] is False, (
"settlement must end the burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
def test_overlapping_external_append_receives_fresh_budget():
"""#6717 re-gate (a): an externally initiated render (message append,
scroll-settle, genuine content change) that lands while an internal
measurement callback is still PENDING in the rAF queue must reset the burst
— external provenance overrides a pending internal callback — so the new
cycle receives a fresh budget instead of being starved by the stale burst's
seen-key memory (0 retries).
Phase 1: the append lands between the two rAF layers and measures key B
(already seen in the stale burst). Only the external reset lets B proceed;
without it the repeated key would terminate the append's cycle immediately.
The stale layer-2 callback (queued for the DEAD burst) must then degrade to
an UNMARKED render — the append's own measurement must not revive an
internal marker onto it, because the provenance belongs to the queued
render, not to a global flag (#6717 re-gate MUST-FIX 2) — and the fresh
cycle it starts completes with its own internal render, so the append is
never starved of retries.
Phase 2: an external render that measures SETTLED lands while a layer-2
callback is pending; the stale callback must then degrade to an UNMARKED
render (the external reset consumed its provenance), and the fresh cycle it
starts must complete with its own internal render."""
source = _measurement_burst_harness("""
measurePlan = ['B','B','C', undefined, 'E', undefined, 'F', undefined];
renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued
flushRaf(); // L1 fires -> L2 (internal callback) now pending
renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires:
// resets the burst -> fresh cycle, seen=[B] again
flushRaf(); flushRaf(); // stale L2 fires UNMARKED (R2, external won),
// fresh cycle completes (R3 internal) -> settles
renderMessages({}); // R4 external: new cycle, L1 queued
flushRaf(); // L1 fires -> L2 (internal callback) now pending
renderMessages({}); // R5 external scroll-settle: resets the burst,
// measures SETTLED (no new schedule)
flushRaf(); // stale L2 fires -> marker consumed as FALSE ->
// R6 UNMARKED render (external provenance won)
flushRaf(); flushRaf(); // R6's fresh cycle completes (R7 internal) -> settles
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
appendExternal: renderLog[1].internal,
staleL2Unmarked: renderLog[2].internal,
postAppendCycleInternal: renderLog[3].internal,
staleCallbackUnmarked: renderLog[6].internal,
postResetCycleInternal: renderLog[7].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["renders"] == 8, (
"the overlapping append must NOT starve the new cycle: the chain must "
"continue past the append to completion "
f"({metrics['renders']} renders; expected 8)"
)
assert metrics["appendExternal"] is False, (
"the append is an unmarked external render"
)
assert metrics["staleL2Unmarked"] is False, (
"the stale layer-2 callback (queued for the burst the append reset) "
"must degrade to an UNMARKED render: the append's own measurement "
"must not revive an internal marker onto it — provenance belongs to "
"the queued render, external wins (#6717 re-gate MUST-FIX 2)"
)
assert metrics["postAppendCycleInternal"] is True, (
"the fresh cycle after the degraded stale callback must complete with "
"its own internal render (fresh budget — the repeated key B was not "
"treated as oscillation because the external render reset the burst)"
)
assert metrics["staleCallbackUnmarked"] is False, (
"external provenance must override the pending internal callback: the "
"stale internal rAF callback must degrade to an unmarked render after "
"the external render reset the burst"
)
assert metrics["postResetCycleInternal"] is True, (
"the cycle started by the degraded stale callback must complete with "
"its own internal render (retries were not starved)"
)
assert metrics["burstActive"] is False, (
"the fresh cycles must end settled: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
def test_all_distinct_keys_terminate_burst_with_bounded_storage():
"""#6717 re-gate MUST-FIX 1: an ALL-DISTINCT monotonic key sequence
(A->B->C->D->... never repeating) must still terminate. The seen-key rule
alone only ends the burst on a REPEATED key, so a browser emitting a
never-repeating geometry would loop without limit — the #6654 CPU-runaway
class under a different trigger — while _messageVirtualMeasurementSeenKeys
grows without bound (memory). The absolute per-burst cap ends the burst
regardless of key novelty and bounds the seen-key collection to the cap.
Driven through the real two-rAF-layer chain with MORE distinct keys than
the cap, and asserting the burst stops at exactly the cap with no rAF left
queued and the seen-key memory cleared."""
source = _measurement_burst_harness("""
measurePlan = [];
for(let i=0;i<40;i++) measurePlan.push('K'+i); // 40 distinct keys, well over the cap
renderMessages({}); // external trigger: starts the burst
for(let i=0;i<40;i++) flushRaf(); // drain both rAF layers repeatedly
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
rafPending: rafQueue.length,
cap: MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS,
}));
""")
metrics = json.loads(_run_node(source))
cap = metrics["cap"]
assert metrics["internal"] == cap, (
"an all-distinct sequence must be cut off by the absolute per-burst "
f"cap: {metrics['internal']} internal renders (expected exactly {cap})"
)
assert metrics["renders"] == cap + 1, (
"the all-distinct burst must terminate after the cap (1 external + cap "
f"internal renders): {metrics['renders']} renders (expected {cap + 1})"
)
assert metrics["rafPending"] == 0, (
"no rAF may remain queued after the cap ends the burst: "
f"rafPending {metrics['rafPending']}"
)
assert metrics["burstActive"] is False, (
"the cap must end the burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
"termination must clear the seen-key memory (bounded storage — at most "
f"cap keys were ever retained): seenKeys {metrics['seenKeys']}"
)
assert metrics["queuedOrigin"] is None, (
"no internal provenance may remain queued after the cap ends the burst: "
f"queuedOrigin {metrics['queuedOrigin']}"
)
def test_queued_raf_external_scroll_wins_provenance():
"""#6717 re-gate MUST-FIX 2: when an external render (user scroll /
scroll-settle) overlaps a QUEUED internal rAF, the render that actually
fires must be treated as EXTERNAL. The old global consumable flag
(_messageVirtualMeasurementRenderPending) could be consumed by an
unrelated render, marking external renders as internal so the burst was
never reset — leaving stale virtual-window / spacer geometry until the
next unrelated render.
Sequence: external R0 measures A (L2 queued internal); an external R1
lands while L2 is pending — it resets the burst, measures B, and its own
layer-1 rAF COALESCES into the pending L2 (the early-return path where
the old flag leaked). The L2 that fires must be UNMARKED — external
provenance wins — so the A it measures starts a fresh burst instead of
terminating on the repeated key with zero retry queued."""
source = _measurement_burst_harness("""
measurePlan = ['A','B','A', undefined];
renderMessages({}); // R0 external: measures A -> burst, L1 queued
flushRaf(); // L1 -> L2 queued with internal provenance
renderMessages({}); // R1 EXTERNAL scroll lands while L2 pending:
// resets the burst, measures B -> L1 queued
flushRaf(); // B's L1 coalesces into the pending L2 (no new rAF)
flushRaf(); // L2 fires -> must be UNMARKED (external won),
// measures A -> fresh burst -> L1 queued
flushRaf(); flushRaf(); // A's fresh cycle: L2 internal render -> settles
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
r0External: renderLog[0].internal,
r1External: renderLog[1].internal,
coalescedL2Unmarked: renderLog[2].internal,
freshCycleInternal: renderLog[3].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["renders"] == 4, (
"the scroll overlap chain must run to completion: "
f"{metrics['renders']} renders (expected 4)"
)
assert metrics["r0External"] is False and metrics["r1External"] is False, (
"R0 and R1 are unmarked external renders"
)
assert metrics["coalescedL2Unmarked"] is False, (
"the queued internal rAF must degrade to an UNMARKED render when an "
"external request coalesced into it — external provenance wins and an "
"internal marker must never survive onto an external render"
)
assert metrics["freshCycleInternal"] is True, (
"the fresh cycle started by the degraded render must complete with its "
"own internal render: the repeated A key must NOT terminate it because "
"the burst was reset (zero retry queued would leave stale geometry)"
)
assert metrics["burstActive"] is False, (
"the fresh cycle must end settled: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
@@ -423,6 +423,72 @@ def test_auth_retry_success_does_not_append_error_turn(tmp_path, monkeypatch):
assert not any(msg.get("_error") for msg in saved.messages)
def test_auth_retry_success_observes_replacement_agent_runtime(tmp_path, monkeypatch):
from api import run_journal
session = _prepare_session("auth_runtime", "stream_auth_runtime", pending_user_message="Please retry")
Base = _build_auth_failure_agent(token_text="")
class ReplacementAgent(Base):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = "original" if self.__class__.runs == 0 else "healed-model"
self.provider = "old-provider" if self.__class__.runs == 0 else "healed-provider"
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path / "sessions")
q = queue.Queue()
streaming.STREAMS["stream_auth_runtime"] = q
config.STREAM_PARTIAL_TEXT["stream_auth_runtime"] = ""
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=ReplacementAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
streaming._run_agent_streaming(session.session_id, "Please retry", "test-model", str(tmp_path), "stream_auth_runtime")
events = _queue_events(q)
assert any(name == "done" for name, _ in events)
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
("healed-model", "healed-provider")]
summary = run_journal.latest_run_summary("auth_runtime", "stream_auth_runtime", session_dir=tmp_path / "sessions")
assert summary["runtime_model"]["model"] == "healed-model"
def test_auth_exception_retry_observes_replacement_agent(tmp_path, monkeypatch):
session = _prepare_session("auth_exception_runtime", "stream_auth_exception_runtime", pending_user_message="Retry")
class RetryAgent(MockAgent):
runs = 0
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = "original" if type(self).runs == 0 else "retry-model"
self.provider = "original-provider" if type(self).runs == 0 else "retry-provider"
def run_conversation(self, **kwargs):
type(self).runs += 1
if type(self).runs == 1:
raise RuntimeError("401 unauthorized")
return {"messages": list(kwargs.get("conversation_history") or []) + [
{"role": "assistant", "content": "Recovered answer"}]}
q = queue.Queue()
streaming.STREAMS["stream_auth_exception_runtime"] = q
config.STREAM_PARTIAL_TEXT["stream_auth_exception_runtime"] = ""
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=RetryAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
streaming._run_agent_streaming(session.session_id, "Retry", "test-model", str(tmp_path), "stream_auth_exception_runtime")
events = _queue_events(q)
assert any(name == "done" for name, _ in events)
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
("retry-model", "retry-provider")]
def test_auth_exception_retry_structured_failure_still_emits_error(
tmp_path,
monkeypatch,
@@ -109,7 +109,6 @@ function buildRuntime() {
globalThis.removeThinking = () => calls.push('removeThinking');
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
globalThis._smdEndParser = () => calls.push('smdEndParser');
@@ -141,6 +140,15 @@ function buildRuntime() {
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;
@@ -162,6 +170,10 @@ function buildRuntime() {
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;
@@ -179,6 +191,10 @@ function buildRuntime() {
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;
@@ -421,3 +437,109 @@ def test_terminal_error_marker_is_single_instance_and_not_duplicated(driver_path
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
+1 -1
View File
@@ -161,7 +161,7 @@ class TestMcpList:
monkeypatch.setenv('HERMES_CONFIG_PATH', str(override_path))
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home)
monkeypatch.setattr(routes, 'get_active_hermes_home', lambda: active_home)
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda: {})
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda *_args, **_kwargs: {})
config.reload_config()
h = _make_handler()
@@ -34,15 +34,25 @@ def test_model_picker_renders_selected_badge_without_replacing_configured_badge(
def test_selected_badge_is_keyed_to_current_model_value():
assert "String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')" in UI_JS
# The selected badge must key on the resolved model identity, not a raw
# string of the dropdown value: catalog rows carry the qualified
# @custom:<slug>:<model> value while the outgoing state model is bare
# (#6884), so both sides are normalized via _modelPickerOptionIdentity
# before comparison.
assert "const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'" in UI_JS
assert "_norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)" in UI_JS
def test_selected_badge_is_keyed_to_current_model_provider():
assert "const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS
assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS
assert "return (_provider&&_provider!=='default')?_provider:null;" in UI_JS
assert "String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
assert "const _isSelectedModelRow=(m)=>" in UI_JS
# Provider identity still gates the row match — the normalized model
# comparison alone would collapse same-id rows across provider groups.
assert "_rowProvider=String(_modelProviderForSelectedBadge(m)||'')" in UI_JS
assert "_stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
assert "_rowProvider===_stateProvider" in UI_JS
assert "const _isSelectedModelRow=(m)=>{" in UI_JS
assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS
+5 -4
View File
@@ -140,10 +140,11 @@ console.log(JSON.stringify({{
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
# The served model must be read from the agent AFTER the run — the agent
# mutates agent.model when a fallback fires, so the pre-run resolved_model
# would mis-attribute fallback turns.
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
# Read the served model from the agent after the run (including fallback),
# without treating a requested/configured selection as proof of service.
assert "_observed_model = getattr(agent, 'model', None)" in STREAMING_PY
assert "_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None" in STREAMING_PY
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" not in STREAMING_PY
assert "usage['used_model'] = _used_model" in STREAMING_PY
File diff suppressed because it is too large. Load diff
+50 -3
View File
@@ -52,10 +52,57 @@ def test_virtualization_affordances_have_styling_hooks():
assert "border-radius:999px" in CSS
def test_measurement_rerenders_are_bounded_per_virtual_window_cycle():
assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS
def test_measurement_rerenders_are_cycle_aware_per_virtual_window_burst():
assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS
assert "function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)" in UI_JS
assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS
assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS
assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS
# Cycle-aware burst tracking: unseen keys proceed, repeated keys terminate,
# and the seen-key memory lives in _messageVirtualMeasurementSeenKeys.
assert "_messageVirtualMeasurementSeenKeys.includes(cycleKey)" in UI_JS
assert "_messageVirtualMeasurementSeenKeys.push(cycleKey)" in UI_JS
assert "function _resetMessageVirtualMeasurementBurst()" in UI_JS
def test_measurement_burst_not_reset_by_cycle_key_change():
"""#6654/#6717: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh
must record the new key but never reset the burst state. Resetting on key
change lets WebKit's A->B->A->B metric oscillation renew the chain forever,
keeping the rAF/measure loop alive. The ONLY resets allowed inside the
scheduler are: the repeated-key termination (oscillation) and the fresh-burst
start (when no burst is active), guarded by if(!_messageVirtualMeasurementBurstActive)."""
idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)")
end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx)
body = UI_JS[idx:end]
# The key-change branch must only assign the key.
assert "if(_messageVirtualMeasurementCycleKey!==cycleKey){" in body
key_branch_start = body.index("if(_messageVirtualMeasurementCycleKey!==cycleKey){")
key_branch_end = body.index("}", key_branch_start)
key_branch = body[key_branch_start:key_branch_end]
assert "_messageVirtualMeasurementSeenKeys" not in key_branch, (
"cycle-key change must not reset the burst's seen-key memory (issue #6654)"
)
assert "_messageVirtualMeasurementBurstActive=false" not in key_branch, (
"cycle-key change must not end the burst (issue #6654)"
)
# The fresh-burst reset is allowed only at the start of a new external cycle.
assert "if(!_messageVirtualMeasurementBurstActive){" in body
guard_pos = body.index("if(!_messageVirtualMeasurementBurstActive){")
seen_reset_pos = body.index("_messageVirtualMeasurementSeenKeys=[];")
assert guard_pos < seen_reset_pos, (
"the seen-key reset must be guarded by the burst-active check "
"(per-burst lifecycle, issue #6717)"
)
# A NON-consecutive repeat (A -> B -> A) is oscillation and terminates the
# burst. A consecutive repeat (same window needing a second re-measure, e.g.
# a row shrinking 1500 -> 1400 -> 100 px) proceeds; unseen keys proceed.
assert "if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body
assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body
# The internal-measurement origin travels WITH the scheduled render request
# (threaded through both rAF layers), never as a global consumable flag that
# a coalesced external render could steal (#6717 re-gate MUST-FIX 2).
assert "origin:'internal'" in body
assert "_messageVirtualMeasurementRenderPending" not in UI_JS
# Absolute per-burst ceiling: even an all-distinct key sequence terminates,
# bounding the seen-key memory (#6717 re-gate MUST-FIX 1).
assert "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS" in UI_JS
@@ -0,0 +1,97 @@
"""Regression test for issue #7548:
CLI-archived cron/webhook/kanban sessions reappear from state.db projection (no sidecar case).
"""
import sqlite3
from pathlib import Path
from api.agent_sessions import read_importable_agent_session_rows
from api.models import get_cli_sessions, _state_projection_sidecar_metadata
def _create_state_db(path: Path, rows: list[tuple]):
conn = sqlite3.connect(str(path))
conn.execute(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER,
started_at REAL,
source TEXT,
session_source TEXT,
archived INTEGER DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT,
role TEXT,
timestamp REAL
)
"""
)
for r in rows:
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source, archived) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
r,
)
# insert a dummy user message so actual_message_count > 0
conn.execute(
"INSERT INTO messages (session_id, role, timestamp) VALUES (?, 'user', ?)",
(r[0], r[4]),
)
conn.commit()
conn.close()
def test_cli_archived_session_without_sidecar_retains_archived_state(tmp_path, monkeypatch):
"""When a session is marked archived in state.db and has no WebUI sidecar,
the projection must preserve archived=True and not reset it to False."""
db_path = tmp_path / "state.db"
session_rows = [
("cron-active-1", "Cron Active", "model", 1, 1000.0, "cron", "cron", 0),
("cron-archived-1", "Cron Archived", "model", 1, 1000.0, "cron", "cron", 1),
]
_create_state_db(db_path, session_rows)
# Test read_importable_agent_session_rows directly
projected = read_importable_agent_session_rows(
db_path,
limit=200,
exclude_sources=None,
include_sources=("cron",),
)
by_id = {r["id"]: r for r in projected}
assert "cron-active-1" in by_id
assert "cron-archived-1" in by_id
assert bool(by_id["cron-active-1"].get("archived")) is False
assert bool(by_id["cron-archived-1"].get("archived")) is True
# Test _state_projection_sidecar_metadata returns archived: None when no sidecar exists
meta = _state_projection_sidecar_metadata("cron-archived-1")
assert meta.get("archived") is None
# Test get_cli_sessions() projection
from api.models import clear_cli_sessions_cache
clear_cli_sessions_cache()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import api.profiles # noqa: F401 # makes the monkeypatch target below importable
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: str(tmp_path))
except Exception:
pass
monkeypatch.setattr("api.models._active_state_db_path", lambda: db_path)
monkeypatch.setattr("api.models.SESSION_DIR", tmp_path / "webui_sessions")
(tmp_path / "webui_sessions").mkdir(parents=True, exist_ok=True)
cli_sessions = get_cli_sessions(source_filter="cron")
cli_by_id = {s["session_id"]: s for s in cli_sessions}
assert "cron-active-1" in cli_by_id
assert "cron-archived-1" in cli_by_id
assert cli_by_id["cron-active-1"]["archived"] is False
assert cli_by_id["cron-archived-1"]["archived"] is True
@@ -0,0 +1,185 @@
"""Settled writeback must extract inner steer text from typed steer rows (#7600).
A mid-turn ``/steer`` is emitted as a standalone typed user row
(``role: 'user'``, ``display_kind: 'steer'``). The surrounding
``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block is
transport-level control data. Settlement unwraps the single validated frame
in place, preserving the user's authored steer instruction while dropping
the wrapper.
Legacy tool rows and rows where markers are multiple, nested, incomplete,
or contain ambiguous delimiters are preserved byte-for-byte.
"""
from __future__ import annotations
import copy
import json
from api.models import Session
from api.streaming import _settle_result_messages, _unwrap_steer_row_oob_marker
from api import streaming as _streaming
PROMPT = "please run the smoke checks"
OOB_OPEN = (
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once "
"at this position; not tool output and not a new delivery when replayed from "
"conversation history]"
)
OOB_CLOSE = "[/OUT-OF-BAND USER MESSAGE]"
OOB_BLOCK = f"{OOB_OPEN}\nsteer: use the staging bucket this time\n{OOB_CLOSE}"
OOB_VARIANT = f"[OUT-OF-BAND USER MESSAGE]steer: also bump the timeout{OOB_CLOSE}"
def _contains_oob(value) -> bool:
return "OUT-OF-BAND USER MESSAGE" in json.dumps(value)
def _session_with_prior_turn() -> Session:
"""Prior turn already settled: user -> assistant(tool_call) -> tool -> answer."""
display = [
{"role": "user", "content": "deploy the app", "timestamp": 1788439000},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-1"}],
"timestamp": 1788439005,
},
{
"role": "tool",
"tool_call_id": "call-1",
"content": "deploy finished: revision 41",
"timestamp": 1788439010,
},
{"role": "assistant", "content": "Deploy done (revision 41).", "timestamp": 1788439015},
]
session = Session(session_id="7" * 12, title="steer writeback", messages=copy.deepcopy(display))
session.context_messages = copy.deepcopy(display)
return session
def _settle_turn_with_steer(session, monkeypatch, *, tool_content: str = "checks passed", steer_rows=None):
"""Settle one turn with an optional steer row emitted by the Agent."""
monkeypatch.setattr(
_streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None
)
previous = list(session.messages)
previous_context = list(session.context_messages)
ts = 1788440000
steers = list(steer_rows or [])
result = copy.deepcopy(previous_context) + [
{"role": "user", "content": PROMPT, "timestamp": ts},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-2"}],
"timestamp": ts + 1,
},
{
"role": "tool",
"tool_call_id": "call-2",
"content": tool_content,
"timestamp": ts + 2,
},
*steers,
{"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3},
]
_settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None)
return previous
def test_settle_unwraps_steer_row_in_place_preserving_user_text(monkeypatch):
"""A typed steer row is unwrapped in place: inner text preserved, wrapper gone."""
session = _session_with_prior_turn()
steer_row = {
"role": "user",
"display_kind": "steer",
"content": OOB_BLOCK,
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
# The outer wrapper is dropped from display and context
assert not _contains_oob(session.messages)
assert not _contains_oob(session.context_messages)
# The user's inner steer text is preserved exactly
steer_contents = [
m.get("content")
for m in session.messages
if isinstance(m, dict) and m.get("display_kind") == "steer"
]
assert steer_contents == ["steer: use the staging bucket this time"]
# In-place mutation preserved the dictionary object identity
assert steer_row["content"] == "steer: use the staging bucket this time"
def test_settle_unwraps_list_based_content_steer_row(monkeypatch):
"""List-based content parts on a steer row are unwrapped too."""
session = _session_with_prior_turn()
steer_row = {
"role": "user",
"display_kind": "steer",
"content": [{"type": "text", "text": OOB_VARIANT}],
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
assert not _contains_oob(session.messages)
assert not _contains_oob(session.context_messages)
assert steer_row["content"] == [{"type": "text", "text": "steer: also bump the timeout"}]
def test_settle_leaves_legacy_tool_rows_alone(monkeypatch):
"""Legacy tool rows carrying raw markers are left alone (not truncated/deleted)."""
session = _session_with_prior_turn()
tool_text = f"checks passed\n\n{OOB_BLOCK}"
_settle_turn_with_steer(session, monkeypatch, tool_content=tool_text)
tool_bodies = [
m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool"
]
assert tool_text in tool_bodies
def test_settle_preserves_malformed_nested_and_multiple_blocks_byte_for_byte():
"""Nested markers, multiple frames, and incomplete markers degrade gracefully."""
nested = f"{OOB_OPEN}\nhello {OOB_VARIANT} world\n{OOB_CLOSE}"
multiple = f"{OOB_VARIANT} and {OOB_BLOCK}"
incomplete = "[OUT-OF-BAND USER MESSAGE — truncated"
for malformed in (nested, multiple, incomplete):
steer_row = {
"role": "user",
"display_kind": "steer",
"content": malformed,
"timestamp": 1788440002.5,
}
_unwrap_steer_row_oob_marker(steer_row)
assert steer_row["content"] == malformed, f"malformed content was corrupted: {malformed!r}"
def test_settle_keeps_normal_user_pasted_marker_intact(monkeypatch):
"""A normal user row that quotes a complete marker survives byte-for-byte."""
pasted = (
"our bot log shows this block, is that normal?\n"
f"{OOB_BLOCK}\n"
"the docs say the gateway adds it"
)
session = _session_with_prior_turn()
session.messages[0]["content"] = pasted
session.context_messages = copy.deepcopy(session.messages)
steer_row = {
"role": "user",
"display_kind": "steer",
"content": OOB_BLOCK,
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
# The normal user row still carries its quoted block intact
assert session.messages[0]["content"] == pasted
# The steer row had its wrapper unwrapped
assert session.messages[-2]["content"] == "steer: use the staging bucket this time"
@@ -816,20 +816,26 @@ def test_stream_end_restore_attaches_projected_anchor_scene_before_render():
filter_idx = restore.index("S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);")
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})")
assert carry_idx < filter_idx < attach_idx < render_idx
offset_idx = restore.index("_oldestIdx=session._messages_offset||0")
assert offset_idx < carry_idx < filter_idx < attach_idx < render_idx
assert "_stagedMatchesCurrentSuffix" in restore
def test_cancel_settlement_attaches_projected_anchor_scene_before_render():
cancel = _event_listener_body(MESSAGES_JS, "cancel")
fetch_idx = cancel.index("const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);")
offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0")
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
render_idx = cancel.index("renderMessages({preserveScroll:true});")
assert fetch_idx < attach_idx < carry_idx < render_idx
assert fetch_idx < offset_idx < attach_idx < carry_idx < render_idx
embedded_idx = cancel.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;")
fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);")
# #7310/#7625: the HTTP fallback is a bounded tail now (full-transcript
# reloads were stacking on every cancel recovery); ordering contract below
# is unchanged.
fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);")
fallback_apply_idx = cancel.index("if(data&&data.session) _applyCancelSessionPayload(data.session);")
assert embedded_idx < fallback_get_idx < fallback_apply_idx
+482
View File
@@ -0,0 +1,482 @@
"""MCP status, inventory and /reload-mcp follow the request profile.
Two profiles configure a stdio MCP server with the same name (``atlassian``):
one read-only, one with write tools. These tests drive the real Hermes Agent MCP
runtime with a local fake MCP server (no network, no credentials) and check
that each profile sees and reloads only its own connection.
Chat turns are simulated the way ``api/streaming.py`` runs them: the session
profile's home is mirrored into ``os.environ['HERMES_HOME']`` and installed as
the context-local Hermes-home override before ``discover_mcp_tools()``.
"""
from __future__ import annotations
import json
import os
import sys
import textwrap
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
try:
import mcp # noqa: F401
import hermes_constants
from agent.secret_scope import current_secret_scope, serves_routed_profile # noqa: F401
from tools import mcp_tool as mcp_core
from tools.mcp_tool_discovery import discover_mcp_tools
from tools.mcp_tool_lifecycle import shutdown_mcp_servers
except Exception as exc: # pragma: no cover - depends on the installed agent
pytest.skip(
f"requires hermes-agent with profile-scoped MCP (serves_routed_profile) and the mcp SDK: {exc}",
allow_module_level=True,
)
from api import profiles
from api.commands import execute_agent_command
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
requires_process_home_pin = pytest.mark.skipif(
not hasattr(hermes_constants, "pin_process_hermes_home"),
reason="streaming turns need hermes_constants.pin_process_hermes_home for profile-scoped MCP keys",
)
SCOPE_PROFILE = "profile"
SCOPE_UNAVAILABLE = "unavailable"
READ_TOOLS = {"mcp__atlassian__jira_get_issue", "mcp__atlassian__confluence_search"}
WRITE_TOOL = "mcp__atlassian__jira_create_issue"
FAKE_SERVER = textwrap.dedent('''
import os
try: # mcp 1.x (CI pins mcp<2)
from mcp.server.fastmcp import FastMCP
except ImportError: # mcp 2.x renamed the high-level server to MCPServer
from mcp.server import MCPServer as FastMCP
mcp = FastMCP("fake-atlassian")
@mcp.tool()
def jira_get_issue(key: str) -> str:
"""Read a Jira issue (fake)."""
return key
@mcp.tool()
def confluence_search(query: str) -> str:
"""Search Confluence (fake)."""
return "[]"
if os.environ.get("READ_ONLY_MODE", "true").lower() == "false":
@mcp.tool()
def jira_create_issue(summary: str) -> str:
"""Create a Jira issue (fake, write)."""
return "created"
mcp.run()
''')
def _server_cfg(script: Path, *, read_only: bool, account: str) -> dict:
# Distinct accounts, like distinct Atlassian credentials: identical routes would
# legitimately share one connection (hermes-agent adopts matching routes).
return {
"command": sys.executable,
"args": [str(script)],
"env": {"READ_ONLY_MODE": "true" if read_only else "false", "FAKE_ACCOUNT": account},
"timeout": 30,
"connect_timeout": 30,
}
def _write_config(home: Path, servers: dict) -> None:
import yaml
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}), encoding="utf-8")
class Env:
def __init__(self, base: Path, script: Path):
self.base = base
self.script = script
def home(self, profile: str) -> Path:
return self.base if profile == "default" else self.base / "profiles" / profile
def configure(self, profile: str, **servers) -> None:
_write_config(self.home(profile), servers)
def atlassian(self, profile: str, *, read_only: bool) -> dict:
return _server_cfg(self.script, read_only=read_only, account=profile)
def chat_turn(self, profile: str) -> list[str]:
home = str(self.home(profile))
old = os.environ.get("HERMES_HOME")
os.environ["HERMES_HOME"] = home
token = hermes_constants.set_hermes_home_override(home)
try:
return discover_mcp_tools()
finally:
hermes_constants.reset_hermes_home_override(token)
os.environ["HERMES_HOME"] = old
def call(self, fn, profile: str) -> dict:
profiles.set_request_profile(profile)
try:
handler = MagicMock()
fn(handler)
return json.loads(handler.wfile.write.call_args[0][0])
finally:
profiles.clear_request_profile()
def servers(self, profile: str) -> dict:
payload = self.call(_handle_mcp_servers_list, profile)
return {srv["name"]: srv for srv in payload["servers"]} | {"_scope": payload.get("runtime_scope")}
def tools(self, profile: str) -> dict:
return self.call(_handle_mcp_tools_list, profile)
def reload(self, profile: str) -> str:
profiles.set_request_profile(profile)
try:
return execute_agent_command("/reload-mcp")
finally:
profiles.clear_request_profile()
def connection(self, profile: str):
"""The live connection object owned by *profile* for ``atlassian``."""
key = (hermes_constants.hermes_home_key(self.home(profile)), "atlassian")
if profile == "default":
key = "atlassian"
with mcp_core._lock:
return mcp_core._servers.get(key)
@pytest.fixture
def mcp_env(tmp_path, monkeypatch):
base = tmp_path / "hermes-home"
base.mkdir()
script = tmp_path / "fake_atlassian.py"
script.write_text(FAKE_SERVER, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(base))
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False) # suite-wide config pin
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", getattr(profiles, "_PROCESS_PROFILE_HOME", None), raising=False)
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
monkeypatch.setattr(
hermes_constants, "_PINNED_PROCESS_HERMES_HOME", hermes_constants._PINNED_PROCESS_HERMES_HOME
)
# What init_profile_state() does at startup for a WebUI launched on ``base``.
pin = getattr(profiles, "_pin_process_profile_home", None) or getattr(
hermes_constants, "pin_process_hermes_home", lambda _home: None
)
pin(base)
shutdown_mcp_servers()
env = Env(base, script)
env.configure("default")
env.configure("profile-read", atlassian=env.atlassian("profile-read", read_only=True))
env.configure("profile-write", atlassian=env.atlassian("profile-write", read_only=False))
yield env
shutdown_mcp_servers()
def _tool_names(payload: dict) -> set[str]:
return {tool["name"] for tool in payload["tools"]}
@requires_process_home_pin
def test_same_named_servers_stay_isolated_per_profile(mcp_env):
read_tools = set(mcp_env.chat_turn("profile-read"))
write_tools = set(mcp_env.chat_turn("profile-write"))
assert WRITE_TOOL not in read_tools
assert WRITE_TOOL in write_tools
assert mcp_env.connection("profile-read") is not mcp_env.connection("profile-write")
for profile, expect_write in (("profile-read", False), ("profile-write", True)):
servers = mcp_env.servers(profile)
inventory = mcp_env.tools(profile)
names = _tool_names(inventory)
assert servers["atlassian"]["status"] == "active"
assert READ_TOOLS <= names
assert (WRITE_TOOL in names) is expect_write
# Status counter and inventory describe the same connection.
assert servers["atlassian"]["tool_count"] == inventory["total"] == len(names)
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
assert servers["_scope"] == SCOPE_PROFILE
# The root profile configures no MCP server and must not see either profile's tools.
default_servers = mcp_env.servers("default")
assert set(default_servers) == {"_scope"}
assert mcp_env.tools("default")["total"] == 0
def test_status_and_inventory_are_passive(mcp_env):
for profile in ("profile-write", "profile-read", "default"):
servers = mcp_env.servers(profile)
inventory = mcp_env.tools(profile)
if profile != "default":
assert servers["atlassian"]["status"] == "configured"
assert not servers["atlassian"]["active"]
assert inventory["unavailable_servers"] == ["atlassian"]
assert inventory["total"] == 0
with mcp_core._lock:
assert not mcp_core._servers
assert not mcp_core._server_connecting
@requires_process_home_pin
def test_reload_applies_the_requested_profiles_config_only(mcp_env):
# profile-write connected while its config was still read-only (stale runtime).
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
read_conn = mcp_env.connection("profile-read")
stale_write_conn = mcp_env.connection("profile-write")
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
stale = mcp_env.servers("profile-write")["atlassian"]
assert stale["env"]["READ_ONLY_MODE"] == "false"
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-write"))
output = mcp_env.reload("profile-write")
assert "Reconnected: atlassian" in output
assert mcp_env.connection("profile-write") is not stale_write_conn
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
# The other profile's same-named connection was neither stopped nor replaced.
assert mcp_env.connection("profile-read") is read_conn
assert read_conn.session is not None
read_inventory = mcp_env.tools("profile-read")
assert WRITE_TOOL not in _tool_names(read_inventory)
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_inventory["total"]
@requires_process_home_pin
def test_reloading_an_adopting_profile_keeps_the_owners_connection(mcp_env):
"""Identical routes share one connection; the adopter's reload must not stop it."""
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-read", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
owner_conn = mcp_env.connection("profile-read")
assert mcp_env.connection("profile-write") is None # adopted, not owned
# The adopted connection serves profile-write: status, inventory and the
# reload summary all count it (it is not "Added" from the user's point of view).
assert mcp_env.servers("profile-write")["atlassian"]["status"] == "active"
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-write"))
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
output = mcp_env.reload("profile-write")
assert "Reconnected: atlassian" in output and "Added" not in output
assert mcp_env.connection("profile-read") is owner_conn
assert owner_conn.session is not None
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-read"))
@requires_process_home_pin
def test_deleting_an_adopting_profiles_last_server_detaches_its_tools_on_reload(mcp_env):
"""A profile that adopted another profile's identical connection deletes its only
server: its reload must drop its tools (they were callable after reload before)
while the owner's connection keeps running."""
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-read", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
owner_conn = mcp_env.connection("profile-read")
assert mcp_env.connection("profile-write") is None # adopted, not owned
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-write"))
mcp_env.configure("profile-write") # no server left
output = mcp_env.reload("profile-write")
assert "Removed: atlassian" in output and "Reconnected" not in output
assert mcp_env.tools("profile-write")["total"] == 0
from tools.registry import registry
write_scope = hermes_constants.hermes_home_key(mcp_env.home("profile-write"))
assert all(registry.snapshot_registration(t, scope=write_scope) is None for t in READ_TOOLS)
assert mcp_env.connection("profile-read") is owner_conn
assert owner_conn.session is not None
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-read"))
@requires_process_home_pin
def test_root_profile_reload_leaves_named_profiles_running(mcp_env):
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
mcp_env.chat_turn("default")
mcp_env.chat_turn("profile-write")
root_conn = mcp_env.connection("default")
write_conn = mcp_env.connection("profile-write")
assert root_conn is not None and write_conn is not None
assert "Reconnected: atlassian" in mcp_env.reload("default")
assert mcp_env.connection("default") is not root_conn
assert mcp_env.connection("profile-write") is write_conn
assert write_conn.session is not None
assert WRITE_TOOL not in _tool_names(mcp_env.tools("default"))
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
@requires_process_home_pin
def test_root_profile_status_never_shows_a_routed_profiles_connection(mcp_env):
"""The agent's launch-profile view is process-wide; the root profile must only
report its own (unscoped) connection, never a routed profile's."""
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
mcp_env.chat_turn("profile-write")
assert mcp_env.connection("profile-write") is not None
assert mcp_env.connection("default") is None
root = mcp_env.servers("default")
assert root["_scope"] == SCOPE_PROFILE
assert root["atlassian"]["status"] == "configured" and not root["atlassian"]["active"]
assert not root["atlassian"]["tool_count"]
assert mcp_env.tools("default")["total"] == 0
mcp_env.chat_turn("default")
root = mcp_env.servers("default")
root_inventory = mcp_env.tools("default")
assert root["atlassian"]["status"] == "active"
# Status counter and inventory describe the root profile's own connection
# (the agent also registers generic resource/prompt tools per server).
assert root["atlassian"]["tool_count"] == root_inventory["total"]
assert READ_TOOLS <= _tool_names(root_inventory)
assert WRITE_TOOL not in _tool_names(root_inventory)
@requires_process_home_pin
def test_reload_retries_a_server_that_failed_to_spawn(mcp_env):
"""A failed spawn sits under the agent's connect backoff (30s+) and never enters
the ledger, so a scoped shutdown leaves that backoff alone; the profile's reload
must still retry it, without dropping another profile's backoff."""
broken = {"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5}
mcp_env.configure("default", atlassian=broken)
mcp_env.configure("profile-write", atlassian=broken)
mcp_env.chat_turn("default")
mcp_env.chat_turn("profile-write")
# A permanent spawn failure is parked (task retained, no session) or dropped,
# depending on the agent; either way nothing is connected.
for profile in ("default", "profile-write"):
parked = mcp_env.connection(profile)
assert parked is None or parked.session is None
assert mcp_env.servers(profile)["atlassian"]["status"] == "configured"
retry_after = getattr(mcp_core, "_server_connect_retry_after", None)
if retry_after is None:
pytest.skip("agent has no connect-retry cooldown ledger")
write_key = (hermes_constants.hermes_home_key(mcp_env.home("profile-write")), "atlassian")
with mcp_core._lock:
assert write_key in retry_after and "atlassian" in retry_after
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
assert "Added: atlassian" in mcp_env.reload("profile-write")
assert mcp_env.connection("profile-write") is not None
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
with mcp_core._lock:
assert write_key not in retry_after
assert "atlassian" in retry_after # the root profile's own backoff is untouched
assert mcp_env.servers("default")["atlassian"]["status"] == "configured"
def test_unconfirmed_scope_hides_runtime_and_refuses_reload(mcp_env, monkeypatch):
"""A live turn's HERMES_HOME mirror on an agent without the pin skews routing."""
mcp_env.chat_turn("profile-read")
before = dict(mcp_core._servers)
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None)
monkeypatch.setenv("HERMES_HOME", str(mcp_env.home("profile-write"))) # mid-turn mirror
servers = mcp_env.servers("profile-write")
inventory = mcp_env.tools("profile-write")
assert servers["atlassian"]["status"] == "configured"
assert servers["atlassian"]["tool_count"] is None
assert inventory["total"] == 0
with pytest.raises(RuntimeError, match="scope could not be confirmed"):
mcp_env.reload("profile-write")
assert dict(mcp_core._servers) == before
assert servers["_scope"] == inventory.get("runtime_scope") == SCOPE_UNAVAILABLE
@requires_process_home_pin
def test_disabled_and_failed_servers_are_reported_per_profile(mcp_env):
mcp_env.configure(
"profile-write",
atlassian=mcp_env.atlassian("profile-write", read_only=False),
off={**mcp_env.atlassian("profile-write", read_only=False), "enabled": False},
broken={"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5},
invalid="not-a-mapping",
)
mcp_env.chat_turn("profile-write")
servers = mcp_env.servers("profile-write")
assert servers["atlassian"]["status"] == "active"
assert servers["off"]["status"] == "disabled"
assert servers["broken"]["status"] == "configured"
assert not servers["broken"]["active"]
assert servers["invalid"]["status"] == "invalid_config"
inventory = mcp_env.tools("profile-write")
assert inventory["unavailable_servers"] == ["broken"]
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
def test_runtime_scope_is_restored_after_an_exception(mcp_env):
from api.config import _thread_ctx
from api.mcp_runtime import mcp_runtime_scope
previous_env = dict(getattr(_thread_ctx, "env", {}) or {})
profiles.set_request_profile("profile-write")
try:
with pytest.raises(ValueError):
with mcp_runtime_scope("test") as view:
assert view.trusted
assert hermes_constants.get_hermes_home_override() == str(mcp_env.home("profile-write"))
assert current_secret_scope() is not None
raise ValueError("boom")
finally:
profiles.clear_request_profile()
assert hermes_constants.get_hermes_home_override() is None
assert current_secret_scope() is None
assert dict(getattr(_thread_ctx, "env", {}) or {}) == previous_env
assert os.environ["HERMES_HOME"] == str(mcp_env.base)
@requires_process_home_pin
def test_concurrent_profile_reads_and_reload_never_cross_profiles(mcp_env):
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
read_count = mcp_env.servers("profile-read")["atlassian"]["tool_count"]
write_count = mcp_env.servers("profile-write")["atlassian"]["tool_count"]
assert read_count != write_count
barrier = threading.Barrier(3)
failures: list[str] = []
def poll(profile: str, expected: int):
barrier.wait()
for _ in range(20):
srv = mcp_env.servers(profile)["atlassian"]
names = _tool_names(mcp_env.tools(profile))
allowed = (expected,) if profile == "profile-read" else (expected, 0, None)
if srv["tool_count"] not in allowed:
failures.append(f"{profile} saw {srv['tool_count']} tools")
if profile == "profile-read" and WRITE_TOOL in names:
failures.append("profile-read inventory leaked a write tool")
def reload_write():
barrier.wait()
mcp_env.reload("profile-write")
threads = [
threading.Thread(target=poll, args=("profile-read", read_count)),
threading.Thread(target=poll, args=("profile-write", write_count)),
threading.Thread(target=reload_write),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=120)
assert not failures
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_count
assert mcp_env.servers("profile-write")["atlassian"]["tool_count"] == write_count
@@ -0,0 +1,649 @@
"""Agent-free contract tests for profile-scoped MCP status, inventory and /reload-mcp.
``tests/test_mcp_runtime_profile_scope.py`` drives the real Hermes Agent MCP
runtime; CI has no Hermes Agent checkout, so this file models the small agent
surface WebUI relies on (context-local home override, routed-profile predicate,
scoped registry slots, scoped MCP ledger with adoption and connect cooldowns)
and pins the WebUI side of the contract: which profile is bound, which scope is
reset, what the root profile may see, and the fail-closed paths.
"""
from __future__ import annotations
import json
import os
import sys
import threading
import types
from contextvars import ContextVar
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
import yaml
from api import profiles
class FakeAgent:
"""Minimal model of the hermes-agent MCP surface used by WebUI.
Mirrors the agent's visibility rules: a routed scope sees the connections it
owns or adopted; the launch profile's view (scope ``None``) is process-wide.
"""
def __init__(self, *, pin_supported=True, scoped=True):
self.override: ContextVar[Optional[str]] = ContextVar("fake_home_override", default=None)
self.secret: ContextVar[Optional[dict]] = ContextVar("fake_secret_scope", default=None)
self.pinned = None
self.servers = {} # ledger key -> connection
self.owners = {} # ledger key -> owner scope
self.adopters = {} # ledger key -> {adopter scope}
self.connecting = set()
self.errors = {} # ledger key -> error text
self.retry_after = {} # ledger key -> deadline (connect cooldown)
self.failures = {} # ledger key -> failure count
self.lazy = {} # ledger key -> cfg
self.global_tools = {} # tool name -> (toolset, schema)
self.scoped_tools = {} # scope -> {tool name -> (toolset, schema)}
self.calls = []
self.lock = threading.Lock()
self.modules = self._build_modules(pin_supported, scoped)
# hermes_constants
def home(self):
return self.override.get() or os.environ.get("HERMES_HOME", "")
@staticmethod
def key(path):
return str(Path(path).resolve())
def routed(self):
override = self.override.get()
anchor = self.pinned or os.environ.get("HERMES_HOME", "")
return override is not None and self.key(override) != self.key(anchor)
def scope(self):
return self.key(self.home()) if self.routed() else None
def _merged(self, scope):
return {**self.global_tools, **self.scoped_tools.get(scope, {})}
def _slot(self, scope):
return self.global_tools if scope is None else self.scoped_tools.setdefault(scope, {})
@staticmethod
def ledger_key(name, scope):
return name if scope is None else (scope, name)
def visible(self, key, scope):
"""The agent's ``_server_visible_in_scope``: process-wide for scope ``None``."""
if scope is None:
return True
return self.owners.get(key) == scope or scope in self.adopters.get(key, ())
def connect(self, name, tools, *, scope):
key = self.ledger_key(name, scope)
self.servers[key] = SimpleNamespace(name=name, session=object(), _registered_tool_names=list(tools))
self.owners[key] = scope
for tool in tools:
self._slot(scope)[tool] = (f"mcp-{name}", {"description": f"{tool} ({scope})"})
return self.servers[key]
def adopt(self, name, *, owner, adopter):
"""*adopter* shares *owner*'s identical connection (agent: ``_server_tool_scopes``)."""
key = self.ledger_key(name, owner)
self.adopters.setdefault(key, set()).add(adopter)
for tool in self.servers[key]._registered_tool_names:
self._slot(adopter)[tool] = (f"mcp-{name}", {"description": f"{tool} ({adopter})"})
def park(self, name, *, scope, error="spawn failed"):
"""A permanent spawn failure the agent parks: task retained in ``_servers``
without a session, error + backoff recorded under its key."""
key = self.ledger_key(name, scope)
self.servers[key] = SimpleNamespace(name=name, session=None, _registered_tool_names=[])
self.owners[key] = scope
self.fail(name, scope=scope, error=error)
return self.servers[key]
def fail(self, name, *, scope, error="spawn failed"):
"""A connect failure: never in ``_servers``; error + backoff recorded under its key."""
key = self.ledger_key(name, scope)
self.errors[key] = error
self.failures[key] = self.failures.get(key, 0) + 1
self.retry_after[key] = float("inf")
def _build_modules(self, pin_supported, scoped):
agent = self
hc: Any = types.ModuleType("hermes_constants")
hc.set_hermes_home_override = lambda path: agent.override.set(None if path is None else str(path))
hc.reset_hermes_home_override = lambda token: agent.override.reset(token)
hc.get_hermes_home_override = lambda: agent.override.get()
hc.hermes_home_key = lambda path=None: agent.key(path if path is not None else agent.home())
if pin_supported:
def pin(path):
agent.pinned = None if path is None else str(path)
hc.pin_process_hermes_home = pin
agent_pkg: Any = types.ModuleType("agent")
agent_pkg.__path__ = []
secret: Any = types.ModuleType("agent.secret_scope")
secret.set_secret_scope = lambda mapping: agent.secret.set(dict(mapping))
secret.reset_secret_scope = lambda token: agent.secret.reset(token)
secret.current_secret_scope = lambda: agent.secret.get()
if scoped:
secret.serves_routed_profile = agent.routed
tools_pkg: Any = types.ModuleType("tools")
tools_pkg.__path__ = []
registry_mod: Any = types.ModuleType("tools.registry")
class Registry:
@staticmethod
def current_scope_key():
return agent.key(agent.home())
def _view(self):
return agent._merged(agent.scope() if scoped else None)
def get_all_tool_names(self):
return sorted(self._view())
def get_toolset_for_tool(self, name):
return self._view().get(name, (None, None))[0]
def get_schema(self, name):
return self._view().get(name, (None, None))[1]
def snapshot_registration(self, name, *, scope=None):
slot = agent.global_tools if scope is None else agent.scoped_tools.get(scope, {})
return slot.get(name)
registry_mod.registry = Registry()
scope_mod: Any = types.ModuleType("tools.mcp_tool_scope")
scope_mod._key_name = lambda key: key[1] if isinstance(key, tuple) else key
scope_mod._key_scope = lambda key: key[0] if isinstance(key, tuple) else None
mcp_mod: Any = types.ModuleType("tools.mcp_tool")
mcp_mod._servers = agent.servers
mcp_mod._lock = agent.lock
mcp_mod._server_scope_keys = agent.owners
mcp_mod._server_tool_scopes = agent.adopters
mcp_mod._server_connecting = agent.connecting
mcp_mod._server_connect_errors = agent.errors
mcp_mod._server_connect_retry_after = agent.retry_after
mcp_mod._server_connect_failures = agent.failures
mcp_mod._lazy_server_configs = agent.lazy
def get_mcp_status(configured=None):
agent.calls.append(("status", agent.override.get(), dict(configured or {})))
scope = agent.scope() if scoped else None
with agent.lock:
# As the agent: keyed by bare name, last key wins (process-wide for scope None).
active = {scope_mod._key_name(k): s for k, s in agent.servers.items() if agent.visible(k, scope)}
errors = {scope_mod._key_name(k): e for k, e in agent.errors.items() if agent.visible(k, scope)}
rows = []
for name, cfg in (configured or {}).items():
enabled = cfg.get("enabled", True) is not False
server = active.get(name)
live = server is not None and server.session is not None
status = ("connected" if live else "disabled" if not enabled
else "failed" if name in errors else "configured")
row = {"name": name, "connected": live, "disabled": not enabled, "status": status,
"tools": len(server._registered_tool_names) if live else 0}
if status == "failed":
row["error"] = errors[name]
rows.append(row)
return rows
def shutdown_mcp_servers(*, scope=None, names=None, timeout=15.0):
agent.calls.append(("shutdown", scope, None if names is None else set(names)))
wildcard = scope is None and names is None
with agent.lock:
selected = [k for k in agent.servers
if wildcard or (agent.owners.get(k) == scope
and (names is None or scope_mod._key_name(k) in names))]
for key in selected:
del agent.servers[key]
agent.owners.pop(key, None)
agent.adopters.pop(key, None)
# Cooldown/error state: the wildcard drops everything, a scoped call
# only what it tore down (the agent's ``selected_status``).
for key in (list(agent.errors) if wildcard else selected):
agent.errors.pop(key, None)
agent.retry_after.pop(key, None)
agent.failures.pop(key, None)
def register_mcp_servers(servers):
"""The agent's overlay heal for the current scope: a server serving this
scope through adoption whose config entry is gone (or omitted from
``servers`` and absent from the profile's config) loses only this
profile's overlay; the owner's connection is untouched."""
agent.calls.append(("register", agent.override.get(), dict(servers)))
scope = agent.scope() if scoped else None
if scope is None:
return []
cfg = yaml.safe_load(Path(agent.home(), "config.yaml").read_text()) or {}
configured = cfg.get("mcp_servers") or {}
with agent.lock:
for key, adopters in list(agent.adopters.items()):
name = scope_mod._key_name(key)
if scope in adopters and name not in servers and name not in configured:
adopters.discard(scope)
slot = agent.scoped_tools.get(scope, {})
for tool in [t for t, (ts, _) in slot.items() if ts == f"mcp-{name}"]:
del slot[tool]
return sorted(agent._merged(scope))
def discover_mcp_tools():
home = agent.home()
agent.calls.append(("discover", agent.override.get()))
cfg = yaml.safe_load(Path(home, "config.yaml").read_text()) or {}
scope = agent.scope() if scoped else None
names = []
if not cfg.get("mcp_servers"):
return names # as the agent: nothing configured, no reconcile pass
for name, srv in (cfg.get("mcp_servers") or {}).items():
key = agent.ledger_key(name, scope)
served = any(scope_mod._key_name(k) == name and agent.visible(k, scope) and scope is not None
for k in agent.servers) or key in agent.servers
if served:
names += [t for k, s in agent.servers.items()
if scope_mod._key_name(k) == name for t in s._registered_tool_names]
continue
if key in agent.retry_after: # backoff honoured: no retry
continue
if srv.get("fake_fail"):
agent.fail(name, scope=scope, error=str(srv["fake_fail"]))
continue
agent.connect(name, srv.get("fake_tools", []), scope=scope)
names += srv.get("fake_tools", [])
return names
mcp_mod.get_mcp_status = get_mcp_status
mcp_mod.shutdown_mcp_servers = shutdown_mcp_servers
mcp_mod.discover_mcp_tools = discover_mcp_tools
mcp_mod.register_mcp_servers = register_mcp_servers
return {
"hermes_constants": hc,
"agent": agent_pkg,
"agent.secret_scope": secret,
"tools": tools_pkg,
"tools.registry": registry_mod,
"tools.mcp_tool_scope": scope_mod,
"tools.mcp_tool": mcp_mod,
"tools.mcp_tool_discovery": mcp_mod,
"tools.mcp_tool_lifecycle": mcp_mod,
}
READ = ["mcp__atlassian__jira_get_issue"]
WRITE = READ + ["mcp__atlassian__jira_create_issue"]
def _install(monkeypatch, tmp_path, **agent_kwargs):
agent = FakeAgent(**agent_kwargs)
for name, module in agent.modules.items():
monkeypatch.setitem(sys.modules, name, module)
base = tmp_path / "home"
for profile, tools in (("default", None), ("profile-read", READ), ("profile-write", WRITE)):
home = base if profile == "default" else base / "profiles" / profile
home.mkdir(parents=True, exist_ok=True)
servers = {} if tools is None else {"atlassian": {"command": "fake", "fake_tools": tools}}
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
monkeypatch.setenv("HERMES_HOME", str(base))
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
monkeypatch.setattr(profiles, "_secret_scope_available", None)
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", None)
profiles._pin_process_profile_home(base)
agent.base = base
return agent
def _configure(agent, profile, servers):
home = agent.base if profile == "default" else agent.base / "profiles" / profile
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
def _as_profile(profile, fn, *args):
profiles.set_request_profile(profile)
try:
return fn(*args)
finally:
profiles.clear_request_profile()
def _call(profile, handler_fn):
handler = MagicMock()
_as_profile(profile, handler_fn, handler)
return json.loads(handler.wfile.write.call_args[0][0])
def _reload(profile):
from api.commands import execute_agent_command
return _as_profile(profile, execute_agent_command, "/reload-mcp")
def _profile_scope(agent, profile):
return agent.key(agent.base / "profiles" / profile)
def _servers_by_name(payload):
return {row["name"]: row for row in payload["servers"]}
def test_init_pins_the_process_profile_home_for_the_agent(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
assert agent.pinned == str(agent.base)
assert profiles.get_process_profile_home() == agent.base
def test_process_wide_home_change_repins_in_the_same_step(monkeypatch, tmp_path):
"""``_set_hermes_home`` is the single writer of the process home: startup and
``switch_profile(process_wide=True)`` both go through it, so the MCP routing
anchor (WebUI and agent pin) can never drift from ``HERMES_HOME``."""
agent = _install(monkeypatch, tmp_path)
new_home = agent.base / "profiles" / "profile-write"
profiles._set_hermes_home(new_home)
assert os.environ["HERMES_HOME"] == str(new_home)
assert profiles.get_process_profile_home() == new_home
assert agent.pinned == str(new_home)
def test_status_and_inventory_bind_the_request_profile(monkeypatch, tmp_path):
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
servers = _call("profile-write", _handle_mcp_servers_list)
tools = _call("profile-write", _handle_mcp_tools_list)
assert servers["runtime_scope"] == tools["runtime_scope"] == "profile"
assert servers["servers"][0]["tool_count"] == 2
assert {t["name"] for t in tools["tools"]} == set(WRITE)
# get_mcp_status ran under the profile's home and was handed the displayed config.
status_calls = [c for c in agent.calls if c[0] == "status"]
assert all(c[1] == str(agent.base / "profiles" / "profile-write") for c in status_calls)
assert all(set(c[2]) == {"atlassian"} for c in status_calls)
# Nothing leaked into the request thread after the calls.
assert agent.override.get() is None and agent.secret.get() is None
read_tools = _call("profile-read", _handle_mcp_tools_list)
assert {t["name"] for t in read_tools["tools"]} == set(READ)
assert _call("default", _handle_mcp_tools_list)["total"] == 0
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
def test_inventory_excludes_the_process_profiles_global_slot(monkeypatch, tmp_path):
from api.routes import _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", WRITE, scope=None) # launch profile's unscoped connection
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
names = {t["name"] for t in _call("profile-read", _handle_mcp_tools_list)["tools"]}
assert names == set(READ)
def test_root_profile_status_hides_routed_profiles_runtime(monkeypatch, tmp_path):
"""The agent's launch-profile view is process-wide; WebUI narrows it to the
root profile's own (unscoped) connections, so a routed profile's live
connection or connect error never shows up as the root profile's."""
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
agent.fail("atlassian", scope=_profile_scope(agent, "profile-read"), error="bad token")
servers = _call("default", _handle_mcp_servers_list)
row = _servers_by_name(servers)["atlassian"]
assert servers["runtime_scope"] == "profile"
assert row["status"] == "configured" and row["active"] is False
assert not row["tool_count"]
assert _call("default", _handle_mcp_tools_list)["total"] == 0
# The root profile's own parked task (failed spawn, no session) must not borrow
# the routed profile's live same-named connection: the agent's process-wide
# view indexes by bare name and the last key wins.
agent.park("atlassian", scope=None, error="exec: not found")
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "configured" and row["active"] is False and not row["tool_count"]
# The root profile's own connection is still reported normally.
agent.connect("atlassian", READ, scope=None)
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(READ)
assert {t["name"] for t in _call("default", _handle_mcp_tools_list)["tools"]} == set(READ)
def test_adopted_connection_serves_status_inventory_and_reload(monkeypatch, tmp_path):
"""A profile that adopted another profile's identical connection sees it as
active; its reload counts it and never tears down the owner's connection."""
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
owner_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.adopt("atlassian", owner=read_scope, adopter=write_scope)
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(READ)
assert {t["name"] for t in _call("profile-write", _handle_mcp_tools_list)["tools"]} == set(READ)
output = _reload("profile-write")
assert "Reconnected: atlassian" in output
assert "Added" not in output and "No MCP servers connected" not in output
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
assert agent.servers[(read_scope, "atlassian")] is owner_conn
assert write_scope in agent.adopters[(read_scope, "atlassian")]
def test_reload_with_an_empty_config_detaches_adopted_tools_but_keeps_the_owner(monkeypatch, tmp_path):
"""Deleting a profile's last server must stop its tools on reload, even when the
connection was adopted from another profile: the adopter's overlay goes, the
owner's connection stays (master's wildcard shutdown stopped the owner too)."""
from api.routes import _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
owner_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.adopt("atlassian", owner=read_scope, adopter=write_scope)
assert {t["name"] for t in _call("profile-write", _handle_mcp_tools_list)["tools"]} == set(READ)
_configure(agent, "profile-write", {}) # the user deleted the profile's only server
output = _reload("profile-write")
assert "Removed: atlassian" in output and "Reconnected" not in output
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
assert not agent.scoped_tools.get(write_scope)
assert write_scope not in agent.adopters.get((read_scope, "atlassian"), ())
# The owner is untouched: same connection, same tools.
assert agent.servers[(read_scope, "atlassian")] is owner_conn
assert {t["name"] for t in _call("profile-read", _handle_mcp_tools_list)["tools"]} == set(READ)
# The teardown stayed scoped to this profile (never the process-wide wildcard) and the
# overlay reconcile ran under this profile's home before rediscovery.
shutdowns = [c for c in agent.calls if c[0] == "shutdown"]
assert shutdowns == [("shutdown", write_scope, {"atlassian"})]
assert ("register", str(agent.base / "profiles" / "profile-write"), {}) in agent.calls
def test_portable_plugin_tools_are_listed_for_their_profile_without_a_config_entry(monkeypatch, tmp_path):
"""The agent merges plugin-provided (portable) MCP servers into the running config;
they never appear in the profile's raw ``mcp_servers``. Their tools must still be
listed for the profile whose registry slot holds them, and for no other profile."""
from api.routes import _handle_mcp_tools_list, _handle_notes_sources_list
agent = _install(monkeypatch, tmp_path)
write_scope = _profile_scope(agent, "profile-write")
agent.connect("notes-plugin", ["mcp__notes-plugin__search_notes"], scope=write_scope)
inventory = _call("profile-write", _handle_mcp_tools_list)
assert [(t["name"], t["server"]) for t in inventory["tools"]] == [
("mcp__notes-plugin__search_notes", "notes-plugin")
]
assert inventory["source"] == "tool_registry"
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
notes = _call("profile-write", _handle_notes_sources_list)
assert [s["name"] for s in notes["sources"]] == ["notes-plugin"]
assert notes["sources"][0]["tool_count"] == 1
# Isolation still holds: another profile's slot does not list them.
assert _call("profile-read", _handle_mcp_tools_list)["total"] == 0
assert _call("default", _handle_mcp_tools_list)["total"] == 0
def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path):
from api.routes import (
_handle_mcp_servers_list,
_handle_mcp_tools_list,
_handle_notes_sources_list,
)
agent = _install(monkeypatch, tmp_path, pin_supported=False)
_configure(agent, "profile-write", {
"atlassian": {"command": "fake", "fake_tools": WRITE},
"joplin": {"command": "fake", "fake_tools": []}, # a notes-drawer source
})
agent.connect("atlassian", READ, scope=None)
monkeypatch.setenv("HERMES_HOME", str(agent.base / "profiles" / "profile-write")) # turn mirror
servers = _call("profile-write", _handle_mcp_servers_list)
assert servers["runtime_scope"] == "unavailable"
assert servers["servers"][0]["status"] == "configured"
assert servers["servers"][0]["tool_count"] is None
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
# The notes drawer reads the same inventory and must be told the runtime is withheld.
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
notes = _call("profile-write", _handle_notes_sources_list)
assert notes["runtime_scope"] == "unavailable"
assert [s["name"] for s in notes["sources"]] == ["joplin"]
assert notes["sources"][0]["active"] is False
with pytest.raises(RuntimeError, match="could not be confirmed"):
_reload("profile-write")
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
assert "atlassian" in agent.servers
def test_reload_resets_only_the_requested_profile(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
read_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.connect("atlassian", READ, scope=write_scope) # stale write-profile connection
output = _reload("profile-write")
assert "Reconnected: atlassian" in output
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
assert agent.servers[(read_scope, "atlassian")] is read_conn
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
def test_reload_retries_the_profiles_failed_servers_only(monkeypatch, tmp_path):
"""A server that failed to spawn is under connect backoff and absent from the
ledger, so a scoped shutdown leaves its cooldown alone; the reload must still
retry it (as the process-wide wildcard did) without touching other owners'."""
agent = _install(monkeypatch, tmp_path)
write_scope = _profile_scope(agent, "profile-write")
agent.fail("atlassian", scope=write_scope, error="exec: not found")
agent.fail("atlassian", scope=None, error="root still broken") # root's own backoff
_configure(agent, "profile-write", {"atlassian": {"command": "fake", "fake_tools": WRITE}})
output = _reload("profile-write")
assert "Added: atlassian" in output
assert ("shutdown", write_scope, set()) in agent.calls
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
assert (write_scope, "atlassian") not in agent.errors
assert "atlassian" in agent.retry_after and "atlassian" in agent.errors # root untouched
def test_root_profile_reload_never_uses_the_wildcard_shutdown(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
agent.connect("atlassian", READ, scope=None)
write_conn = agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
assert "Reconnected: atlassian" in _reload("default")
shutdowns = [c for c in agent.calls if c[0] == "shutdown"]
assert shutdowns == [("shutdown", None, {"atlassian"})]
assert agent.servers[(_profile_scope(agent, "profile-write"), "atlassian")] is write_conn
def test_legacy_agent_keeps_process_wide_reload_but_reads_the_request_profile(monkeypatch, tmp_path):
from api.routes import _handle_mcp_servers_list
agent = _install(monkeypatch, tmp_path, pin_supported=False, scoped=False)
assert _call("profile-write", _handle_mcp_servers_list)["runtime_scope"] == "legacy_process"
_reload("profile-write")
assert ("shutdown", None, None) in agent.calls
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
def test_status_read_survives_an_uninspectable_get_mcp_status(monkeypatch, tmp_path):
"""Signature introspection failing must fall back to the bare call, not blank
every server's status."""
from api.routes import _handle_mcp_servers_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
real = agent.modules["tools.mcp_tool"].get_mcp_status
class Uninspectable:
@property
def __signature__(self):
raise ValueError("no signature")
def __call__(self, *args, **kwargs):
assert not args and not kwargs, "bare call expected"
return real(configured={"atlassian": {"command": "fake"}})
monkeypatch.setattr(agent.modules["tools.mcp_tool"], "get_mcp_status", Uninspectable())
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(WRITE)
def test_ledger_key_helpers_prefer_the_agents_and_fall_back_without_them(monkeypatch, tmp_path):
from api import mcp_runtime
agent = _install(monkeypatch, tmp_path)
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
assert mcp_runtime.mcp_key_scope(("scope-a", "atlassian")) == "scope-a"
assert mcp_runtime.mcp_key_name("atlassian") == "atlassian"
assert mcp_runtime.mcp_key_scope("atlassian") is None
monkeypatch.delitem(sys.modules, "tools.mcp_tool_scope")
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
assert mcp_runtime.mcp_key_scope("atlassian") is None
assert mcp_runtime.accepts_keywords(len, "scope") is False
assert mcp_runtime.accepts_keywords(agent.modules["tools.mcp_tool"].shutdown_mcp_servers, "scope", "names")
def test_readonly_profile_scope_default_is_unchanged_and_root_is_opt_in(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
with profiles.profile_env_for_active_request_readonly("t") as bound:
assert bound is False and agent.override.get() is None
with profiles.profile_env_for_active_request_readonly("t", include_root=True) as bound:
assert bound is True and agent.override.get() == str(agent.base)
assert agent.override.get() is None
def test_runtime_scope_restores_context_after_exception(monkeypatch, tmp_path):
from api.mcp_runtime import mcp_runtime_scope
agent = _install(monkeypatch, tmp_path)
with pytest.raises(KeyError):
_as_profile("profile-write", lambda: _raise_inside(mcp_runtime_scope, agent))
assert agent.override.get() is None and agent.secret.get() is None
def _raise_inside(scope_factory, agent):
with scope_factory("t") as view:
assert view.trusted and agent.override.get() is not None and agent.secret.get() is not None
raise KeyError("boom")
+566
View File
@@ -0,0 +1,566 @@
"""Regression: mobile soft-keyboard Enter must insert a newline, not send.
This module covers the three production changes in this PR, each with a test
that provably fails when only that change is reverted:
A. ``_isTouchOnlyDevice()`` supplements media-query touch detection for phones.
Several iOS Safari builds report ``(any-pointer:fine)`` as **true** on a
plain iPhone (Apple Pencil / pointer-emulation heuristics), so the upstream
``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()`` test
evaluates false on exactly the devices it is meant to detect, and plain
Enter sends instead of inserting a newline. Phone UAs bypass that unreliable
signal, while tablets still honor it so attached hardware keyboards retain
desktop Enter-to-send behavior.
B. ``window._sendKey`` is initialised synchronously from ``localStorage``
before the composer handler is registered. Without it, on a slow network the
handler runs while ``window._sendKey`` is still undefined, so a user who
configured ``shift+enter`` has plain Enter send the message.
C. The composer textarea carries ``enterkeyhint="enter"`` so the iOS software
keyboard renders a return key rather than a "Go"/"Send" key.
The behavioural tests execute the **real** helpers and composer handler
extracted verbatim from ``static/boot.js`` — reverting the production code
changes what these tests run.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - playwright optional in some envs
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
IPHONE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
IPAD_UA = (
"Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
ANDROID_TABLET_UA = (
"Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
TOUCH_MAC_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"
)
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
# ─── Source extraction ────────────────────────────────────────────────────
def _extract_function(name: str, source: str) -> str:
"""Extract a top-level ``function name(...){...}`` with brace matching."""
start = source.index(f"function {name}(")
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"could not extract function {name}")
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` call."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_send_key_init() -> str:
"""Extract the synchronous ``window._sendKey`` bootstrap from boot.js.
Returns an empty string when the line is absent, so reverting change B
removes it from the harness too (which is what makes the test bite).
"""
match = re.search(
r"^try\{\s*window\._sendKey=localStorage\.getItem\([^\n]*$",
BOOT_JS,
re.MULTILINE,
)
return match.group(0) if match else ""
def _available_helpers() -> str:
"""Extract the touch/IME/numpad helpers the composer handler may call.
``_isTouchOnlyDevice`` is this PR's addition and ``_hasFinePointerCoexisting``
is the upstream helper; include whichever exist so the harness stays valid
with the production change reverted.
"""
names = (
"_isImeEnter",
"_isNumpadEnter",
"_hasFinePointerCoexisting",
"_isTouchOnlyDevice",
)
out = []
for name in names:
if f"function {name}(" in BOOT_JS:
out.append(_extract_function(name, BOOT_JS))
return "\n".join(out)
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
var _imeComposing = false;
function $(id) { return document.getElementById(id); }
// --- Real helpers extracted from boot.js ---
__HELPERS__
// --- Real synchronous _sendKey bootstrap extracted from boot.js ---
// Empty when that line has been removed from production code.
__SENDKEY_INIT__
window.__sendCount = 0;
window.__defaultPrevented = 0;
window.send = function() { window.__sendCount++; };
var send = window.send;
// --- Real composer handler extracted from boot.js ---
__HANDLER__
// Observe whether the handler suppressed the browser's default newline.
document.getElementById('msg').addEventListener('keydown', function(e){
if (e.defaultPrevented) window.__defaultPrevented++;
});
</script>
</body></html>
"""
def _build_harness_html() -> str:
return (
HARNESS_HTML.replace("__HELPERS__", _available_helpers())
.replace("__SENDKEY_INIT__", _extract_send_key_init())
.replace("__HANDLER__", _extract_composer_handler())
)
def _write_harness() -> Path:
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(_build_harness_html(), encoding="utf-8")
return tmp
# ─── Media-query shim ─────────────────────────────────────────────────────
# Reproduces the iOS Safari quirk: a touch-primary phone that ALSO reports
# (any-pointer:fine) as true. Installed before any page script runs.
IOS_SAFARI_MATCHMEDIA_QUIRK = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A plain desktop: no coarse pointer, fine pointer present.
DESKTOP_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: false, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A tablet / iPadOS-desktop UA with only a software keyboard: a coarse pointer
# is present (touch screen) but no fine pointer (no attached hardware
# keyboard or trackpad). This is the path that the production change leaves
# alone — these devices must keep the mobile "Enter inserts newline" behavior.
TABLET_NO_FINE_POINTER_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: false, media: q};
return real(q);
};
})();
"""
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code, keyCode: 13, which: 13, location,
shiftKey: shift, ctrlKey: ctrl, metaKey: meta,
bubbles: true, cancelable: true,
}));
}
""",
{
"shift": shift,
"ctrl": ctrl,
"meta": meta,
"code": "NumpadEnter" if numpad else "Enter",
"location": 3 if numpad else 0,
},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Change A: UA-first touch detection ───────────────────────────────────
def test_iphone_reporting_fine_pointer_still_treats_enter_as_newline():
"""Change A. iPhone UA + ``(any-pointer:fine)`` true → Enter must NOT send.
Reverting to ``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()``
makes ``_mobileDefault`` false here, so plain Enter sends and this fails.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the quirk is actually in place, otherwise the test is vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is True
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is True
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
assert sends == 0, (
"plain Enter sent the message on an iPhone that reports (any-pointer:fine); "
"the soft keyboard's return key must insert a newline"
)
assert prevented == 0, "Enter should fall through so the browser inserts the newline"
def test_android_phone_treats_enter_as_newline():
"""Change A. Android UA → Enter must NOT send, regardless of media queries."""
playwright = _require_playwright()
harness = _write_harness()
android_ua = (
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
)
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=android_ua,
viewport={"width": 412, "height": 915},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 0, "plain Enter must insert a newline on an Android phone"
def _run_enter_case(
playwright,
harness: Path,
*,
user_agent: str,
max_touch_points: int = 0,
matchmedia_shim: str = IOS_SAFARI_MATCHMEDIA_QUIRK,
expect_coarse: bool = True,
expect_fine: bool = True,
) -> tuple[int, int]:
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=user_agent,
viewport={"width": 1024, "height": 768},
has_touch=True,
)
ctx.add_init_script(
f"Object.defineProperty(navigator, 'maxTouchPoints', {{value: {max_touch_points}}});"
)
ctx.add_init_script(matchmedia_shim)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the shim is actually in place, otherwise the test would be vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is expect_coarse
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is expect_fine
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
return sends, prevented
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with Magic Keyboard"),
(ANDROID_TABLET_UA, 5, "Android tablet with hardware keyboard"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop UA"),
],
)
def test_tablet_with_fine_pointer_keeps_enter_to_send(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Tablet/touch-desktop UA + fine pointer must retain desktop Enter=send."""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
)
assert sends == 1, f"plain Enter must send on {device}"
assert prevented == 1, f"plain Enter must be consumed after sending on {device}"
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with software keyboard only"),
(ANDROID_TABLET_UA, 5, "Android tablet with software keyboard only"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop with software keyboard only"),
],
)
def test_tablet_without_fine_pointer_inserts_newline(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Control for the tablet-with-fine-pointer test.
A tablet / iPadOS-desktop UA reporting only a coarse pointer (no fine
pointer — i.e. no attached hardware keyboard) must keep the mobile
"plain Enter inserts a newline" behavior, because the production change
only short-circuits phone UAs and otherwise still falls back to the
media-query test ``(pointer:coarse) && !(any-pointer:fine)``.
Reverting the change to a phone-only match would break this test, and a
tablet shortcut that always returns true would invert
``test_tablet_with_fine_pointer_keeps_enter_to_send``.
"""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
matchmedia_shim=TABLET_NO_FINE_POINTER_MATCHMEDIA,
expect_fine=False,
)
assert sends == 0, (
f"plain Enter must insert a newline on {device} (no fine pointer "
"is exposed, so the media-query path should still classify it as touch-only)"
)
assert prevented == 0, (
f"Enter must fall through so the browser inserts the newline on {device}"
)
def test_mobile_ctrl_enter_still_sends():
"""Change A must not cost mobile users a way to send from the keyboard."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page, ctrl=True)
ctrl_sends = _send_count(page)
_press_enter(page, numpad=True)
total_sends = _send_count(page)
browser.close()
assert ctrl_sends == 1, "Ctrl+Enter must still send on mobile"
assert total_sends == 2, "Numpad Enter must still send on mobile"
def test_desktop_enter_still_sends():
"""Change A must not alter desktop behaviour."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 1, "plain Enter must still send on a desktop browser"
# ─── Change B: synchronous _sendKey bootstrap ─────────────────────────────
def test_stored_shift_enter_preference_applies_before_settings_fetch():
"""Change B. A stored ``shift+enter`` must hold on the very first keypress.
Reverting the synchronous ``window._sendKey`` bootstrap leaves the value
undefined while the async ``/api/settings`` fetch is still in flight, so the
handler falls into the default branch and plain Enter sends.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
# Seed the preference the way a returning user's browser would have it,
# before any page script runs.
ctx.add_init_script(
"try{localStorage.setItem('hermes-pref-send_key','shift+enter');}catch(e){}"
)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
assert (
page.evaluate("localStorage.getItem('hermes-pref-send_key')")
== "shift+enter"
), "harness failed to seed the preference; test would be vacuous"
_press_enter(page)
plain_sends = _send_count(page)
_press_enter(page, shift=True)
after_shift = _send_count(page)
browser.close()
assert plain_sends == 0, (
"plain Enter sent the message even though the stored preference is "
"shift+enter; window._sendKey was not initialised before the handler ran"
)
assert after_shift == 1, "Shift+Enter must send when the preference is shift+enter"
def test_send_key_bootstrap_precedes_composer_handler():
"""Change B, structural. The bootstrap must sit ABOVE the handler in boot.js.
Ordering is what closes the race; a bootstrap placed after the
``addEventListener`` call would still leave the first keypress undefined.
"""
init = _extract_send_key_init()
assert init, "synchronous window._sendKey bootstrap is missing from boot.js"
handler_idx = BOOT_JS.index("$('msg').addEventListener('keydown',e=>")
assert BOOT_JS.index(init) < handler_idx, (
"window._sendKey bootstrap must appear before the composer keydown "
"listener is registered"
)
# ─── Change C: enterkeyhint on the composer ───────────────────────────────
def test_composer_textarea_declares_enterkeyhint():
"""Change C, structural. iOS renders the return key from ``enterkeyhint``.
The rendered keycap is a native-keyboard behaviour with no DOM-observable
effect in a headless browser, so this asserts the attribute is present and
parsed rather than claiming to verify the keyboard itself.
"""
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
assert 'enterkeyhint="enter"' in match.group(0), (
"composer textarea must declare enterkeyhint=\"enter\" so the iOS "
"software keyboard shows a return key instead of Go/Send"
)
def test_enterkeyhint_reaches_the_dom():
"""Change C. The attribute must survive parsing as the DOM property."""
playwright = _require_playwright()
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
snippet = (
"<!doctype html><html><head><meta charset='utf-8'></head><body>"
+ match.group(0)
+ "</textarea></body></html>"
)
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(snippet, encoding="utf-8")
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
page = browser.new_page()
page.goto(tmp.as_uri())
hint = page.evaluate("document.getElementById('msg').enterKeyHint")
browser.close()
assert hint == "enter", f"enterKeyHint resolved to {hint!r}, expected 'enter'"
-9
View File
@@ -1697,15 +1697,6 @@ def test_mobile_enter_newline_does_not_depend_on_viewport_heuristic():
"the viewport height-delta probe must no longer gate the mobile Enter override"
def test_mobile_enter_newline_respects_hardware_keyboard_on_touch_devices():
"""Touch devices with a co-existing fine pointer (hardware keyboard) keep desktop Enter=send."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "any-pointer:fine" in boot_js, \
"boot.js must use any-pointer:fine to detect a co-existing hardware keyboard/trackpad"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"mobile Enter newline override must skip touch devices that also expose a fine pointer"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -70,7 +70,7 @@ let _visWithIdxCacheLen = 0;
let _visWithIdxCacheSrc = null;
const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT');
const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart);
const heightEnd = src.indexOf('let _messageVirtualMeasurementSeenKeys', heightStart);
if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd));
if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage'));
eval(extractFunc('_stripWorkspaceDisplayPrefix'));
+162
View File
@@ -0,0 +1,162 @@
"""Composed prompt-owner and real delivery regressions for #7493."""
from tests.test_pwa_notification_controls import MESSAGES_JS, _extract_fn, _run_node
def run(body, extra=()):
names = ['_promptNotifyKey', '_retirePromptNotifyKey', '_notifyPromptCard',
'_approvalPromptGeneration', '_bumpApprovalPromptGeneration',
'_clarifyPromptGeneration', '_bumpClarifyPromptGeneration',
'_rememberApprovalPending', '_clearApprovalPendingForSession',
'_clearClarifyPendingForSession', '_approvalDismissKey', '_legacyApprovalDismissKey',
'_getDismissedApprovals', '_isApprovalDismissed', '_markApprovalDismissed',
'_unmarkApprovalDismissed', 'dismissApprovalCard',
'_startApprovalFallbackPoll', 'showApprovalCard',
'activeSessionHasPendingPromptAttention']
source = '\n'.join(_extract_fn(MESSAGES_JS, n) for n in names + list(extra))
return _run_node('''
const _promptNotifySeen = new Map();
const _approvalPendingBySession = new Map(), _clarifyPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map(), _clarifyPromptGenerationBySession = new Map();
const _DISMISSED_APPROVALS_KEY = 'dismissed';
const store = new Map();
const localStorage = {getItem:k=>store.get(k), setItem:(k,v)=>store.set(k,v)};
const S = {busy:true,session:{session_id:'s'}};
const _approvalPromptBelongsToActiveSession=()=>false;
let _approvalSessionId = 's', _approvalCurrentId = 'id';
let _approvalFallbackPollInFlight=false, _approvalPollTimer;
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
const _promptActiveSessionId=()=> 's';
const _approvalPollingSessionMissingOrMismatched=()=>false;
const _hideApprovalCardIfOwner=()=>{};
const stopApprovalPolling=()=>{}, stopApprovalPollingForSession=()=>{};
const hideApprovalCard=()=>{};
const setInterval=()=>1;
const _isSessionActivelyViewed=()=>false;
const flush=()=>new Promise(r=>setImmediate(r));
''' + source + '\n(async()=>{\n' + body + '\n})().catch(e=>{console.error(e);process.exit(1)});')
def test_legacy_dismissal_does_not_transfer_to_distinct_full_owner():
result = run(r'''
const pending={approval_id:'id',run_id:'run-2',_gateway_mirror_token:'token-2'};
const legacy='s'+'\0'+'id';
store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy]));
const dismissed=_isApprovalDismissed('s',pending);
const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY));
console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey('s',pending))}));
''')
assert result == dict(dismissed=False, legacyPresent=False, fullPresent=False)
def test_legacy_dismissal_migrates_when_no_stronger_owner_exists():
result = run(r"""
const pending={approval_id:"id"};
const legacy="s"+"\0"+"id";
store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy]));
const dismissed=_isApprovalDismissed("s",pending);
const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY));
console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey("s",pending))}));
""")
assert result == dict(dismissed=True, legacyPresent=False, fullPresent=True)
def test_dismiss_poll_resolution_and_new_run_owner():
result = run('''
const sent=[];
global.sendBrowserNotification=()=>{sent.push(1);return true};
const a={_session_id:'s',approval_id:'id',run_id:'r1',_gateway_mirror_token:'t1'};
global.showApprovalForSession=(sid,p)=>showApprovalCard(p,1);
showApprovalForSession('s',a);
dismissApprovalCard();
const dismissed=_isApprovalDismissed('s',a);
if(activeSessionHasPendingPromptAttention())throw Error('dismissed attention remains');
showApprovalForSession('s',a);
const quiet=sent.length;
const b={...a,run_id:'r2',_gateway_mirror_token:'t2'};
showApprovalForSession('s',b);
const replacement=sent.length;
dismissApprovalCard();
global.api=async()=>({pending:null});
_startApprovalFallbackPoll('s');await flush();
showApprovalForSession('s',b);
console.log(JSON.stringify({dismissed,quiet,replacement,reused:sent.length}));
''')
assert result == dict(dismissed=True, quiet=1, replacement=2, reused=3)
def test_empty_map_terminal_clear_fences_inflight_poll():
result = run('''
let resolve;const shown=[];
global.api=()=>new Promise(r=>resolve=r);
global.showApprovalForSession=(sid,p)=>shown.push(p);
_startApprovalFallbackPoll('s');
_clearApprovalPendingForSession('s');
_clearClarifyPendingForSession('s');
resolve({pending:{approval_id:'stale'}});await flush();
console.log(JSON.stringify({shown,clarifyGeneration:_clarifyPromptGeneration('s')}));
''')
assert result == dict(shown=[], clarifyGeneration=1)
def test_real_delivery_denied_failed_and_inflight_retry():
result = run('''
const window=global.window={_notificationsEnabled:true};
let attempts=0,fail=false;
window.Notification=global.Notification=function(){attempts++;if(fail)throw Error('unavailable')};
Notification.permission='denied';
global.assistantDisplayName=()=> 'Hermes';
global._notificationOptions=()=>({});
Object.defineProperty(global,'navigator',{value:{serviceWorker:{getRegistration:async()=>({active:true,showNotification:async()=>{throw Error('SW failed')}})}}});
const p={approval_id:'id'};
_notifyPromptCard('approval','s',p);await flush();
Notification.permission='granted';fail=true;
_notifyPromptCard('approval','s',p);await flush();
const failedConsumed=_promptNotifySeen.size;
fail=false;
_notifyPromptCard('approval','s',p);
_notifyPromptCard('approval','s',p);await flush();
_notifyPromptCard('approval','s',p);await flush();
console.log(JSON.stringify({attempts,failedConsumed,consumed:_promptNotifySeen.size}));
''', ('sendBrowserNotification', '_showPwaNotification'))
assert result == dict(attempts=2, failedConsumed=0, consumed=1)
def test_empty_clarify_poll_cannot_resurrect_after_terminal_clear():
result = run('''
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
let resolve;const shown=[];
global.api=()=>new Promise(r=>resolve=r);
global.showClarifyForSession=(sid,p)=>shown.push(p);
_startClarifyFallbackPoll('s');
_clearClarifyPendingForSession('s');
resolve({pending:{clarify_id:'stale'}});await flush();
console.log(JSON.stringify({shown}));
''', ('_startClarifyFallbackPoll',))
assert result == dict(shown=[])
def test_default_permission_is_auto_requested_once_not_every_poll_tick():
"""A pending prompt re-surfaces on every 1.5s poll tick; with permission still
'default' (a second browser/device) the automatic, non-gesture request must be
made at most once per page, so the user is not re-prompted and re-toasted
"notifications denied" on every tick. The explicit Send-test path still asks."""
result = run(r'''
const window=global.window={_notificationsEnabled:true};
let requests=0; const toasts=[];
global.t=(k)=>k;
global.showToast=(msg)=>toasts.push(msg);
global.updateNotificationPermissionStatus=()=>{};
window.Notification=global.Notification=function(){};
Notification.permission='default';
Notification.requestPermission=()=>{requests++;return Promise.resolve('default');};
global.assistantDisplayName=()=> 'Hermes';
global._notificationOptions=()=>({});
Object.defineProperty(global,'navigator',{value:{}});
const p={approval_id:'id'};
for (let i=0;i<5;i++){ _notifyPromptCard('approval','s',p); await flush(); await flush(); }
const autoRequests=requests, autoToasts=toasts.length, seenAfterAuto=_promptNotifySeen.size;
await sendBrowserNotification('Test','body',{force:true}); await flush();
console.log(JSON.stringify({autoRequests,autoToasts,seenAfterAuto,forcedRequests:requests-autoRequests}));
''', ('sendBrowserNotification', '_showPwaNotification', 'requestNotificationPermission'))
assert result == dict(autoRequests=1, autoToasts=1, seenAfterAuto=0, forcedRequests=1)
+360 -2
View File
@@ -41,8 +41,366 @@ def test_notification_payload_uses_completion_session_when_provided():
assert "_completionNotificationPreviewText(lastAsst," in MESSAGES_JS
assert "sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" in MESSAGES_JS
assert "assistantText?assistantText.slice(0,100)" not in MESSAGES_JS
assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS
def _extract_fn(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start >= 0, f"{name} not found"
brace = src.find("{", src.find(")", start))
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"{name} body did not close")
def _run_node(source: str) -> dict:
import json
import shutil
import subprocess
import tempfile
node = shutil.which("node")
if node is None:
import pytest
pytest.skip("node is required for the executed notification-gate test")
with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False) as script:
script.write(source)
script_path = Path(script.name)
try:
result = subprocess.run([node, str(script_path)], cwd=str(ROOT), capture_output=True, text=True, timeout=30)
finally:
script_path.unlink(missing_ok=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip())
def test_notify_prompt_card_executed_gate_behavior():
"""Executed node-VM proof of _notifyPromptCard's runtime gates:
(1) dedupes per logical owner (kind + session id + prompt id + gateway
run owner) — a second call for the same owner is a no-op, while the same
prompt id under a different session or gateway run owner notifies again;
(2) suppresses only when the prompt's session is ACTIVELY VIEWED (open in
the pane + tab visible + tab focused) WITHOUT recording, so the same id
still notifies the moment the user looks away (blocking prompt semantics);
(3) notifies for a non-active session EVEN with the tab focused (case 1);
(4) notifies when the tab is hidden or the window unfocused (cases 2-3),
with the right title/body and sid routing."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: false, visibilityState: 'visible', hasFocus: () => true }};
const _promptNotifySeen = new Map(); // module-level state closed over by the helper
const _PROMPT_NOTIFY_TTL_MS = 600000; // same, for the stale-key cleanup pass
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
const CASES = {{}};
// Case 3: active session sid-1, tab visible, WINDOW unfocused → notifies.
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }});
CASES.windowUnfocused = sent.length; // 1
// Case 2: tab hidden (different tab selected) → notifies.
document.hidden = true; document.visibilityState = 'hidden';
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'tab hidden' }});
CASES.tabHidden = sent.length; // 2
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true;
// Case 1: prompt for a NON-active session while tab is fully focused → notifies.
_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a1', description: 'other session' }});
CASES.otherSession = sent.length; // 3
// Owner-scope regression: same approval_id in a DIFFERENT session must
// notify again — the dedupe key is owner-scoped (kind + sid + id), not
// prompt-id-only.
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true;
_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a3', description: 'same id, other session' }});
CASES.sameIdOtherSession = sent.length; // 4 (a3 was already sent for sid-1)
// Gateway owner-scope regression: same session + same approval_id but a
// distinct gateway run owner (run_id + _gateway_mirror_token) must notify
// again; repeating the SAME full owner stays deduped. Window blurred so the
// actively-viewed gate (which would suppress an ACTIVE-session prompt the
// user is looking at) does not interfere with the owner-scope assertions.
document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A' }});
CASES.gatewayRunA = sent.length; // 5
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-B', _gateway_mirror_token: 'tok-B', description: 'gateway run B' }});
CASES.gatewayRunB = sent.length; // 6
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A repeat' }});
CASES.gatewayRunARepeat = sent.length; // still 6 (same full owner deduped)
document.hasFocus = () => true;
// Suppressed case: prompt for the ACTIVE session with tab visible+focused —
// the user is literally looking at the card. NOT recorded, so...
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }});
CASES.activelyViewed = sent.length; // still 3 (no ping while watching)
// ...looking away (window blur) pings that same id exactly once.
document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }});
CASES.afterBlur = sent.length; // 4
document.hasFocus = () => true;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); // dedupe
// Dedupe holds across focus changes for the first id too.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }});
// Clarify shape routes its own title/body (window blurred again).
document.hasFocus = () => false;
_notifyPromptCard('clarify', 'sid-1', {{ clarify_id: 'c1', question: 'Which one?' }});
CASES.clarify = sent.length; // 5
process.stdout.write(JSON.stringify({{ CASES, sent }}));
"""
result = _run_node(script)
cases = result["CASES"]
sent = result["sent"]
# The user's three notification cases must all fire.
assert cases["windowUnfocused"] == 1, f"case 3 (window unfocused) must notify (got {cases['windowUnfocused']})"
assert cases["tabHidden"] == 2, f"case 2 (tab hidden) must notify (got {cases['tabHidden']})"
assert cases["otherSession"] == 3, f"case 1 (non-active session, tab focused) must notify (got {cases['otherSession']})"
# Suppression only while actively viewing; the suppressed id fires after blur.
assert cases["activelyViewed"] == 6, f"no ping while actively viewing the card (got {cases['activelyViewed']})"
assert cases["afterBlur"] == 7, f"the suppressed id must ping exactly once after blur (got {cases['afterBlur']})"
assert cases["clarify"] == 8, f"clarify routes its own title/body (got {cases['clarify']})"
assert cases["sameIdOtherSession"] == 4, f"same approval_id in another session must notify again (got {cases['sameIdOtherSession']})"
assert cases["gatewayRunA"] == 5, f"distinct gateway run owner must notify again (got {cases['gatewayRunA']})"
assert cases["gatewayRunB"] == 6, f"second distinct gateway run owner must notify again (got {cases['gatewayRunB']})"
assert cases["gatewayRunARepeat"] == 6, f"repeated calls for the same full owner must stay deduped (got {cases['gatewayRunARepeat']})"
assert len(sent) == 8, f"expected exactly 8 pings, got {len(sent)}: {sent}"
assert sent[0]["title"] == "Approval required"
assert sent[0]["options"] == {"sid": "sid-1", "forceHidden": True}
assert sent[2]["options"] == {"sid": "sid-2", "forceHidden": True}
assert sent[2]["body"] == "other session"
assert sent[7]["title"] == "Clarification needed"
assert sent[7]["body"] == "Which one?"
# forceHidden must be set: _notifyPromptCard already made the visibility
# decision, and sendBrowserNotification's live gate ("notify only when
# document.hidden") would otherwise veto the unfocused-but-visible case.
assert all(o["options"].get("forceHidden") for o in sent)
# ── Reviewer reproduction regressions (PR #7493 round 2) ────────────────────
def test_owner_key_is_injective():
"""The dedupe key is built from a typed owner TUPLE serialized with
JSON.stringify, so delimiter-bearing producer strings (the gateway
accepts approval_id as an unrestricted string) cannot collide with a
different owner that merely contains the same delimiters."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
// Owner 1: approval_id='x' with the full gateway-owner pair.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x', run_id: 'r', _gateway_mirror_token: 't', description: 'owner one' }});
// Owner 2: approval_id CONTAINING the exact delimiter sequence of owner 1,
// with NO gateway-owner fields - a plain concatenation key would collide
// here and suppress this second, distinct owner.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x\u0000r\u0000t', description: 'owner two' }});
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }}));
"""
result = _run_node(script)
assert result["count"] == 2, f"two distinct owners must both notify (got {result})"
assert result["bodies"] == ['owner one', 'owner two']
def test_same_pending_owner_does_not_renotify_on_wall_clock():
"""A prompt left pending beyond the old 10-minute TTL must NOT re-notify:
seen entries retire on prompt lifecycle (resolution), not wall-clock age.
Simulated by backdating the seen entry far past the retired TTL window."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'first ping' }});
// Backdate the seen entry by 24 hours - far past the old TTL. With
// lifecycle-based retirement this must NOT re-notify on the next poll tick.
const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'pending-1' }});
_promptNotifySeen.set(ownerKey, Date.now() - 24 * 60 * 60 * 1000);
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'poll tick 24h later' }});
process.stdout.write(JSON.stringify({{ count: sent.length }}));
"""
result = _run_node(script)
assert result["count"] == 1, f"still-pending owner must not re-notify after 24h (got {result})"
def test_resolved_owner_allows_legitimate_id_reuse():
"""When a prompt's lifecycle ENDS (resolution/dismissal clears pending
state via _clearApprovalPendingForSession), the seen entry is retired, so
a later prompt reusing the same ID notifies again."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{retire}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
const pending1 = {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'first' }};
_notifyPromptCard('approval', 'sid-1', pending1);
// Resolution path: the pending map entry clears, which must retire the
// dedupe entry for that exact owner.
_retirePromptNotifyKey('approval', 'sid-1', pending1);
// Same session + same ID + same run owner later: legitimate reuse, notifies.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'reused after resolution' }});
// But an owner whose prompt is STILL pending stays deduped.
_notifyPromptCard('approval', 'sid-1', pending1);
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }}));
"""
result = _run_node(script)
assert result["count"] == 2, f"resolved owner ID reuse must notify again, still-pending owner must not (got {result})"
assert result["bodies"] == ['first', 'reused after resolution']
def test_disabled_notifications_do_not_consume_notify():
"""When notifications are disabled, no seen entry is recorded - so the
same owner still notifies once the user enables notifications mid-prompt.
Failed/denied delivery must not permanently consume the notification."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const window = {{ _notificationsEnabled: false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'while disabled' }});
// Enabling notifications mid-prompt must re-notify the SAME owner.
window._notificationsEnabled = true;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'after enabling' }});
// And once the owner HAS been notified while enabled, dedupe holds.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'repeat while enabled' }});
// The disabled first call recorded nothing; the enabled second call is now
// the consumed notify for this owner.
const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'd1' }});
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body), consumed: _promptNotifySeen.has(ownerKey) }}));
"""
result = _run_node(script)
assert result["count"] == 1, f"unexpected ping count (got {result})"
assert result["bodies"][0] == 'after enabling', f"enabling mid-prompt must re-notify the same owner (got {result})"
assert result["consumed"] is True, "the enabled notify must be recorded (consumed) for the owner"
def test_displaced_pending_prompt_key_is_retired_on_replacement():
"""Greptile round-2 regression: when a NEW approval/clarify replaces the
session pending entry, the DISPLACED prompt notification-dedupe key must
retire. Without retirement (and with TTL-based expiry gone) the stale key
persists forever, so the same externally supplied ID re-surfacing later is
wrongly suppressed as already-notified. A re-render of the SAME owner must
keep its key (still deduped)."""
remember_ap = _extract_fn(MESSAGES_JS, "_rememberApprovalPending")
remember_cl = _extract_fn(MESSAGES_JS, "_rememberClarifyPending")
clear_ap = _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey")
active = _extract_fn(MESSAGES_JS, "_promptActiveSessionId")
approval_generation = _extract_fn(MESSAGES_JS, "_approvalPromptGeneration")
bump_approval_generation = _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration")
clarify_generation = _extract_fn(MESSAGES_JS, "_clarifyPromptGeneration")
bump_clarify_generation = _extract_fn(MESSAGES_JS, "_bumpClarifyPromptGeneration")
script = f"""
const _promptNotifySeen = new Map();
const _isApprovalDismissed = () => false;
const _unmarkApprovalDismissed = () => {{}};
const S = {{ session: {{ session_id: "sid-1" }} }};
{keyfn}
{retire}
{active}
{approval_generation}
{bump_approval_generation}
{clarify_generation}
{bump_clarify_generation}
{remember_ap}
{remember_cl}
{clear_ap}
const _approvalPendingBySession = new Map();
const _clarifyPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
const _clarifyPromptGenerationBySession = new Map();
// Displaced approval owner: id-1, then a NEW different approval for sid-1.
_rememberApprovalPending({{ approval_id: "id-1", description: "first" }}, 1);
const key1 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-1" }});
_promptNotifySeen.set(key1, Date.now());
_rememberApprovalPending({{ approval_id: "id-2", description: "replacement" }}, 1);
const displacedRetired = !_promptNotifySeen.has(key1);
// Same-owner re-render must NOT retire its own key (seed it as already
// notified, as a prior poll tick would have).
const key2 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }});
_promptNotifySeen.set(key2, Date.now());
_rememberApprovalPending({{ approval_id: "id-2", description: "re-render same owner" }}, 1);
const sameOwnerKept = _promptNotifySeen.has(key2);
// Clear path retires the CURRENT entry.
_clearApprovalPendingForSession("sid-1");
const clearedRetired = !_promptNotifySeen.has(_promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }}));
// Clarify mirror: displacement retires, same-owner re-render keeps.
_rememberClarifyPending({{ clarify_id: "c-1" }});
const ckey1 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-1" }});
_promptNotifySeen.set(ckey1, Date.now());
_rememberClarifyPending({{ clarify_id: "c-2" }});
const clarifyDisplacedRetired = !_promptNotifySeen.has(ckey1);
const ckey2 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-2" }});
_promptNotifySeen.set(ckey2, Date.now());
_rememberClarifyPending({{ clarify_id: "c-2" }});
const clarifySameOwnerKept = _promptNotifySeen.has(ckey2);
process.stdout.write(JSON.stringify({{ displacedRetired, sameOwnerKept, clearedRetired, clarifyDisplacedRetired, clarifySameOwnerKept }}));
"""
result = _run_node(script)
assert result["displacedRetired"] is True, f"displaced approval key must retire on replacement (got {result})"
assert result["sameOwnerKept"] is True, f"same-owner re-render must keep its dedupe key (got {result})"
assert result["clearedRetired"] is True, f"clear path must retire the current entry key (got {result})"
assert result["clarifyDisplacedRetired"] is True, f"displaced clarify key must retire on replacement (got {result})"
assert result["clarifySameOwnerKept"] is True, f"clarify same-owner re-render must keep its key (got {result})"
def test_completion_notification_preview_uses_settled_message_not_live_prefix():
+2 -1
View File
@@ -793,7 +793,8 @@ def test_renderMessages_preserves_loading_placeholder_for_session_switch(cleanup
ui_src = (REPO_ROOT / "static/ui.js").read_text()
fn_start = ui_src.find("function renderMessages")
assert fn_start >= 0, "renderMessages() not found in ui.js"
fn_body = ui_src[fn_start:fn_start + 1400]
# Window sized to reach the render-window reset; renderMessages() preamble grows (#6717).
fn_body = ui_src[fn_start:fn_start + 2400]
compact = re.sub(r"\s+", "", fn_body)
assert (
+206
View File
@@ -0,0 +1,206 @@
"""Observed Agent runtime, durable replay and HTTP reconnect projection."""
import json
import queue
import sys
import types
from unittest import mock
import pytest
from api.run_journal import RunJournalWriter, latest_run_summary, read_run_events
def observation(model="backup", *, session_id="session-a", stream_id="run-a"):
return {"session_id": session_id, "stream_id": stream_id, "model": model,
"provider": "custom", "fallback_active": True, "phase": "observed_output"}
def test_journal_runtime_is_owned_and_invalidated(tmp_path):
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("token", {"text": "answer"})
first = writer.append_sse_event("runtime_model", observation())
assert read_run_events("session-a", "run-a", session_dir=tmp_path)["events"][-1] == first
summary = latest_run_summary("session-a", "run-a", session_dir=tmp_path)
assert summary["runtime_model"] == observation()
summary["runtime_model"]["model"] = "corrupted"
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
writer.append_sse_event("warning", {"type": "fallback", "message": "Trying another route"})
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", {**observation(), "stream_id": "foreign"})
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", observation("recovered"))
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered"
writer.append_sse_event("runtime_model", "malformed observation")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", observation("recovered-again"))
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered-again"
assert latest_run_summary("session-a", "another-run", session_dir=tmp_path)["runtime_model"] is None
@pytest.mark.parametrize("malformed", ["bad observation", ["bad observation"], None])
def test_non_object_observation_invalidates_summary_and_http_snapshot(tmp_path, monkeypatch, malformed):
from api import routes, run_journal
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("runtime_model", observation("first"))
writer.append_sse_event("token", malformed)
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "first"
writer.append_sse_event("runtime_model", malformed if malformed is not None else {"placeholder": True})
if malformed is None:
# The writer normalizes None to {}; exercise a legacy/raw null journal row.
path = run_journal._run_path("session-a", "run-a", session_dir=tmp_path)
rows = [json.loads(line) for line in path.read_text().splitlines()]
rows[-1]["payload"] = None
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
snapshot = routes._run_journal_live_snapshot("run-a")
assert snapshot["runtime_model"] is None
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] is None
@pytest.mark.parametrize("suffix,expected", [("", "backup"), ("warning", None), ("foreign", None)])
def test_http_snapshot_projects_exact_journal_window(tmp_path, monkeypatch, suffix, expected):
import api.routes as routes
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("runtime_model", observation())
writer.append_sse_event("token", {"text": "answer"})
if suffix == "warning":
writer.append_sse_event("warning", {"type": "fallback"})
elif suffix == "foreign":
writer.append_sse_event("runtime_model", {**observation(), "session_id": "foreign"})
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
snapshot = routes._run_journal_live_snapshot("run-a")
assert snapshot["last_assistant_text"] == "answer"
value = snapshot["runtime_model"]
assert (value["model"] if value else None) == expected
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] == value
def run_worker(steps, *, fail=False, ephemeral=False, tmp_path=None, monkeypatch=None):
"""Drive the production local worker; fake only external Agent/provider calls."""
from api import streaming, run_journal
from api.config import SESSION_AGENT_CACHE
SESSION_AGENT_CACHE.clear()
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path)
class Session:
session_id = "session-a"
title = "Session"
workspace = "/tmp"
model = "primary"
model_provider = "anthropic"
profile = None
personality = None
messages = [{"role": "user", "content": "old"}, {"role": "assistant", "content": "old answer"}]
context_messages = messages
input_tokens = output_tokens = cache_read_tokens = cache_write_tokens = 0
estimated_cost = 0.0
tool_calls = []
gateway_routing = None
gateway_routing_history = []
active_stream_id = "run-a"
pending_user_message = None
pending_attachments = []
pending_started_at = None
context_length = threshold_tokens = last_prompt_tokens = 0
llm_title_generated = True
def save(self, *args, **kwargs): pass
def compact(self): return {"session_id": self.session_id}
class Agent:
def __init__(self, model=None, provider=None, session_id=None,
stream_delta_callback=None, reasoning_callback=None,
status_callback=None, **kwargs):
self.model, self.provider, self.session_id = model, provider, session_id
self.stream_delta_callback = stream_delta_callback
self.reasoning_callback = reasoning_callback
self.status_callback = status_callback
self._provider_fallback_active = False
self.context_compressor = None
self.session_prompt_tokens = self.session_completion_tokens = 0
self.session_estimated_cost_usd = None
self.session_cache_read_tokens = self.session_cache_write_tokens = 0
self.reasoning_config = self.ephemeral_system_prompt = self._last_error = None
def run_conversation(self, **kwargs):
for model, provider, fallback, channel, text in steps:
self.model, self.provider, self._provider_fallback_active = model, provider, fallback
if channel == "token": self.stream_delta_callback(text)
elif channel == "reasoning": self.reasoning_callback(text)
elif channel == "status": self.status_callback("lifecycle", text)
if fail: return {"error": "provider unavailable", "messages": []}
return {"messages": kwargs.get("conversation_history", []) + [
{"role": "user", "content": kwargs["persist_user_message"]},
{"role": "assistant", "content": "served answer"}]}
def interrupt(self, message): pass
runtime_module = types.ModuleType("hermes_cli.runtime_provider")
runtime_module.resolve_runtime_provider = mock.Mock(return_value={
"provider": "anthropic", "base_url": None, "api_key": "sk-test",
"api_mode": "chat_completions", "command": None, "args": [], "credential_pool": None})
cli_module = types.ModuleType("hermes_cli")
cli_module.runtime_provider = runtime_module
state_module = types.ModuleType("hermes_state")
state_module.SessionDB = mock.Mock(return_value=None)
injected = {"hermes_cli": cli_module, "hermes_cli.runtime_provider": runtime_module,
"hermes_state": state_module}
sentinel = object()
saved = {name: sys.modules.get(name, sentinel) for name in injected}
sys.modules.update(injected)
q = queue.Queue()
s = Session()
try:
with mock.patch.object(streaming, "get_session", return_value=s), \
mock.patch.object(streaming, "_get_ai_agent", return_value=Agent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("primary", "anthropic", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
streaming.STREAMS["run-a"] = q
streaming._run_agent_streaming("session-a", "new turn", "primary", "/tmp", "run-a", ephemeral=ephemeral)
finally:
for name, original in saved.items():
if original is sentinel: sys.modules.pop(name, None)
else: sys.modules[name] = original
streaming.STREAMS.pop("run-a", None)
SESSION_AGENT_CACHE.clear()
return s, list(q.queue)
def test_worker_observes_serving_runtime_not_requested(tmp_path, monkeypatch):
s, events = run_worker([
("primary", "anthropic", False, "reasoning", "thinking"),
("backup", "custom", True, "token", "answer"),
("backup", "custom", True, "status", "Switched to fallback: backup (custom)"),
], tmp_path=tmp_path, monkeypatch=monkeypatch)
names = [name for name, _ in events]
assert names.count("runtime_model") == 3 # primary, backup, post-warning correction
observed = [value for name, value in events if name == "runtime_model"]
assert [value["model"] for value in observed] == ["primary", "backup", "backup"]
assert observed[-1] == observation()
assert names.index("runtime_model") < names.index("reasoning")
assert names.index("warning") < len(names) - 1 - names[::-1].index("runtime_model")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
assert s.model == "primary" # selection is not overwritten
def test_worker_does_not_observe_attempt_or_missing_model(tmp_path, monkeypatch):
_, events = run_worker([
("backup", "custom", True, "status", "Trying fallback backup"),
(None, "custom", True, "token", "partial"),
], fail=True, tmp_path=tmp_path, monkeypatch=monkeypatch)
assert not any(name == "runtime_model" for name, _ in events)
def test_missing_agent_model_does_not_stamp_requested_as_served(tmp_path, monkeypatch):
s, events = run_worker([(None, "custom", True, "token", "partial")],
tmp_path=tmp_path, monkeypatch=monkeypatch)
assert not any(name == "runtime_model" for name, _ in events)
assert "_usedModel" not in s.messages[-1]
usage = next(value["usage"] for name, value in events if name == "done")
assert not usage.get("used_model")
def test_worker_nonstreaming_success_observes_fallback(tmp_path, monkeypatch):
_, events = run_worker([("backup", "custom", True, "route", "")],
tmp_path=tmp_path, monkeypatch=monkeypatch, ephemeral=True)
assert [value["model"] for name, value in events if name == "runtime_model"] == ["backup"]
+386
View File
@@ -0,0 +1,386 @@
"""A save must not read and parse the whole sidecar just to count its messages.
The #1558 backup safeguard in `Session.save()` compares the on-disk message
count with the incoming one, and writes `<sid>.json.bak` only when the save
would SHRINK the array. Sound. But it obtained the on-disk count by
`json.loads(self.path.read_text())` -- the entire file -- on EVERY save,
including the 99.9% of saves that grow the conversation and never back
anything up. `session_recovery._msg_count()` did the same on every boot, for
every live file that has a `.bak`.
Measured in production on 2026-09-15, in the running container:
sessions/fa3bca34a0c6.json 203,439,398 bytes / 266,940 messages
read_text 17,453 ms
json.loads 2,924 ms
TOTAL to obtain one integer: 20,377 ms <- paid on every save
That was the 39,357 ms `/api/session` request stuck at stage
`t6_after_json_write`, after the read-side re-parse (#4633 recurrence) had
already been fixed. The cost scales linearly with the file, and the file grows
for as long as the conversation does.
What the check needs is ONE integer -- how many messages the file on disk
holds -- and `save()` already writes that integer into the metadata prefix,
before `messages`, as `message_count`, exactly so readers can have it without
parsing the body (that is how `load_metadata_only()` and the sidebar freshness
check work). So `save()` reads that, through the same bounded 64 KiB prefix
reader, and falls back to the full read + parse whenever the prefix does not
carry a usable count. The `.bak` body is the one thing that still needs the
full text, and a shrink is the one time it is needed.
The count is read from the FILE, not cached in the object, and that is the
point. An earlier revision of this PR trusted an in-memory (inode, size,
mtime_ns) signature: "this object wrote the file and stat says nothing changed,
so its length is already known". Review (nesquena-hermes, 2026-09-21) showed
that is not a content identity -- a same-length in-place rewrite inside one
mtime tick keeps all three fields, because ext4 stamps mtime from a coarse
clock, so a stale cached count reads a real shrink as a growth and skips the
#1558 backup. That is the test at the bottom of this file. A count read from
the bytes cannot be fooled that way: any rewrite of the bytes rewrites it.
Fail-open is the contract these tests pin -- whenever the prefix has no usable
count (a legacy pre-#5854 sidecar, a file with no top-level `messages` key, a
corrupt or truncated prefix, metadata that overflows the budget), the behaviour
must be exactly today's full parse, never "assume no shrink".
`session_recovery._msg_count()` pays the same cost at boot and is left alone
on purpose: its "torn file -> -1" contract is what makes recovery restore a
`.bak` over a truncated live file, and no bounded read can prove a file is
not truncated.
"""
import json
import os
import pathlib
from collections import OrderedDict
import pytest
import api.models as M
@pytest.fixture
def session_store(tmp_path, monkeypatch):
sdir = tmp_path / "sessions"
sdir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(M, "SESSION_DIR", sdir)
monkeypatch.setattr(M, "SESSIONS", OrderedDict())
return sdir
def _msgs(n):
return [{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(n)]
def _make(session_store, sid, n):
s = M.Session(session_id=sid, title="T", workspace=str(session_store.parent),
model="glm", messages=_msgs(n))
s.save()
return s
def _spy_full_reads(monkeypatch, target):
"""Count `Path.read_text()` calls on ONE path.
`read_text` is the whole-file read; the metadata-prefix reader uses `open()`
with a byte budget and never goes through it. So this counter is exactly
"how many times was this file read in full", which is the cost being removed.
"""
calls = {"n": 0}
real = pathlib.Path.read_text
target = pathlib.Path(target)
def counting(self, *a, **k):
if pathlib.Path(self) == target:
calls["n"] += 1
return real(self, *a, **k)
monkeypatch.setattr(type(pathlib.Path()), "read_text", counting)
return calls
# ── save(): the grow path must be free of full reads ────────────────────────
def test_grow_save_does_not_read_the_existing_file_in_full(session_store, monkeypatch):
"""The common case. Appending messages must not read the previous file
in full -- this object wrote that file and it is untouched, so the on-disk
count is already known."""
s = _make(session_store, "g1", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(7)
s.save()
assert calls["n"] == 0, f"grow-save read the existing sidecar in full {calls['n']}x"
assert not s.path.with_suffix(".json.bak").exists(), "a grow-save must not produce a backup"
assert len(M.Session.load("g1").messages) == 7
def test_same_size_save_does_not_read_the_existing_file_in_full(session_store, monkeypatch):
"""Metadata-only saves (title, flags, stream state) keep the array as-is.
They are the most frequent save of all and must be just as cheap."""
s = _make(session_store, "g2", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.title = "renamed"
s.save()
assert calls["n"] == 0
assert not s.path.with_suffix(".json.bak").exists()
# ── save(): the safeguards must be untouched ────────────────────────────────
def test_shrink_save_still_writes_bak_with_the_pre_shrink_content(session_store, monkeypatch):
"""#1558 must keep working. A shrink still produces a `.bak` holding the
pre-shrink array -- and reads the file in full exactly once, for that body.
Not twice: knowing the count must not be followed by a redundant parse."""
s = _make(session_store, "s1", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(3)
s.save()
reads_by_save = calls["n"] # taken before load() below, which reads the file too
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "a shrinking save must leave a recoverable backup"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
assert len(M.Session.load("s1").messages) == 3
assert reads_by_save == 1, f"shrink-save read the sidecar in full {reads_by_save}x; the .bak body needs exactly one"
def test_empty_active_snapshot_is_still_refused(session_store, monkeypatch):
"""The other guard in the same block: an empty array with a live stream or
pending prompt must NOT overwrite a populated file (the #1558 data-loss
shape). This object was neither loaded nor saved by this process, so it has
no signature to trust and refusing may cost today's single full read --
never more, and it must still refuse."""
_make(session_store, "r1", 5)
empty = M.Session(session_id="r1", title="T", workspace=str(session_store.parent),
model="glm", messages=[], active_stream_id="a" * 32,
pending_user_message="still typing")
calls = _spy_full_reads(monkeypatch, empty.path)
empty.save()
reads_by_save = calls["n"] # taken before load() below, which reads the file too
assert len(M.Session.load("r1").messages) == 5, "the populated file must survive"
assert reads_by_save <= 1, f"refusing read the file {reads_by_save}x; an unknown identity costs at most one"
# ── save(): fail-open whenever the prefix carries no usable count ───────────
def _write_legacy_without_count(session_store, sid, n):
"""A pre-#5854 sidecar: no persisted `message_count` at all."""
doc = {"session_id": sid, "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"messages": _msgs(n)}
p = session_store / f"{sid}.json"
p.write_text(json.dumps(doc, indent=2), encoding="utf-8")
return p
def test_legacy_sidecar_without_message_count_still_backs_up_on_shrink(session_store):
"""The prefix has no count to give, so this must fall back to the full
parse and keep the safeguard. Silently treating "no count" as "no shrink"
would reopen #1558."""
_write_legacy_without_count(session_store, "l1", 5)
s = M.Session(session_id="l1", title="Legacy", workspace=str(session_store.parent),
model="glm", messages=_msgs(3))
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "fail-open: a legacy file must still be backed up on shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
def test_file_changed_on_disk_since_last_save_falls_back_and_still_backs_up(session_store):
"""An externally grown file. This process saved 3 messages; something else
then rewrote the file with 6 (an external appender, another process, a
restore). The next save with 4 is a real shrink relative to DISK, and the
count that governs it is the one the file now carries (6), not the 3 this
process remembers: the `.bak` must hold the 6-message array."""
s = _make(session_store, "x1", 3)
external = {"session_id": "x1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 6, "messages": _msgs(6)}
s.path.write_text(json.dumps(external, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "an externally grown file must still be backed up on shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_file_changed_on_disk_without_a_count_still_backs_up_on_shrink(session_store):
"""The same external rewrite, but the replacement carries no
`message_count` (an older writer, a hand-edited file). No prefix count ->
the full parse runs and the 6-message array is still backed up."""
s = _make(session_store, "x2", 3)
external = {"session_id": "x2", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"messages": _msgs(6)}
s.path.write_text(json.dumps(external, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "fail-open: a count-less external rewrite must still be backed up"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_unmarked_stale_count_from_a_foreign_writer_still_backs_up_on_shrink(session_store):
"""A persisted count with no `_mc_v` marker is not trusted.
The exact shape review (nesquena-hermes, 2026-09-22) pinned: a sidecar
materialized by an OLDER writer can carry a count stale against the
messages array beside it (the recovery writer used to copy a denormalized
state.db count over real rows). Here the foreign file says 2 but really
holds 6; a save of 4 must not read that 2 as "the save grows the file" and
skip the #1558 backup. The marker gate makes the prefix reader refuse the
unmarked count, the full parse sees the real array, and the 6 messages
land in the `.bak`. Remove the gate in `_prefix_message_count` and this
test fails with no backup written.
"""
s = _make(session_store, "x3", 3)
foreign = {"session_id": "x3", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 2, "messages": _msgs(6)} # stale count, NO _mc_v
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "an unmarked stale count must not hide the shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
assert len(M.Session.load("x3").messages) == 4
def test_wrong_marker_version_still_backs_up_on_shrink(session_store):
"""A count marked by a DIFFERENT writer contract is equally untrusted."""
s = _make(session_store, "x4", 3)
foreign = {"session_id": "x4", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"_mc_v": M._MESSAGE_COUNT_MARKER + 1,
"message_count": 2, "messages": _msgs(6)}
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "a count from another writer version must not hide the shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_first_save_remarks_the_file_and_the_fast_path_resumes(session_store, monkeypatch):
"""The gate costs one full parse, once. After a save by the current writer
the file carries `_mc_v`, and the cheap prefix path is back in service."""
s = _make(session_store, "x5", 3)
foreign = {"session_id": "x5", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 2, "messages": _msgs(6)} # unmarked foreign file
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(6)
s.save() # equal-count save; unmarked prefix count ignored, full parse, no shrink
assert json.loads(s.path.read_text(encoding="utf-8"))["_mc_v"] == M._MESSAGE_COUNT_MARKER
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(8)
s.save()
assert calls["n"] == 0, "a marked file must take the bounded-prefix path again"
assert not s.path.with_suffix(".json.bak").exists()
def _tool_partial(ts=123):
"""The exact shape the #2592 collapse recognises (copied from its test)."""
return {"role": "assistant", "content": "", "_partial": True, "timestamp": ts,
"reasoning": "same reasoning",
"_partial_tool_calls": [{"name": "execute_code",
"args": {"code": "raise RuntimeError('boom')"},
"done": True, "is_error": True, "duration": 3.87}]}
def test_collapse_self_heal_on_load_still_backs_up_the_pre_collapse_array(session_store):
"""#2592: load() de-duplicates adjacent partials and immediately saves the
shorter transcript, and that save must produce a `.bak` because it shrinks
the array on purpose. The count that decides it comes from the file's own
prefix, which the writer stamped with the length it wrote (5) -- the
ON-DISK length, not the post-collapse length of the object (3). Read the
object's own length instead and this backup silently disappears, so the
fixture carries the count a real save() would have written."""
doc = {"session_id": "h1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"_mc_v": M._MESSAGE_COUNT_MARKER,
"message_count": 5,
"messages": [{"role": "user", "content": "run this"},
_tool_partial(), _tool_partial(), _tool_partial(),
{"role": "assistant", "content": "**Task cancelled.**", "_error": True}]}
(session_store / "h1.json").write_text(json.dumps(doc), encoding="utf-8")
loaded = M.Session.load("h1")
assert sum(1 for m in loaded.messages if m.get("_partial")) == 1, "the collapse must have fired"
assert len(loaded.messages) == 3
bak = (session_store / "h1.json").with_suffix(".json.bak")
assert bak.exists(), "the self-heal shrink must leave the pre-collapse array recoverable"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
# ── the collision a stat identity cannot see (review, 2026-09-21) ───────────
def test_same_length_in_place_rewrite_inside_one_mtime_tick_still_backs_up(session_store):
"""A same-length, same-mtime, same-inode rewrite must not hide a shrink.
The removed revision of this PR trusted an in-memory (inode, size,
mtime_ns) identity: "this object wrote the file, stat says nothing changed,
so the on-disk length is the one I remember". A stat tuple is not a content
identity. Another writer replaces the sidecar IN PLACE with a longer
transcript that occupies the same number of bytes, inside one mtime tick,
so all three fields still match -- and the next shrinking save then reads
its stale count as "growing", overwrites the longer transcript and writes
no `.bak`. That is the #1558 data-loss shape the safeguard exists to
prevent, reachable on the branch, which is why the identity cache is gone.
The count now comes from the file's own metadata prefix, which any rewrite
of the bytes has to carry, so the shrink is seen for what it is.
"""
p = session_store / "i1.json"
base = {"session_id": "i1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0}
def _render(n):
return json.dumps({**base, "_mc_v": M._MESSAGE_COUNT_MARKER,
"message_count": n, "messages": _msgs(n)},
indent=2, ensure_ascii=False)
width = max(len(_render(2)), len(_render(5)))
short, grown = _render(2).ljust(width), _render(5).ljust(width)
assert len(short) == len(grown) == width
p.write_text(short, encoding="utf-8")
loaded = M.Session.load("i1") # the only version this object knows
before = p.stat()
p.write_text(grown, encoding="utf-8") # same inode, same byte length...
os.utime(p, ns=(before.st_atime_ns, before.st_mtime_ns))
after = p.stat()
assert (after.st_ino, after.st_size, after.st_mtime_ns) == \
(before.st_ino, before.st_size, before.st_mtime_ns), \
"premise: the rewrite must be invisible to (inode, size, mtime_ns)"
assert len(loaded.messages) == 2, "premise: this object remembers 2 messages"
loaded.messages = _msgs(3)
loaded.save()
bak = p.with_suffix(".json.bak")
assert bak.exists(), "the 5-message transcript must be recoverable"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
assert len(M.Session.load("i1").messages) == 3
@@ -0,0 +1,287 @@
"""Regression: send-key preference changes take effect without page reload.
This test catches the bug where an inline capture-phase interceptor in
index.html snapshotted ``_sendKey`` into a closure at page-parse time.
When the user later changed the setting (which updates ``window._sendKey``
via panels.js), the stale closure still called ``stopImmediatePropagation()``
and blocked the newly configured shortcut from reaching the boot.js
composer handler.
The fix removes the inline interceptor entirely — boot.js already
initialises ``window._sendKey`` synchronously and its composer handler
reads the live value on every event.
This Playwright test loads a minimal HTML page that includes the real
boot.js composer keydown handler (extracted verbatim), simulates a
preference change at runtime, and verifies the correct behaviour for
each send-key mode — including the transition from one mode to another
without a page reload.
"""
from __future__ import annotations
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception:
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` handler
from boot.js, including its full body and the closing ``);``."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
# Include the closing ``);`` that completes the addEventListener call.
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_function(name: str, source: str) -> str:
"""Extract a function by name, matching braces properly."""
pattern = f"function {name}("
start = source.index(pattern)
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise ValueError(f"Could not extract function {name}")
def _extract_helper_functions() -> str:
"""Extract the helper functions the composer handler depends on."""
names = ("_isImeEnter", "_isNumpadEnter", "_isTouchOnlyDevice")
return "\n".join(_extract_function(n, BOOT_JS) for n in names)
# Minimal HTML that loads the real handler logic.
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
// _imeComposing is a module-level flag in boot.js that _isImeEnter references.
var _imeComposing = false;
// Minimal $ shim (boot.js uses $('msg') === document.getElementById('msg'))
function $(id) { return document.getElementById(id); }
// --- Real helper functions extracted from boot.js ---
__HELPERS__
// --- Synchronous _sendKey init (from boot.js line ~2362) ---
try { window._sendKey = localStorage.getItem('hermes-pref-send_key') || 'enter'; } catch(_) { window._sendKey = 'enter'; }
// --- Real composer handler extracted from boot.js ---
// send() stub: count invocations instead of doing real work.
window.__sendCount = 0;
window.send = function() { window.__sendCount++; };
var send = window.send; // local alias so extracted handler's send() resolves
__HANDLER__
</script>
</body></html>
"""
def _build_harness_html() -> str:
return HARNESS_HTML.replace("__HELPERS__", _extract_helper_functions()).replace(
"__HANDLER__", _extract_composer_handler()
)
@pytest.fixture
def page():
_require_playwright()
with sync_playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
context = browser.new_context()
pg = context.new_page()
html = _build_harness_html()
import tempfile
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(html, encoding="utf-8")
pg.goto(tmp.as_uri())
pg.wait_for_load_state("domcontentloaded")
yield pg
browser.close()
def _set_send_key(page, mode: str):
"""Simulate panels.js applying a saved settings preference at runtime."""
page.evaluate(
f"""() => {{
localStorage.setItem('hermes-pref-send_key', '{mode}');
window._sendKey = '{mode}';
}}"""
)
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
"""Dispatch a real Enter keydown on #msg with optional modifiers."""
code = "NumpadEnter" if numpad else "Enter"
location = 3 if numpad else 0
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
code: code,
keyCode: 13,
which: 13,
location: location,
shiftKey: shift,
ctrlKey: ctrl,
metaKey: meta,
bubbles: true,
cancelable: true,
}));
}
""",
{"shift": shift, "ctrl": ctrl, "meta": meta, "code": code, "location": location},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Static guard ─────────────────────────────────────────────────────────
def test_no_inline_interceptor_remains():
"""The stale-snapshot interceptor must be gone from index.html."""
assert "stopImmediatePropagation" not in INDEX_HTML, (
"index.html still contains a capture-phase interceptor with "
"stopImmediatePropagation — this reintroduces the stale _sendKey bug"
)
# ─── Behavioural tests ────────────────────────────────────────────────────
def test_enter_mode_plain_enter_sends(page):
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
def test_enter_mode_shift_enter_does_not_send(page):
_set_send_key(page, "enter")
_press_enter(page, shift=True)
assert _send_count(page) == 0
def test_shift_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "shift+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_shift_enter_mode_shift_enter_sends(page):
_set_send_key(page, "shift+enter")
_press_enter(page, shift=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_ctrl_enter_mode_ctrl_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, ctrl=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_cmd_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, meta=True)
assert _send_count(page) == 1
# ─── Core regression: live preference change ─────────────────────────────
def test_preference_change_enter_to_shift_enter_without_reload(page):
"""The bug: interceptor snapshotted 'enter' at load time. Changing to
'shift+enter' at runtime didn't update the closure, so Shift+Enter was
still blocked. After the fix, the live window._sendKey is read on every
event, so the transition takes effect immediately."""
# Start in 'enter' mode — plain Enter sends.
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
# Switch to 'shift+enter' without reloading.
_set_send_key(page, "shift+enter")
# Plain Enter should no longer send.
_press_enter(page)
assert _send_count(page) == 1 # unchanged
# Shift+Enter should now send.
_press_enter(page, shift=True)
assert _send_count(page) == 2
def test_preference_change_shift_enter_to_enter_without_reload(page):
"""Reverse transition: 'shift+enter' → 'enter'."""
_set_send_key(page, "shift+enter")
_press_enter(page) # no send
_press_enter(page, shift=True) # sends
assert _send_count(page) == 1
_set_send_key(page, "enter")
_press_enter(page) # now sends
assert _send_count(page) == 2
_press_enter(page, shift=True) # no longer sends
assert _send_count(page) == 2
def test_preference_change_enter_to_ctrl_enter_without_reload(page):
"""Transition: 'enter' → 'ctrl+enter'."""
_set_send_key(page, "enter")
_press_enter(page) # sends
assert _send_count(page) == 1
_set_send_key(page, "ctrl+enter")
_press_enter(page) # no longer sends
assert _send_count(page) == 1
_press_enter(page, ctrl=True) # sends
assert _send_count(page) == 2
@@ -45,9 +45,54 @@ def test_recover_missing_sidecars_from_state_db_materializes_webui_row(tmp_path)
assert data["parent_session_id"] == "parent-1"
assert data["source_tag"] == "webui"
assert data["session_source"] == "webui"
assert data["message_count"] == 2
assert [m["content"] for m in data["messages"]] == ["message 1", "message 2"]
def test_recovered_sidecar_derives_message_count_from_materialized_rows(tmp_path, monkeypatch):
"""Recovery must not copy a stale state.db summary into a live sidecar.
`Session.save()` relies on sidecar `message_count` for both #1558 guards.
A recovery row whose denormalized DB count says zero but whose canonical
message rows contain five messages must therefore refuse an empty active
snapshot and preserve a five-message backup on a later shrink.
"""
import api.models as models
sid = _make_state_db(tmp_path / "state.db", sid="recovered_stale_count", messages=5)
with sqlite3.connect(tmp_path / "state.db") as conn:
conn.execute("UPDATE sessions SET message_count = 0 WHERE id = ?", (sid,))
assert recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")["materialized"] == 1
sidecar = tmp_path / f"{sid}.json"
assert json.loads(sidecar.read_text(encoding="utf-8"))["message_count"] == 5
monkeypatch.setattr(models, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", tmp_path / "_index.json")
active_empty = models.Session(
session_id=sid,
title="Recovered from DB",
workspace=str(tmp_path),
model="openai/gpt-5",
messages=[],
active_stream_id="a" * 32,
pending_user_message="still typing",
)
active_empty.save()
assert len(json.loads(sidecar.read_text(encoding="utf-8"))["messages"]) == 5
shrinking = models.Session(
session_id=sid,
title="Recovered from DB",
workspace=str(tmp_path),
model="openai/gpt-5",
messages=[{"role": "user", "content": f"replacement {i}"} for i in range(3)],
)
shrinking.save()
backup = sidecar.with_suffix(".json.bak")
assert len(json.loads(backup.read_text(encoding="utf-8"))["messages"]) == 5
def test_recover_missing_sidecars_from_state_db_skips_deleted_webui_tombstone(tmp_path, monkeypatch):
import api.models as _m
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
+229
View File
@@ -0,0 +1,229 @@
"""Tests: GET /api/session full-transcript escape hatch + bounded recovery
calls — #7310 / #7625.
Bare ``/api/session`` reads (no ``msg_limit``) were issued by six frontend
recovery paths (offline/bfcache ``refreshSession``, stream-end settle, cancel
sync, /compress preflight, /retry, /undo). Each used to re-walk, re-redact and
re-serialize the ENTIRE transcript (4-15s / 28MB on a 5k-row session, measured
in #7625). Those call sites now request a bounded tail explicitly
(``msg_limit=30``), while the two paths that address rows by absolute
transcript index (outline jump, jump-to-start) opt in to the full transcript
via the explicit ``msg_limit=all`` escape hatch.
The bare no-limit HTTP shape deliberately keeps its historical full-transcript
contract: upstream contract tests pin tool-row preservation
(``test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide``)
and the runtime-journal live snapshot
(``test_paginated_session_followup_does_not_repeat_runtime_snapshot``) on that
shape, so the fix is enforced at the frontend call sites rather than by
changing the server default.
"""
from __future__ import annotations
import re
from pathlib import Path
from api.routes import (
_MAX_MSG_LIMIT,
_parse_msg_limit,
_resolve_effective_msg_limit,
)
_ROOT = Path(__file__).resolve().parents[1]
# ── helper: _resolve_effective_msg_limit ──
def test_bare_request_keeps_full_transcript_contract():
"""A bare reload (no msg_limit, no msg_before) keeps the historical
full-transcript contract — server-side default windowing was rejected
because contract tests pin tool-row preservation and the runtime-journal
snapshot on this shape (#7310/#7625)."""
limit, explicit_all = _resolve_effective_msg_limit(None)
assert limit is None
assert explicit_all is False
def test_empty_and_malformed_keep_contract():
assert _resolve_effective_msg_limit("") == (None, False)
assert _resolve_effective_msg_limit("not-a-number") == (None, False)
def test_explicit_all_resolves_to_full_transcript():
""""msg_limit=all → (None, True): the explicit full-transcript escape
hatch for outline jump / jump-to-start."""
limit, explicit_all = _resolve_effective_msg_limit("all")
assert limit is None
assert explicit_all is True
def test_explicit_all_case_insensitive():
assert _resolve_effective_msg_limit("ALL") == (None, True)
assert _resolve_effective_msg_limit(" All ") == (None, True)
def test_numeric_limit_unchanged():
"""Explicit numeric limits keep the clamp behaviour — real pagination is
unaffected."""
assert _resolve_effective_msg_limit("30") == (30, False)
assert _resolve_effective_msg_limit("9999") == (_MAX_MSG_LIMIT, False)
assert _resolve_effective_msg_limit("0") == (1, False)
def test_parse_msg_limit_all_returns_none():
"""The parse helper returns None for 'all' — the handler distinguishes it
from a bare request via _resolve_effective_msg_limit."""
assert _parse_msg_limit("all") is None
assert _parse_msg_limit("ALL") is None
# ── frontend call sites: bounded shapes (no bare GET /api/session left) ──
_UI_JS = (_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
_MESSAGES_JS = (_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
_SESSIONS_JS = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
_COMMANDS_JS = (_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
_OUTLINE_JS = (_ROOT / "static" / "outline.js").read_text(encoding="utf-8")
_BOUNDED = "&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1"
_ALL_QS = "&messages=1&resolve_model=0&msg_limit=all"
def test_refresh_session_uses_bounded_tail():
"""Offline/bfcache recovery refreshSession must not pull the full
transcript (#7310/#7625)."""
assert _BOUNDED in _UI_JS
def test_settle_and_cancel_paths_use_bounded_tail():
"""stream_end settle recovery AND cancel sync must not pull the full
transcript."""
assert _BOUNDED in _MESSAGES_JS
assert _MESSAGES_JS.count(_BOUNDED) >= 2
def test_cancel_payload_restores_truncation_signal():
"""_applyCancelSessionPayload keeps the Load-earlier paging gate honest
after a bounded cancel reload."""
assert "!!sessionPayload._messages_truncated" in _MESSAGES_JS
assert "sessionPayload._messages_offset||0" in _MESSAGES_JS
def test_settle_restores_truncation_signal():
"""_restoreSettledSession keeps the Load-earlier paging gate honest after
a bounded settle reload."""
assert "!!session._messages_truncated" in _MESSAGES_JS
assert "session._messages_offset||0" in _MESSAGES_JS
def _restore_settled_session_body():
start = _MESSAGES_JS.index("async function _restoreSettledSession")
end = _MESSAGES_JS.index("function _handleStreamError", start)
return _MESSAGES_JS[start:end]
def _apply_cancel_session_payload_body():
start = _MESSAGES_JS.index("const _applyCancelSessionPayload=(sessionPayload)=>")
end = _MESSAGES_JS.index("Prefer the canonical session snapshot", start)
return _MESSAGES_JS[start:end]
def test_settle_refreshes_paging_before_anchor_persist():
"""Anchor persist reads _oldestIdx; a bounded tail must refresh it first (#7628)."""
restore = _restore_settled_session_body()
offset_idx = restore.index("_oldestIdx=session._messages_offset||0")
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
assert offset_idx < attach_idx
def test_cancel_refreshes_paging_before_anchor_persist():
"""Cancel recovery has the same persist-before-offset trap as settle (#7628)."""
cancel = _apply_cancel_session_payload_body()
offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0")
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
assert offset_idx < attach_idx
def test_settle_preserves_terminal_marker_against_bounded_suffix():
"""Long-session bounded tails are suffixes of S.messages, not prefixes (#7628)."""
restore = _restore_settled_session_body()
assert "_stagedMatchesCurrentSuffix" in restore
assert "(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix)" in restore
def test_settle_truncation_signal_decides_match_strategy():
"""The server's truncation signal must decide prefix-vs-suffix, never an `||`:
bounded settles get suffix-exclusive matching (identical repeated turns make
BOTH comparisons succeed; the prefix branch then splices at the wrong offset
and silently drops/duplicates rows) (#7628)."""
restore = _restore_settled_session_body()
assert "_truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0))" in restore
# The splice offset must follow the SAME truncation decision as the match
# strategy — no fallthrough to prefix when both match.
assert "_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length" in restore
def test_apperror_embedded_session_refreshes_paging_before_anchor_persist():
"""The apperror embedded-session path has the same persist-before-offset trap (#7628)."""
start = _MESSAGES_JS.index("source.addEventListener('apperror'")
end = _MESSAGES_JS.index("source.addEventListener('warning'", start)
block = _MESSAGES_JS[start:end]
offset_idx = block.index("_oldestIdx=d.session._messages_offset||0")
attach_idx = block.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
assert offset_idx < attach_idx
def test_compress_preflight_uses_bounded_tail():
"""/compress preflight only needs session existence + the current tail."""
assert _BOUNDED in _COMMANDS_JS
def test_retry_uses_bounded_tail():
"""/retry recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 1
def test_undo_uses_bounded_tail():
"""/undo recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 2
def test_recovery_paths_restore_truncation_signal():
"""preflight/retry/undo must NOT hardcode _messagesTruncated=false — they
read the server's truncation signal instead."""
assert "!!(live.session._messages_truncated)" in _COMMANDS_JS
assert "!!(data.session._messages_truncated)" in _COMMANDS_JS
def test_jump_to_start_requests_explicit_full_transcript():
"""_ensureAllMessagesLoaded genuinely needs everything (absolute-index
addressing of the earliest rows) — it must opt in via msg_limit=all."""
assert _ALL_QS in _SESSIONS_JS
def test_outline_jump_requests_explicit_full_transcript():
"""Outline jump addresses rows by absolute transcript index — it must opt
in via msg_limit=all."""
assert _ALL_QS in _OUTLINE_JS
def test_no_bare_session_fetch_remains_in_frontend():
"""Regression guard: NO static/ caller may issue a bare GET /api/session
(no messages=/msg_limit= params). Use the bounded tail shape or the
explicit msg_limit=all escape hatch."""
bare_template = re.compile(
r"api\(`/api/session\?session_id=\$\{encodeURIComponent\([^)]*\}\)`\)"
)
bare_concat = re.compile(
r"api\('/api/session\?session_id='\s*\+\s*encodeURIComponent\([^)]*\)\)"
)
for name, src in (
("ui.js", _UI_JS),
("messages.js", _MESSAGES_JS),
("sessions.js", _SESSIONS_JS),
("commands.js", _COMMANDS_JS),
("outline.js", _OUTLINE_JS),
):
assert not bare_template.findall(src), f"{name} still has a bare template-literal /api/session fetch"
assert not bare_concat.findall(src), f"{name} still has a bare concatenated /api/session fetch"
+17 -2
View File
@@ -67,7 +67,22 @@ def _build_status_callback():
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "_agent_status_callback"
)
module = ast.Module(body=[fn], type_ignores=[])
# The callback is a closure inside ``_run_agent_streaming`` and may declare
# ``nonlocal`` state (e.g. the runtime-model dedupe identity it resets on a
# fallback warning). Compiled alone at module level, ``nonlocal`` has no
# binding and raises SyntaxError, so wrap it in an enclosing function that
# binds every nonlocal name it declares, then return the inner callback.
nonlocals = sorted({
name
for node in ast.walk(fn)
if isinstance(node, ast.Nonlocal)
for name in node.names
})
binds = "".join(f" {name} = None\n" for name in nonlocals)
outer = ast.parse(f"def _status_callback_scope():\n{binds} pass\n").body[0]
assert isinstance(outer, ast.FunctionDef)
outer.body[-1:] = [fn, ast.Return(value=ast.Name(id=fn.name, ctx=ast.Load()))]
module = ast.fix_missing_locations(ast.Module(body=[outer], type_ignores=[]))
events = []
ns = {
"put": lambda event, data: events.append((event, data)),
@@ -78,7 +93,7 @@ def _build_status_callback():
"_is_session_lease_wait_message": streaming._is_session_lease_wait_message,
}
exec(compile(module, "streaming_status_callback", "exec"), ns)
return ns["_agent_status_callback"], events
return ns["_status_callback_scope"](), events
@pytest.mark.parametrize(
+6
View File
@@ -925,6 +925,9 @@ eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const child = {{session_id:'child', title:'Subagent', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20, source:'webui'}};
@@ -973,6 +976,9 @@ eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const child = {{session_id:'child', title:'Fork in projA', parent_session_id:'parent', relationship_type:'child_session', project_id:'projA', message_count:3, updated_at:20, last_message_at:20, source:'webui'}};
+249 -2
View File
@@ -954,8 +954,9 @@ def test_continuation_classification_timestamp_tolerance_and_guards():
model_config=json.dumps({'_delegate_from': 'some-other-session'}),
),
)
# Unparsable model_config degrades to no markers (no crash, no match).
assert _is_continuation_session(
# Unparsable model_config is untrusted lineage identity: it fails closed
# as a boundary (no crash), even inside the tolerance window.
assert not _is_continuation_session(
make_parent(),
make_child(started_at=999.95, model_config='not-json'),
)
@@ -980,6 +981,58 @@ def test_continuation_classification_timestamp_tolerance_and_guards():
assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number'))
@pytest.mark.parametrize("parent_source", ["tool", ""])
def test_tool_child_is_not_stitched_into_parent_lineage(_isolate, parent_source):
"""A marker-less tool child stays independent even with a matching/empty source."""
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
parent_id, child_id = "tool_boundary_parent", "tool_boundary_child"
try:
_insert_state_row(
conn, parent_id, source=parent_source, title="Parent conversation",
started_at=t0, ended_at=t0 + 5, end_reason="compression",
)
_insert_state_row(
conn, child_id, source="tool", title="Tool conversation",
parent=parent_id, started_at=t0 + 4.5,
)
_insert_state_message(
conn, parent_id, role="user", content="parent request", timestamp=t0 + 1,
)
_insert_state_message(
conn, child_id, role="user", content="tool request", timestamp=t0 + 6,
)
projected = read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None,
)
rows = {row["id"]: row for row in projected}
assert set(rows) == {parent_id, child_id}
assert rows[child_id]["relationship_type"] == "child_session"
assert "_lineage_root_id" not in rows[child_id]
assert "_lineage_tip_id" not in rows[parent_id]
metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id})
assert metadata[child_id]["relationship_type"] == "child_session"
assert "_lineage_root_id" not in metadata[child_id]
assert read_session_lineage_report(_isolate, parent_id)["total_segments"] == 1
assert read_session_lineage_report(_isolate, child_id)["total_segments"] == 1
messages = models.get_state_db_session_messages(
child_id, stitch_continuations=True,
)
assert [message["content"] for message in messages] == ["tool request"]
finally:
conn.close()
def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate):
"""#7021 r2: the open/import transcript stitcher (get_state_db_session_messages
with stitch_continuations=True) must apply the same model_config branch-marker
@@ -1148,3 +1201,197 @@ def test_state_db_stitch_old_schema_without_identity_columns_still_stitches(_iso
assert "parent turn" in contents
finally:
conn.close()
def test_branch_markers_report_fail_closed_unknown_state():
"""Untrusted model_config lineage evidence is 'unknown', never 'no markers'."""
from api.agent_sessions import _branch_markers
assert _branch_markers(None) == ('none', {})
assert _branch_markers({'model_config': None}) == ('none', {})
assert _branch_markers({'model_config': ''}) == ('none', {})
assert _branch_markers({'model_config': ' '}) == ('none', {})
assert _branch_markers({'model_config': json.dumps({'model': 'x'})}) == ('none', {})
assert _branch_markers({'model_config': json.dumps({'_reset_from': None})}) == ('none', {})
assert _branch_markers(
{'model_config': json.dumps({'_reset_from': 'p', '_delegate_from': 'a'})}
) == ('markers', {'_reset_from': 'p', '_delegate_from': 'a'})
assert _branch_markers({'model_config': {'_branched_from': ' p '}}) == (
'markers',
{'_branched_from': 'p'},
)
for raw in ('{not-json', '[]', '"a string"', '17', 'null', '{"a":'):
assert _branch_markers({'model_config': raw}) == ('unknown', {}), raw
assert _branch_markers({'model_config': ['_delegate_from']}) == ('unknown', {})
for hostile in (
{'_delegate_from': 123},
{'_delegate_from': ['p']},
{'_delegate_from': {'id': 'p'}},
{'_branched_from': True},
{'_branched_from': ' '},
{'_reset_from': ''},
# A usable first marker never hides a malformed second one.
{'_delegate_from': 'ancestor', '_branched_from': 123},
):
assert _branch_markers({'model_config': json.dumps(hostile)}) == ('unknown', {}), hostile
# A valid but pathologically deep payload overflows the decoder with
# RecursionError (not ValueError); it must degrade to 'unknown', not crash.
depth = 12_000
deep_payload = '[' * depth + ']' * depth
with pytest.raises(RecursionError):
json.loads(deep_payload)
assert _branch_markers({'model_config': deep_payload}) == ('unknown', {})
def test_untrusted_model_config_markers_fail_closed_inside_tolerance():
"""Master tolerance/predicate shape, but unknown identity is a boundary."""
from api.agent_sessions import _is_continuation_session
parent = {'id': 'fc_parent', 'source': 'webui', 'ended_at': 200.0, 'end_reason': 'compression'}
child = {'id': 'fc_child', 'source': 'webui', 'started_at': 199.7, 'parent_session_id': 'fc_parent'}
# Containment: marker-free and null-marker children still continue.
assert _is_continuation_session(parent, child)
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_delegate_from': None})}
)
# Master's shape is kept: a marker naming another session (inherited from
# the rotated agent's model_config) does not split a real continuation.
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_delegate_from': 'somewhere_else'})}
)
for raw in (
'{not-json',
'[]',
json.dumps({'_delegate_from': 123}),
json.dumps({'_branched_from': ''}),
json.dumps({'_reset_from': ['fc_parent']}),
json.dumps({'_delegate_from': 'somewhere_else', '_branched_from': 123}),
'[' * 12_000 + ']' * 12_000,
):
assert not _is_continuation_session(parent, {**child, 'model_config': raw}), raw[:40]
# Legacy rows without ended_at keep continuing, unless identity is untrusted.
legacy_parent = {**parent, 'ended_at': None}
assert _is_continuation_session(legacy_parent, child)
assert not _is_continuation_session(legacy_parent, {**child, 'model_config': '{not-json'})
def test_reset_from_marker_naming_parent_is_a_boundary():
"""Hermes Agent binds ``_reset_from`` like ``_branched_from``/``_delegate_from``.
``gateway/session_recovery.py`` stamps ``model_config._reset_from`` on a
reset child and ``_NON_CONTINUATION_CHILD_FILTER_SQL`` rejects it as a
compression continuation. The WebUI predicate must agree inside the
tolerance window and on legacy rows without ``ended_at``.
"""
from api.agent_sessions import _is_continuation_session
for end_reason in ('compression', 'cli_close'):
parent = {'id': 'reset_parent', 'source': 'telegram', 'ended_at': 300.0, 'end_reason': end_reason}
child = {'id': 'reset_child', 'source': 'telegram', 'started_at': 299.5, 'parent_session_id': 'reset_parent'}
assert _is_continuation_session(parent, child), end_reason
assert not _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_reset_from': 'reset_parent'})}
), end_reason
assert not _is_continuation_session(
parent, {**child, 'model_config': {'max_iterations': 40, '_reset_from': 'reset_parent'}}
), end_reason
assert not _is_continuation_session(
{**parent, 'ended_at': None},
{**child, 'model_config': json.dumps({'_reset_from': 'reset_parent'})},
), end_reason
# A direct reset marker is not masked by an inherited delegate marker.
assert not _is_continuation_session(
parent,
{
**child,
'model_config': json.dumps(
{'_delegate_from': 'ancestor', '_reset_from': 'reset_parent'}
),
},
), end_reason
# An inherited reset marker naming an ancestor does not split the lineage.
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_reset_from': 'ancestor'})}
), end_reason
def test_reset_child_inside_tolerance_stays_independent_across_readers(_isolate):
"""Sidebar listing, lineage metadata/report and transcript stitcher agree.
A resumed parent closed by compression within the tolerance window of its
reset child's creation must not absorb that reset conversation.
"""
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
parent_id = 'reset_reader_parent'
child_id = 'reset_reader_child'
try:
_insert_state_row(
conn,
parent_id,
title='Reset parent conversation',
started_at=t0,
ended_at=t0 + 5,
end_reason='compression',
)
_insert_state_message(conn, parent_id, role='user', content='parent turn', timestamp=t0 + 1)
_insert_state_row(
conn,
child_id,
title='Reset child conversation',
parent=parent_id,
started_at=t0 + 5 - 0.5,
model_config=json.dumps({'_reset_from': parent_id}),
)
_insert_state_message(conn, child_id, role='user', content='reset turn', timestamp=t0 + 6)
projected = {
row['id']: row
for row in read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None
)
}
assert {parent_id, child_id} <= set(projected)
assert projected[child_id]['title'] == 'Reset child conversation'
assert projected[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in projected[child_id]
assert '_compression_segment_count' not in projected[parent_id]
assert 'model_config' not in projected[child_id]
metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id})
assert metadata[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in metadata[child_id]
report = read_session_lineage_report(_isolate, child_id)
assert report['tip_session_id'] == child_id
assert report['total_segments'] == 1
stitched = models.get_state_db_session_messages(child_id, stitch_continuations=True)
assert [m['content'] for m in stitched] == ['reset turn']
# Control: the same row without the reset marker is a continuation.
conn.execute('UPDATE sessions SET model_config = NULL WHERE id = ?', (child_id,))
conn.commit()
stitched = models.get_state_db_session_messages(child_id, stitch_continuations=True)
assert [m['content'] for m in stitched] == ['parent turn', 'reset turn']
projected_ids = {
row['id']
for row in read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None
)
}
assert parent_id not in projected_ids and child_id in projected_ids
finally:
conn.close()
@@ -818,6 +818,169 @@ def test_get_session_syncs_sidecar_from_newer_state_db_even_when_stream_not_term
assert reloaded.messages[-1]["content"] == "latest live progress"
def test_get_session_sync_keeps_pending_image_prompt_when_agent_row_is_proven(monkeypatch, hermes_home):
sid = "state_newer_pending_image_sid"
stream_id = "dead_pending_image_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
agent_only_note = "Agent-only recall note"
agent_user_content = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
{"type": "text", "text": agent_only_note},
]
previous_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*previous_messages,
{"role": "user", "content": agent_user_content, "timestamp": timestamp},
{"role": "assistant", "content": "recovered assistant tail", "timestamp": timestamp + 1},
]
s = Session(
session_id=sid,
title="Pending image recovery",
messages=previous_messages,
context_messages=previous_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
s.save()
models.SESSIONS.pop(sid, None)
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp + 1,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
models.get_session(sid)
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["timestamp"] == timestamp
assert submitted_rows[0]["attachments"] == [attachment]
assert reloaded.messages[-1] == state_messages[-1]
assert agent_only_note not in str(reloaded.messages)
context_rows = models.reconciled_state_db_messages_for_session(
reloaded,
prefer_context=True,
state_messages=state_messages,
)
enriched_rows = [
message for message in context_rows
if message.get("role") == "user" and message.get("timestamp") == timestamp
]
assert len(enriched_rows) == 1
assert enriched_rows[0]["content"] == agent_user_content
assert agent_only_note in str(enriched_rows[0]["content"])
def test_state_db_pending_image_row_alone_does_not_skip_stale_journal_recovery(
monkeypatch, hermes_home,
):
sid = "state_pending_image_without_output_sid"
stream_id = "dead_pending_image_without_output_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
old_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*old_messages,
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
],
"timestamp": timestamp,
},
]
session = Session(
session_id=sid,
title="Pending image without output",
messages=old_messages,
context_messages=old_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
session.save()
models.SESSIONS[sid] = session
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
append_run_event(sid, stream_id, "token", {"text": "Recovered partial text."})
# Cached get_session runs state.db self-heal, then the real stale-stream
# cleanup must still get the pending turn so it can replay the run journal.
loaded = models.get_session(sid)
assert loaded is session
assert loaded.active_stream_id == stream_id
assert loaded.pending_user_message == prompt
assert loaded.pending_attachments == [attachment]
import api.routes as routes
assert routes._clear_stale_stream_state(loaded) is True
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["attachments"] == [attachment]
assert any(
message.get("_recovered_from_run_journal")
and "Recovered partial text." in message.get("content", "")
for message in reloaded.messages
)
markers = [
message for message in reloaded.messages
if message.get("_error") and message.get("type") == "interrupted"
]
assert len(markers) == 1
assert "**Response interrupted.**" in markers[0]["content"]
assert "partial output above was recovered" in markers[0]["content"]
def test_get_session_does_not_sync_while_stream_is_still_live(monkeypatch):
"""A still-running worker owns its own writeback; do not race it.
@@ -884,6 +1047,7 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
"""
sid = "state_sync_save_fail_sid"
stream_id = "stream_save_fail_no_done"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
s = Session(
session_id=sid,
title="Save failure",
@@ -893,7 +1057,10 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
],
active_stream_id=stream_id,
pending_user_message="new request",
pending_attachments=[attachment],
pending_started_at=102.0,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, 102.0),
)
s.save()
@@ -926,7 +1093,13 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
assert result is False
assert s.active_stream_id == stream_id
assert s.pending_user_message == "new request"
assert s.pending_attachments == [attachment]
assert [m["content"] for m in s.messages] == ["old question", "old answer"]
reloaded = Session.load(sid)
assert reloaded.active_stream_id == stream_id
assert reloaded.pending_user_message == "new request"
assert reloaded.pending_attachments == s.pending_attachments
assert [m["content"] for m in reloaded.messages] == ["old question", "old answer"]
def test_sync_persists_recovered_state_db_tail_when_stream_dead(monkeypatch):
+5 -5
View File
@@ -31,11 +31,11 @@ def test_render_uses_single_pass_partition_helper():
render_body = _function_block("renderSessionListFromCache")
assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)])" in render_body
assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)])" in render_body
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
assert ": null;" in render_body
assert "null is a deliberate \"not computed\" sentinel" in render_body
assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body
@@ -72,8 +72,8 @@ def test_partition_helper_keeps_raw_source_counts_while_render_owns_visible_coun
assert "cliSessionsRaw," in _partition_block()
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS
assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS
assert "_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows)" in SESSIONS_JS
+2 -2
View File
@@ -61,7 +61,7 @@ def test_unassigned_chip_filter_logic():
assert "if(_activeProject===NO_PROJECT_FILTER){" in js, (
"The Unassigned filter must select sessions without a project_id"
)
assert "if(s.project_id) continue;" in js, (
assert "if(projectId) continue;" in js, (
"The Unassigned filter must skip sessions with a project_id"
)
@@ -76,7 +76,7 @@ def test_unassigned_chip_only_shown_when_relevant():
— though that's vanishingly rare).
"""
js = _js()
assert "const hasUnprojected=profileFiltered.some(s=>!s.project_id);" in js, (
assert "const hasUnprojected=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor);" in js, (
"The render function must compute whether unassigned sessions exist"
)
assert "if(_allProjects.length>0||hasUnprojected){" in js, (
+188
View File
@@ -0,0 +1,188 @@
"""Delegated subagent children must follow their parent's project filter.
Subagent child sessions are created by the agent (state.db) and never carry a
``project_id`` of their own. The sidebar project filter compared each row's own
``project_id``, so selecting a project dropped every subagent child and the
parent rendered with no stacked children, while the Unassigned view kept them.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_PREAMBLE = """
const src = %s;
function extractFunc(name) {
const start = src.search(new RegExp('function\\\\s+' + name + '\\\\s*\\\\('));
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start) + 1, depth = 1;
while (depth > 0 && i < src.length) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; }
return src.slice(start, i);
}
function _isCliSession(s){ return !!(s && (s.is_cli_session || s.session_source==='cli')); }
function _isExternalSession(s){ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }
function _isMessagingSession(s){ return !!(s && s.session_source==='messaging'); }
function _hasUnreadForSession(s){ return !!(s && s.has_unread); }
function _sessionDisplayTitle(s){ return s && s.title; }
Object.assign(global, {_isCliSession, _isExternalSession, _isMessagingSession, _hasUnreadForSession, _sessionDisplayTitle});
global.INFLIGHT = {};
global.NO_PROJECT_FILTER = '__no_project__';
global.window = {};
global._archivedCliCount = 0; global._archivedWebuiCount = 0;
global._sidebarReferenceSessions = [];
global.S = { session: null, busy: false, activeStreamId: null };
global._showArchived = false;
global._sessionSourceFilter = 'webui';
for (const fn of ['_isSessionLocallyStreaming','_hasPendingUserMessageSignal','_isSessionEffectivelyStreaming',
'_isChildSession','_isForkWithResolvableParent','_sessionLineageKey','_sidebarLineageKeyForRow',
'_collapseSessionLineageForSidebar','_attachChildSessionsToSidebarRows','_sessionAttentionState',
'_sidebarRowHasVisibleMessages','_isDelegatedSubagentRow','_sidebarProjectResolver','_sidebarRowsById','_sidebarHasUnprojectedRows',
'_partitionSidebarSessionRows','_scopedSidebarReferenceRows','_renderSidebarRowsFromRawSessions']) {
eval.call(global, extractFunc(fn));
}
const parent = { session_id:'proj_parent', title:'Parent', session_source:'webui', raw_source:'webui', source_tag:'webui', message_count:5, project_id:'projX', updated_at:100, last_message_at:100 };
const child = { session_id:'sub_child', title:'Subagent Session', parent_session_id:'proj_parent', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', _parent_lineage_root_id:'proj_parent', _cross_surface_child_session:true, message_count:3, updated_at:101, last_message_at:101 };
const grandchild = { session_id:'sub_grandchild', title:'Nested subagent', parent_session_id:'sub_child', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', message_count:2, updated_at:102, last_message_at:102 };
function render(project, rows) {
global._activeProject = project;
const part = _partitionSidebarSessionRows(rows, null);
const out = _renderSidebarRowsFromRawSessions(part.sessionsRaw, part.webuiReferenceRaw);
const p = out.find(r => r.session_id === 'proj_parent') || {};
return { raw: part.sessionsRaw.map(s => s.session_id).sort(), top: out.map(r => r.session_id),
children: (p._child_sessions || []).map(c => c.session_id) };
}
"""
def _run(scenario: str) -> dict:
assert NODE is not None
source = (_PREAMBLE % json.dumps(SESSIONS_JS)) + scenario
result = subprocess.run([NODE], input=source, cwd=str(REPO_ROOT),
capture_output=True, text=True, encoding="utf-8", timeout=30)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return json.loads(result.stdout.strip())
def test_subagent_child_is_shown_inside_parent_project():
out = _run("console.log(JSON.stringify(render('projX', [parent, child])));")
assert out["raw"] == ["proj_parent", "sub_child"]
assert out["top"] == ["proj_parent"]
assert out["children"] == ["sub_child"]
def test_nested_subagent_inherits_project_through_child():
out = _run("console.log(JSON.stringify(render('projX', [parent, child, grandchild])));")
assert out["raw"] == ["proj_parent", "sub_child", "sub_grandchild"]
assert out["top"] == ["proj_parent"]
def test_subagent_of_project_parent_is_not_unassigned():
out = _run("console.log(JSON.stringify(render(NO_PROJECT_FILTER, [parent, child])));")
assert out["raw"] == []
assert out["top"] == []
def test_subagent_excluded_from_other_project_and_own_project_id_wins():
out = _run("""
const other = render('projY', [parent, child]);
const explicit = render('projY', [parent, Object.assign({}, child, {project_id:'projY'})]);
console.log(JSON.stringify({other, explicit}));
""")
assert out["other"]["raw"] == []
assert out["explicit"]["raw"] == ["sub_child"]
def test_parent_resolved_from_reference_rows():
"""Parent only present as a hidden reference row still scopes the child."""
out = _run("""
global._sidebarReferenceSessions = [parent];
const rows = render('projX', [child]);
global._activeProject = 'projX';
const scoped = _scopedSidebarReferenceRows(false).map(s => s.session_id);
console.log(JSON.stringify({raw: rows.raw, scoped}));
""")
assert out["raw"] == ["sub_child"]
assert out["scoped"] == ["proj_parent"]
def test_unassigned_chip_ignores_subagents_of_project_parents():
out = _run("console.log(JSON.stringify(_sidebarHasUnprojectedRows([parent, child], _sidebarProjectResolver(_sidebarRowsById([[parent, child]])))));")
assert out is False
def test_unassigned_chip_uses_reference_parent():
"""A child whose project parent is only a reference row is not unassigned."""
out = _run("""
global._sidebarReferenceSessions = [parent];
global._activeProject = null;
const part = _partitionSidebarSessionRows([child], null);
console.log(JSON.stringify({has: _sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor),
unassigned: render(NO_PROJECT_FILTER, [child]).raw}));
""")
assert out["unassigned"] == []
assert out["has"] is False
def test_unassigned_fork_stays_unassigned():
"""A fork moved to "No project" keeps project_id null; only subagents inherit."""
out = _run("""
const fork = { session_id:'fork_child', title:'Fork', parent_session_id:'proj_parent', relationship_type:'child_session', session_source:'fork', raw_source:'webui', source_tag:'webui', project_id:null, message_count:2, updated_at:103, last_message_at:103 };
global._activeProject = null;
const p = _partitionSidebarSessionRows([parent, fork], null);
console.log(JSON.stringify({unassigned: render(NO_PROJECT_FILTER, [parent, fork]).raw,
project: render('projX', [parent, fork]).raw,
chip: _sidebarHasUnprojectedRows(p.profileFiltered, p.projectIdFor)}));
""")
assert out["unassigned"] == ["fork_child"]
assert out["project"] == ["proj_parent"]
assert out["chip"] is True
def test_project_resolution_is_linear_in_lineage_depth():
"""Each lineage resolves once: ancestor lookups stay O(n) for a deep chain."""
out = _run("""
function chain(n) {
const rows = [parent];
for (let i = 0; i < n; i++) rows.push(Object.assign({}, grandchild, {session_id:'c'+i,
parent_session_id: i ? 'c'+(i-1) : 'proj_parent', updated_at:200+i, last_message_at:200+i}));
return rows;
}
function lookups(n) {
const rows = chain(n);
let gets = 0;
const realGet = Map.prototype.get;
Map.prototype.get = function(k) { gets++; return realGet.call(this, k); };
try {
global._activeProject = 'projX';
global._sidebarReferenceSessions = rows.slice(0, 50);
const part = _partitionSidebarSessionRows(rows, null);
_scopedSidebarReferenceRows(false, part.projectIdFor);
_sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor);
return {gets, raw: part.sessionsRaw.length};
} finally { Map.prototype.get = realGet; }
}
console.log(JSON.stringify({small: lookups(1000), big: lookups(4000)}));
""")
assert out["small"]["raw"] == 1001 and out["big"]["raw"] == 4001
# 4x the rows must cost ~4x the lookups, not ~16x.
assert out["big"]["gets"] <= 5 * out["small"]["gets"], out
assert out["big"]["gets"] <= 20 * 4001, out
def test_project_resolver_is_cycle_safe():
out = _run("""
const a = Object.assign({}, grandchild, {session_id:'a', parent_session_id:'b'});
const b = Object.assign({}, grandchild, {session_id:'b', parent_session_id:'a'});
const resolve = _sidebarProjectResolver(_sidebarRowsById([[a, b]]));
console.log(JSON.stringify([resolve(a), resolve(b)]));
""")
assert out == [None, None]
+68
View File
@@ -25,6 +25,7 @@ from api.gateway_chat import (
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
from api.turn_journal import derive_turn_journal_states, read_turn_journal
def test_gateway_chat_backend_is_default_off_for_truthy_values():
@@ -397,6 +398,73 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
assert all(len(item) == 3 and item[2] for item in events)
def test_gateway_chat_worker_records_turn_journal_completion(tmp_path, monkeypatch):
"""#6366 re-gate: a successful Gateway run must record durable
same-stream completion evidence in the crash-safe turn journal.
The stale-cancel recovery predicate derives its completion evidence
from the turn journal, so a Gateway run that persisted its final
answer but lost the run journal's terminal write would otherwise
have no completion evidence at all — and recovery would re-append a
duplicate recovered row after the valid final answer.
"""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(
gateway_chat.urllib.request,
"urlopen",
lambda req, timeout=0: FakeResponse(),
)
s = new_session()
stream_id = "stream-gateway-turn-journal-completed"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 456
s.save()
channel = create_stream_channel()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
journal = read_turn_journal(s.session_id, session_dir=session_dir)
states, _ = derive_turn_journal_states(journal.get("events") or [])
stream_events = [
event
for event in states.values()
if str(event.get("stream_id") or "") == stream_id
]
assert stream_events, "expected turn journal events for the gateway stream"
assert any(
event.get("event") == "completed" for event in stream_events
), "the gateway success writeback must record a completed turn journal event"
def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp_path, monkeypatch):
"""Gateway terminal errors must survive an empty assistant stream."""
from unittest.mock import MagicMock