Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
976 lines
36 KiB
TypeScript
976 lines
36 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(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().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(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 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();
|
|
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('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('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('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('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);
|
|
});
|
|
});
|