Skip to content

fix(chat): keep compression tip selected in sidebar - #2913

Merged
2 commits merged into
nesquena:masterfrom
ai-ag2026:fix/session-lineage-tip-restore
May 25, 2026
Merged

2 commits merged into
nesquena:masterfrom
ai-ag2026:fix/session-lineage-tip-restore

Conversation

@ai-ag2026

Copy link
Copy Markdown
Contributor

Summary

  • prefer non-snapshot compression continuations when collapsed sidebar lineage rows share the same backend segment count
  • add regression coverage for a refreshed parent snapshot reopening instead of the current child/tip
  • document the user-visible sidebar restore fix in the changelog

Tests

  • pytest -q tests/test_session_lineage_collapse.py
  • python3 -m py_compile api/models.py api/routes.py
  • secret hygiene scan over changed diff

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Read the diff against origin/master and the existing _collapseSessionLineageForSidebar site at static/sessions.js:2801 plus the existing lineage helpers. The fix targets two related symptoms cleanly.

Code reference

The collapse-tiebreaker change (static/sessions.js around the existing sort comparator):

if(bSeg||aSeg){
  if(bSeg!==aSeg) return bSeg-aSeg;
}
// Preserved pre-compression parents can share the same backend segment
// count as the continuation. Prefer the non-snapshot tip before falling
// back to timestamps, otherwise a recently-polled parent reopens the
// older transcript and makes the active continuation look lost.
const bSnapshot=!!(b&&b.pre_compression_snapshot);
const aSnapshot=!!(a&&a.pre_compression_snapshot);
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
return _sessionTimestampMs(b)-_sessionTimestampMs(a);

And the new _resolveSessionIdFromSidebarLineage wires the same preference into loadSession:

async function loadSession(sid){
  const opts = arguments[1] || {};
  if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){
    const resolvedSid=_resolveSessionIdFromSidebarLineage(sid);
    if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid;
  }

The resolver scans the collapsed sidebar rows and only retargets when:

  • the input sid is not already a visible row, and
  • some collapsed row's lineage metadata names it (matches _lineage_root_id, parent_session_id, or _sessionLineageContainsSession).

That's the right gate — it avoids retargeting plain non-lineage sessions that happen to be missing from the current sidebar payload (preserves the existing 404 self-heal path mentioned in the canonical-session-resolution RFC at #2922).

Tests

The two new tests are sharp:

sessions = [
  {session_id:'parent', ..., pre_compression_snapshot:true, _lineage_root_id:'parent', _compression_segment_count:2},
  {session_id:'child', ..., parent_session_id:'parent', _lineage_root_id:'parent', _compression_segment_count:2},
];
const collapsed = _collapseSessionLineageForSidebar(sessions);
...
assert [row["session_id"] for row in collapsed] == ["child"]
assert collapsed[0]["_lineage_collapsed_count"] == 2
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["child", "parent"]

This is exactly the case the diff fixes — identical _compression_segment_count between parent and child, parent has a fresher updated_at (because polling refreshes it), and the previous comparator fell back to timestamps and picked parent. The new snapshot-deprioritization tiebreaker keeps the comparison on the child.

The second test exercises the direct-URL resolver:

const _allSessions = [
  {session_id:'child', ..., parent_session_id:'parent', _lineage_root_id:'parent', _compression_segment_count:2},
  {session_id:'other', ..., (unrelated)},
];
console.log(JSON.stringify({parent:_resolveSessionIdFromSidebarLineage('parent'), child:_resolveSessionIdFromSidebarLineage('child'), other:_resolveSessionIdFromSidebarLineage('other')}));
...
assert result == {"parent": "child", "child": "child", "other": "other"}

Confirms parent → child retargeting, child → child no-op, and other → other (unrelated session is preserved as-is). The non-retargeting of other is the case I'd worry about most — that assertion is the canary that says the resolver doesn't randomly grab a fresher row.

One subtle case

The sort comparator inside _resolveSessionIdFromSidebarLineage ranks _compression_segment_count descending, then non-snapshot preference, then timestamp:

candidates.sort((a,b)=>{
  const bSeg=Number(b&&b._compression_segment_count||b&&b._lineage_collapsed_count||0);
  const aSeg=Number(a&&a._compression_segment_count||a&&a._lineage_collapsed_count||0);
  if(bSeg!==aSeg) return bSeg-aSeg;
  const bSnapshot=!!(b&&b.pre_compression_snapshot);
  const aSnapshot=!!(a&&a.pre_compression_snapshot);
  if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
  return _sessionTimestampMs(b)-_sessionTimestampMs(a);
});

That matches the collapse comparator (good — same ordering rule used in two places). One subtle thing: if a stale visible row in visibleRows has _lineage_collapsed_count > _compression_segment_count because of an in-progress merge, the OR fallback in the candidates sort here could prefer it over an actually-canonical row. Not a regression — the original code didn't consider this at all — but if you see flicker on slow-network reloads it'd be the spot to look. Probably fine in practice because both fields are populated together by _attachChildSessionsToSidebarRows.

Diagnosis

The fix matches the canonical-session-resolution RFC's Rule 2 verbatim ("snapshot parents defer to visible continuation tips"). The loadSession hook adds the symmetric direct-open path so URL/route restore takes the same code path as sidebar click. The skipLineageResolve opt-out is a sensible escape hatch for the eventual archive-inspection mode named in the RFC's non-goals.

LGTM. One nit: the _lineage_collapsed_count fallback in the resolver's sort is intentional but worth a one-line comment explaining why both fields are checked (matches the same shape the collapse pass uses, so the two stay in sync).

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Merged in Release DI / v0.51.137 (stage-batch19, batch with PRs #2913 #2915 #2923 #2933 #2937 #2940).

Thanks @ai-ag2026! 🚢

Sanjays2402 pushed a commit to Sanjays2402/hermes-webui that referenced this pull request May 25, 2026
# Conflicts:
#	CHANGELOG.md
@ai-ag2026
ai-ag2026 deleted the fix/session-lineage-tip-restore branch June 12, 2026 06:25
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
# Conflicts:
#	CHANGELOG.md
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
# Conflicts:
#	CHANGELOG.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants