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 { CodingConversationPatchBatchEvent, 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-batch', 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, ): CodingConversationPatchBatchEvent { return { type: 'patch-batch', conversationId, workerGeneration, fromSeq: seq, toSeq: seq, items: [{ seq, at: 1_000 + seq, patch }], }; } function patchBatch( conversationId: string, fromSeq: number, patches: ConversationPatch[], workerGeneration = 1, ): CodingConversationPatchBatchEvent { const items = patches.map((patch, index) => ({ seq: fromSeq + index, at: 2_000 + fromSeq + index, patch, })); return { type: 'patch-batch', conversationId, workerGeneration, fromSeq, toSeq: items.at(-1)?.seq ?? fromSeq, items, }; } 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('queues observation sync only for a live completed ordinary prompt', () => { const queueSettledSessionSync = vi.fn(); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(), queueSettledSessionSync, createId: ids(), }); const running = { ...snapshot('conversation-a', 1, 3), run: { status: 'running', runId: 'run-a', mode: 'prompt' } as const, }; store.getState().applySnapshotEvent(snapshotEvent(running)); expect(queueSettledSessionSync).not.toHaveBeenCalled(); store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 4, { op: 'run.state', run: { status: 'idle', runId: 'run-a', settledAt: 1_004, terminalReason: 'completed', }, })); expect(queueSettledSessionSync).toHaveBeenCalledTimes(1); expect(queueSettledSessionSync).toHaveBeenCalledWith(expect.objectContaining({ conversation: expect.objectContaining({ id: 'conversation-a', projectId: 'project-a' }), run: expect.objectContaining({ status: 'idle', terminalReason: 'completed' }), cursor: expect.objectContaining({ seq: 4 }), })); }); it('does not queue observation sync for hydration or a follow-up settlement', () => { const queueSettledSessionSync = vi.fn(); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(), queueSettledSessionSync, createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent({ ...snapshot('conversation-a', 1, 3), run: { status: 'idle', runId: 'hydrated-run', settledAt: 1_003, terminalReason: 'completed', }, })); store.getState().applySnapshotEvent(snapshotEvent({ ...snapshot('conversation-b', 1, 3), run: { status: 'running', runId: 'run-b', mode: 'follow-up' }, })); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 4, { op: 'run.state', run: { status: 'idle', runId: 'run-b', settledAt: 1_004, terminalReason: 'completed', }, })); expect(queueSettledSessionSync).not.toHaveBeenCalled(); }); 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-batch', 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-batch', 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(false); expect(getSnapshot).toHaveBeenCalledTimes(1); expect(store.getState().connectionState).toBe('live'); }); it('marks a hidden Conversation unread only when it needs interaction or its task settles', async () => { const source = new FakeEventSource(); const store = createCodingConversationStore({ getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)), openEvents: vi.fn(async () => source as unknown as EventSource), submitPrompt: vi.fn(), createId: ids(), }); await store.getState().selectConversation('conversation-a'); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 1, { op: 'run.state', run: { status: 'running', runId: 'run-b', mode: 'prompt', startedAt: 1_001 }, })); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 2, { op: 'message.upsert', node: { kind: 'message', id: 'assistant-b', role: 'assistant', status: 'streaming', blocks: [{ kind: 'thinking', id: 'thinking-b', text: 'Checking', status: 'streaming' }], }, })); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 3, { op: 'tool.upsert', node: { kind: 'tool', id: 'tool-b', toolCallId: 'tool-call-b', toolName: 'bash', title: 'Run command', inputText: 'false', status: 'error', output: [{ kind: 'text', id: 'tool-output-b', text: 'Command failed', status: 'complete' }], }, })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 4, { op: 'interaction.upsert', interaction: { id: 'interaction-b', conversationId: 'conversation-b', runId: 'run-b', kind: 'confirm', title: 'Allow this action?', status: 'pending', }, })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true); store.getState().markUnread('conversation-b', false); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 5, { op: 'interaction.remove', interactionId: 'interaction-b', })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 6, { op: 'run.state', run: { status: 'idle', runId: 'run-b', mode: 'prompt', startedAt: 1_001, settledAt: 1_006, terminalReason: 'completed', }, })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true); store.getState().markUnread('conversation-b', false); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 7, { op: 'context.replace', context: { usedTokens: 12, contextWindow: 1_024, compaction: 'idle' }, })); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 8, { op: 'run.state', run: { status: 'idle', runId: 'run-b', mode: 'prompt', startedAt: 1_001, settledAt: 1_008, terminalReason: 'completed', }, })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false); }); it.each([ { label: 'failed', run: { status: 'error' as const, runId: 'run-b', settledAt: 1_002, terminalReason: 'failed' as const, error: { code: 'CODING_RUNTIME_PROTOCOL_ERROR' as const, message: 'Task failed', recoverable: true, }, }, }, { label: 'aborted', run: { status: 'idle' as const, runId: 'run-b', settledAt: 1_002, terminalReason: 'aborted' as const, }, }, ])('marks a hidden Conversation unread when its task ends as $label', ({ run }) => { const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent({ ...snapshot('conversation-b', 1, 1), run: { status: 'running', runId: 'run-b', mode: 'prompt', startedAt: 1_001 }, })); store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 2, { op: 'run.state', run, })); expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true); }); 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().applyPatchBatchEvent(patchEvent('conversation-b', 1, { op: 'run.state', run: { status: 'running' }, }, 0)); store.getState().applyPatchBatchEvent(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().applyPatchBatchEvent(patchEvent('conversation-a', 2, { op: 'run.state', run: { status: 'running', runId: 'run-gap' }, })); store.getState().applyPatchBatchEvent(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('rehydrates a selected live Conversation without replaying an accepted mutation', async () => { const source = new FakeEventSource(); const refresh = deferred(); const staleRunning = { ...snapshot('conversation-a', 1, 4), run: { status: 'running', runId: 'run-stale', mode: 'prompt', startedAt: 1_000, } as const, }; const persistedTerminal = { ...snapshot('conversation-a', 1, 5), run: { status: 'idle', runId: 'run-stale', mode: 'prompt', startedAt: 1_000, settledAt: 2_000, terminalReason: 'aborted', } as const, }; const getSnapshot = vi.fn() .mockResolvedValueOnce(staleRunning) .mockImplementationOnce(() => refresh.promise); const openEvents = vi.fn(async () => source as unknown as EventSource); const submitPrompt = vi.fn(); const store = createCodingConversationStore({ getSnapshot, openEvents, submitPrompt, createId: ids(), }); await store.getState().selectConversation('conversation-a'); const reselection = store.getState().selectConversation('conversation-a'); expect(getSnapshot).toHaveBeenCalledTimes(2); expect(openEvents).toHaveBeenCalledTimes(1); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status) .toBe('running'); refresh.resolve(persistedTerminal); await reselection; expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run) .toMatchObject({ status: 'idle', terminalReason: 'aborted' }); expect(submitPrompt).not.toHaveBeenCalled(); }); 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().applyPatchBatchEvent(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.each(['steer', 'follow-up'] as const)('clears %s acceptance even when settlement precedes the response', async (mode) => { const pending = deferred(); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(() => pending.promise), createId: ids(), }); const running = snapshot('conversation-a'); running.run = { status: 'running', mode: 'prompt', runId: 'run-1' }; store.getState().applySnapshotEvent(snapshotEvent(running)); store.getState().setDraft('conversation-a', '排队的消息'); const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode }); const settled = snapshot('conversation-a', 1, 1); settled.run = { status: 'idle', mode: 'prompt', runId: 'run-1', terminalReason: 'completed', settledAt: 1_000 }; store.getState().applySnapshotEvent(snapshotEvent(settled)); pending.resolve({ ...acceptance('conversation-a', 'request-1'), mode }); await submission; expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe(''); }); it('preserves the optimistic UI id when a reconnect snapshot is already durable', async () => { const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(async (input) => acceptance( input.conversationId, input.clientRequestId, )), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().setDraft('conversation-a', 'Already durable'); await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' }); const durable = snapshot('conversation-a', 1, 1); store.getState().applySnapshotEvent(snapshotEvent({ ...durable, nodes: [{ kind: 'message', id: 'durable-user-1', sourceEntryId: 'entry-user-1', clientRequestId: 'request-1', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'durable-text-1', text: 'Already durable', status: 'complete' }], }], })); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', sourceEntryId: 'entry-user-1', clientRequestId: 'request-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() .mockRejectedValueOnce(new AppError('RUNTIME', 'Model unavailable', undefined, { backendCode: 'CODING_MODEL_UNAVAILABLE', })) .mockImplementationOnce(async (input) => acceptance( input.conversationId, input.clientRequestId, )); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt, createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); store.getState().setDraft('conversation-a', 'Try again', [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview', }]); store.getState().setDraft('conversation-b', 'Other conversation draft'); 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(store.getState().entriesByConversationId['conversation-a'].error).toBeNull(); expect(store.getState().entriesByConversationId['conversation-b']).toMatchObject({ loadState: 'live', error: null, }); expect(store.getState().draftsByConversationId['conversation-b'].text) .toBe('Other conversation draft'); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', status: 'error' }); store.getState().setDraft('conversation-a', 'Retry now'); await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' }); expect(submitPrompt).toHaveBeenCalledTimes(2); expect(store.getState().requestsByConversationId['conversation-a']['request-2']) .toMatchObject({ status: 'accepted' }); }); it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => { const recover = vi.fn(async () => undefined); const submitPrompt = vi.fn(async (input) => acceptance( input.conversationId, input.clientRequestId, )); const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt, recover, createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().setDraft('conversation-a', 'Inspect the image'); await expect(store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', prepareAttachments: async () => { throw new AppError('VALIDATION', '图片附件无效。', undefined, { backendCode: 'CODING_ATTACHMENT_INVALID', }); }, })).rejects.toThrow('图片附件无效。'); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); expect(store.getState().draftsByConversationId['conversation-a'].text) .toBe('Inspect the image'); expect(store.getState().requestsByConversationId['conversation-a']['request-1']) .toMatchObject({ status: 'rejected', errorCode: 'CODING_ATTACHMENT_INVALID' }); expect(submitPrompt).not.toHaveBeenCalled(); expect(recover).not.toHaveBeenCalled(); await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', prepareAttachments: async () => [{ attachmentId: 'attachment-valid', mime: 'image/png', previewUrl: 'blob:valid', }], }); expect(submitPrompt).toHaveBeenCalledOnce(); }); 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(); const activeSnapshot = snapshot('conversation-a'); source.emit('snapshot', snapshotEvent({ ...activeSnapshot, run: { status: 'running', runId: 'run-uncertain', mode: 'prompt', startedAt: 1_000, error: { code: 'CODING_REQUEST_UNCERTAIN', message: '请求确认延迟,可能仍在执行。', recoverable: true, }, }, })); 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(store.getState().entriesByConversationId['conversation-a'].error).toBeNull(); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ id: 'node-1', status: 'optimistic' }); expect(submitPrompt).toHaveBeenCalledTimes(1); store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 1, [{ op: 'run.state', run: { status: 'idle', runId: 'run-uncertain', mode: 'prompt', startedAt: 1_000, settledAt: 12_000, terminalReason: 'completed', }, }])); expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay'); expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull(); }); it('keeps a primed first Conversation usable while runtime preparation is still held', async () => { const runtimeSnapshot = deferred(); const attachmentUpload = deferred>(); const submitPrompt = vi.fn(async (input) => acceptance( input.conversationId, input.clientRequestId, )); const store = createCodingConversationStore({ getSnapshot: vi.fn(() => runtimeSnapshot.promise), openEvents: vi.fn(async () => new FakeEventSource() as unknown as EventSource), submitPrompt, createId: ids(), }); store.getState().primeConversation(snapshot('conversation-a', 0)); const selection = store.getState().selectConversation('conversation-a'); store.getState().setDraft('conversation-a', 'Describe this image'); const prepareAttachments = vi.fn(() => attachmentUpload.promise); const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', prepareAttachments, }); expect(store.getState().selectedConversationId).toBe('conversation-a'); expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('loading'); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ role: 'user', status: 'optimistic', blocks: [{ kind: 'text', text: 'Describe this image' }], }); expect(prepareAttachments).toHaveBeenCalledOnce(); expect(submitPrompt).not.toHaveBeenCalled(); attachmentUpload.resolve([{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:local-preview', }]); await submission; expect(submitPrompt).toHaveBeenCalledWith(expect.objectContaining({ text: 'Describe this image', attachments: [{ attachmentId: 'attachment-1' }], })); expect(prepareAttachments).toHaveBeenCalledOnce(); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) .toMatchObject({ blocks: [ { kind: 'text', text: 'Describe this image' }, { kind: 'image', attachmentId: 'attachment-1', mime: 'image/png' }, ], }); runtimeSnapshot.resolve(snapshot('conversation-a', 1)); await selection; }); it('refreshes an existing Conversation silently without surfacing a reconnect state', async () => { const refresh = deferred(); const store = createCodingConversationStore({ getSnapshot: vi.fn(() => refresh.promise), openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a', 1))); const flight = store.getState().loadSnapshot('conversation-a', 'silent'); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); const refreshed = snapshot('conversation-a', 1, 1); refreshed.conversation.model = { model: { accountId: 'account-a', modelId: 'model-b', thinkingLevel: 'low' }, modelResolution: 'resolved', }; refresh.resolve(refreshed); await flight; expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.conversation.model) .toEqual(refreshed.conversation.model); }); it('surfaces a cold preparation deadline and recovers without losing selection or draft', async () => { const getSnapshot = vi.fn() .mockImplementationOnce(() => new Promise(() => undefined)) .mockResolvedValueOnce(snapshot('conversation-a', 1)); const recover = vi.fn(async () => undefined); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(async () => new FakeEventSource() as unknown as EventSource), submitPrompt: vi.fn(), recover, preparationTimeoutMs: 5, createId: ids(), }); store.getState().primeConversation(snapshot('conversation-a', 0)); store.getState().setDraft('conversation-a', 'Keep this draft'); await expect(store.getState().selectConversation('conversation-a')) .rejects.toThrow('本地 Agent 准备超时,请重试。'); expect(store.getState()).toMatchObject({ selectedConversationId: 'conversation-a' }); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'error', error: '本地 Agent 准备超时,请重试。', }); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Keep this draft'); await store.getState().recoverConversation('conversation-a'); expect(recover).toHaveBeenCalledWith('conversation-a'); expect(getSnapshot).toHaveBeenCalledTimes(2); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Keep this draft'); }); it('leaves a failed recover retryable without losing the target Snapshot, selection, or draft', async () => { const initialSnapshot = snapshot('conversation-a', 1, 4); const recoveredSnapshot = snapshot('conversation-a', 2, 5); const recover = vi.fn() .mockRejectedValueOnce(new Error('Provider authentication is required')) .mockResolvedValueOnce(undefined); const getSnapshot = vi.fn() .mockResolvedValueOnce(initialSnapshot) .mockResolvedValueOnce(recoveredSnapshot); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(async () => new FakeEventSource() as unknown as EventSource), submitPrompt: vi.fn(), recover, createId: ids(), }); await store.getState().selectConversation('conversation-a'); store.getState().setDraft('conversation-a', 'Keep retry context', [{ attachmentId: 'attachment-retry', mime: 'image/png', previewUrl: 'blob:retry-preview', }]); const lastGoodSnapshot = selectCodingConversationSnapshot('conversation-a')(store.getState()); await expect(store.getState().recoverConversation('conversation-a')) .rejects.toThrow('Provider authentication is required'); expect(store.getState().selectedConversationId).toBe('conversation-a'); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'error', error: 'Provider authentication is required', }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())).toBe(lastGoodSnapshot); expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ text: 'Keep retry context', attachments: [{ attachmentId: 'attachment-retry', mime: 'image/png', previewUrl: 'blob:retry-preview', }], }); expect(getSnapshot).toHaveBeenCalledTimes(1); await store.getState().recoverConversation('conversation-a'); expect(recover).toHaveBeenCalledTimes(2); expect(getSnapshot).toHaveBeenCalledTimes(2); expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'live', error: null, }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())).toEqual(recoveredSnapshot); expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Keep retry context'); }); it('folds a 100 KB thinking batch in one notification without duplicate deltas', () => { const store = createCodingConversationStore({ getSnapshot: vi.fn(), openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 1, { op: 'message.upsert', node: { kind: 'message', id: 'assistant-a', role: 'assistant', status: 'streaming', blocks: [{ kind: 'thinking', id: 'thinking-a', text: '', status: 'streaming' }], }, })); const chunk = 'x'.repeat(1_024); const batch = patchBatch( 'conversation-a', 2, Array.from({ length: 100 }, () => ({ op: 'message.block-delta' as const, messageId: 'assistant-a', blockId: 'thinking-a', delta: chunk, })), ); let notifications = 0; const unsubscribe = store.subscribe(() => { notifications += 1; }); const startedAt = performance.now(); store.getState().applyPatchBatchEvent(batch); const durationMs = performance.now() - startedAt; unsubscribe(); const finalSnapshot = selectCodingConversationSnapshot('conversation-a')(store.getState()); const message = finalSnapshot?.nodes[0]; expect(message).toMatchObject({ kind: 'message' }); if (!message || message.kind !== 'message') throw new Error('Expected assistant message'); expect(message.blocks[0]).toMatchObject({ kind: 'thinking', text: chunk.repeat(100), }); expect(finalSnapshot?.cursor.seq).toBe(101); expect(notifications).toBe(1); expect(durationMs).toBeLessThan(50); }); it('rejects a malformed batch atomically and recovers only its target Conversation', async () => { const recovery = deferred(); const getSnapshot = vi.fn((conversationId: string) => { expect(conversationId).toBe('conversation-a'); return recovery.promise; }); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); const b = snapshot('conversation-b'); store.getState().applySnapshotEvent(snapshotEvent(b)); const bBefore = selectCodingConversationSnapshot('conversation-b')(store.getState()); const malformed = patchBatch('conversation-a', 1, [ { op: 'run.state', run: { status: 'running', runId: 'run-a' } }, { op: 'run.state', run: { status: 'error', runId: 'run-a' } }, ]); malformed.items[1].seq = 3; malformed.toSeq = 3; store.getState().applyPatchBatchEvent(malformed); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status) .toBe('idle'); expect(selectCodingConversationSnapshot('conversation-b')(store.getState())).toBe(bBefore); expect(getSnapshot).toHaveBeenCalledOnce(); recovery.resolve(snapshot('conversation-a')); await waitFor(() => { expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live'); }); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status) .toBe('idle'); }); it('rejects a patch batch with a non-array item payload before mutating state', () => { const getSnapshot = vi.fn(() => Promise.resolve(snapshot('conversation-a'))); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); const before = selectCodingConversationSnapshot('conversation-a')(store.getState()); store.getState().applyPatchBatchEvent({ type: 'patch-batch', conversationId: 'conversation-a', workerGeneration: 1, fromSeq: 1, toSeq: 1, items: null, } as unknown as CodingConversationPatchBatchEvent); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())).toBe(before); expect(getSnapshot).toHaveBeenCalledOnce(); }); it('trims snapshot-covered recovery items and replays one continuous batch tail', async () => { const recovery = deferred(); const store = createCodingConversationStore({ getSnapshot: vi.fn(() => recovery.promise), openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 3, [ { op: 'run.state', run: { status: 'running', runId: 'run-a' } }, { op: 'context.replace', context: { usedTokens: 10, contextWindow: 100, compaction: 'idle' }, }, ])); store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 5, { op: 'run.state', run: { status: 'error', runId: 'run-a', terminalReason: 'failed' }, })); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.cursor.seq).toBe(0); recovery.resolve({ ...snapshot('conversation-a', 1, 3), run: { status: 'running', runId: 'run-a' }, }); await waitFor(() => { expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toMatchObject({ cursor: { workerGeneration: 1, seq: 5 }, context: { usedTokens: 10, contextWindow: 100 }, run: { status: 'error', runId: 'run-a', terminalReason: 'failed' }, }); }); }); it('ignores a snapshot-covered batch and applies only an overlapping unseen tail', () => { const getSnapshot = vi.fn(() => Promise.resolve(snapshot('conversation-a'))); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent({ ...snapshot('conversation-a', 1, 3), run: { status: 'running', runId: 'run-a' }, })); store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 2, [ { op: 'run.state', run: { status: 'running', runId: 'run-a' } }, { op: 'context.replace', context: { usedTokens: 10, contextWindow: 100, compaction: 'idle' }, }, ])); expect(getSnapshot).not.toHaveBeenCalled(); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toMatchObject({ cursor: { seq: 3 }, context: { usedTokens: 0 } }); store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 3, [ { op: 'context.replace', context: { usedTokens: 10, contextWindow: 100, compaction: 'idle' }, }, { op: 'run.state', run: { status: 'idle', runId: 'run-a', terminalReason: 'completed' }, }, ])); expect(getSnapshot).not.toHaveBeenCalled(); expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toMatchObject({ cursor: { workerGeneration: 1, seq: 4 }, context: { usedTokens: 0 }, run: { status: 'idle', runId: 'run-a', terminalReason: 'completed' }, }); expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live'); }); it('refreshes a target Snapshot once more when the first recovery response is still behind', async () => { const firstRecovery = deferred(); const secondRecovery = deferred(); const getSnapshot = vi.fn() .mockImplementationOnce(() => firstRecovery.promise) .mockImplementationOnce(() => secondRecovery.promise); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 3, [ { op: 'run.state', run: { status: 'running', runId: 'run-a' } }, { op: 'context.replace', context: { usedTokens: 20, contextWindow: 100, compaction: 'idle' }, }, ])); firstRecovery.resolve(snapshot('conversation-a', 1, 1)); await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('recovering'); secondRecovery.resolve(snapshot('conversation-a', 1, 2)); await waitFor(() => { expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) .toMatchObject({ cursor: { workerGeneration: 1, seq: 4 }, context: { usedTokens: 20, contextWindow: 100 }, run: { status: 'running', runId: 'run-a' }, }); }); expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live'); }); it('returns a repeatedly stale recovery Snapshot to a retryable error state', async () => { const firstRecovery = deferred(); const secondRecovery = deferred(); const getSnapshot = vi.fn() .mockImplementationOnce(() => firstRecovery.promise) .mockImplementationOnce(() => secondRecovery.promise); const store = createCodingConversationStore({ getSnapshot, openEvents: vi.fn(), submitPrompt: vi.fn(), createId: ids(), }); store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 3, { op: 'run.state', run: { status: 'running', runId: 'run-a' }, })); firstRecovery.resolve(snapshot('conversation-a', 1, 1)); await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); secondRecovery.resolve(snapshot('conversation-a', 1, 1)); await waitFor(() => { expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ loadState: 'error', error: 'Conversation patch batch is not continuous', }); }); expect(getSnapshot).toHaveBeenCalledTimes(2); }); 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().applyPatchBatchEvent(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().applyPatchBatchEvent(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().applyPatchBatchEvent(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); }); });