1295 lines
48 KiB
TypeScript
1295 lines
48 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 {
|
|
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<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-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<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('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<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().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('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().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('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<ConversationSnapshot>();
|
|
const attachmentUpload = deferred<Array<{
|
|
attachmentId: string;
|
|
mime: string;
|
|
previewUrl: string;
|
|
}>>();
|
|
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<ConversationSnapshot>();
|
|
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<ConversationSnapshot>(() => 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<ConversationSnapshot>();
|
|
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<ConversationSnapshot>();
|
|
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<ConversationSnapshot>();
|
|
const secondRecovery = deferred<ConversationSnapshot>();
|
|
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<ConversationSnapshot>();
|
|
const secondRecovery = deferred<ConversationSnapshot>();
|
|
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<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().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);
|
|
});
|
|
});
|