feat: implement PI core chat timeline
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user