Merge PR #7577 into stage/0924-l
This commit is contained in:
commit
96921c2cfc
18 files changed
+1998
-66
No files matched your search
@@ -1343,6 +1343,8 @@ class Session:
|
||||
truncation_boundary=None,
|
||||
clear_generation=None,
|
||||
intentional_shrink_generation=None,
|
||||
transcript_generation: int=0,
|
||||
transcript_generation_baseline: int=0,
|
||||
gateway_routing=None, gateway_routing_history=None,
|
||||
llm_title_generated: bool=False,
|
||||
manual_title: bool=False,
|
||||
@@ -1446,6 +1448,10 @@ class Session:
|
||||
self.truncation_boundary = truncation_boundary
|
||||
self.clear_generation = clear_generation
|
||||
self.intentional_shrink_generation = intentional_shrink_generation
|
||||
self.transcript_generation = max(0, _parse_nonnegative_int(transcript_generation) or 0)
|
||||
self.transcript_generation_baseline = max(
|
||||
0, _parse_nonnegative_int(transcript_generation_baseline) or 0
|
||||
)
|
||||
self.gateway_routing = gateway_routing if isinstance(gateway_routing, dict) else None
|
||||
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
|
||||
self.llm_title_generated = bool(llm_title_generated)
|
||||
@@ -1538,6 +1544,8 @@ class Session:
|
||||
'truncation_boundary',
|
||||
'clear_generation',
|
||||
'intentional_shrink_generation',
|
||||
'transcript_generation',
|
||||
'transcript_generation_baseline',
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'manual_title',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
@@ -1936,6 +1944,8 @@ class Session:
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'last_message_at': last_message_at,
|
||||
'transcript_generation': self.transcript_generation,
|
||||
'transcript_generation_baseline': self.transcript_generation_baseline,
|
||||
'pinned': self.pinned,
|
||||
'archived': self.archived,
|
||||
'project_id': self.project_id,
|
||||
|
||||
@@ -53,6 +53,8 @@ _SIDEBAR_SESSION_RESPONSE_FIELDS = {
|
||||
"model",
|
||||
"model_provider",
|
||||
"message_count",
|
||||
"transcript_generation",
|
||||
"transcript_generation_baseline",
|
||||
"user_message_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@@ -608,6 +608,8 @@ def _stamp_intentional_shrink_generation(session, old_message_count: int, new_me
|
||||
if new_message_count >= old_message_count:
|
||||
return False
|
||||
session.intentional_shrink_generation = uuid.uuid4().hex
|
||||
session.transcript_generation = max(0, int(getattr(session, 'transcript_generation', 0) or 0)) + 1
|
||||
session.transcript_generation_baseline = max(0, int(new_message_count))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ _METADATA_FIELDS = frozenset(
|
||||
"compression_recovery_action",
|
||||
"truncation_watermark",
|
||||
"truncation_boundary",
|
||||
"transcript_generation",
|
||||
"transcript_generation_baseline",
|
||||
"gateway_routing",
|
||||
"gateway_routing_history",
|
||||
"llm_title_generated",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- **Status:** Proposed
|
||||
- **Author:** @franksong2702
|
||||
- **Created:** 2026-05-16
|
||||
- **Updated:** 2026-08-22
|
||||
- **Updated:** 2026-09-15
|
||||
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
|
||||
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`hermes-run-adapter-contract.md`](hermes-run-adapter-contract.md), [`stable-assistant-turn-anchors.md`](stable-assistant-turn-anchors.md)
|
||||
|
||||
@@ -138,6 +138,7 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
| Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent |
|
||||
| Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists |
|
||||
| Sidebar/session metadata | Helps the user find active and recent sessions | Must reflect meaningful user or assistant activity | Treat background cleanup as a fresh user-facing update |
|
||||
| Client-side unread stores (`localStorage`) | Backs the sidebar unread dot for every client on the origin | Converges counts/markers across clients and stores clear ordering independently per session | Let one client's stale cache lower a count or resurrect a cleared marker |
|
||||
|
||||
## Core Invariants
|
||||
|
||||
@@ -267,6 +268,94 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
timestamp (falling back to run start), so a long-running turn cancelled
|
||||
moments ago is never mistaken for an orphan.
|
||||
|
||||
## Client-side unread persistence (sidebar layer)
|
||||
|
||||
The sidebar unread dot is backed by two client-side stores in `static/sessions.js`.
|
||||
Both live in `localStorage` under the origin, so every WebUI client on the same
|
||||
origin/profile (a PWA window and a browser tab, for example) shares them while each
|
||||
client also caches them in module state. They are projections of the sidebar layer
|
||||
above; the rules below describe what stays coherent when more than one client
|
||||
writes.
|
||||
|
||||
| Store | Key | Semantics |
|
||||
|---|---|---|
|
||||
| Viewed counts | `hermes-session-viewed-counts` | `sid -> {message_count, transcript_generation}`, meaning "seen up to N messages in this transcript generation" |
|
||||
| Completion markers | `hermes-session-completion-unread` | `sid -> {message_count, completed_at, ...}` behind the visible dot |
|
||||
|
||||
- **Viewed counts are generation-scoped.** Session mutation routes increment the
|
||||
persisted `transcript_generation` whenever edit, regenerate, retry, undo, clear,
|
||||
or truncate reduces the visible transcript, and record the retained count as
|
||||
`transcript_generation_baseline`. Both fields survive the bounded `/api/sessions`
|
||||
projection for visible and sidebar-reference rows, including cached responses.
|
||||
A newer generation replaces an older one even
|
||||
when its count is lower; counts are monotonic only within one generation and
|
||||
merge by maximum there. A client first observing a newer generation acknowledges
|
||||
only that retained baseline, so messages added after the shrink remain unread
|
||||
even when the shrink and later growth arrive in one coalesced sidebar refresh.
|
||||
Legacy numeric records are generation zero and migrate to the structured
|
||||
representation on their next save. This prevents an old pre-truncate high-water
|
||||
mark from masking messages added after a transcript reset. A deletion records
|
||||
its own key under
|
||||
`hermes-session-viewed-counts:deleted:v1:<encoded-sid>`; merges drop any count
|
||||
whose session has a live deletion record, so a client that still caches the
|
||||
acknowledgement prunes it instead of writing it back. List membership cannot
|
||||
decide this, because the sidebar filters by profile, project, and source, so an
|
||||
absent row is not evidence of deletion. Deletion records expire on the same
|
||||
7-day policy as clear records, so a tab left open longer than that can re-add a
|
||||
count for a session deleted more than seven days earlier. That consequence is
|
||||
bounded and invisible: the session is no longer listed, so the retained entry
|
||||
produces no indicator, and it is re-examined only on the next deletion or clear.
|
||||
- **Completion markers are ordered by logical stamps, not wall clock.**
|
||||
Markers are add/remove and cannot be max-ordered, so each clear records a stamp
|
||||
under `hermes-session-completion-unread-cleared:v1:<encoded-sid>:<stamp>`.
|
||||
Independent immutable keys mean clients clearing different sessions—or clearing
|
||||
the same session in an interleaved operation—cannot replace newer ordering
|
||||
facts, and a reader folds the maximum per session. Markers carry
|
||||
`unread_order`: the greatest stamp the marker's creator had observed (its own
|
||||
clear state, its cached and stored markers) plus one. A marker whose stamp does
|
||||
not exceed its session's clear stamp loses, so a clear wins the tie when a
|
||||
marker was prepared before it but written after; a completion that happens after
|
||||
the clear observes it and stamps higher, so it still wins. Milliseconds are not
|
||||
used for ordering: a clear and a genuine later completion can share one tick, and
|
||||
a single observed clock cannot order them.
|
||||
- **The previous clear representation is migrated once, then dropped.** The
|
||||
unsuffixed `hermes-session-completion-unread-cleared` whole map is read, its
|
||||
facts are imported as independent records, and the key is removed. Keeping it
|
||||
would retain both of the defects it caused: concurrent clears could replace one
|
||||
another in the shared blob, and the blob grew with every session ever cleared.
|
||||
A client still running the previous revision therefore does not observe clears
|
||||
recorded after the migration; that reload boundary is deliberate, because a
|
||||
dual write cannot make the shared blob concurrency-safe. Versioned records are
|
||||
pruned by age (7 days), or when a newer ordering fact for that session was
|
||||
successfully persisted — never by an in-memory-only superseding clear and never
|
||||
by session existence, because the sidebar list is filtered by profile, project,
|
||||
and source, so an absent row may simply be hidden. They are never part of the
|
||||
marker map consumers read. A clear order is retained in module memory even when
|
||||
storage quota prevents allocating its versioned key; the client still attempts
|
||||
the smaller write that removes the marker from the existing marker map, so a user
|
||||
can dismiss unread state under storage pressure. The failed allocation also
|
||||
leaves any older durable clear record in place so reload does not lose the last
|
||||
persisted ordering fact. Logical clear order and retention time are separate:
|
||||
records compare markers using their order stamp,
|
||||
but the 7-day cap uses the wall-clock time at which the record was written, so
|
||||
a future logical stamp cannot extend retention indefinitely. The in-memory
|
||||
fallback lasts until reload, while successfully persisted records retain the
|
||||
same 7-day policy.
|
||||
- **Cross-client repair, not cache invalidation.** The `storage` listener routes a
|
||||
changed unread key (including any per-session clear key) back through the same
|
||||
merge instead of only dropping the local cache. A client that still holds an
|
||||
acknowledgement re-asserts it after the other client's write, the loser sees a
|
||||
value it cannot beat and stops, and repair converges instead of ping-ponging
|
||||
storage events.
|
||||
- **Whole-map facts converge; clear ordering does not share a map.** Viewed counts
|
||||
and completion markers still use read-modify-write maps, so interleaved writes
|
||||
can transiently lose an entry. Their monotonic cache merge and storage-event
|
||||
repair re-assert held facts. Clear ordering is different: each session has its
|
||||
own atomic `localStorage` write, so concurrent clears of different sessions
|
||||
cannot clobber each other. A viewed-count advance records its clear even when a
|
||||
competing client has prepared but not yet persisted the older completion
|
||||
marker; repeated observations at the same count do not refresh the tombstone.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Use this checklist for PRs that touch run state, streaming, replay, compression,
|
||||
@@ -289,6 +378,10 @@ context reconstruction, or session metadata:
|
||||
- If it introduces or changes a reclamation window, what proves an in-flight
|
||||
cancellation is not evicted early, and that a wedged one is eventually freed?
|
||||
- Can automatic compression or recovery text become visible active-turn content?
|
||||
- Does this change write one of the client-side unread stores
|
||||
(`hermes-session-viewed-counts`, `hermes-session-completion-unread`,
|
||||
`hermes-session-completion-unread-cleared`), and does it keep the merge and
|
||||
tombstone rules in the client-side unread persistence section?
|
||||
- What test or manual evidence proves the invariant?
|
||||
|
||||
## Existing Issue Map
|
||||
|
||||
+499
-15
@@ -346,7 +346,20 @@ function _clearComposerDraft(sid, text, files) {
|
||||
}
|
||||
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
// A deleted session's acknowledgement must not return from a stale client's
|
||||
// cache. Membership in the rendered list cannot decide this: the sidebar filters
|
||||
// by profile, project, and source, so an absent row is not evidence of deletion.
|
||||
// Each deletion therefore records its own key, and merges drop any entry whose
|
||||
// session has one. Records are pruned by age so the store stays bounded.
|
||||
const SESSION_VIEWED_COUNTS_DELETED_PREFIX = 'hermes-session-viewed-counts:deleted:v1:';
|
||||
const SESSION_VIEWED_COUNTS_DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
// Clear ordering is stored per session so two clients clearing different
|
||||
// sessions never replace one another's tombstones with whole-map writes. The
|
||||
// unsuffixed key is the legacy map read during migration.
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_KEY = 'hermes-session-completion-unread-cleared';
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${SESSION_COMPLETION_UNREAD_CLEARED_KEY}:v1:`;
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||||
// Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how
|
||||
// many sessions each profile rendered last time, keyed by profile name, so a
|
||||
@@ -359,6 +372,7 @@ const SESSION_PROFILE_COUNTS_KEY = 'hermes-session-profile-counts';
|
||||
let _sessionProfileCounts = null;
|
||||
let _sessionViewedCounts = null;
|
||||
let _sessionCompletionUnread = null;
|
||||
let _sessionCompletionUnreadClearedMemory = {};
|
||||
let _sessionObservedStreaming = null;
|
||||
const _sessionStreamingById = new Map();
|
||||
const _sessionListSnapshotById = new Map();
|
||||
@@ -476,23 +490,216 @@ function _knownSessionProfileCount(profile) {
|
||||
return (typeof v === 'number' && Number.isFinite(v)) ? v : null;
|
||||
}
|
||||
|
||||
// Read a persisted sid -> value map. Missing or unreadable values read as an
|
||||
// empty map, matching the getters above.
|
||||
function _readStoredJsonMap(key) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_){
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Does this session still exist as far as this client can tell? The sidebar list
|
||||
// is the authority (deletion drops the row) and the visit snapshot covers the
|
||||
// window before the first list render.
|
||||
function _sessionExistsForUnreadState(sid) {
|
||||
if (!sid) return false;
|
||||
if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions)
|
||||
&& _allSessions.some((s) => s && s.session_id === sid)) {
|
||||
return true;
|
||||
}
|
||||
return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById
|
||||
&& typeof _sessionListSnapshotById.has === 'function'
|
||||
&& _sessionListSnapshotById.has(sid);
|
||||
}
|
||||
|
||||
// Is there a session list to judge existence against at all? Without one,
|
||||
// "unknown" must not be read as "deleted": a client that has not rendered a list
|
||||
// yet would otherwise refuse to persist a fresh acknowledgement, and would prune
|
||||
// every tombstone it holds.
|
||||
function _sessionListLoaded() {
|
||||
if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions)
|
||||
&& _allSessions.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById
|
||||
&& typeof _sessionListSnapshotById.size === 'number'
|
||||
&& _sessionListSnapshotById.size > 0;
|
||||
}
|
||||
|
||||
// Viewed acknowledgements are generation-scoped. Transcript mutation routes
|
||||
// increment `transcript_generation` whenever the visible message list shrinks;
|
||||
// counts are monotonic only within one generation. Legacy numeric entries belong
|
||||
// to generation zero and are migrated on their next save.
|
||||
function _sessionViewedCountRecord(value, transcriptGeneration = 0) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return {
|
||||
message_count: Number(value.message_count) || 0,
|
||||
transcript_generation: Math.max(0, Number(value.transcript_generation) || 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
message_count: Number(value) || 0,
|
||||
transcript_generation: Math.max(0, Number(transcriptGeneration) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _sessionViewedCountValue(value) {
|
||||
return _sessionViewedCountRecord(value).message_count;
|
||||
}
|
||||
|
||||
function _sessionViewedRecordWins(candidate, current) {
|
||||
const ours = _sessionViewedCountRecord(candidate);
|
||||
const theirs = _sessionViewedCountRecord(current);
|
||||
if (ours.transcript_generation !== theirs.transcript_generation) {
|
||||
return ours.transcript_generation > theirs.transcript_generation;
|
||||
}
|
||||
return ours.message_count > theirs.message_count;
|
||||
}
|
||||
|
||||
function _sessionTranscriptGenerationForUnread(sid, explicit = null) {
|
||||
if (explicit !== null && explicit !== undefined && Number.isFinite(Number(explicit))) {
|
||||
return Math.max(0, Number(explicit) || 0);
|
||||
}
|
||||
let active = null;
|
||||
try {
|
||||
active = (S && S.session && S.session.session_id === sid) ? S.session : null;
|
||||
} catch (_) {}
|
||||
const snapshot = (typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById)
|
||||
? _sessionListSnapshotById.get(sid) : null;
|
||||
const listed = (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions))
|
||||
? _allSessions.find((s) => s && s.session_id === sid) : null;
|
||||
const session = active || snapshot || listed;
|
||||
return Math.max(0, Number(session && session.transcript_generation) || 0);
|
||||
}
|
||||
|
||||
function _mergeSessionViewedCounts(disk, cache) {
|
||||
const deleted = _readSessionViewedCountDeletions();
|
||||
const gone = (sid) => Object.prototype.hasOwnProperty.call(deleted, sid);
|
||||
const counts = {};
|
||||
let changed = false;
|
||||
for (const sid of Object.keys(disk || {})) {
|
||||
if (gone(sid)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const record = _sessionViewedCountRecord(disk[sid]);
|
||||
counts[sid] = record;
|
||||
if (!disk[sid] || typeof disk[sid] !== 'object' || Array.isArray(disk[sid])) changed = true;
|
||||
}
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
if (gone(sid)) continue;
|
||||
const ours = _sessionViewedCountRecord(cache[sid]);
|
||||
if (Object.prototype.hasOwnProperty.call(counts, sid)) {
|
||||
if (_sessionViewedRecordWins(ours, counts[sid])) {
|
||||
counts[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (_sessionListLoaded() && !_sessionExistsForUnreadState(sid)) continue;
|
||||
counts[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
const live = {};
|
||||
for (const sid of Object.keys(counts)) live[sid] = counts[sid];
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
if (gone(sid)) continue;
|
||||
const ours = _sessionViewedCountRecord(cache[sid]);
|
||||
if (!Object.prototype.hasOwnProperty.call(live, sid) || _sessionViewedRecordWins(ours, live[sid])) {
|
||||
live[sid] = ours;
|
||||
}
|
||||
}
|
||||
return {counts, live, changed};
|
||||
}
|
||||
|
||||
function _sessionViewedCountDeletedKey(sid) {
|
||||
return `${SESSION_VIEWED_COUNTS_DELETED_PREFIX}${encodeURIComponent(String(sid))}`;
|
||||
}
|
||||
|
||||
// Read the deletion records, dropping any that have aged out.
|
||||
function _readSessionViewedCountDeletions() {
|
||||
const deleted = {};
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {
|
||||
return deleted;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const key of keys) {
|
||||
if (typeof key !== 'string' || !key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX)) continue;
|
||||
const encoded = key.slice(SESSION_VIEWED_COUNTS_DELETED_PREFIX.length);
|
||||
let sid = '';
|
||||
try {
|
||||
sid = decodeURIComponent(encoded);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (!sid) continue;
|
||||
let stamp = 0;
|
||||
try {
|
||||
stamp = Number(localStorage.getItem(key)) || 0;
|
||||
} catch (_) {}
|
||||
if (stamp && now - stamp > SESSION_VIEWED_COUNTS_DELETED_TTL_MS) {
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
continue;
|
||||
}
|
||||
deleted[sid] = stamp;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function _recordSessionViewedCountDeleted(sid) {
|
||||
if (!sid) return;
|
||||
try {
|
||||
localStorage.setItem(_sessionViewedCountDeletedKey(sid), String(Date.now()));
|
||||
} catch (_) {
|
||||
// Without the record the deletion still removed the entry; the next delete
|
||||
// or merge attempt records it again.
|
||||
}
|
||||
}
|
||||
|
||||
function _saveSessionViewedCounts() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
|
||||
const merged = _mergeSessionViewedCounts(
|
||||
_readStoredJsonMap(SESSION_VIEWED_COUNTS_KEY),
|
||||
_getSessionViewedCounts()
|
||||
);
|
||||
// Write only when we hold something the store does not (a strictly greater
|
||||
// count, or an entry it is missing). Rewriting an unchanged map would let two
|
||||
// clients ping-pong storage events forever.
|
||||
if (merged.changed) {
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(merged.counts));
|
||||
}
|
||||
_sessionViewedCounts = merged.live;
|
||||
} catch (_){
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function _setSessionViewedCount(sid, messageCount = 0) {
|
||||
function _setSessionViewedCount(sid, messageCount = 0, transcriptGeneration = null) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
counts[sid] = next;
|
||||
const generation = _sessionTranscriptGenerationForUnread(sid, transcriptGeneration);
|
||||
const incoming = _sessionViewedCountRecord(next, generation);
|
||||
const hadCount = Object.prototype.hasOwnProperty.call(counts, sid);
|
||||
const previous = hadCount ? _sessionViewedCountRecord(counts[sid]) : null;
|
||||
if (!previous || _sessionViewedRecordWins(incoming, previous)) {
|
||||
counts[sid] = incoming;
|
||||
} else {
|
||||
counts[sid] = previous;
|
||||
}
|
||||
_saveSessionViewedCounts();
|
||||
// If the viewed count is now current, any prior completion-unread marker is
|
||||
// stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020).
|
||||
_clearSessionCompletionUnread(sid);
|
||||
// A generation change or count advance acknowledges completion state even if
|
||||
// another client prepared an older marker before this save.
|
||||
const advanced = !previous
|
||||
|| generation > previous.transcript_generation
|
||||
|| (generation === previous.transcript_generation && next > previous.message_count);
|
||||
_clearSessionCompletionUnread(sid, advanced);
|
||||
}
|
||||
|
||||
function _getSessionCompletionUnread() {
|
||||
@@ -506,9 +713,140 @@ function _getSessionCompletionUnread() {
|
||||
return _sessionCompletionUnread;
|
||||
}
|
||||
|
||||
// Completion-unread markers are add/remove, so unlike viewed counts they cannot
|
||||
// be max-merged: the later event has to win. Every clear records a tombstone
|
||||
// (sid -> clearedAt) in SESSION_COMPLETION_UNREAD_CLEARED_KEY, and a marker only
|
||||
// survives when it does not predate its sid's clear.
|
||||
function _markerLosesToUnreadClear(marker, clearedAt) {
|
||||
const cleared = Number(clearedAt) || 0;
|
||||
if (!cleared) return false;
|
||||
return _sessionCompletionUnreadOrder(marker) <= cleared;
|
||||
}
|
||||
|
||||
function _sessionCompletionUnreadOrder(marker) {
|
||||
return Number(marker && (marker.unread_order || marker.completed_at)) || 0;
|
||||
}
|
||||
|
||||
function _sessionCompletionUnreadClearedKey(sid, clearedAt = null) {
|
||||
const base = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent(String(sid))}`;
|
||||
return clearedAt == null ? base : `${base}:${Number(clearedAt) || 0}`;
|
||||
}
|
||||
|
||||
function _parseSessionCompletionUnreadClearedKey(key) {
|
||||
if (!key || !key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX)) return null;
|
||||
const suffix = key.slice(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX.length);
|
||||
const separator = suffix.lastIndexOf(':');
|
||||
const encodedSid = separator > 0 ? suffix.slice(0, separator) : suffix;
|
||||
const version = separator > 0 ? Number(suffix.slice(separator + 1)) || 0 : 0;
|
||||
let sid = '';
|
||||
try { sid = decodeURIComponent(encodedSid); } catch (_) { return null; }
|
||||
if (!sid) return null;
|
||||
return { sid, version };
|
||||
}
|
||||
|
||||
// Read persisted clear ordering independently from the in-memory fallback. The
|
||||
// distinction matters during pruning: a higher clear that failed quota and lives
|
||||
// only in memory must not delete the older durable record it supersedes.
|
||||
function _readPersistedSessionCompletionUnreadCleared() {
|
||||
// Snapshot keys before values. A clear that starts after this operation began
|
||||
// is deliberately excluded, so a marker prepared first gets the same logical
|
||||
// order and loses the clear-wins tie. A marker started after a completed clear
|
||||
// observes that clear and advances beyond it.
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {}
|
||||
const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY);
|
||||
try {
|
||||
for (const key of keys) {
|
||||
const parsed = _parseSessionCompletionUnreadClearedKey(key);
|
||||
if (!parsed) continue;
|
||||
const storedValue = Number(localStorage.getItem(key)) || 0;
|
||||
const value = parsed.version || storedValue;
|
||||
if (value > (Number(cleared[parsed.sid]) || 0)) cleared[parsed.sid] = value;
|
||||
}
|
||||
} catch (_) {
|
||||
// A storage implementation that cannot enumerate still retains legacy data.
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
// Combine durable clear ordering with this client's in-memory fallback. The
|
||||
// fallback keeps the current window correct when quota rejects a new key.
|
||||
function _readSessionCompletionUnreadCleared() {
|
||||
const cleared = _readPersistedSessionCompletionUnreadCleared();
|
||||
const now = Date.now();
|
||||
for (const [sid, memoryRecord] of Object.entries(_sessionCompletionUnreadClearedMemory || {})) {
|
||||
const value = Number(memoryRecord && memoryRecord.order) || 0;
|
||||
const recordedAt = Number(memoryRecord && memoryRecord.recorded_at) || 0;
|
||||
if (recordedAt && now - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) {
|
||||
delete _sessionCompletionUnreadClearedMemory[sid];
|
||||
continue;
|
||||
}
|
||||
if (value > (Number(cleared[sid]) || 0)) cleared[sid] = value;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function _nextSessionCompletionUnreadOrder(sid, cleared = null) {
|
||||
const clears = cleared || _readSessionCompletionUnreadCleared();
|
||||
const cached = _getSessionCompletionUnread()[sid];
|
||||
const stored = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY)[sid];
|
||||
return Math.max(
|
||||
Date.now(),
|
||||
Number(clears[sid]) || 0,
|
||||
_sessionCompletionUnreadOrder(cached),
|
||||
_sessionCompletionUnreadOrder(stored)
|
||||
) + 1;
|
||||
}
|
||||
|
||||
// Merge the persisted marker map with our cache under the tombstones. Markers
|
||||
// the store holds that a clear already outranks are dropped from the result, so
|
||||
// the caller can write the repaired map back and no reader sees them again.
|
||||
function _mergeSessionCompletionUnread(disk, cache, cleared) {
|
||||
const markers = {};
|
||||
let changed = false;
|
||||
for (const sid of Object.keys(disk || {})) {
|
||||
const stored = disk[sid];
|
||||
if (_markerLosesToUnreadClear(stored, (cleared || {})[sid])) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
markers[sid] = stored;
|
||||
}
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
const ours = cache[sid];
|
||||
if (_markerLosesToUnreadClear(ours, (cleared || {})[sid])) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(markers, sid)) {
|
||||
// Both sides hold a marker: the later logical operation wins, which is the
|
||||
// same order the clear comparison uses. Comparing completed_at here would
|
||||
// keep the earlier of two markers written in one millisecond and leave that
|
||||
// one's message count or cron profile metadata for later cleanup.
|
||||
if (_sessionCompletionUnreadOrder(ours) > _sessionCompletionUnreadOrder(markers[sid])) {
|
||||
markers[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
markers[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
return {markers, changed};
|
||||
}
|
||||
|
||||
function _saveSessionCompletionUnread() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(_getSessionCompletionUnread()));
|
||||
const merged = _mergeSessionCompletionUnread(
|
||||
_readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY),
|
||||
_getSessionCompletionUnread(),
|
||||
_readSessionCompletionUnreadCleared()
|
||||
);
|
||||
// Same convergence rule as the viewed counts: write only when the store does
|
||||
// not already hold the merged result.
|
||||
if (merged.changed) {
|
||||
localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(merged.markers));
|
||||
}
|
||||
_sessionCompletionUnread = merged.markers;
|
||||
} catch (_){
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
@@ -518,7 +856,12 @@ function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) {
|
||||
if (!sid) return;
|
||||
const unread = _getSessionCompletionUnread();
|
||||
const count = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
const entry = {message_count: count, completed_at: Date.now()};
|
||||
const clearsAtIntent = _readSessionCompletionUnreadCleared();
|
||||
const entry = {
|
||||
message_count: count,
|
||||
completed_at: Date.now(),
|
||||
unread_order: _nextSessionCompletionUnreadOrder(sid, clearsAtIntent),
|
||||
};
|
||||
// Cron markers carry source+profile so profile switches can clear only that
|
||||
// cross-profile leak without wiping ordinary chat completion unread (#5960).
|
||||
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
|
||||
@@ -550,11 +893,88 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta
|
||||
return true;
|
||||
}
|
||||
|
||||
function _clearSessionCompletionUnread(sid) {
|
||||
// Record that the completion-unread marker(s) for `sidOrSids` were cleared at
|
||||
// `clearedAt`. Each session owns a separate key: clears for different sessions
|
||||
// are independent atomic writes and cannot discard one another. Markers carry a
|
||||
// completed_at timestamp, so a marker that does not postdate the clear loses,
|
||||
// while a completion that happened afterwards still wins. Old or deleted-session
|
||||
// tombstones are pruned opportunistically to bound quota use.
|
||||
function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) {
|
||||
const justCleared = new Set(
|
||||
(Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]).map((sid) => String(sid))
|
||||
);
|
||||
const at = Number(clearedAt) || 0;
|
||||
for (const sid of justCleared) {
|
||||
const previous = _sessionCompletionUnreadClearedMemory[sid];
|
||||
if (at > (Number(previous && previous.order) || 0)) {
|
||||
_sessionCompletionUnreadClearedMemory[sid] = {order: at, recorded_at: Date.now()};
|
||||
}
|
||||
}
|
||||
|
||||
// One-way migration. The previous representation kept every clear fact in one
|
||||
// shared blob, so concurrent clears could replace one another and the blob
|
||||
// only ever grew. Import those facts as independently addressable records and
|
||||
// drop the blob. A client still running the previous revision will not observe
|
||||
// clears recorded after this point; that is a reload boundary, not something a
|
||||
// dual write could have fixed, because the shared blob cannot be written
|
||||
// concurrently without losing an update.
|
||||
const legacy = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY);
|
||||
let migrated = true;
|
||||
for (const [sid, value] of Object.entries(legacy)) {
|
||||
const stamp = Number(value) || 0;
|
||||
if (!stamp) continue;
|
||||
try {
|
||||
localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, stamp), String(Date.now()));
|
||||
} catch (_) {
|
||||
migrated = false;
|
||||
}
|
||||
}
|
||||
for (const sid of justCleared) {
|
||||
try {
|
||||
localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(Date.now()));
|
||||
} catch (_) {}
|
||||
}
|
||||
if (Object.keys(legacy).length && migrated) {
|
||||
try { localStorage.removeItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
const persistedCleared = _readPersistedSessionCompletionUnreadCleared();
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {}
|
||||
for (const key of keys) {
|
||||
const parsed = _parseSessionCompletionUnreadClearedKey(key);
|
||||
if (!parsed) continue;
|
||||
try {
|
||||
const recordedAt = Number(localStorage.getItem(key)) || 0;
|
||||
const version = parsed.version || recordedAt;
|
||||
const expired = recordedAt > 0 && Date.now() - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS;
|
||||
const superseded = version < (Number(persistedCleared[parsed.sid]) || 0);
|
||||
// Prune by age, or when a newer ordering fact is itself durable. A
|
||||
// memory-only clear may keep this window correct under quota pressure, but
|
||||
// it cannot safely replace the older record across reload.
|
||||
if (expired || superseded) localStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _clearSessionCompletionUnread(sid, recordIfMissing = true) {
|
||||
if (!sid) return;
|
||||
const unread = _getSessionCompletionUnread();
|
||||
if (!Object.prototype.hasOwnProperty.call(unread, sid)) return;
|
||||
let stored = false;
|
||||
if (!Object.prototype.hasOwnProperty.call(unread, sid)) {
|
||||
// Another client may have recorded this marker since our cache was loaded,
|
||||
// so the store decides whether there is anything to clear.
|
||||
stored = Object.prototype.hasOwnProperty.call(
|
||||
_readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), sid);
|
||||
if (!stored && !recordIfMissing) return;
|
||||
}
|
||||
const order = _nextSessionCompletionUnreadOrder(sid);
|
||||
delete unread[sid];
|
||||
// Write the ordering fact before the marker map. This is required even when
|
||||
// the marker has been prepared by another client but has not reached storage.
|
||||
_writeSessionCompletionUnreadCleared(sid, order);
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
|
||||
@@ -666,6 +1086,7 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
: 'default';
|
||||
const unread = _getSessionCompletionUnread();
|
||||
let changed = false;
|
||||
const clearedSids = [];
|
||||
for (const sid of Object.keys(unread)) {
|
||||
const marker = unread[sid];
|
||||
if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue;
|
||||
@@ -676,9 +1097,16 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
if (!resolved.profile) continue;
|
||||
if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue;
|
||||
delete unread[sid];
|
||||
clearedSids.push(sid);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return false;
|
||||
// Each cleared marker needs its durable ordering fact, or the merge in
|
||||
// _saveSessionCompletionUnread() (and a stale client's later save) would
|
||||
// restore it from the store.
|
||||
for (const sid of clearedSids) {
|
||||
_writeSessionCompletionUnreadCleared(sid, _nextSessionCompletionUnreadOrder(sid));
|
||||
}
|
||||
_saveSessionCompletionUnread();
|
||||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||||
return true;
|
||||
@@ -687,9 +1115,34 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
function _clearSessionViewedCount(sid) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, sid)) return;
|
||||
const hadCachedEntry = Object.prototype.hasOwnProperty.call(counts, sid);
|
||||
delete counts[sid];
|
||||
_saveSessionViewedCounts();
|
||||
// Record the deletion before any merge, so this client cannot re-adopt the
|
||||
// entry it is deleting and another client holding the same acknowledgement
|
||||
// prunes it instead of writing it back.
|
||||
_recordSessionViewedCountDeleted(sid);
|
||||
// A removal must actually leave disk, unlike the additive max-merge used on
|
||||
// the set path (which never deletes). Delete just this key from the persisted
|
||||
// map so entries a concurrent client added are preserved. The store is
|
||||
// consulted even when our cache never held the key: an entry another client
|
||||
// added since our cache was loaded is exactly the one this path has to prune.
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, sid)) {
|
||||
delete parsed[sid];
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(parsed));
|
||||
}
|
||||
// Adopt the store's entries but keep any higher count we already hold: we
|
||||
// are the only client left that can repair a count another client
|
||||
// lowered, so dropping ours here would lose the acknowledgement.
|
||||
_sessionViewedCounts = _mergeSessionViewedCounts(parsed, counts).live;
|
||||
return;
|
||||
}
|
||||
} catch (_){
|
||||
// Fall through to the cache-only write on read/parse failure.
|
||||
}
|
||||
if (hadCachedEntry) _saveSessionViewedCounts();
|
||||
}
|
||||
|
||||
function _hasSessionCompletionUnread(sid) {
|
||||
@@ -739,12 +1192,22 @@ function _hasUnreadForSession(s) {
|
||||
if (!s || !s.session_id) return false;
|
||||
if (_hasSessionCompletionUnread(s.session_id)) return true;
|
||||
const counts = _getSessionViewedCounts();
|
||||
const generation = _sessionTranscriptGenerationForUnread(s.session_id, s.transcript_generation);
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
|
||||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
|
||||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0), generation);
|
||||
return false;
|
||||
}
|
||||
if (!Number.isFinite(s.message_count)) return false;
|
||||
return s.message_count > Number(counts[s.session_id] || 0);
|
||||
const viewed = _sessionViewedCountRecord(counts[s.session_id]);
|
||||
if (generation > viewed.transcript_generation) {
|
||||
const baseline = Math.max(0, Math.min(
|
||||
Number(s.message_count || 0),
|
||||
Number(s.transcript_generation_baseline) || 0
|
||||
));
|
||||
_setSessionViewedCount(s.session_id, baseline, generation);
|
||||
return Number.isFinite(s.message_count) && s.message_count > baseline;
|
||||
}
|
||||
if (generation < viewed.transcript_generation || !Number.isFinite(s.message_count)) return false;
|
||||
return s.message_count > viewed.message_count;
|
||||
}
|
||||
|
||||
// Keep the sidebar polling snapshot current for a just-visited session so a
|
||||
@@ -9214,10 +9677,31 @@ async function _handleShowAllProfilesStorageEvent(e){
|
||||
if(typeof renderSessionList==='function') await renderSessionList({deferWhileInteracting:false});
|
||||
}
|
||||
|
||||
function _handleUnreadStorageEvent(e){
|
||||
if(!e || !e.key) return;
|
||||
// A concurrent WebUI client on the same origin/profile changed one of the
|
||||
// unread stores. Both saves re-read the store, merge our cache into it (max for
|
||||
// viewed counts, tombstone-ordered for completion markers), keep the merged map
|
||||
// as the live cache, and write back only what the store is missing. So this
|
||||
// client re-asserts the acknowledgement it still holds instead of dropping it
|
||||
// and re-reading a value the other client's write already lost.
|
||||
if(e.key === SESSION_VIEWED_COUNTS_KEY
|
||||
|| (typeof e.key === 'string' && e.key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX))){
|
||||
_saveSessionViewedCounts();
|
||||
}
|
||||
else if(e.key === SESSION_COMPLETION_UNREAD_KEY
|
||||
|| e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY
|
||||
|| (typeof e.key === 'string'
|
||||
&& e.key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX))){
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
}
|
||||
|
||||
if(typeof window!=='undefined'){
|
||||
window.addEventListener('storage', (e) => {
|
||||
void _handleActiveSessionStorageEvent(e);
|
||||
void _handleShowAllProfilesStorageEvent(e);
|
||||
void _handleUnreadStorageEvent(e);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Node-harness scaffolding shared by the unread-store persistence tests.
|
||||
|
||||
``static/sessions.js`` persists two unread stores (``hermes-session-viewed-counts``
|
||||
and ``hermes-session-completion-unread``) through a small set of pure helpers:
|
||||
reading a stored map, asking whether a session still exists, and merging a cache
|
||||
into a store under the monotonic / tombstone rules. The node harnesses in this
|
||||
suite work by extracting individual functions from the module by name, so a
|
||||
harness that drives a persistence path must also provide those helpers.
|
||||
|
||||
Keeping the list here means every harness injects the same set from the same
|
||||
source, and the store keys / tombstone cap always match the module.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
_HELPER_NAMES = (
|
||||
"_readStoredJsonMap",
|
||||
"_sessionExistsForUnreadState",
|
||||
"_sessionListLoaded",
|
||||
"_sessionViewedCountRecord",
|
||||
"_sessionViewedCountValue",
|
||||
"_sessionViewedRecordWins",
|
||||
"_sessionTranscriptGenerationForUnread",
|
||||
"_mergeSessionViewedCounts",
|
||||
"_sessionViewedCountDeletedKey",
|
||||
"_readSessionViewedCountDeletions",
|
||||
"_recordSessionViewedCountDeleted",
|
||||
"_markerLosesToUnreadClear",
|
||||
"_sessionCompletionUnreadOrder",
|
||||
"_nextSessionCompletionUnreadOrder",
|
||||
"_sessionCompletionUnreadClearedKey",
|
||||
"_parseSessionCompletionUnreadClearedKey",
|
||||
"_readPersistedSessionCompletionUnreadCleared",
|
||||
"_readSessionCompletionUnreadCleared",
|
||||
"_mergeSessionCompletionUnread",
|
||||
"_writeSessionCompletionUnreadCleared",
|
||||
)
|
||||
|
||||
# Declared from the module source so a harness cannot drift from the values the
|
||||
# module uses. Harnesses keep their own stub keys for the two older stores.
|
||||
_CONST_NAMES = (
|
||||
"SESSION_VIEWED_COUNTS_DELETED_PREFIX",
|
||||
"SESSION_VIEWED_COUNTS_DELETED_TTL_MS",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_KEY",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_PREFIX",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS",
|
||||
)
|
||||
|
||||
_CONST_FALLBACKS = {
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_KEY": (
|
||||
"const SESSION_COMPLETION_UNREAD_CLEARED_KEY = "
|
||||
"'hermes-session-completion-unread-cleared';"
|
||||
),
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS": (
|
||||
"const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
"""Brace-match a top-level ``function name(...) { ... }`` definition."""
|
||||
marker = f"function {name}("
|
||||
if marker not in SESSIONS_JS:
|
||||
return ""
|
||||
start = SESSIONS_JS.index(marker)
|
||||
brace = SESSIONS_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(SESSIONS_JS)):
|
||||
ch = SESSIONS_JS[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return SESSIONS_JS[start:i + 1]
|
||||
raise AssertionError(f"could not brace-match {name}")
|
||||
|
||||
|
||||
def _const_source(name: str) -> str:
|
||||
for line in SESSIONS_JS.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(f"const {name} ="):
|
||||
return stripped
|
||||
return _CONST_FALLBACKS.get(name, "")
|
||||
|
||||
|
||||
# Ready to interpolate into a node harness.
|
||||
BLOCK = "\n".join(
|
||||
["var _sessionCompletionUnreadClearedMemory = {};\n"]
|
||||
+ [_const_source(name) for name in _CONST_NAMES]
|
||||
+ [_function_source(name) for name in _HELPER_NAMES if f"function {name}(" in SESSIONS_JS]
|
||||
)
|
||||
@@ -599,20 +599,12 @@ def test_session_model_parent_session_id():
|
||||
|
||||
|
||||
def test_session_compact_includes_parent():
|
||||
"""Verify compact() includes parent_session_id."""
|
||||
src = _read('api/models.py')
|
||||
# Find the compact method and scan its full body for parent_session_id.
|
||||
# PR #1591 (May 2026) added a has_pending_user_message recompute block at
|
||||
# the top of compact() which pushed the parent_session_id field beyond a
|
||||
# 1500-char window — widen the scan to 3000 chars to cover the full
|
||||
# return-dict body without re-tightening every time compact() grows.
|
||||
# compact() accepts optional projection flags on separate lines; match the
|
||||
# method name and first parameter without pinning formatting.
|
||||
compact_def_match = re.search(r"def compact\(\s*self", src)
|
||||
assert compact_def_match, "Could not find compact() method"
|
||||
snippet = src[compact_def_match.start():compact_def_match.start() + 3000]
|
||||
assert "'parent_session_id'" in snippet, \
|
||||
"compact() should include parent_session_id"
|
||||
"""Verify compact() exposes a fork's parent and omits it for root sessions."""
|
||||
fork = routes.Session(session_id="fork-1", parent_session_id="parent-1")
|
||||
assert fork.compact()["parent_session_id"] == "parent-1"
|
||||
|
||||
root = routes.Session(session_id="root-1")
|
||||
assert "parent_session_id" not in root.compact()
|
||||
|
||||
|
||||
def test_session_metadata_fields_includes_parent():
|
||||
|
||||
@@ -48,7 +48,10 @@ def test_deleted_sessions_prune_all_session_tracking_maps():
|
||||
|
||||
|
||||
def test_session_viewed_count_prune_is_best_effort_and_persists_when_changed():
|
||||
viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount")
|
||||
# The window is a source slice, not a semantic bound: keep it wide enough to
|
||||
# cover the whole function so unrelated edits to the clear path do not fail
|
||||
# this assertion by pushing its last statement out of range.
|
||||
viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount", window=2200)
|
||||
assert "Object.prototype.hasOwnProperty.call(counts, sid)" in viewed_block
|
||||
assert "delete counts[sid]" in viewed_block
|
||||
assert "_saveSessionViewedCounts()" in viewed_block
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,6 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
# Sibling harness scaffolding lives beside this file and is not on sys.path by
|
||||
# default; import it the same way the other node-harness tests do.
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
import _unread_store_helpers as unread_store_helpers # noqa: E402
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
@@ -91,6 +97,8 @@ def _run_done_compaction_harness(
|
||||
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
|
||||
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
|
||||
_extract_function(SESSIONS_JS, "_markSessionCompletedInList"),
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
]
|
||||
)
|
||||
has_pre_list_sync = _has_pre_list_view_sync()
|
||||
@@ -153,7 +161,7 @@ def _run_done_compaction_harness(
|
||||
_markSessionCompletedInList(completedSession, activeSid);
|
||||
const cacheRow=_allSessions.find(s=>s&&s.session_id===completedSid);
|
||||
const unreadAfterCacheUpdate=_hasUnreadForSession(cacheRow);
|
||||
const viewedCountAfterCacheUpdate=_getSessionViewedCounts()[completedSid] ?? null;
|
||||
const viewedCountAfterCacheUpdate=_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]);
|
||||
if ({str(is_session_viewed).lower()}) {{
|
||||
_markSessionViewed(completedSid, completedMessageCount);
|
||||
}}
|
||||
@@ -164,7 +172,7 @@ def _run_done_compaction_harness(
|
||||
unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow),
|
||||
hasCompletionUnread:_hasSessionCompletionUnread(completedSid),
|
||||
completionUnreadMessageCount:_getSessionCompletionUnread()[completedSid]?.message_count ?? null,
|
||||
viewedCount:_getSessionViewedCounts()[completedSid] ?? null,
|
||||
viewedCount:_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]),
|
||||
cacheRow,
|
||||
}}));
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,11 @@ SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
# Sibling harness scaffolding lives beside this file and is not on sys.path by
|
||||
# default; import it the same way the other node-harness tests do.
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
import _unread_store_helpers as unread_store_helpers # noqa: E402
|
||||
|
||||
|
||||
def _extract_function(source: str, name: str) -> str:
|
||||
start = source.index(f"function {name}(")
|
||||
@@ -152,6 +158,8 @@ def test_profile_switch_clears_persisted_old_profile_cron_markers_only():
|
||||
save_unread = _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread")
|
||||
clear_helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
|
||||
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
|
||||
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -173,6 +181,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -371,6 +381,8 @@ def test_legacy_untagged_cron_marker_cleared_via_sidebar_metadata():
|
||||
"""Re-gate #5975: untagged markers resolve from sidebar session source/profile."""
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
|
||||
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
|
||||
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -393,6 +405,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -434,6 +448,8 @@ def test_root_alias_keeps_current_profile_cron_marker():
|
||||
"""Re-gate #5975: default/renamed-root must not erase current-root cron dots."""
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
|
||||
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
|
||||
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -456,6 +472,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -495,6 +513,8 @@ def test_switch_to_literal_default_clears_other_profile_cron_markers():
|
||||
"""
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
|
||||
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
|
||||
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -521,6 +541,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -575,6 +597,8 @@ def test_switch_to_literal_default_without_roster_fails_closed_on_unknown_names(
|
||||
gets exact-name semantics (cleared), and literal-'default' markers stay."""
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
|
||||
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
|
||||
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -598,6 +622,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -636,6 +662,8 @@ def test_stale_pre_switch_session_list_does_not_recreate_cron_markers():
|
||||
mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions")
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(sessions_js, "_isCronSessionForUnread"),
|
||||
_extract_function(sessions_js, "_sourceKeyForSession"),
|
||||
_extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -657,6 +685,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
@@ -765,6 +795,8 @@ def test_fresh_session_list_still_marks_when_unread_gen_matches():
|
||||
mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions")
|
||||
helpers = "\n".join(
|
||||
[
|
||||
# Merge/tombstone helpers the unread persistence paths call.
|
||||
unread_store_helpers.BLOCK,
|
||||
_extract_function(sessions_js, "_isCronSessionForUnread"),
|
||||
_extract_function(sessions_js, "_sourceKeyForSession"),
|
||||
_extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"),
|
||||
@@ -783,6 +815,8 @@ global.localStorage={{
|
||||
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
|
||||
setItem:(key,value)=>{{ store[key]=String(value); }},
|
||||
removeItem:(key)=>{{ delete store[key]; }},
|
||||
key:(i)=>Object.keys(store)[i]??null,
|
||||
get length(){{ return Object.keys(store).length; }},
|
||||
}};
|
||||
let _sessionCompletionUnread=null;
|
||||
let _sessionViewedCounts={{}};
|
||||
|
||||
@@ -30,6 +30,16 @@ def test_recovered_prefix_survives_regeneration_truncation():
|
||||
assert [row["content"] for row in session.messages] == ["old", "old answer", "latest"]
|
||||
|
||||
|
||||
def test_regeneration_advances_transcript_generation_when_it_shrinks():
|
||||
session = _session()
|
||||
session.transcript_generation = 7
|
||||
plan = plan_regeneration(session)
|
||||
|
||||
assert apply_regeneration_plan(session, plan)
|
||||
|
||||
assert session.transcript_generation == 8
|
||||
|
||||
|
||||
def test_stale_revision_rejects_without_mutation():
|
||||
session = _session()
|
||||
before = copy.deepcopy(session.__dict__)
|
||||
|
||||
@@ -162,6 +162,36 @@ def test_generation_save_load_round_trip_is_in_metadata_prefix(monkeypatch, tmp_
|
||||
assert metadata.intentional_shrink_generation == generation
|
||||
|
||||
|
||||
def test_transcript_generation_round_trips_and_advances_only_on_shrink(monkeypatch, tmp_path):
|
||||
models, _session_dir = _seed_session_dir(monkeypatch, tmp_path)
|
||||
from api.session_ops import truncate_session_at_keep
|
||||
from api.webui_session_db import WebUIJsonSessionDB
|
||||
|
||||
session = models.Session(
|
||||
session_id="issue6911_transcript_generation",
|
||||
messages=_history(),
|
||||
transcript_generation=4,
|
||||
)
|
||||
session.save()
|
||||
|
||||
truncate_session_at_keep(session, len(session.messages))
|
||||
assert session.transcript_generation == 4
|
||||
truncate_session_at_keep(session, 2)
|
||||
assert session.transcript_generation == 5
|
||||
assert session.transcript_generation_baseline == 2
|
||||
session.save()
|
||||
|
||||
loaded = models.Session.load(session.session_id)
|
||||
metadata = models.Session.load_metadata_only(session.session_id)
|
||||
listed = WebUIJsonSessionDB().list_sessions()
|
||||
assert loaded.transcript_generation == 5
|
||||
assert loaded.transcript_generation_baseline == 2
|
||||
assert metadata.transcript_generation == 5
|
||||
assert metadata.transcript_generation_baseline == 2
|
||||
assert listed[0]["transcript_generation"] == 5
|
||||
assert listed[0]["transcript_generation_baseline"] == 2
|
||||
|
||||
|
||||
def test_no_actual_message_shrink_keeps_generation(monkeypatch, tmp_path):
|
||||
models, _session_dir = _seed_session_dir(monkeypatch, tmp_path)
|
||||
from api.session_ops import truncate_session_at_keep
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Regression checks for #856 background completion unread markers."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
sys.path.insert(0, str(REPO / "tests"))
|
||||
import _unread_store_helpers as unread_store_helpers # noqa: E402
|
||||
|
||||
|
||||
def _done_block() -> str:
|
||||
@@ -40,25 +49,81 @@ def _function_body(block: str) -> str:
|
||||
raise AssertionError("function closing brace not found")
|
||||
|
||||
|
||||
def _extract_sessions_function(name: str) -> str:
|
||||
start = SESSIONS_JS.index(f"function {name}(")
|
||||
brace = SESSIONS_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(SESSIONS_JS)):
|
||||
if SESSIONS_JS[i] == "{":
|
||||
depth += 1
|
||||
elif SESSIONS_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return SESSIONS_JS[start:i + 1]
|
||||
raise AssertionError(f"could not brace-match {name}")
|
||||
|
||||
|
||||
def _run_unread_behavior(body: str) -> dict:
|
||||
functions = "\n".join(
|
||||
_extract_sessions_function(name)
|
||||
for name in (
|
||||
"_getSessionViewedCounts",
|
||||
"_saveSessionViewedCounts",
|
||||
"_setSessionViewedCount",
|
||||
"_getSessionCompletionUnread",
|
||||
"_saveSessionCompletionUnread",
|
||||
"_clearSessionCompletionUnread",
|
||||
"_hasSessionCompletionUnread",
|
||||
"_hasUnreadForSession",
|
||||
)
|
||||
)
|
||||
script = f"""
|
||||
const store = {{}};
|
||||
const localStorage = {{
|
||||
getItem: (key) => Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null,
|
||||
setItem: (key, value) => {{ store[key] = String(value); }},
|
||||
removeItem: (key) => {{ delete store[key]; }},
|
||||
key: (index) => Object.keys(store)[index] ?? null,
|
||||
get length() {{ return Object.keys(store).length; }},
|
||||
}};
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
let _sessionViewedCounts = null;
|
||||
let _sessionCompletionUnread = null;
|
||||
let _allSessions = [{{session_id: 'X'}}];
|
||||
const _sessionListSnapshotById = new Map();
|
||||
const S = {{session: null}};
|
||||
{unread_store_helpers.BLOCK}
|
||||
{functions}
|
||||
{body}
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert result.returncode == 0, f"node harness failed:\n{result.stderr}"
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_background_completion_unread_uses_explicit_marker_not_message_delta():
|
||||
"""A background completion must stay unread even when message_count has no delta."""
|
||||
assert "SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'" in SESSIONS_JS
|
||||
assert "function _markSessionCompletionUnread(" in SESSIONS_JS
|
||||
assert "function _clearSessionCompletionUnread(" in SESSIONS_JS
|
||||
assert "function _hasSessionCompletionUnread(" in SESSIONS_JS
|
||||
|
||||
has_unread_idx = SESSIONS_JS.find("function _hasUnreadForSession(s)")
|
||||
assert has_unread_idx != -1, "_hasUnreadForSession not found"
|
||||
has_unread_block = SESSIONS_JS[has_unread_idx:SESSIONS_JS.find("async function newSession", has_unread_idx)]
|
||||
|
||||
marker_idx = has_unread_block.find("_hasSessionCompletionUnread(s.session_id)")
|
||||
count_idx = has_unread_block.find("s.message_count > Number")
|
||||
assert marker_idx != -1, "_hasUnreadForSession must check explicit completion unread marker"
|
||||
assert count_idx != -1, "_hasUnreadForSession must keep the existing message_count fallback"
|
||||
assert marker_idx < count_idx, (
|
||||
"explicit completion unread marker must be checked before message_count delta, "
|
||||
"because completed streams can have viewed_count == message_count"
|
||||
)
|
||||
out = _run_unread_behavior("""
|
||||
store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({
|
||||
X: {message_count: 3, transcript_generation: 0},
|
||||
});
|
||||
store[SESSION_COMPLETION_UNREAD_KEY] = JSON.stringify({
|
||||
X: {message_count: 3, completed_at: 1000, unread_order: 1000},
|
||||
});
|
||||
const beforeClear = _hasUnreadForSession({
|
||||
session_id: 'X', message_count: 3, transcript_generation: 0,
|
||||
});
|
||||
_clearSessionCompletionUnread('X');
|
||||
const afterClear = _hasUnreadForSession({
|
||||
session_id: 'X', message_count: 3, transcript_generation: 0,
|
||||
});
|
||||
console.log(JSON.stringify({beforeClear, afterClear}));
|
||||
""")
|
||||
assert out == {"beforeClear": True, "afterClear": False}
|
||||
|
||||
|
||||
def test_background_done_sets_marker_when_session_not_actively_viewed():
|
||||
@@ -550,17 +615,54 @@ def test_completion_unread_clears_only_when_session_is_opened():
|
||||
assert "_setSessionViewedCount(sid, messageCount);" in ack_body
|
||||
|
||||
|
||||
def test_historical_sessions_are_not_marked_unread_on_list_render():
|
||||
"""The explicit unread marker must be event-driven, not initialized by _hasUnreadForSession."""
|
||||
has_unread_idx = SESSIONS_JS.find("function _hasUnreadForSession(s)")
|
||||
assert has_unread_idx != -1
|
||||
has_unread_block = SESSIONS_JS[
|
||||
has_unread_idx:SESSIONS_JS.find("function _isSessionActivelyViewedForList", has_unread_idx)
|
||||
]
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_historical_sessions_are_not_marked_unread_on_first_list_render():
|
||||
"""First observation establishes a read baseline instead of inventing unread."""
|
||||
out = _run_unread_behavior("""
|
||||
const session = {
|
||||
session_id: 'X', message_count: 5, transcript_generation: 3,
|
||||
transcript_generation_baseline: 0,
|
||||
};
|
||||
const first = _hasUnreadForSession(session);
|
||||
// A first render that fabricates a completion-unread marker while still
|
||||
// returning false would surface as unread on the next render (in-memory
|
||||
// cache) or after a reload (persisted store), so inspect both before the
|
||||
// second call.
|
||||
const cachedMarker = Boolean(
|
||||
_sessionCompletionUnread &&
|
||||
Object.prototype.hasOwnProperty.call(_sessionCompletionUnread, 'X')
|
||||
);
|
||||
const persistedMarker = Object.prototype.hasOwnProperty.call(
|
||||
JSON.parse(store[SESSION_COMPLETION_UNREAD_KEY] || '{}'), 'X'
|
||||
);
|
||||
const second = _hasUnreadForSession(session);
|
||||
const viewed = JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X;
|
||||
console.log(JSON.stringify({first, second, cachedMarker, persistedMarker, viewed}));
|
||||
""")
|
||||
assert out == {
|
||||
"first": False,
|
||||
"second": False,
|
||||
"cachedMarker": False,
|
||||
"persistedMarker": False,
|
||||
"viewed": {"message_count": 5, "transcript_generation": 3},
|
||||
}
|
||||
|
||||
assert "_markSessionCompletionUnread" not in has_unread_block, (
|
||||
"rendering old historical sessions must not create completion-unread markers"
|
||||
)
|
||||
assert "_setSessionViewedCount(s.session_id, Number(s.message_count || 0));" in has_unread_block, (
|
||||
"missing viewed-count baseline should still initialize as read for historical sessions"
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_new_transcript_generation_keeps_post_shrink_messages_unread():
|
||||
"""A coalesced shrink and growth acknowledges only the retained baseline."""
|
||||
out = _run_unread_behavior("""
|
||||
store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({
|
||||
X: {message_count: 10, transcript_generation: 0},
|
||||
});
|
||||
const unread = _hasUnreadForSession({
|
||||
session_id: 'X', message_count: 3, transcript_generation: 1,
|
||||
transcript_generation_baseline: 2,
|
||||
});
|
||||
const viewed = JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X;
|
||||
console.log(JSON.stringify({unread, viewed}));
|
||||
""")
|
||||
assert out == {
|
||||
"unread": True,
|
||||
"viewed": {"message_count": 2, "transcript_generation": 1},
|
||||
}
|
||||
@@ -29,11 +29,17 @@ Two invariants flagged in review are protected here and MUST NOT regress:
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
# Sibling harness scaffolding lives beside this file and is not on sys.path by
|
||||
# default; import it the same way the other node-harness tests do.
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
import _unread_store_helpers as unread_store_helpers # noqa: E402
|
||||
|
||||
|
||||
def _load_session_block() -> str:
|
||||
start = SESSIONS_JS.index("async function loadSession(sid")
|
||||
@@ -176,6 +182,9 @@ const _store = {{}};
|
||||
const localStorage = {{
|
||||
getItem: (k) => (k in _store ? _store[k] : null),
|
||||
setItem: (k, v) => {{ _store[k] = String(v); }},
|
||||
removeItem: (k) => {{ delete _store[k]; }},
|
||||
key: (i) => Object.keys(_store)[i] ?? null,
|
||||
get length() {{ return Object.keys(_store).length; }},
|
||||
}};
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'v';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'u';
|
||||
@@ -195,6 +204,7 @@ function _forgetObservedStreamingSession() {{}}
|
||||
{set_viewed}
|
||||
{sync}
|
||||
{ack}
|
||||
{unread_store_helpers.BLOCK}
|
||||
// Seed a stale completion-unread marker for the open session.
|
||||
_getSessionCompletionUnread()['open'] = {{message_count: 5, completed_at: 1}};
|
||||
_saveSessionCompletionUnread();
|
||||
@@ -202,7 +212,7 @@ const before = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(
|
||||
_acknowledgeSessionVisit('open', 5, 10);
|
||||
const after = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
|
||||
const snap = _sessionListSnapshotById.get('open');
|
||||
console.log(JSON.stringify({{before, after, repaints, viewed: _getSessionViewedCounts()['open'], snap}}));
|
||||
console.log(JSON.stringify({{before, after, repaints, viewed: _sessionViewedCountValue(_getSessionViewedCounts()['open']), snap}}));
|
||||
"""
|
||||
out = _run_node(script)
|
||||
assert out["before"] is True, "precondition: marker seeded"
|
||||
@@ -238,6 +248,9 @@ const _store = {{}};
|
||||
const localStorage = {{
|
||||
getItem: (k) => (k in _store ? _store[k] : null),
|
||||
setItem: (k, v) => {{ _store[k] = String(v); }},
|
||||
removeItem: (k) => {{ delete _store[k]; }},
|
||||
key: (i) => Object.keys(_store)[i] ?? null,
|
||||
get length() {{ return Object.keys(_store).length; }},
|
||||
}};
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'v';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'u';
|
||||
@@ -272,6 +285,7 @@ const _sessionListSourceById = new Map();
|
||||
{effective}
|
||||
{sync}
|
||||
{ack}
|
||||
{unread_store_helpers.BLOCK}
|
||||
function _isSessionActivelyViewedForList(sid) {{
|
||||
if (!sid || !S.session || S.session.session_id !== sid) return false;
|
||||
if (_loadingSessionId && _loadingSessionId !== sid) return false;
|
||||
@@ -335,6 +349,9 @@ const _store = {{}};
|
||||
const localStorage = {{
|
||||
getItem: (k) => (k in _store ? _store[k] : null),
|
||||
setItem: (k, v) => {{ _store[k] = String(v); }},
|
||||
removeItem: (k) => {{ delete _store[k]; }},
|
||||
key: (i) => Object.keys(_store)[i] ?? null,
|
||||
get length() {{ return Object.keys(_store).length; }},
|
||||
}};
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'v';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'u';
|
||||
@@ -380,6 +397,7 @@ async function api(url) {{ _apiCalled = true; return _apiResult; }}
|
||||
{set_viewed}
|
||||
{actively_viewed}
|
||||
{ensure}
|
||||
{unread_store_helpers.BLOCK}
|
||||
|
||||
function _hasMarker() {{
|
||||
return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
|
||||
@@ -401,12 +419,13 @@ function _hasMarker() {{
|
||||
_releaseApi({{ session: {{ session_id: 'open', message_count: 6, messages: [{{ role: 'assistant', content: 'x' }}] }} }});
|
||||
await p;
|
||||
const markerAfter = _hasMarker();
|
||||
const viewed = _getSessionViewedCounts()['open'];
|
||||
const viewedRecord = _getSessionViewedCounts()['open'];
|
||||
const viewed = viewedRecord === undefined ? null : _sessionViewedCountValue(viewedRecord);
|
||||
console.log(JSON.stringify({{
|
||||
apiIssued,
|
||||
markerBefore,
|
||||
markerAfter,
|
||||
viewed: viewed === undefined ? null : viewed,
|
||||
viewed,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""The sessions route must carry shrink metadata through to unread decisions."""
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_issue4766_sidebar_source_pushdown import (
|
||||
_handle_sessions,
|
||||
_install_common_monkeypatches,
|
||||
)
|
||||
from tests.test_issue856_background_completion_unread import _run_unread_behavior
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH")
|
||||
@pytest.mark.parametrize("retained", [0, 2])
|
||||
def test_sessions_route_preserves_post_shrink_unread(monkeypatch, retained):
|
||||
import api.routes as routes
|
||||
from api.models import Session
|
||||
from api.session_ops import truncate_session_at_keep
|
||||
|
||||
session = Session(session_id="X", messages=[
|
||||
{"role": "user", "content": str(i)} for i in range(10)
|
||||
])
|
||||
truncate_session_at_keep(session, retained)
|
||||
session.messages.append({"role": "assistant", "content": "new response"})
|
||||
row = session.compact()
|
||||
_install_common_monkeypatches(monkeypatch, [row])
|
||||
routes._session_list_cache_clear()
|
||||
try:
|
||||
# Exercise both the initial projection and the cached response.
|
||||
for _ in range(2):
|
||||
response = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui")
|
||||
assert response.status == 200
|
||||
[projected] = response.json_body()["sessions"]
|
||||
result = _run_unread_behavior("""
|
||||
store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({
|
||||
X: {message_count: 10, transcript_generation: 0},
|
||||
});
|
||||
const unread = _hasUnreadForSession(""" + json.dumps(projected) + """);
|
||||
console.log(JSON.stringify({unread, viewed: JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X}));
|
||||
""")
|
||||
assert result == {
|
||||
"unread": True,
|
||||
"viewed": {"message_count": retained, "transcript_generation": 1},
|
||||
}
|
||||
assert "messages" not in projected
|
||||
finally:
|
||||
routes._session_list_cache_clear()
|
||||
@@ -0,0 +1,934 @@
|
||||
"""Regression: unread state must not be clobbered by a second WebUI client.
|
||||
|
||||
Two WebUI windows/tabs on the same origin and browser profile share one
|
||||
``localStorage``, and each caches the unread stores in module state. A stale
|
||||
client that re-serialises its cache wholesale -- or that drops its cache and
|
||||
re-reads a value another client's write already destroyed -- breaks two stores:
|
||||
|
||||
- ``hermes-session-viewed-counts``: a viewed count means "seen at least up to N
|
||||
messages", so it is monotonic per session and additive across clients. It must
|
||||
never roll back, and after a concurrent write the client that holds the higher
|
||||
count must re-assert it into the store (repair) instead of dropping its cache
|
||||
and re-reading whatever the losing write left behind.
|
||||
|
||||
- ``hermes-session-completion-unread``: markers are add/remove, so they cannot be
|
||||
max-merged. Every clear records a tombstone in
|
||||
``hermes-session-completion-unread-cleared`` (sid -> clearedAt), and the later
|
||||
event wins: a marker whose ``completed_at`` does not postdate the clear is
|
||||
dropped, while a genuinely later re-mark survives. This is the store behind the
|
||||
visible dot, and the reported field symptom was a cleared dot coming back.
|
||||
|
||||
These tests drive the real functions extracted from ``static/sessions.js``
|
||||
against a two-client harness that shares one fake ``localStorage``. The harness
|
||||
injects the merge/tombstone helpers when the module defines them, so the same
|
||||
assertions run against the pre-fix code and report the observable failure.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = (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")
|
||||
|
||||
VIEWED_KEY = "hermes-session-viewed-counts"
|
||||
UNREAD_KEY = "hermes-session-completion-unread"
|
||||
CLEARED_KEY = "hermes-session-completion-unread-cleared"
|
||||
|
||||
|
||||
def _extract(name: str) -> str:
|
||||
"""Extract a top-level `function name(...) { ... }` definition by brace match."""
|
||||
marker = f"function {name}("
|
||||
start = SESSIONS_JS.index(marker)
|
||||
brace = SESSIONS_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(SESSIONS_JS)):
|
||||
ch = SESSIONS_JS[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return SESSIONS_JS[start:i + 1]
|
||||
raise AssertionError(f"could not brace-match {name}")
|
||||
|
||||
|
||||
def _extract_optional(name: str) -> str:
|
||||
"""Extraction for functions a change may add (empty string when absent).
|
||||
|
||||
The same assertions therefore run before and after the fix: on the pre-fix
|
||||
code the harness simply has no merge/tombstone helper to inject.
|
||||
"""
|
||||
return _extract(name) if f"function {name}(" in SESSIONS_JS else ""
|
||||
|
||||
|
||||
def _extract_storage_listener() -> str:
|
||||
"""The module-level ``window.addEventListener('storage', ...)`` statement."""
|
||||
marker = "window.addEventListener('storage'"
|
||||
start = SESSIONS_JS.index(marker)
|
||||
brace = SESSIONS_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(SESSIONS_JS)):
|
||||
ch = SESSIONS_JS[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return SESSIONS_JS[start:i + 3] # include the closing "});"
|
||||
raise AssertionError("could not brace-match the storage listener")
|
||||
|
||||
|
||||
def _run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert result.returncode == 0, f"node harness failed:\n{result.stderr}"
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# Functions the unread stores are made of. All of these exist before and after
|
||||
# the fix, so a missing one is a real extraction failure.
|
||||
_CLIENT_FNS = [
|
||||
"_getSessionViewedCounts",
|
||||
"_saveSessionViewedCounts",
|
||||
"_setSessionViewedCount",
|
||||
"_clearSessionViewedCount",
|
||||
"_getSessionCompletionUnread",
|
||||
"_saveSessionCompletionUnread",
|
||||
"_markSessionCompletionUnread",
|
||||
"_clearSessionCompletionUnread",
|
||||
"_hasSessionCompletionUnread",
|
||||
"_hasUnreadForSession",
|
||||
]
|
||||
|
||||
# Helpers that carry the merge/tombstone rules; injected when present.
|
||||
_HELPER_FNS = [
|
||||
"_readStoredJsonMap",
|
||||
"_sessionExistsForUnreadState",
|
||||
"_sessionListLoaded",
|
||||
"_sessionViewedCountRecord",
|
||||
"_sessionViewedCountValue",
|
||||
"_sessionViewedRecordWins",
|
||||
"_sessionTranscriptGenerationForUnread",
|
||||
"_mergeSessionViewedCounts",
|
||||
"_sessionViewedCountDeletedKey",
|
||||
"_readSessionViewedCountDeletions",
|
||||
"_recordSessionViewedCountDeleted",
|
||||
"_markerLosesToUnreadClear",
|
||||
"_sessionCompletionUnreadOrder",
|
||||
"_nextSessionCompletionUnreadOrder",
|
||||
"_sessionCompletionUnreadClearedKey",
|
||||
"_parseSessionCompletionUnreadClearedKey",
|
||||
"_readPersistedSessionCompletionUnreadCleared",
|
||||
"_readSessionCompletionUnreadCleared",
|
||||
"_mergeSessionCompletionUnread",
|
||||
"_writeSessionCompletionUnreadCleared",
|
||||
]
|
||||
|
||||
_HEADER = f"""
|
||||
// One fake localStorage shared by every client, counting writes so a test can
|
||||
// assert a repair converges instead of ping-ponging writes between clients.
|
||||
const _store = {{}};
|
||||
let storageWrites = 0;
|
||||
let _onRead = null;
|
||||
const localStorage = {{
|
||||
getItem: (k) => {{
|
||||
const value = Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null;
|
||||
// Test hook: runs after the value is captured, i.e. inside a read-modify-write.
|
||||
if (_onRead) {{ const hook = _onRead; _onRead = null; hook(); }}
|
||||
return value;
|
||||
}},
|
||||
setItem: (k, v) => {{ storageWrites += 1; _store[k] = String(v); }},
|
||||
removeItem: (k) => {{ delete _store[k]; }},
|
||||
key: (i) => Object.keys(_store)[i] ?? null,
|
||||
get length() {{ return Object.keys(_store).length; }},
|
||||
}};
|
||||
const SESSION_VIEWED_COUNTS_KEY = {VIEWED_KEY!r};
|
||||
const SESSION_VIEWED_COUNTS_DELETED_PREFIX = `${{SESSION_VIEWED_COUNTS_KEY}}:deleted:v1:`;
|
||||
const SESSION_VIEWED_COUNTS_DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const readViewedDeletions = () =>
|
||||
Object.keys(_store).filter((k) => k.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX));
|
||||
const SESSION_COMPLETION_UNREAD_KEY = {UNREAD_KEY!r};
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_KEY = {CLEARED_KEY!r};
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${{SESSION_COMPLETION_UNREAD_CLEARED_KEY}}:v1:`;
|
||||
// Mirrors the module constant. The age assertions use hours vs 8 days so they
|
||||
// do not depend on the exact cap.
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
let _now = 1000;
|
||||
Date.now = () => _now;
|
||||
// The session list this client can see: the authority on "still exists".
|
||||
let _allSessions = [];
|
||||
const _sessionListSnapshotById = new Map();
|
||||
const listed = (...ids) => {{ _allSessions = ids.map((sid) => ({{session_id: sid}})); }};
|
||||
const readDisk = (key) => JSON.parse(_store[key] || '{{}}');
|
||||
const readViewedDisk = () => Object.fromEntries(
|
||||
Object.entries(readDisk(SESSION_VIEWED_COUNTS_KEY)).map(([sid, value]) => [
|
||||
sid,
|
||||
value && typeof value === 'object' ? Number(value.message_count) || 0 : Number(value) || 0,
|
||||
])
|
||||
);
|
||||
const setDisk = (key, value) => {{ _store[key] = JSON.stringify(value); }};
|
||||
// Aggregate either the legacy whole map or independent per-session records.
|
||||
const readAllClears = (client) => client && typeof client.clears === 'function'
|
||||
? client.clears()
|
||||
: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY);
|
||||
const _storageHandlers = [];
|
||||
const window = {{
|
||||
addEventListener: (type, fn) => {{
|
||||
if (type === 'storage') _storageHandlers.push(fn);
|
||||
}},
|
||||
}};
|
||||
// The neighbours the storage registration also routes to; only the unread
|
||||
// handler is under test here.
|
||||
async function _handleActiveSessionStorageEvent() {{}}
|
||||
async function _handleShowAllProfilesStorageEvent() {{}}
|
||||
"""
|
||||
|
||||
|
||||
def _client_factory(with_listener: bool = False) -> str:
|
||||
"""One client's module state plus the whole unread-store API."""
|
||||
fns = "\n".join(_extract(name) for name in _CLIENT_FNS)
|
||||
helpers = "\n".join(_extract_optional(name) for name in _HELPER_FNS)
|
||||
handler = _extract_optional("_handleUnreadStorageEvent")
|
||||
listener = _extract_storage_listener() if with_listener else ""
|
||||
return f"""
|
||||
function makeClient() {{
|
||||
let _sessionViewedCounts = null;
|
||||
let _sessionCompletionUnread = null;
|
||||
let _sessionCompletionUnreadClearedMemory = {{}};
|
||||
{fns}
|
||||
{helpers}
|
||||
{handler}
|
||||
{listener}
|
||||
return {{
|
||||
viewed: (sid) => {{
|
||||
const counts = _getSessionViewedCounts();
|
||||
return Object.prototype.hasOwnProperty.call(counts, sid) ? _sessionViewedCountValue(counts[sid]) : null;
|
||||
}},
|
||||
setViewed: _setSessionViewedCount,
|
||||
clearViewed: _clearSessionViewedCount,
|
||||
markUnread: _markSessionCompletionUnread,
|
||||
clearUnread: _clearSessionCompletionUnread,
|
||||
writeClear: _writeSessionCompletionUnreadCleared,
|
||||
hasUnread: _hasSessionCompletionUnread,
|
||||
hasUnreadFor: (sid, messageCount, transcriptGeneration = 0, transcriptGenerationBaseline = 0) => _hasUnreadForSession({{
|
||||
session_id: sid,
|
||||
message_count: messageCount,
|
||||
transcript_generation: transcriptGeneration,
|
||||
transcript_generation_baseline: transcriptGenerationBaseline,
|
||||
}}),
|
||||
clears: (typeof _readSessionCompletionUnreadCleared === 'function')
|
||||
? _readSessionCompletionUnreadCleared
|
||||
: () => readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY),
|
||||
handleStorage: (typeof _handleUnreadStorageEvent === 'function')
|
||||
? _handleUnreadStorageEvent
|
||||
: () => {{}},
|
||||
deletions: (typeof _readSessionViewedCountDeletions === 'function')
|
||||
? _readSessionViewedCountDeletions
|
||||
: () => ({{}}),
|
||||
}};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _script(body: str, with_listener: bool = False) -> str:
|
||||
return _HEADER + _client_factory(with_listener) + body
|
||||
|
||||
|
||||
# ── Viewed counts: generation-scoped, repaired, never resurrected ─────────────
|
||||
|
||||
def test_shrink_then_growth_is_unread_in_the_new_transcript_generation():
|
||||
"""A pre-shrink high-water mark must not mask new messages after truncation."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
const A = makeClient();
|
||||
A.setViewed('X', 10, 0);
|
||||
A.setViewed('X', 2, 1);
|
||||
console.log(JSON.stringify({
|
||||
afterShrink: A.viewed('X'),
|
||||
unreadAtThree: A.hasUnreadFor('X', 3, 1),
|
||||
}));
|
||||
"""))
|
||||
assert out["afterShrink"] == 2
|
||||
assert out["unreadAtThree"] is True
|
||||
|
||||
|
||||
def test_first_observation_of_new_generation_keeps_post_shrink_growth_unread():
|
||||
"""A coalesced shrink-plus-growth refresh must acknowledge only the shrink baseline."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
const A = makeClient();
|
||||
A.setViewed('X', 10, 0);
|
||||
console.log(JSON.stringify({
|
||||
unread: A.hasUnreadFor('X', 3, 1, 2),
|
||||
viewed: A.viewed('X'),
|
||||
}));
|
||||
"""))
|
||||
assert out == {"unread": True, "viewed": 2}
|
||||
|
||||
|
||||
def test_zero_message_baseline_survives_persistence_and_marks_first_message_unread():
|
||||
"""Presence of a zero baseline is meaningful; it is not a missing record."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
const A = makeClient();
|
||||
A.setViewed('X', 0, 0);
|
||||
console.log(JSON.stringify({
|
||||
diskHasX: Object.prototype.hasOwnProperty.call(readViewedDisk(), 'X'),
|
||||
unreadAtOne: A.hasUnreadFor('X', 1, 0),
|
||||
}));
|
||||
"""))
|
||||
assert out["diskHasX"] is True
|
||||
assert out["unreadAtOne"] is True
|
||||
|
||||
|
||||
def test_in_memory_clear_expiry_uses_recorded_time_not_future_logical_order():
|
||||
"""A future logical stamp must not extend the seven-day memory lifetime."""
|
||||
out = _run_node(_script("""
|
||||
const A = makeClient();
|
||||
_now = 1000;
|
||||
A.writeClear('X', 999999999999);
|
||||
const initially = A.clears();
|
||||
_now += 8 * 24 * 60 * 60 * 1000;
|
||||
A.writeClear('Y', 5);
|
||||
const expired = A.clears();
|
||||
console.log(JSON.stringify({initially, expired}));
|
||||
"""))
|
||||
assert out["initially"]["X"] == 999999999999
|
||||
assert "X" not in out["expired"]
|
||||
|
||||
|
||||
def test_unread_clear_survives_quota_rejecting_a_new_tombstone_key():
|
||||
"""Clearing must still shrink the existing marker map when allocation fails."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
setDisk(SESSION_COMPLETION_UNREAD_KEY, {
|
||||
X: {message_count: 3, completed_at: 1000, unread_order: 1},
|
||||
});
|
||||
const A = makeClient();
|
||||
const before = A.hasUnread('X');
|
||||
const ordinarySetItem = localStorage.setItem;
|
||||
localStorage.setItem = (key, value) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(_store, key)) throw new Error('QuotaExceededError');
|
||||
ordinarySetItem(key, value);
|
||||
};
|
||||
A.clearUnread('X');
|
||||
console.log(JSON.stringify({
|
||||
before,
|
||||
after: A.hasUnread('X'),
|
||||
diskHasX: Object.prototype.hasOwnProperty.call(readDisk(SESSION_COMPLETION_UNREAD_KEY), 'X'),
|
||||
}));
|
||||
"""))
|
||||
assert out == {"before": True, "after": False, "diskHasX": False}
|
||||
|
||||
|
||||
def test_quota_failed_clear_keeps_the_older_durable_tombstone_after_reload():
|
||||
"""An in-memory-only higher clear must not prune the durable ordering fact."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
const oldClearKey = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent('X')}:2000`;
|
||||
_store[oldClearKey] = '1000';
|
||||
const A = makeClient();
|
||||
const ordinarySetItem = localStorage.setItem;
|
||||
localStorage.setItem = (key, value) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(_store, key)) throw new Error('QuotaExceededError');
|
||||
ordinarySetItem(key, value);
|
||||
};
|
||||
// This advances X to 2001 in A's memory, but quota rejects that new durable key.
|
||||
A.clearUnread('X');
|
||||
const durableAfterClear = Object.prototype.hasOwnProperty.call(_store, oldClearKey);
|
||||
// Simulate a reload, then a stale client restoring a marker the durable clear
|
||||
// already outranks. Module memory is gone; only localStorage may suppress it.
|
||||
const B = makeClient();
|
||||
setDisk(SESSION_COMPLETION_UNREAD_KEY, {
|
||||
X: {message_count: 3, completed_at: 1500, unread_order: 1500},
|
||||
});
|
||||
B.handleStorage({ key: oldClearKey });
|
||||
console.log(JSON.stringify({
|
||||
durableAfterClear,
|
||||
unreadAfterReload: B.hasUnread('X'),
|
||||
}));
|
||||
"""))
|
||||
assert out == {"durableAfterClear": True, "unreadAfterReload": False}
|
||||
|
||||
|
||||
def test_stale_client_cannot_roll_a_viewed_count_back():
|
||||
"""Sequential rollback: a client that saw an older list snapshot must not
|
||||
lower a count another client already acknowledged."""
|
||||
out = _run_node(_script("""
|
||||
listed('X');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
A.setViewed('X', 417);
|
||||
B.setViewed('X', 131);
|
||||
console.log(JSON.stringify({
|
||||
disk: readViewedDisk(),
|
||||
bView: B.viewed('X'),
|
||||
}));
|
||||
"""))
|
||||
assert out["disk"] == {"X": 417}, (
|
||||
"a stale client must not lower a session's viewed count"
|
||||
)
|
||||
assert out["bView"] == 417, "the stale client must converge on the merged count"
|
||||
|
||||
|
||||
def test_clear_viewed_count_still_removes_from_disk():
|
||||
"""Guard: session deletion must still prune the viewed count."""
|
||||
out = _run_node(_script("""
|
||||
listed('D');
|
||||
const A = makeClient();
|
||||
A.setViewed('D', 5);
|
||||
A.clearViewed('D');
|
||||
console.log(JSON.stringify({
|
||||
diskHasD: Object.prototype.hasOwnProperty.call(readViewedDisk(), 'D'),
|
||||
}));
|
||||
"""))
|
||||
assert out["diskHasD"] is False, "clearing a viewed count must remove it from disk"
|
||||
|
||||
|
||||
def test_delete_prunes_a_viewed_count_only_the_store_holds():
|
||||
"""Deletion must consult the store, not just our cache. An entry another
|
||||
client added after this client's cache was loaded is exactly what the prune
|
||||
path exists to remove, and an early return on the cache miss leaves it
|
||||
behind for a session the list no longer shows."""
|
||||
out = _run_node(_script("""
|
||||
listed('E');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
// B's cache loads while the store is still empty, so B never sees E.
|
||||
B.viewed('E');
|
||||
// A acknowledges E, which B cannot know about from its own cache.
|
||||
A.setViewed('E', 17);
|
||||
const bCached = B.viewed('E');
|
||||
B.clearViewed('E');
|
||||
console.log(JSON.stringify({
|
||||
bCached: bCached,
|
||||
disk: readViewedDisk(),
|
||||
}));
|
||||
"""))
|
||||
assert out["bCached"] is None, "precondition: B's cache must not hold E"
|
||||
assert out["disk"] == {}, (
|
||||
"deleting a session must prune its viewed count from the store even "
|
||||
"when this client's cache never held the key"
|
||||
)
|
||||
|
||||
|
||||
def test_delete_keeps_the_higher_count_this_client_holds_for_others():
|
||||
"""Pruning one session must not cost this client the acknowledgement it
|
||||
holds for another. Adopting the store's view wholesale while pruning would
|
||||
drop a count another client already lowered, and this client is the only
|
||||
one that can still repair it."""
|
||||
out = _run_node(_script("""
|
||||
listed('X', 'E');
|
||||
const A = makeClient();
|
||||
A.setViewed('X', 417);
|
||||
// Another client lowered X while recording its own acknowledgement for E.
|
||||
setDisk(SESSION_VIEWED_COUNTS_KEY, { X: 131, E: 17 });
|
||||
A.clearViewed('E');
|
||||
const held = A.viewed('X');
|
||||
// A now processes the storage event that clobber arrived with.
|
||||
A.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY });
|
||||
console.log(JSON.stringify({
|
||||
held: held,
|
||||
disk: readViewedDisk(),
|
||||
}));
|
||||
"""))
|
||||
assert out["held"] == 417, (
|
||||
"pruning one session must not drop the higher count this client holds "
|
||||
"for another"
|
||||
)
|
||||
assert out["disk"] == {"X": 417}, (
|
||||
"the client must still hold the acknowledgement that repairs the "
|
||||
"lowered count in the store"
|
||||
)
|
||||
|
||||
|
||||
def test_interleaved_acknowledgements_both_survive():
|
||||
"""Both clients read one baseline, then each write lands inside the other's
|
||||
read-modify-write window. Neither acknowledgement may be lost."""
|
||||
out = _run_node(_script("""
|
||||
listed('X', 'Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
// Both clients read the same baseline before either write commits.
|
||||
A.viewed('X');
|
||||
B.viewed('Y');
|
||||
// A's save reads the store; B's write for its own session lands in that window,
|
||||
// so A's write (built from the store it read) drops B's entry.
|
||||
_onRead = () => { B.setViewed('Y', 999); };
|
||||
A.setViewed('X', 417);
|
||||
const clobbered = readViewedDisk();
|
||||
// B still holds its own acknowledgement, and now processes A's storage event.
|
||||
B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY });
|
||||
const disk = readViewedDisk();
|
||||
const writesAfterRepair = storageWrites;
|
||||
B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY });
|
||||
console.log(JSON.stringify({
|
||||
clobbered: clobbered,
|
||||
disk: disk,
|
||||
stable: storageWrites === writesAfterRepair,
|
||||
aView: A.viewed('X'),
|
||||
bView: B.viewed('Y'),
|
||||
}));
|
||||
"""))
|
||||
assert out["clobbered"] == {"X": 417}, (
|
||||
"precondition: the interleaved write dropped B's entry"
|
||||
)
|
||||
assert out["disk"] == {"X": 417, "Y": 999}, (
|
||||
"the client that still holds an acknowledgement must re-assert it after "
|
||||
"the other client's storage event, not re-read the store it just lost"
|
||||
)
|
||||
assert out["aView"] == 417 and out["bView"] == 999, (
|
||||
"both clients must converge on a view that contains both acknowledgements"
|
||||
)
|
||||
assert out["stable"] is True, (
|
||||
"a repair must converge: a second event must not write again"
|
||||
)
|
||||
|
||||
|
||||
def test_repair_does_not_resurrect_a_deleted_sessions_viewed_count():
|
||||
"""A session deleted in one client is pruned from the store; a stale client
|
||||
that still caches its count must not write it back."""
|
||||
out = _run_node(_script("""
|
||||
listed('S', 'T');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
A.setViewed('S', 5);
|
||||
B.viewed('S');
|
||||
A.clearViewed('S');
|
||||
listed('T');
|
||||
B.setViewed('T', 1);
|
||||
B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY });
|
||||
console.log(JSON.stringify({
|
||||
disk: readViewedDisk(),
|
||||
tView: B.viewed('T'),
|
||||
}));
|
||||
"""))
|
||||
assert out["disk"] == {"T": 1}, (
|
||||
"a deleted session's pruned count must not be resurrected by a stale "
|
||||
f"client's save or repair (disk was {out['disk']})"
|
||||
)
|
||||
assert out["tView"] == 1, "the live acknowledgement for the listed session must survive"
|
||||
|
||||
|
||||
# ── Completion unread: the later event wins ──────────────────────────────────
|
||||
|
||||
def test_stale_client_cannot_resurrect_a_deleted_sessions_count():
|
||||
"""Deleting a session records its own fact, so a client that still caches the
|
||||
acknowledgement prunes it instead of writing it back — list membership cannot
|
||||
decide this, because the sidebar filters by profile, project, and source."""
|
||||
out = _run_node(_script("""
|
||||
listed('S', 'T');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
A.setViewed('S', 5);
|
||||
B.viewed('S');
|
||||
A.clearViewed('S');
|
||||
B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY });
|
||||
B.setViewed('T', 1);
|
||||
console.log(JSON.stringify({
|
||||
disk: readViewedDisk(),
|
||||
deletions: readViewedDeletions().length,
|
||||
bView: B.viewed('S'),
|
||||
}));
|
||||
"""))
|
||||
assert out["disk"] == {"T": 1}, (
|
||||
"a deleted session's count must not come back from a stale client"
|
||||
)
|
||||
assert out["deletions"] == 1
|
||||
assert out["bView"] is None
|
||||
|
||||
|
||||
def test_viewed_count_deletion_records_are_pruned_by_age():
|
||||
"""The deletion records are bounded like the other stores: a later merge
|
||||
drops one that has aged out."""
|
||||
out = _run_node(_script("""
|
||||
listed('S', 'X');
|
||||
const A = makeClient();
|
||||
const young = (() => { A.clearViewed('S'); return readViewedDeletions().length; })();
|
||||
const keptYoung = (() => { A.setViewed('X', 2); return readViewedDeletions().length; })();
|
||||
_now = 1000 + 8 * 24 * 60 * 60 * 1000;
|
||||
A.setViewed('X', 3);
|
||||
console.log(JSON.stringify({
|
||||
young: young,
|
||||
keptYoung: keptYoung,
|
||||
afterAge: readViewedDeletions().length,
|
||||
}));
|
||||
"""))
|
||||
assert out["young"] == 1
|
||||
assert out["keptYoung"] == 1, "a fresh deletion record must survive an unrelated merge"
|
||||
assert out["afterAge"] == 0, "an expired deletion record must be pruned"
|
||||
|
||||
|
||||
def test_marker_merge_keeps_the_later_operation_within_one_tick():
|
||||
"""Two clients can complete a session in the same millisecond. The merge must
|
||||
pick the later logical operation, not compare wall-clock completion times,
|
||||
or the retained marker keeps the older message count and metadata."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('Y', 3);
|
||||
B.markUnread('Y', 7);
|
||||
const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY);
|
||||
console.log(JSON.stringify({
|
||||
count: (disk.Y || {}).message_count || null,
|
||||
completedAt: (disk.Y || {}).completed_at || null,
|
||||
}));
|
||||
"""))
|
||||
assert out["count"] == 7, (
|
||||
"the later completion must win even though both markers share a millisecond"
|
||||
)
|
||||
assert out["completedAt"] == 1000, (
|
||||
"the wall-clock completion time is still recorded for display and pruning"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_client_cannot_resurrect_a_cleared_completion_marker():
|
||||
"""The field symptom: a marker cleared by opening the chat must not come
|
||||
back when another client that still holds it saves later."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y', 'Z');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('Y', 3);
|
||||
B.hasUnread('Y');
|
||||
_now = 2000;
|
||||
A.clearUnread('Y');
|
||||
// B has not processed A's storage event yet and saves for another session.
|
||||
B.markUnread('Z', 7);
|
||||
const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY);
|
||||
console.log(JSON.stringify({
|
||||
y: Object.prototype.hasOwnProperty.call(disk, 'Y'),
|
||||
z: Object.prototype.hasOwnProperty.call(disk, 'Z'),
|
||||
yViewA: A.hasUnread('Y'),
|
||||
}));
|
||||
"""))
|
||||
assert out["z"] is True, "precondition: the stale client's own marker is persisted"
|
||||
assert out["y"] is False, (
|
||||
"a marker cleared in one client must not be resurrected by another "
|
||||
"client that still holds it"
|
||||
)
|
||||
assert out["yViewA"] is False, "the cleared marker must stay cleared for the clear's own client"
|
||||
|
||||
|
||||
def test_a_genuine_later_re_mark_survives_the_earlier_clear():
|
||||
"""Control: ordering must not over-clear. A completion that happened after
|
||||
the clear is new information and must win."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('Y', 3);
|
||||
B.hasUnread('Y');
|
||||
_now = 2000;
|
||||
A.clearUnread('Y');
|
||||
_now = 3000;
|
||||
A.markUnread('Y', 9);
|
||||
B.handleStorage({ key: SESSION_COMPLETION_UNREAD_KEY });
|
||||
const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY);
|
||||
console.log(JSON.stringify({
|
||||
marker: disk.Y || null,
|
||||
aView: A.hasUnread('Y'),
|
||||
bView: B.hasUnread('Y'),
|
||||
}));
|
||||
"""))
|
||||
assert out["marker"] == {
|
||||
"message_count": 9,
|
||||
"completed_at": 3000,
|
||||
"unread_order": 3001,
|
||||
}, (
|
||||
"a completion that postdates the clear must be kept"
|
||||
)
|
||||
assert out["aView"] is True and out["bView"] is True, (
|
||||
"both clients must show the genuine re-mark"
|
||||
)
|
||||
|
||||
|
||||
def test_later_clear_wins_after_same_millisecond_re_mark():
|
||||
"""A clear must advance from the marker's logical order, not reuse the wall
|
||||
clock tick and lose to the marker's synthetic ``clearedAt + 1`` timestamp."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
_now = 1000;
|
||||
A.clearUnread('Y');
|
||||
A.markUnread('Y', 3);
|
||||
const marked = A.hasUnread('Y');
|
||||
// The user visits again before the millisecond clock advances.
|
||||
A.clearUnread('Y');
|
||||
console.log(JSON.stringify({
|
||||
marked,
|
||||
final: A.hasUnread('Y'),
|
||||
disk: readDisk(SESSION_COMPLETION_UNREAD_KEY),
|
||||
}));
|
||||
"""))
|
||||
assert out["marked"] is True, "precondition: the same-ms re-mark must be visible"
|
||||
assert out["final"] is False
|
||||
assert out["disk"] == {}, "the later clear must remove the same-ms marker from disk"
|
||||
|
||||
|
||||
def test_same_millisecond_clear_beats_marker_prepared_before_it():
|
||||
"""A marker whose intent was prepared before a concurrent clear must lose,
|
||||
even when both operations observe the same wall-clock millisecond."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
A.hasUnread('Y');
|
||||
B.hasUnread('Y');
|
||||
_now = 1000;
|
||||
_onRead = () => { A.clearUnread('Y'); };
|
||||
B.markUnread('Y', 3);
|
||||
console.log(JSON.stringify({
|
||||
marker: readDisk(SESSION_COMPLETION_UNREAD_KEY).Y || null,
|
||||
visible: B.hasUnread('Y'),
|
||||
}));
|
||||
"""))
|
||||
assert out["marker"] is None
|
||||
assert out["visible"] is False
|
||||
|
||||
|
||||
def test_repair_removes_a_marker_that_lost_to_a_clear():
|
||||
"""A stale client's write can land after the clear. The repair must drop the
|
||||
losing marker from the store, or the dot returns on every client."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y', 'Z');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('Y', 3);
|
||||
B.hasUnread('Y');
|
||||
_now = 2000;
|
||||
A.clearUnread('Y');
|
||||
// The stale write lands after the clear (saved before the event was processed).
|
||||
setDisk(SESSION_COMPLETION_UNREAD_KEY, {
|
||||
Y: { message_count: 3, completed_at: 1000 },
|
||||
Z: { message_count: 2, completed_at: 1500 },
|
||||
});
|
||||
A.handleStorage({ key: SESSION_COMPLETION_UNREAD_KEY });
|
||||
const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY);
|
||||
console.log(JSON.stringify({
|
||||
diskY: Object.prototype.hasOwnProperty.call(disk, 'Y'),
|
||||
diskZ: Object.prototype.hasOwnProperty.call(disk, 'Z'),
|
||||
yView: A.hasUnread('Y'),
|
||||
zView: A.hasUnread('Z'),
|
||||
}));
|
||||
"""))
|
||||
assert out["diskY"] is False, (
|
||||
"a repair must remove a marker that lost to a clear, not leave it for "
|
||||
"the next reader"
|
||||
)
|
||||
assert out["diskZ"] is True, "an unrelated marker must be left alone"
|
||||
assert out["yView"] is False and out["zView"] is True, (
|
||||
"the repaired view must match the store"
|
||||
)
|
||||
|
||||
|
||||
def test_concurrent_clears_keep_both_ordering_facts():
|
||||
"""Clearing different sessions in the same read/write window must not let
|
||||
either whole-map write erase the other session's ordering fact."""
|
||||
out = _run_node(_script("""
|
||||
listed('X', 'Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('X', 1);
|
||||
B.markUnread('Y', 1);
|
||||
A.hasUnread('Y');
|
||||
B.hasUnread('X');
|
||||
_now = 2000;
|
||||
// A captures the old tombstone state; B's clear lands before A writes.
|
||||
_onRead = () => { B.clearUnread('Y'); };
|
||||
A.clearUnread('X');
|
||||
const tombs = readAllClears(A);
|
||||
console.log(JSON.stringify({ tombs }));
|
||||
"""))
|
||||
assert out["tombs"] == {"X": 2001, "Y": 2001}, (
|
||||
"concurrent clears for different sessions must retain both tombstones"
|
||||
)
|
||||
|
||||
|
||||
def test_new_clear_does_not_dual_write_the_legacy_whole_map():
|
||||
"""The independently addressable representation is authoritative. Keeping
|
||||
dual-write compatibility would retain the old lost-update race forever."""
|
||||
out = _run_node(_script("""
|
||||
const A = makeClient();
|
||||
A.writeClear('new', 2000);
|
||||
console.log(JSON.stringify({
|
||||
legacyPresent: Object.prototype.hasOwnProperty.call(_store, SESSION_COMPLETION_UNREAD_CLEARED_KEY),
|
||||
all: readAllClears(A),
|
||||
}));
|
||||
"""))
|
||||
assert out["legacyPresent"] is False
|
||||
assert out["all"] == {"new": 2000}
|
||||
|
||||
|
||||
def test_legacy_tombstone_map_is_migrated_then_removed():
|
||||
"""A reloaded client imports existing clear facts once, then removes the
|
||||
unbounded whole map instead of pretending concurrent old/new writes are safe."""
|
||||
out = _run_node(_script("""
|
||||
setDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY, { legacy: 1000 });
|
||||
const A = makeClient();
|
||||
A.writeClear('new', 2000);
|
||||
console.log(JSON.stringify({
|
||||
legacyPresent: Object.prototype.hasOwnProperty.call(_store, SESSION_COMPLETION_UNREAD_CLEARED_KEY),
|
||||
all: readAllClears(A),
|
||||
}));
|
||||
"""))
|
||||
assert out["legacyPresent"] is False
|
||||
assert out["all"] == {"legacy": 1000, "new": 2000}
|
||||
|
||||
|
||||
def test_same_session_clear_versions_fold_to_the_newest_operation():
|
||||
"""An older clear operation that lands after a newer one must not lower the
|
||||
ordering fact for that session."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
A.writeClear('Y', 2000);
|
||||
A.writeClear('Y', 1000);
|
||||
console.log(JSON.stringify({ tombs: readAllClears(A) }));
|
||||
"""))
|
||||
assert out["tombs"] == {"Y": 2000}
|
||||
|
||||
|
||||
def test_clear_orders_out_a_marker_prepared_but_not_yet_persisted():
|
||||
"""A user visit after completion intent but before its delayed marker write
|
||||
must record a clear even when neither its cache nor storage has the marker."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y');
|
||||
const A = makeClient();
|
||||
const B = makeClient();
|
||||
A.hasUnread('Y');
|
||||
B.hasUnread('Y');
|
||||
_now = 1000;
|
||||
// B's marker carries t=1000. Its save captures the empty marker map, then A
|
||||
// visits at t=2000 before B's delayed write commits.
|
||||
_onRead = () => { _now = 2000; A.clearUnread('Y'); };
|
||||
B.markUnread('Y', 3);
|
||||
const marker = readDisk(SESSION_COMPLETION_UNREAD_KEY).Y || null;
|
||||
const tombstone = readAllClears(A).Y || null;
|
||||
console.log(JSON.stringify({ marker, tombstone }));
|
||||
"""))
|
||||
assert out["tombstone"] == 2001, (
|
||||
"a clear must persist its ordering fact even when the marker write is delayed"
|
||||
)
|
||||
assert out["marker"] is None, "the completion that predates the visit must remain cleared"
|
||||
|
||||
|
||||
def test_clear_records_survive_a_session_missing_from_the_list():
|
||||
"""A session absent from the visible list is not proof of deletion: the list
|
||||
is filtered by profile, project, and source. Dropping its clear record would
|
||||
re-light a dot the user already cleared, so age is the only pruning signal."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y', 'GONE');
|
||||
const A = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('GONE', 1);
|
||||
A.clearUnread('GONE');
|
||||
listed('Y');
|
||||
_now = 2000;
|
||||
A.markUnread('Y', 1);
|
||||
A.clearUnread('Y');
|
||||
console.log(JSON.stringify({ tombs: readAllClears(A) }));
|
||||
"""))
|
||||
assert out["tombs"] == {"GONE": 1002, "Y": 2002}
|
||||
|
||||
|
||||
def test_tombstones_are_capped_by_age():
|
||||
"""Quota guard: an old tombstone is dropped when a newer clear is recorded."""
|
||||
out = _run_node(_script("""
|
||||
listed('Y', 'Z');
|
||||
const A = makeClient();
|
||||
_now = 1000;
|
||||
A.markUnread('Y', 1);
|
||||
A.clearUnread('Y');
|
||||
const young = _now + 60 * 60 * 1000;
|
||||
_now = young;
|
||||
A.markUnread('Z', 1);
|
||||
A.clearUnread('Z');
|
||||
const keptYoung = readAllClears(A);
|
||||
_now = young + 8 * 24 * 60 * 60 * 1000;
|
||||
A.markUnread('Y', 1);
|
||||
A.clearUnread('Y');
|
||||
console.log(JSON.stringify({
|
||||
keptYoung: keptYoung,
|
||||
afterAge: readAllClears(A),
|
||||
young: young,
|
||||
}));
|
||||
"""))
|
||||
assert out["keptYoung"] == {"Y": 1002, "Z": out["young"] + 2}, (
|
||||
"a tombstone younger than the cap must survive a newer clear"
|
||||
)
|
||||
assert out["afterAge"] == {
|
||||
"Y": out["young"] + 8 * 24 * 60 * 60 * 1000 + 2,
|
||||
}, "a tombstone older than the cap must be removed from storage"
|
||||
|
||||
|
||||
# ── The registration itself ──────────────────────────────────────────────────
|
||||
|
||||
def test_registered_storage_listener_syncs_both_unread_keys_from_storage():
|
||||
"""Behavioural wiring test: capture the handler the module registers on
|
||||
``storage``, dispatch synthetic events for both keys, and assert the client's
|
||||
view comes from the updated store."""
|
||||
out = _run_node(_script("""
|
||||
listed('X', 'Q', 'R');
|
||||
const A = makeClient();
|
||||
const dispatch = (key) => {
|
||||
const value = Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null;
|
||||
for (const handler of _storageHandlers) handler({ key: key, newValue: value });
|
||||
};
|
||||
A.setViewed('X', 511);
|
||||
A.hasUnread('Q');
|
||||
// A stale client clobbers the store with its lower count.
|
||||
setDisk(SESSION_VIEWED_COUNTS_KEY, { X: 131 });
|
||||
dispatch(SESSION_VIEWED_COUNTS_KEY);
|
||||
const writesAfterRepair = storageWrites;
|
||||
dispatch(SESSION_VIEWED_COUNTS_KEY);
|
||||
const repairStable = storageWrites === writesAfterRepair;
|
||||
// A marker another client just recorded, and a clear it recorded.
|
||||
setDisk(SESSION_COMPLETION_UNREAD_KEY, { Q: { message_count: 2, completed_at: 5000 } });
|
||||
const qStale = A.hasUnread('Q');
|
||||
dispatch(SESSION_COMPLETION_UNREAD_KEY);
|
||||
const rClearKey = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent('R')}:9000`;
|
||||
_store[rClearKey] = '9000';
|
||||
setDisk(SESSION_COMPLETION_UNREAD_KEY, {
|
||||
Q: { message_count: 2, completed_at: 5000 },
|
||||
R: { message_count: 3, completed_at: 8000 },
|
||||
});
|
||||
dispatch(rClearKey);
|
||||
const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY);
|
||||
console.log(JSON.stringify({
|
||||
handlers: _storageHandlers.length,
|
||||
xView: A.viewed('X'),
|
||||
diskX: readViewedDisk().X,
|
||||
stable: repairStable,
|
||||
qStale: qStale,
|
||||
qView: A.hasUnread('Q'),
|
||||
rView: A.hasUnread('R'),
|
||||
diskR: Object.prototype.hasOwnProperty.call(disk, 'R'),
|
||||
}));
|
||||
""", with_listener=True))
|
||||
assert out["handlers"] == 1, "the module must register exactly one storage listener"
|
||||
assert out["xView"] == 511 and out["diskX"] == 511, (
|
||||
"a storage event must repair the store with the count this client still "
|
||||
"holds instead of adopting the clobbering value"
|
||||
)
|
||||
assert out["stable"] is True, "the repair must not write again for the same state"
|
||||
assert out["qStale"] is False and out["qView"] is True, (
|
||||
"a storage event for the unread key must refresh the client's marker view"
|
||||
)
|
||||
assert out["rView"] is False and out["diskR"] is False, (
|
||||
"the cleared-marker key must be routed to the handler and its tombstone "
|
||||
"must win over the stale marker in the store"
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""The run state consistency contract must document the client-side unread
|
||||
persistence rules, and what it names has to match what `static/sessions.js`
|
||||
implements.
|
||||
|
||||
The contract is prose, so it cannot be verified behaviourally. What rots silently
|
||||
is the identifier set: the store keys the sidebar layer depends on and the
|
||||
retention window that bounds the tombstone map. These assertions pin the
|
||||
documented names to the implementation constants, so changing either one without
|
||||
the other fails here instead of drifting.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RFC = ROOT / "docs" / "rfcs" / "webui-run-state-consistency-contract.md"
|
||||
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
||||
|
||||
# The stores the sidebar layer persists in localStorage, and the constant that
|
||||
# bounds the tombstone map.
|
||||
DOCUMENTED_CONSTANTS = [
|
||||
"SESSION_VIEWED_COUNTS_KEY",
|
||||
"SESSION_COMPLETION_UNREAD_KEY",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_KEY",
|
||||
]
|
||||
|
||||
|
||||
def _sessions_js() -> str:
|
||||
return SESSIONS_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _string_constant(source: str, name: str) -> str:
|
||||
match = re.search(rf"^const {name} = '([^']*)';", source, re.MULTILINE)
|
||||
assert match, f"{name} must be defined as a string constant in static/sessions.js"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_contract_documents_the_client_side_unread_store_keys():
|
||||
text = RFC.read_text(encoding="utf-8")
|
||||
source = _sessions_js()
|
||||
|
||||
for name in DOCUMENTED_CONSTANTS:
|
||||
key = _string_constant(source, name)
|
||||
assert key in text, (
|
||||
f"the contract must name the store behind {name} ({key})"
|
||||
)
|
||||
|
||||
|
||||
def test_documented_retention_window_matches_the_implemented_cap():
|
||||
source = _sessions_js()
|
||||
match = re.search(
|
||||
r"^const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = (\d+) \*",
|
||||
source,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert match, (
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS must be defined in days in "
|
||||
"static/sessions.js"
|
||||
)
|
||||
days = int(match.group(1))
|
||||
|
||||
text = RFC.read_text(encoding="utf-8")
|
||||
assert f"{days}-day" in text, (
|
||||
f"the contract must state the {days}-day retention cap on the tombstone map"
|
||||
)
|
||||
Reference in new issue
Block a user