"Chat out of date": when the desktop refused every send

You have been working in one desktop session for hours, tool call after tool call. You hit Enter and get “Chat out of date” — the window was behind another view of the same chat, the latest messages were loaded, send again if you still want to. You send again. Same message. Restarting the app changes nothing. None of your data is missing, but that conversation can no longer accept a new message.
PR #125523, merged 2026-09-27, fixes the loop. Issue #123909 holds the full reproduction timeline.
The symptom: a refusal loop that cannot converge
- One trigger looks entirely innocent: moving a session into a project. After the move, the window’s in-memory store freezes at the pre-move boundary, and from then on every send is blocked client-side.
- It was not an isolated case: #123033 and #123856 reported the same guard firing with no obvious trigger at all.
- There is a quieter second casualty: forking an affected chat seeds the fork from that frozen store, silently dropping everything after the move. No error — the new session simply does not contain that history.
Root cause: two consumers disagreeing about one store
Two components read the same session store and had different ideas about what “current” means:
- Retention (
transcript-retention.ts, introduced for #77311) deliberately releases the head of the store, keeping the live window plus one weight page of slack. EveryChatMessage.partscarries rendered tool output, file previews, diffs and images, which is how a long-lived window grows. A tool-heavy coordinator transcript burns that budget quickly, so the trimmed store holds far fewer rendered messages than the session actually has. - The old guard (
stale-transcript-guard.ts) compared counts: the latest 120-row page (includeCompacted: true) folded down to, say, 85 chat messages — more than the trimmed store held — so the view was declared behind, the send was refused and the refreshed page installed. The next window-anchor move trimmed again, and the next send was refused again. Non-convergent by construction, and it survived reload and relaunch because hydration re-trims once the window cut is established.
Short chats never cross that budget. That is why this only bit long, heavy conversations — including the canonical Bot Chat, which is a forever-chat by design.
The fix: compare the tail, not the total
The new logic changes what is compared:
- A new
lastDurableRowIdwalks back from the end of the view to the first message carrying a persistedrowId— the view’s tail. Retention can only ever release the head, so the last durable row is always the live tail; unpersisted rows (optimistic prompts, streaming replies) sit past it. - When the local view’s last durable
rowIdequals the last durablerowIdof the page the backend just returned, the view is current, however much head retention paged out. A peer window with genuinely newer rows still changes the tip, so real staleness is still refused. - One source of false positives went away with it: backend-written
systemNoticerows render on the timeline but belong to no view, so counting them reported a second window that did not exist — an in-place model switch alone was enough to make every send look behind.
What you can do
- Upgrading is the direct answer. Once the fix is in your install, long chats stop being refused by this loop (the desktop follows
hermes update). - On a machine that has not been updated yet, the practical workaround when you see the prompt is to continue in a new session, or keep going from the CLI (
hermes sessions browselets you pick a past session and resume it). Do not keep hitting send. - If you already forked a session because of this, treat that fork as an unreliable copy: the original session still exists with its full history, so do not use the fork as a backup.
- Another long-conversation fix landed the same day and is worth reading next: why mid-turn compaction must not rewrite history. For the desktop app itself, see interface modes and Hermes Desktop in our ecosystem section.
Release status
The patch merged on 2026-09-27, after the v0.21.5 release tag (v2026.9.24), so it is on main only for now.
The takeaway
The lesson here is not in any single line of code: both modules were “correct”. One releases history by weight to bound memory; the other judged freshness by counting rows — put together, the comparison could never succeed. The fix does not tune the budget. It picks a fact both sides agree on: the last durable row. If the tips match, the view is current; if they do not, it really is behind. Memory still gets bounded, and sending works again.