import { describe, expect, test } from 'bun:test' import type { Message, Part } from '@opencode-ai/sdk/v2/client' 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', () => { expect(shouldFetchSessionForRenderableSync({ hasSession: true, shouldLoadMessages: true, force: false, })).toBe(true) }) test('skips session detail fetch when session and messages are already ready', () => { expect(shouldFetchSessionForRenderableSync({ hasSession: true, shouldLoadMessages: false, force: false, })).toBe(false) }) test('fetches when the session record is missing', () => { expect(shouldFetchSessionForRenderableSync({ hasSession: false, shouldLoadMessages: false, force: false, })).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, created = 1): Message { return { id, sessionID: 'ses_1', role: 'assistant', time: { created } } as Message } function userMessage(id: string, created = 1): Message { return { id, sessionID: 'ses_1', role: 'user', time: { created } } 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 authoritative part order across the part ID rollover', () => { const msg = assistantMessage('msg_1') const legacy = textPart('prt_ffffffffffffLegacy', msg.id) const current = textPart('prt_000000000000Current', msg.id) const result = materializeSessionSnapshots( { message: {}, part: {} }, 'ses_1', [{ info: msg, parts: [legacy, current] }], { skipPartTypes: SKIP_PARTS }, ) expect(result.part[msg.id]).toEqual([legacy, current]) }) 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 chronological in the store, // 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, part: {} as Record } // 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, part: { a_1: [prt] } as Record, } // 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, part: {} as Record, } 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('places a post-rollover optimistic message at the chronological tail', () => { const legacy = userMessage('msg_ffffffffffffLegacy', 100) const current = userMessage('msg_000000000000Current', 200) const page = { session: [legacy], part: [], cursor: undefined, complete: true } const merged = mergeOptimisticPage(page, [{ message: current, parts: [] }]) expect(merged.session).toEqual([legacy, current]) }) 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 }) })