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

@@ -27,7 +27,10 @@ import {
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import type { PromptConversationInput } from '../../electron/coding-runtime/contracts';
import type {
ConversationPatchEnvelope,
PromptConversationInput,
} from '../../electron/coding-runtime/contracts';
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
const roots: string[] = [];
@@ -364,13 +367,26 @@ describe('PI-100 coding core Host contract', () => {
expect(firstPatch).toMatchObject({
done: false,
value: {
type: 'patch',
type: 'patch-batch',
conversationId: conversation.id,
workerGeneration: 0,
seq: 1,
patch: { op: 'message.upsert' },
fromSeq: 1,
},
});
if (firstPatch.done || firstPatch.value.type !== 'patch-batch') {
throw new Error('Expected the first live patch-batch');
}
expect(firstPatch.value.items.length).toBeGreaterThan(1);
expect(firstPatch.value.items[0]).toMatchObject({
seq: 1,
patch: { op: 'message.upsert' },
});
expect(firstPatch.value.items.map((item) => item.seq)).toEqual(
Array.from(
{ length: firstPatch.value.toSeq - firstPatch.value.fromSeq + 1 },
(_, index) => firstPatch.value.fromSeq + index,
),
);
stream.close();
const globalStream = await result.conversations.openEventStream();
@@ -384,11 +400,145 @@ describe('PI-100 coding core Host contract', () => {
});
await expect(globalStream.events[Symbol.asyncIterator]().next()).resolves.toMatchObject({
done: false,
value: { type: 'patch', conversationId: conversation.id },
value: { type: 'patch-batch', conversationId: conversation.id },
});
globalStream.close();
});
it('publishes already-sequenced patches once in a 24 ms delivery batch', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);
const conversations = new CodingConversationService(result.projects, result.runtime, {
deliveryBatchWindowMs: 24,
});
const stream = await conversations.openEventStream(conversation.id);
vi.useFakeTimers();
try {
await conversations.acceptPrompt({
conversationId: conversation.id,
clientRequestId: 'request-batch-window',
mode: 'prompt',
text: 'Batch this',
attachments: [],
});
const nextBatch = stream.events[Symbol.asyncIterator]().next();
let delivered = false;
void nextBatch.then(() => { delivered = true; });
await vi.advanceTimersByTimeAsync(23);
expect(delivered).toBe(false);
await vi.advanceTimersByTimeAsync(1);
const next = await nextBatch;
expect(next.done).toBe(false);
if (next.done || next.value.type !== 'patch-batch') {
throw new Error('Expected a patch-batch');
}
expect(next.value.items.length).toBeGreaterThan(1);
expect(next.value.fromSeq).toBe(next.value.items[0].seq);
expect(next.value.toSeq).toBe(next.value.items.at(-1)?.seq);
expect(new Set(next.value.items.map((item) => item.seq)).size)
.toBe(next.value.items.length);
} finally {
stream.close();
vi.useRealTimers();
}
});
it('flushes a legal single-item batch at the configured item bound', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);
const conversations = new CodingConversationService(result.projects, result.runtime, {
deliveryBatchWindowMs: 33,
deliveryBatchMaxItems: 1,
});
const stream = await conversations.openEventStream(conversation.id);
await conversations.acceptPrompt({
conversationId: conversation.id,
clientRequestId: 'request-single-item-batch',
mode: 'prompt',
text: 'Flush early',
attachments: [],
});
const next = await stream.events[Symbol.asyncIterator]().next();
expect(next).toMatchObject({
done: false,
value: {
type: 'patch-batch',
fromSeq: 1,
toSeq: 1,
items: [{ seq: 1 }],
},
});
stream.close();
});
it('cancels an old pending batch when a new worker generation publishes a snapshot', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);
const initial = await result.conversations.getSnapshot(conversation.id);
const replacement = {
...initial,
worker: { status: 'ready' as const, generation: initial.worker.generation + 1 },
cursor: {
...initial.cursor,
workerGeneration: initial.cursor.workerGeneration + 1,
seq: 1,
},
};
let publish: ((event: ConversationPatchEnvelope) => void) | undefined;
const originalSubscribe = result.runtime.subscribe.bind(result.runtime);
vi.spyOn(result.runtime, 'subscribe').mockImplementation((listener) => {
publish = listener;
return originalSubscribe(listener);
});
vi.spyOn(result.runtime, 'getSnapshot')
.mockResolvedValueOnce(initial)
.mockResolvedValue(replacement);
const conversations = new CodingConversationService(result.projects, result.runtime, {
deliveryBatchWindowMs: 24,
});
const stream = await conversations.openEventStream(conversation.id);
if (!publish) throw new Error('Runtime subscriber was not installed');
vi.useFakeTimers();
try {
publish({
conversationId: conversation.id,
workerGeneration: initial.cursor.workerGeneration,
seq: 1,
at: 1,
patch: { op: 'run.state', run: { status: 'running', runId: 'old-run' } },
});
publish({
conversationId: conversation.id,
workerGeneration: replacement.cursor.workerGeneration,
seq: 1,
at: 2,
patch: { op: 'worker.state', state: replacement.worker },
});
const iterator = stream.events[Symbol.asyncIterator]();
const next = iterator.next();
await vi.advanceTimersByTimeAsync(0);
await expect(next).resolves.toMatchObject({
done: false,
value: {
type: 'snapshot',
workerGeneration: replacement.cursor.workerGeneration,
seq: 1,
},
});
const afterReplacement = iterator.next();
let delivered = false;
void afterReplacement.then(() => { delivered = true; });
await vi.advanceTimersByTimeAsync(24);
expect(delivered).toBe(false);
stream.close();
await expect(afterReplacement).resolves.toEqual({ value: undefined, done: true });
} finally {
stream.close();
vi.useRealTimers();
}
});
it('streams Host SSE snapshot before target patches', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);
@@ -439,7 +589,7 @@ describe('PI-100 coding core Host contract', () => {
attachments: [],
});
const patchEvent = await nextEvent();
expect(patchEvent).toContain('event: patch');
expect(patchEvent).toContain('event: patch-batch');
expect(patchEvent).toContain(`"conversationId":"${conversation.id}"`);
controller.abort();
await reader.cancel().catch(() => undefined);