feat: implement PI core chat timeline

This commit is contained in:
2026-08-24 00:34:03 +08:00
parent 54d8bb2e4a
commit 612135f911
40 changed files with 3881 additions and 119 deletions

View File

@@ -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',