feat: implement PI core chat timeline
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
selectCodingConversationSnapshot,
|
||||
} from '@/stores/coding-conversations';
|
||||
import type {
|
||||
CodingConversationPatchEvent,
|
||||
CodingConversationPatchBatchEvent,
|
||||
CodingConversationSnapshotEvent,
|
||||
ConversationPatch,
|
||||
ConversationSnapshot,
|
||||
@@ -29,7 +29,7 @@ class FakeEventSource {
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: 'snapshot' | 'patch', value: unknown): void {
|
||||
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);
|
||||
@@ -77,14 +77,35 @@ function patchEvent(
|
||||
seq: number,
|
||||
patch: ConversationPatch,
|
||||
workerGeneration = 1,
|
||||
): CodingConversationPatchEvent {
|
||||
): CodingConversationPatchBatchEvent {
|
||||
return {
|
||||
type: 'patch',
|
||||
type: 'patch-batch',
|
||||
conversationId,
|
||||
workerGeneration,
|
||||
seq,
|
||||
at: 1_000 + seq,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,12 +153,12 @@ describe('coding Conversation store', () => {
|
||||
source.emit('snapshot', snapshotEvent(snapshot('conversation-b')));
|
||||
const aBeforeB = selectCodingConversationSnapshot('conversation-a')(store.getState());
|
||||
|
||||
source.emit('patch', patchEvent('conversation-a', 1, {
|
||||
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', patchEvent('conversation-b', 1, {
|
||||
source.emit('patch-batch', patchEvent('conversation-b', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
@@ -176,11 +197,11 @@ describe('coding Conversation store', () => {
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
|
||||
const bBefore = selectCodingConversationSnapshot('conversation-b')(store.getState());
|
||||
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 1, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 1, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running' },
|
||||
}, 0));
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 2, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 2, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running' },
|
||||
}));
|
||||
@@ -205,11 +226,11 @@ describe('coding Conversation store', () => {
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 2, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 2, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running', runId: 'run-gap' },
|
||||
}));
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 3, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 3, {
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'error',
|
||||
@@ -319,7 +340,7 @@ describe('coding Conversation store', () => {
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||
.toMatchObject({ id: 'node-1', clientRequestId: 'request-1', status: 'optimistic' });
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 1, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
@@ -440,6 +461,250 @@ describe('coding Conversation store', () => {
|
||||
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('does not rerender a selected timeline when a hidden Conversation streams', () => {
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
@@ -461,7 +726,7 @@ describe('coding Conversation store', () => {
|
||||
const aNodes = result.current;
|
||||
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 1, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
@@ -476,7 +741,7 @@ describe('coding Conversation store', () => {
|
||||
expect(result.current).toBe(aNodes);
|
||||
const streamingSummary = store.getState().summariesByConversationId['conversation-b'];
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 2, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 2, {
|
||||
op: 'message.block-delta',
|
||||
messageId: 'assistant-b',
|
||||
blockId: 'text-b',
|
||||
@@ -487,7 +752,7 @@ describe('coding Conversation store', () => {
|
||||
expect(renderCount).toBe(initialRenderCount);
|
||||
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 1, {
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
|
||||
Reference in New Issue
Block a user