Files
makelore/tests/unit/coding-conversations-store.test.tsx
inman 04320ddfd4 merge: integrate foreground conversation reconciliation
# Conflicts:
#	tests/e2e/pi-coding-first-chat.spec.ts
2026-09-03 10:47:56 +08:00

1350 lines
50 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('rehydrates a selected live Conversation without replaying an accepted mutation', async () => {
const source = new FakeEventSource();
const refresh = deferred<ConversationSnapshot>();
const staleRunning = {
...snapshot('conversation-a', 1, 4),
run: {
status: 'running',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
} as const,
};
const persistedTerminal = {
...snapshot('conversation-a', 1, 5),
run: {
status: 'idle',
runId: 'run-stale',
mode: 'prompt',
startedAt: 1_000,
settledAt: 2_000,
terminalReason: 'aborted',
} as const,
};
const getSnapshot = vi.fn()
.mockResolvedValueOnce(staleRunning)
.mockImplementationOnce(() => refresh.promise);
const openEvents = vi.fn(async () => source as unknown as EventSource);
const submitPrompt = vi.fn();
const store = createCodingConversationStore({
getSnapshot,
openEvents,
submitPrompt,
createId: ids(),
});
await store.getState().selectConversation('conversation-a');
const reselection = store.getState().selectConversation('conversation-a');
expect(getSnapshot).toHaveBeenCalledTimes(2);
expect(openEvents).toHaveBeenCalledTimes(1);
expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({
loadState: 'live',
error: null,
});
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status)
.toBe('running');
refresh.resolve(persistedTerminal);
await reselection;
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run)
.toMatchObject({ status: 'idle', terminalReason: 'aborted' });
expect(submitPrompt).not.toHaveBeenCalled();
});
it('does not let a cancelled connection clear a newer connection flight', async () => {
const first = deferred<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);
});
});