488 lines
18 KiB
TypeScript
488 lines
18 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('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);
|
|
});
|
|
});
|