fix(sync): commit first message page before expansion loop (#2084) (#2086)

* fix(sync): commit first message page before expansion loop (#2084)

loadMessages committed the store only after the full expansion sequence
(50→100→150), so the hydrating skeleton stayed for 3 sequential HTTP
round-trips when a session tail had no user message boundary.

Move the store write (materialize + setState) to happen after the first
fetch. The expansion loop now commits each expanded page incrementally
instead of overwriting a page variable and committing once at the end.

The first commit is gated on hasUserMessage(page.session) || page.complete:
if the tail is assistant-only, deferring to the expansion loop keeps the
skeleton (loading state) instead of rendering an empty chat that looks
like a fresh session. Sessions with a user boundary in the first 50
messages get content after a single round-trip.

* fix(sync): address review nits for #2084

- deferred init uses page.session instead of [] so limit reflects the
  real fetched count if the expansion loop is ever a no-op
- both stale branches in commitMessagesToStore return messages: [] for
  consistency
- add isStale guard between expansion fetch and commit for defense-in-depth

* fix(sync): always commit prepend-mode pages to store (#2084)

The deferred init path (assistant-only tail) skipped commitMessagesToStore
entirely when options.before was set — prepend mode. The fetched older
messages were never written to the store, silently dropping them.

Gate the deferral on !options.before: prepend mode always commits because
messages are already rendered (no skeleton to protect) and skipping the
store write would lose the fetched page.

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Leonid
2026-07-11 14:39:49 +03:00
committed by GitHub
co-authored by bashrusakh Bohdan Triapitsyn
parent 37768958f3
commit d8a904954b
2 changed files with 328 additions and 47 deletions
+216 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test'
import type { Message, Part } from '@opencode-ai/sdk/v2/client'
import { shouldFetchSessionForRenderableSync } from './use-sync'
import { shouldFetchSessionForRenderableSync, hasUserMessage } from './use-sync'
import { mergeOptimisticPage } from './optimistic'
import { materializeSessionSnapshots } from './materialization'
describe('shouldFetchSessionForRenderableSync', () => {
test('fetches full session detail when a lightweight list session is opened', () => {
@@ -27,3 +30,215 @@ describe('shouldFetchSessionForRenderableSync', () => {
})).toBe(true)
})
})
// --- loadMessages incremental commit (#2084) ---------------------------------
// loadMessages is a React hook callback and cannot be unit-tested in isolation.
// These tests cover the three invariants the refactor relies on:
// 1. hasUserMessage gates the first store commit (assistant-only tails defer
// to the expansion loop so the skeleton stays instead of an empty chat).
// 2. Incremental materialization of superset pages preserves references for
// already-committed messages (no re-render churn, no reference breaks).
// 3. mergeOptimisticPage + clearOptimistic is idempotent across commits.
function assistantMessage(id: string): Message {
return { id, sessionID: 'ses_1', role: 'assistant', time: { created: 1 } } as Message
}
function userMessage(id: string): Message {
return { id, sessionID: 'ses_1', role: 'user', time: { created: 1 } } as Message
}
function assistantMessageWithClientRole(id: string): Message {
// OpenCode sets clientRole on the wire; role may be absent.
return { id, sessionID: 'ses_1', clientRole: 'user', time: { created: 1 } } as unknown as Message
}
function textPart(id: string, messageID: string): Part {
return { id, messageID, sessionID: 'ses_1', type: 'text', text: id } as Part
}
describe('hasUserMessage', () => {
test('returns true when a user message is present', () => {
expect(hasUserMessage([assistantMessage('m_1'), userMessage('m_2')])).toBe(true)
})
test('returns true when clientRole marks the message as user', () => {
expect(hasUserMessage([assistantMessageWithClientRole('m_1')])).toBe(true)
})
test('returns false when only assistant messages are present', () => {
expect(hasUserMessage([assistantMessage('m_1'), assistantMessage('m_2')])).toBe(false)
})
test('returns false for undefined', () => {
expect(hasUserMessage(undefined)).toBe(false)
})
test('returns false for an empty array', () => {
expect(hasUserMessage([])).toBe(false)
})
})
describe('incremental materialization of superset pages (#2084)', () => {
const SKIP_PARTS = new Set(['patch', 'step-start', 'step-finish'])
test('preserves message references for already-committed messages', () => {
// Simulate expansion: commit 50 (assistant-only), then 100 (with user), then 150.
// Pages are supersets: the 100-page includes all 50 from the first page,
// the 150-page includes all 100 from the second.
// Messages are sorted by id in the store (mergeMessages uses cmp by id),
// so look them up by id rather than positional index.
const a1 = assistantMessage('a_1')
const a2 = assistantMessage('a_2')
const u1 = userMessage('u_1')
const u0 = userMessage('u_0')
const a3 = assistantMessage('a_3')
const page50 = [a1, a2]
const page100 = [u1, a1, a2]
const page150 = [u0, u1, a1, a2, a3]
const partsFor = (msgs: Message[]): { info: Message; parts: Part[] }[] =>
msgs.map((info) => ({
info,
parts: info.role === 'assistant' ? [textPart(`p_${info.id}`, info.id)] : [],
}))
let state = { message: {} as Record<string, Message[]>, part: {} as Record<string, Part[]> }
// Commit 1: assistant-only page (skeleton stays — no user message).
// But materialization itself is valid; we test the reference property here.
const m1 = materializeSessionSnapshots(state, 'ses_1', partsFor(page50), { skipPartTypes: SKIP_PARTS })
state = { message: m1.message, part: m1.part }
const afterFirst = state.message.ses_1
expect(afterFirst.find((m) => m.id === 'a_1')).toBe(a1)
expect(afterFirst.find((m) => m.id === 'a_2')).toBe(a2)
// Commit 2: 100-message superset.
const m2 = materializeSessionSnapshots(state, 'ses_1', partsFor(page100), { skipPartTypes: SKIP_PARTS })
state = { message: m2.message, part: m2.part }
const afterSecond = state.message.ses_1
expect(afterSecond.find((m) => m.id === 'a_1')).toBe(a1)
expect(afterSecond.find((m) => m.id === 'a_2')).toBe(a2)
expect(afterSecond.find((m) => m.id === 'u_1')).toBe(u1)
// Commit 3: 150-message superset.
const m3 = materializeSessionSnapshots(state, 'ses_1', partsFor(page150), { skipPartTypes: SKIP_PARTS })
const afterThird = m3.message.ses_1
// All previously-committed messages keep their references.
expect(afterThird.find((m) => m.id === 'a_1')).toBe(a1)
expect(afterThird.find((m) => m.id === 'a_2')).toBe(a2)
expect(afterThird.find((m) => m.id === 'u_1')).toBe(u1)
// New messages from the 150-page are present.
expect(afterThird.find((m) => m.id === 'u_0')).toBe(u0)
expect(afterThird.find((m) => m.id === 'a_3')).toBe(a3)
})
test('preserves part references for unchanged assistant messages', () => {
const msg = assistantMessage('a_1')
const prt = textPart('p_a_1', 'a_1')
const state = {
message: { ses_1: [msg] } as Record<string, Message[]>,
part: { a_1: [prt] } as Record<string, Part[]>,
}
// Re-materialize with the same message + part (superset adds a new message).
const m = materializeSessionSnapshots(state, 'ses_1', [
{ info: msg, parts: [prt] },
{ info: userMessage('u_1'), parts: [] },
], { skipPartTypes: new Set(['patch', 'step-start', 'step-finish']) })
// The existing part array reference is preserved (equivalent snapshot).
expect(m.part.a_1).toBe(state.part.a_1)
expect(m.partsChanged).toBe(false) // user message has no parts → no part change
})
test('does not create a no-op store update when re-committing the same page', () => {
const msg = userMessage('u_1')
const state = {
message: { ses_1: [msg] } as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
}
const m = materializeSessionSnapshots(state, 'ses_1', [
{ info: msg, parts: [] },
], { skipPartTypes: new Set(['patch', 'step-start', 'step-finish']) })
expect(m.messagesChanged).toBe(false)
expect(m.partsChanged).toBe(false)
expect(m.message).toBe(state.message)
expect(m.part).toBe(state.part)
})
})
describe('mergeOptimisticPage idempotency across commits (#2084)', () => {
test('second call after clearOptimistic returns the page unchanged', () => {
// Simulate: first commit confirmed an optimistic item, clearOptimistic
// removed it; second commit (expansion) finds no optimistic items.
const page = { session: [userMessage('u_1')], part: [{ id: 'u_1', part: [] }], cursor: 'c1', complete: false }
// First merge with one optimistic item.
const optimisticItem = { message: userMessage('opt_1'), parts: [textPart('p_opt_1', 'opt_1')] }
const merged1 = mergeOptimisticPage(page, [optimisticItem])
expect(merged1.confirmed).toEqual([]) // opt_1 is not in page.session → not confirmed
// After clearOptimistic (simulated by passing empty items), merge is a no-op.
const merged2 = mergeOptimisticPage(page, [])
expect(merged2.confirmed).toEqual([])
expect(merged2.session).toEqual(page.session)
expect(merged2.cursor).toBe('c1')
expect(merged2.complete).toBe(false)
})
test('mergeOptimisticPage with empty items is a fast no-op path', () => {
const page = {
session: [assistantMessage('a_1')],
part: [{ id: 'a_1', part: [textPart('p_a_1', 'a_1')] }],
cursor: undefined,
complete: true,
}
const merged = mergeOptimisticPage(page, [])
expect(merged.session).toEqual(page.session)
expect(merged.confirmed).toEqual([])
expect(merged.complete).toBe(true)
})
})
describe('first-commit gating invariant (#2084)', () => {
// The loadMessages refactor gates the first store commit on hasUserMessage.
// When the first page is assistant-only, it defers to the expansion loop
// and keeps the skeleton. This test verifies the gate condition itself:
// hasUserMessage(page.session) must be false for an assistant-only page.
test('assistant-only page does not trigger an early commit', () => {
const assistantOnlyPage = [assistantMessage('a_1'), assistantMessage('a_2')]
expect(hasUserMessage(assistantOnlyPage)).toBe(false)
})
test('page with a user message triggers an early commit', () => {
const pageWithUser = [assistantMessage('a_1'), userMessage('u_1')]
expect(hasUserMessage(pageWithUser)).toBe(true)
})
test('complete page triggers an early commit even without a user message', () => {
// page.complete means there are no older messages to expand into.
// Committing immediately is correct — the skeleton should disappear.
// This mirrors the `hasUserMessage(page.session) || page.complete` gate.
const completeAssistantOnlyPage = [assistantMessage('a_1')]
const isComplete = true
expect(hasUserMessage(completeAssistantOnlyPage) || isComplete).toBe(true)
})
test('prepend mode always commits — deferral does not apply', () => {
// The deferred init path exists to keep the skeleton visible for an
// assistant-only initial fetch. Prepend mode (loading older history)
// has no skeleton to protect and must always write to the store —
// otherwise the fetched older messages would be silently dropped.
//
// This test pins the gate condition: deferral requires `!options.before`.
// An assistant-only, incomplete page in prepend mode must NOT defer.
const assistantOnlyPage = [assistantMessage('a_1'), assistantMessage('a_2')]
const hasBefore = true // prepend mode
const isComplete = false
// The actual gate in loadMessages is:
// const deferFirstCommit = !options?.before && !page.complete && !hasUserMessage(page.session)
const deferFirstCommit = !hasBefore && !isComplete && !hasUserMessage(assistantOnlyPage)
expect(deferFirstCommit).toBe(false) // prepend must not defer
})
})
+112 -46
View File
@@ -127,7 +127,7 @@ function isUserMessage(message: Message): boolean {
return role === "user"
}
function hasUserMessage(messages: Message[] | undefined): boolean {
export function hasUserMessage(messages: Message[] | undefined): boolean {
return Boolean(messages?.some(isUserMessage))
}
@@ -349,75 +349,141 @@ export function useSync() {
setMetaFor(sessionID, { loading: true })
try {
// A resync (no `before`) must fetch at least as many messages as we
// already have on screen. Live events append to the store WITHOUT growing
// m.limit, so reusing the stale m.limit here would under-fetch and make
// the server hand back a spurious "older" cursor — surfacing a phantom
// "load older" button for a session whose full history is already shown
// (e.g. after a reconnect resync following a few new messages).
// Commit a fetched page to the store: merge optimistic items, run
// materialization, and write the result so the UI can render it.
// Returns the committed meta so the caller can update pagination
// state once at the end. The store write happens here (per page) so
// the hydrating skeleton disappears after the first fetch instead of
// waiting for the full expansion sequence.
const commitMessagesToStore = (
page: Awaited<ReturnType<typeof fetchMessages>>,
mode: "replace" | "prepend" | undefined,
isStale?: () => boolean,
) => {
const items = getOptimistic(sessionID)
const merged = mergeOptimisticPage(page, items)
for (const messageID of merged.confirmed) {
clearOptimistic(sessionID, messageID)
}
if (isStale?.()) {
return { messages: [], cursor: merged.cursor, complete: merged.complete }
}
const current = store.getState()
const materialized = materializeSessionSnapshots(
current,
sessionID,
merged.session.map((info) => ({
info,
parts: merged.part.find((item) => item.id === info.id)?.part ?? [],
})),
{ skipPartTypes: SKIP_PARTS, mode: mode === "prepend" ? "prepend" : "merge" },
)
// materializeSessionSnapshots is synchronous today, so this check
// is defense-in-depth: it guards the store write if materialization
// ever becomes async or yields between the check above and setState.
if (isStale?.()) {
return { messages: [], cursor: merged.cursor, complete: merged.complete }
}
if (materialized.messagesChanged || materialized.partsChanged) {
store.setState({
...(materialized.messagesChanged ? { message: materialized.message } : {}),
...(materialized.partsChanged ? { part: materialized.part } : {}),
})
}
return { messages: materialized.messages, cursor: merged.cursor, complete: merged.complete }
}
// Live events can append messages without growing m.limit. A resync
// must cover everything already rendered or it can manufacture an
// "older" cursor for history that is already on screen.
const storeMessageCount = store.getState().message[sessionID]?.length ?? 0
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : Math.max(m.limit, storeMessageCount)
let page = await fetchMessages(sessionID, limit, options?.before)
const limit = options?.before
? HISTORY_MESSAGE_PAGE_SIZE
: Math.max(m.limit, storeMessageCount)
const page = await fetchMessages(sessionID, limit, options?.before)
// Commit the first page to the store immediately so the hydrating
// skeleton disappears after a single round-trip — but only when the
// page already contains a user message boundary. If the tail is
// assistant/tool-only (a very large final turn), committing now would
// drop the skeleton and render an empty chat (turn projection skips
// assistant messages without a user parent), which looks like a fresh
// session instead of a loading state. In that case defer the first
// commit to the expansion loop below, which fetches older records
// until a user boundary appears and commits that page instead.
//
// The deferral only applies to the initial fetch (no `before`).
// Prepend mode (loading older history) always commits — messages are
// already rendered, so there is no skeleton to protect, and skipping
// the store write would drop the fetched older messages entirely.
//
// The deferred fallback carries page.session (not []) so that if the
// expansion loop is ever a no-op (e.g. all nextLimit <= limit after a
// constant change), the final setMetaFor reflects the real fetched
// count instead of overwriting it with 0.
const deferFirstCommit =
!options?.before && !page.complete && !hasUserMessage(page.session)
let committed = deferFirstCommit
? { messages: page.session, cursor: page.cursor, complete: page.complete }
: commitMessagesToStore(page, options?.mode, options?.isStale)
// If the first commit detected a stale session, bail out immediately
// instead of relying on downstream guards to skip the final setMetaFor.
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
// Keep the initial page small for switch performance. Some sessions
// have a very large final turn, so the latest records can
// contain only assistant/tool records and no user boundary. That makes
// turn projection render an empty chat until the user manually loads
// older messages. Expand only this initial tail fetch, with a hard cap.
// Each expanded page is committed to the store incrementally so the
// user sees content as soon as a user boundary appears, instead of
// waiting for the full expansion sequence before the first paint.
if (!options?.before && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of getInitialPageExpansionLimits()) {
if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
const expandedPage = await fetchMessages(sessionID, nextLimit)
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
committed = commitMessagesToStore(expandedPage, options?.mode, options?.isStale)
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
if (expandedPage.complete || hasUserMessage(expandedPage.session)) break
}
}
// Merge optimistic items
const items = getOptimistic(sessionID)
const merged = mergeOptimisticPage(page, items)
for (const messageID of merged.confirmed) {
clearOptimistic(sessionID, messageID)
}
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
const current = store.getState()
const materialized = materializeSessionSnapshots(
current,
sessionID,
merged.session.map((info) => ({
info,
parts: merged.part.find((item) => item.id === info.id)?.part ?? [],
})),
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
)
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
setMetaFor(sessionID, {
limit: materialized.messages.length,
cursor: merged.cursor,
complete: merged.complete,
limit: committed.messages.length,
cursor: committed.cursor,
complete: committed.complete,
loading: false,
})
if (materialized.messagesChanged || materialized.partsChanged) {
store.setState({
...(materialized.messagesChanged ? { message: materialized.message } : {}),
...(materialized.partsChanged ? { part: materialized.part } : {}),
})
}
setSessionPrefetch({
directory,
sessionID,
limit: materialized.messages.length,
cursor: merged.cursor,
complete: merged.complete,
limit: committed.messages.length,
cursor: committed.cursor,
complete: committed.complete,
})
} catch {
setMetaFor(sessionID, { loading: false })