Merge PR #7577 into stage/0924-l

This commit is contained in:
nesquena-hermes committed 2026-09-24 17:53:18 +00:00
commit 96921c2cfc
18 files changed
+1998 -66

No files matched your search

+10
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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;
+94
View File
@@ -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]
)
+6 -14
View File
@@ -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():
+4 -1
View File
@@ -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},
}
+22 -3
View File
@@ -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()
+934
View File
@@ -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"
)