"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. Every ChatMessage.parts carries 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 lastDurableRowId walks back from the end of the view to the first message carrying a persisted rowId — 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 rowId equals the last durable rowId of 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 systemNotice rows 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 browse lets 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.