Skip to content

fix(site/src/api/queries): keep live messages when an older chat page resolves - #29444

Draft
jscottmiller wants to merge 3 commits into
mainfrom
scott/transcript-pagination-drop
Draft

jscottmiller wants to merge 3 commits into
mainfrom
scott/transcript-pagination-drop

Conversation

@jscottmiller

Copy link
Copy Markdown
Contributor

Problem

Assistant messages that stream in over the chat WebSocket while an older-page history request (GET /api/experimental/chats/{chat}/messages?before_id=...) is in flight disappear from the transcript until the page is reloaded. With a 10s delay on that request, a 24-step turn rendered 20 of 24 step rows on main; the missing messages were exactly those created between the request starting and its response. Messages streamed during the fetch should merge like any other live message, so the transcript matches a reload once the page lands.

The transcript history is a TanStack infinite query and live messages are written into its cache with setQueryData. TanStack's infiniteQueryBehavior snapshots the existing pages when fetchNextPage starts and writes [...snapshot, newPage] when the request resolves, discarding every cache write made in between. useChatStore's sync effect then sees those IDs missing from the fetched set, classifies them as stale (the edit-truncation path) and removes them from the store as well.

Fix

chatMessagesForInfiniteScroll gets a structuralSharing merge, mergeChatMessagesPages. When a result only appends pages under the cached page params, it keeps the current cached pages and appends the new ones; a cache whose last page already has has_more: false is left untouched; every other write shape falls through to replaceEqualDeep as before. useChatStore is unchanged.

Coverage: a Vitest drives a real InfiniteQueryObserver over the query with a deferred before_id page and an upsert mid-flight (fails on main), plus direct tests of the merge branches.

Surfaced by the collapse-assistant-steps UAT report (internal repo), issue I1: https://github.com/coder/scott-misc/blob/main/uat/collapse-steps-uat/README.md

Follow-ups (pre-existing, not changed here)

  • editChatMessage.onMutate calls cancelChatMessages, whose revert restores the pre-fetch query state wholesale, so an edit started during an in-flight older-page fetch can drop WebSocket-upserted messages through a different path (state revert, not setData). Recovery currently relies on the post-edit refresh.
  • The history_reset path in useChatStore does not cancel an in-flight older-page fetch. The has_more guard makes the resolving page a no-op; cancelling the fetch there would be the tidier fix.
Root-cause note

Mechanism

The transcript's message history is a TanStack infinite query (chatMessagesForInfiniteScroll, site/src/api/queries/chats.ts). Live WebSocket messages are written to the store directly and also into the infinite cache via upsertChatMessages -> queryClient.setQueryData (useChatStore.ts, upsertCacheMessages).

TanStack's infiniteQueryBehavior.onFetch captures oldPages = context.state.data.pages when fetchNextPage starts, and when the page request resolves it writes { pages: [...oldPages, newPage] } as the query data (@tanstack/query-core@5.82.0, src/infiniteQueryBehavior.ts). Any setQueryData write made while the page request was in flight is therefore overwritten: page 0 reverts to its pre-fetch snapshot.

The store then loses the messages too. useChatStore's sync effect compares the flattened cache messages against the previous sync (lastSyncedMessagesRef). The WS upsert had already been synced into prevIDs; after the pagination result lands those IDs are absent from the fetched set but present in prevIDs, so hasStaleEntries is true and the effect calls store.replaceMessages(chatMessages), which removes them from the store. This path exists to handle edit truncation.

Trigger conditions

  • Chat has more than one page (has_more true), so the client issues GET .../messages?before_id=... (fetchNextPage).
  • At least one durable message stream event arrives between the start of that request and its response.
  • Slow older-page requests widen the window; without delay the window is typically shorter than one tool step, which matches the UAT (20/20 without delay, 20/24 and 21/24 with a 10s delay).

Backend ruled out

before_id pages only contain ids below the cursor, so the new messages could never appear in that response. The WS delivered them (the UAT saw them render and then vanish), and a reload shows them, so they are persisted. Nothing server-side needs to change.

Confirmation

  • Code reading of infiniteQueryBehavior.ts (oldPages captured at fetch start) and useChatStore.ts (stale-entry replace).
  • Failing Vitest in site/src/api/queries/chats.test.ts: seed a two-page cache, start fetchNextPage on an InfiniteQueryObserver with a deferred getChatMessages, call upsertChatMessages while pending, resolve; on unfixed code page 0 no longer contains the upserted message.

Generated by Coder Agents on behalf of @jscottmiller.

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.

1 participant