import { act, renderHook, waitFor } from '@testing-library/react'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { useStore } from 'zustand'; import { describe, expect, it, vi } from 'vitest'; import { AppError } from '@/lib/error-model'; import { createCodingConversationStore, selectCodingConversationSnapshot, } from '@/stores/coding-conversations'; import type { CodingConversationPatchEvent, CodingConversationSnapshotEvent, ConversationPatch, ConversationSnapshot, PromptAcceptance, } from '@/types/coding-conversation'; import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures'; class FakeEventSource { onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; readonly close = vi.fn(); private readonly listeners = new Map>(); addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); this.listeners.set(type, listeners); } emit(type: 'snapshot' | 'patch', value: unknown): void { const event = new MessageEvent(type, { data: JSON.stringify(value) }); for (const listener of this.listeners.get(type) ?? []) { if (typeof listener === 'function') listener(event); else listener.handleEvent(event); } } open(): void { this.onopen?.(new Event('open')); } fail(): void { this.onerror?.(new Event('error')); } } function snapshot( conversationId: string, workerGeneration = 1, seq = 0, ): ConversationSnapshot { const value = createProductSnapshot(conversationId, workerGeneration); return { ...value, conversation: { ...value.conversation, title: `Conversation ${conversationId}`, }, cursor: { ...value.cursor, seq }, }; } function snapshotEvent(value: ConversationSnapshot): CodingConversationSnapshotEvent { return { type: 'snapshot', conversationId: value.conversation.id, workerGeneration: value.cursor.workerGeneration, seq: value.cursor.seq, snapshot: value, }; } function patchEvent( conversationId: string, seq: number, patch: ConversationPatch, workerGeneration = 1, ): CodingConversationPatchEvent { return { type: 'patch', conversationId, workerGeneration, seq, at: 1_000 + seq, patch, }; } function acceptance( conversationId: string, clientRequestId: string, ): PromptAcceptance { return { accepted: true, conversationId, clientRequestId, runId: 'run-1', mode: 'prompt', }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function ids() { const counters = { request: 0, node: 0 }; return (kind: 'request' | 'node') => `${kind}-${++counters[kind]}`; } describe('coding Conversation store', () => { it('isolates interleaved snapshot and patch state for two Conversations', async () => { const source = new FakeEventSource(); const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId)); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(async () => source as unknown as EventSource), submitPrompt: vi.fn(), createId: ids(), }); await store.getState().selectConversation('conversation-a'); source.open(); source.emit('snapshot', snapshotEvent(snapshot('conversation-b'))); const aBeforeB = selectCodingConversationSnapshot('conversation-a')(store.getState()); source.emit('patch', patchEvent('conversation-a', 1, { op: 'run.state', run: { status: 'running', runId: 'run-a', mode: 'prompt' }, })); const aAfterOwnPatch = selectCodingConversationSnapshot('conversation-a')(store.getState()); source.emit('patch', patchEvent('conversation-b', 1, { op: 'message.upsert', node: { kind: 'message', id: 'assistant-b', role: 'assistant', status: 'streaming', blocks: [{ kind: 'text', id: 'text-b', text: 'B', status: 'streaming' }], }, })); expect(aBeforeB?.run.status).toBe('idle'); expect(aAfterOwnPatch?.run.status).toBe('running'); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toBe(aAfterOwnPatch); expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes) .toHaveLength(1); expect(store.getState().summariesByConversationId['conversation-a'].unread).toBe(false); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true); expect(getSnapshot).toHaveBeenCalledTimes(1); expect(store.getState().connectionState).toBe('live'); }); it('refreshes only the gapped target and drops an older worker generation', async () => { const recoveredA = snapshot('conversation-a', 1, 2); const getSnapshot = vi.fn(async (conversationId: string) => { expect(conversationId).toBe('conversation-a'); return recoveredA; }); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); const bBefore = selectCodingConversationSnapshot('conversation-b')(store.getState()); store.getState().applyPatchEvent(patchEvent('conversation-b', 1, { op: 'run.state', run: { status: 'running' }, }, 0)); store.getState().applyPatchEvent(patchEvent('conversation-a', 2, { op: 'run.state', run: { status: 'running' }, })); await waitFor(() => { expect(getSnapshot).toHaveBeenCalledTimes(1); expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live'); }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.cursor.seq).toBe(2); expect(selectCodingConversationSnapshot('conversation-b')(store.getState())).toBe(bBefore); expect(store.getState().entriesByConversationId['conversation-b'].reducer.invalidation).toBeNull(); }); it('replays target patches that arrive while a gap snapshot is loading', async () => { const recovery = deferred(); const getSnapshot = vi.fn(() => recovery.promise); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applyPatchEvent(patchEvent('conversation-a', 2, { op: 'run.state', run: { status: 'running', runId: 'run-gap' }, })); store.getState().applyPatchEvent(patchEvent('conversation-a', 3, { op: 'run.state', run: { status: 'error', runId: 'run-gap', terminalReason: 'failed', }, })); recovery.resolve({ ...snapshot('conversation-a', 1, 2), run: { status: 'running', runId: 'run-gap' }, }); await waitFor(() => { expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toMatchObject({ cursor: { workerGeneration: 1, seq: 3 }, run: { status: 'error', runId: 'run-gap', terminalReason: 'failed' }, }); }); expect(getSnapshot).toHaveBeenCalledTimes(1); expect(store.getState().entriesByConversationId['conversation-a'].reducer.invalidation) .toBeNull(); }); it('uses native EventSource reconnect without reopening or replaying a mutation', async () => { const source = new FakeEventSource(); const openEvents = vi.fn(async () => source as unknown as EventSource); const submitPrompt = vi.fn(async (input) => acceptance(input.conversationId, input.clientRequestId)); const store = createCodingConversationStore({ getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)), openEvents, submitPrompt, createId: ids(), }); await store.getState().selectConversation('conversation-a'); store.getState().setDraft('conversation-a', 'one request'); await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' }); source.fail(); expect(store.getState().connectionState).toBe('reconnecting'); source.open(); expect(store.getState().connectionState).toBe('live'); expect(openEvents).toHaveBeenCalledTimes(1); expect(submitPrompt).toHaveBeenCalledTimes(1); }); it('does not let a cancelled connection clear a newer connection flight', async () => { const first = deferred(); const second = deferred(); const firstSource = new FakeEventSource(); const secondSource = new FakeEventSource(); const openEvents = vi.fn() .mockImplementationOnce(() => first.promise) .mockImplementationOnce(() => second.promise); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents, submitPrompt: vi.fn(), createId: ids(), }); const firstConnect = store.getState().connectEvents(); store.getState().disconnectEvents(); const secondConnect = store.getState().connectEvents(); first.resolve(firstSource as unknown as EventSource); await firstConnect; const joinedSecondConnect = store.getState().connectEvents(); expect(openEvents).toHaveBeenCalledTimes(2); expect(firstSource.close).toHaveBeenCalledOnce(); second.resolve(secondSource as unknown as EventSource); await Promise.all([secondConnect, joinedSecondConnect]); expect(openEvents).toHaveBeenCalledTimes(2); }); it('reconciles an accepted optimistic node by clientRequestId without changing its UI id', async () => { const pending = deferred(); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(() => pending.promise), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().setDraft('conversation-a', 'Build it', [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview', }]); const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', }); const optimistic = selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]; expect(optimistic).toMatchObject({ id: 'node-1', clientRequestId: 'request-1', status: 'optimistic' }); expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ text: '', attachments: [], }); pending.resolve(acceptance('conversation-a', 'request-1')); await submission; expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status) .toBe('accepted'); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', clientRequestId: 'request-1', status: 'optimistic' }); store.getState().applyPatchEvent(patchEvent('conversation-a', 1, { op: 'message.upsert', node: { kind: 'message', id: 'durable-user-1', sourceEntryId: 'entry-user-1', clientRequestId: 'request-1', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text-1', text: 'Build it', status: 'complete' }], }, })); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', sourceEntryId: 'entry-user-1', status: 'complete' }); expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); }); it('restores draft attachments and marks the optimistic node on definite rejection', async () => { const submitPrompt = vi.fn(async () => { throw new AppError('RUNTIME', 'Model unavailable', undefined, { backendCode: 'CODING_MODEL_UNAVAILABLE', }); }); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt, createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().setDraft('conversation-a', 'Try again', [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview', }]); await expect(store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', })).rejects.toThrow('Model unavailable'); expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ text: 'Try again', attachments: [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview' }], }); expect(store.getState().requestsByConversationId['conversation-a']['request-1']) .toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', status: 'error' }); }); it('keeps an uncertain optimistic request recoverable without replaying it on reconnect', async () => { const source = new FakeEventSource(); const submitPrompt = vi.fn(async () => { throw new AppError('NETWORK', 'Delivery is uncertain', undefined, { backendCode: 'CODING_REQUEST_UNCERTAIN', }); }); const store = createCodingConversationStore({ getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)), openEvents: vi.fn(async () => source as unknown as EventSource), submitPrompt, createId: ids(), }); await store.getState().selectConversation('conversation-a'); store.getState().setDraft('conversation-a', 'Do not replay'); await expect(store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', })).rejects.toThrow('Delivery is uncertain'); source.fail(); source.open(); source.emit('snapshot', snapshotEvent(snapshot('conversation-a'))); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay'); expect(store.getState().requestsByConversationId['conversation-a']['request-1']) .toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', status: 'optimistic' }); expect(submitPrompt).toHaveBeenCalledTimes(1); }); it('does not rerender a selected timeline when a hidden Conversation streams', () => { const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); const selectANodes = (state: ReturnType) => ( selectCodingConversationSnapshot('conversation-a')(state)?.nodes ?? [] ); let renderCount = 0; const { result } = renderHook(() => { renderCount += 1; return useStore(store, selectANodes); }); const initialRenderCount = renderCount; const aNodes = result.current; act(() => { store.getState().applyPatchEvent(patchEvent('conversation-b', 1, { op: 'message.upsert', node: { kind: 'message', id: 'assistant-b', role: 'assistant', status: 'streaming', blocks: [{ kind: 'text', id: 'text-b', text: 'token', status: 'streaming' }], }, })); }); expect(renderCount).toBe(initialRenderCount); expect(result.current).toBe(aNodes); const streamingSummary = store.getState().summariesByConversationId['conversation-b']; act(() => { store.getState().applyPatchEvent(patchEvent('conversation-b', 2, { op: 'message.block-delta', messageId: 'assistant-b', blockId: 'text-b', delta: ' two', })); }); expect(store.getState().summariesByConversationId['conversation-b']).toBe(streamingSummary); expect(renderCount).toBe(initialRenderCount); act(() => { store.getState().applyPatchEvent(patchEvent('conversation-a', 1, { op: 'message.upsert', node: { kind: 'message', id: 'assistant-a', role: 'assistant', status: 'streaming', blocks: [{ kind: 'text', id: 'text-a', text: 'token', status: 'streaming' }], }, })); }); expect(renderCount).toBe(initialRenderCount + 1); expect(result.current).not.toBe(aNodes); }); it('keeps the Renderer surface vendor-neutral and both runtimes on the same reducer', async () => { const files = [ '../../src/types/coding-conversation.ts', '../../src/lib/coding-conversations.ts', '../../src/stores/coding-conversations.ts', ]; for (const relativePath of files) { const source = await readFile(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); expect(source).not.toMatch(/opencode|(?:import|export)[\s\S]*?from\s+['"][^'"]*pi(?:-|\/)/i); expect(source).not.toMatch(/\bPi[A-Z][A-Za-z]*/); } const [shared, main] = await Promise.all([ import('../../shared/coding-conversation-reducer'), import('../../electron/coding-runtime/conversation-reducer'), ]); expect(main.createConversationReducerState).toBe(shared.createConversationReducerState); expect(main.reduceConversationPatch).toBe(shared.reduceConversationPatch); }); });