Files
makelore/tests/unit/coding-conversations-store.test.tsx

525 lines
19 KiB
TypeScript

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<string, Set<EventListenerOrEventListenerObject>>();
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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<ConversationSnapshot>();
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<EventSource>();
const second = deferred<EventSource>();
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<PromptAcceptance>();
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('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(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<typeof store.getState>) => (
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);
});
});