Merge branch 'master' into feat/gateway-run-reattach-after-restart
This commit is contained in:
86 files changed
+14695
-480
No files matched your search
@@ -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"] == []
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]}"
|
||||
)
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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'"
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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'}};
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user