feat: project Pi conversation events
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
|
||||
import { PiSessionProjectionError } from '../../electron/coding-runtime/pi/session-projector';
|
||||
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
|
||||
import {
|
||||
PiWorkerPool,
|
||||
@@ -24,18 +25,33 @@ import type {
|
||||
PiRpcRequestOptions,
|
||||
PiRpcResponse,
|
||||
} from '../../electron/coding-runtime/pi/rpc-client';
|
||||
import { PI_084_TEXT_TURN } from '../fixtures/pi-0.84.2-projector-fixtures';
|
||||
|
||||
const roots: string[] = [];
|
||||
const NOW = '2026-08-22T15:00:00.000Z';
|
||||
|
||||
class RuntimeFakeWorker implements PiConversationWorker {
|
||||
readonly requests: PiRpcCommand[] = [];
|
||||
private stateData: unknown;
|
||||
private entriesData: unknown = { entries: [], leafId: null };
|
||||
private statsData: unknown = {
|
||||
contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 },
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
private failType: string | null = null;
|
||||
private readonly responseGates = new Map<string, Promise<void>>();
|
||||
private readonly events = new Set<(event: PiRpcEvent) => void>();
|
||||
private readonly invalidations = new Set<(error: PiProcessError) => void>();
|
||||
|
||||
constructor(readonly id: string, readonly generation: number) {}
|
||||
constructor(readonly id: string, readonly generation: number) {
|
||||
this.stateData = {
|
||||
sessionId: `session-${id}`,
|
||||
thinkingLevel: 'medium',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
command: PiRpcCommand,
|
||||
@@ -48,7 +64,23 @@ class RuntimeFakeWorker implements PiConversationWorker {
|
||||
this.failType = null;
|
||||
throw new Error(`fake ${command.type} rejection`);
|
||||
}
|
||||
return { type: 'response', id: `${this.id}-${this.requests.length}`, success: true };
|
||||
const data = command.type === 'get_state'
|
||||
? this.stateData
|
||||
: command.type === 'get_entries'
|
||||
? this.entriesData
|
||||
: command.type === 'get_session_stats' ? this.statsData : undefined;
|
||||
return {
|
||||
type: 'response',
|
||||
id: `${this.id}-${this.requests.length}`,
|
||||
success: true,
|
||||
...(data === undefined ? {} : { data: structuredClone(data) as T }),
|
||||
};
|
||||
}
|
||||
|
||||
setSessionData(input: { state?: unknown; entries?: unknown; stats?: unknown }): void {
|
||||
if (input.state !== undefined) this.stateData = structuredClone(input.state);
|
||||
if (input.entries !== undefined) this.entriesData = structuredClone(input.entries);
|
||||
if (input.stats !== undefined) this.statsData = structuredClone(input.stats);
|
||||
}
|
||||
|
||||
failNext(type: string): void { this.failType = type; }
|
||||
@@ -124,6 +156,7 @@ describe('Pi Conversation runtime', () => {
|
||||
}));
|
||||
const workers = new Map<string, RuntimeFakeWorker>();
|
||||
const workerHistory = new Map<string, RuntimeFakeWorker[]>();
|
||||
const durableSessions = new Map<string, Parameters<RuntimeFakeWorker['setSessionData']>[0]>();
|
||||
const openInputs: Array<{ conversationId: string; forkSource?: string; sourceEntryId?: string }> = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 4,
|
||||
@@ -136,6 +169,8 @@ describe('Pi Conversation runtime', () => {
|
||||
} : {}),
|
||||
});
|
||||
const worker = new RuntimeFakeWorker(`worker-${conversation.conversationId}-${generation}`, generation);
|
||||
const durable = durableSessions.get(conversation.conversationId);
|
||||
if (durable) worker.setSessionData(durable);
|
||||
workers.set(conversation.conversationId, worker);
|
||||
workerHistory.set(conversation.conversationId, [
|
||||
...(workerHistory.get(conversation.conversationId) ?? []),
|
||||
@@ -181,16 +216,70 @@ describe('Pi Conversation runtime', () => {
|
||||
acceptanceResolved = true;
|
||||
return value;
|
||||
});
|
||||
await expect.poll(() => workers.get(left.id)!.requests.length).toBe(1);
|
||||
await expect.poll(() => workers.get(left.id)!.requests.at(-1)?.type).toBe('prompt');
|
||||
expect(workers.get(left.id)!.requests).toHaveLength(4);
|
||||
expect(acceptanceResolved).toBe(false);
|
||||
releasePromptAcceptance();
|
||||
const accepted = await acceptance;
|
||||
expect(accepted).toMatchObject({ accepted: true, runId: 'run-fixed', mode: 'prompt' });
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('running');
|
||||
for (const event of PI_084_TEXT_TURN.events) {
|
||||
workers.get(left.id)!.emit(structuredClone(event));
|
||||
}
|
||||
const streamed = await runtime.getSnapshot(left.id);
|
||||
expect(streamed.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
id: 'client:request-left',
|
||||
clientRequestId: 'request-left',
|
||||
status: 'complete',
|
||||
}));
|
||||
expect(JSON.stringify(streamed.nodes)).toContain('Implemented');
|
||||
const durable = {
|
||||
state: {
|
||||
sessionId: `session-${left.id}`,
|
||||
thinkingLevel: 'medium',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
},
|
||||
entries: PI_084_TEXT_TURN.entries,
|
||||
stats: PI_084_TEXT_TURN.stats,
|
||||
};
|
||||
durableSessions.set(left.id, durable);
|
||||
workers.get(left.id)!.setSessionData(durable);
|
||||
workers.get(left.id)!.emit({ type: 'agent_end' });
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('running');
|
||||
const checkpoint = await runtime.getSnapshot(left.id);
|
||||
expect(checkpoint.cursor.leafEntryId).toBe('entry-assistant-a');
|
||||
expect(checkpoint.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
id: 'client:request-left',
|
||||
sourceEntryId: 'entry-user-a',
|
||||
}));
|
||||
const releaseSettledHydration = workers.get(left.id)!.holdNext('get_entries');
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
let continuationAccepted = false;
|
||||
const continuation = runtime.followUp({
|
||||
clientRequestId: 'request-after-settled',
|
||||
conversationId: left.id,
|
||||
text: 'Continue only after the checkpoint',
|
||||
attachments: [],
|
||||
}).then((result) => {
|
||||
continuationAccepted = true;
|
||||
return result;
|
||||
});
|
||||
await expect.poll(() => workers.get(left.id)!.requests.filter(
|
||||
({ type }) => type === 'get_entries',
|
||||
).length).toBe(3);
|
||||
expect(workers.get(left.id)!.requests.some(({ type }) => type === 'follow_up')).toBe(false);
|
||||
expect(continuationAccepted).toBe(false);
|
||||
releaseSettledHydration();
|
||||
await expect(continuation).resolves.toMatchObject({ mode: 'follow-up', queuePosition: 1 });
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
const settledNodes = (await runtime.getSnapshot(left.id)).nodes;
|
||||
expect(settledNodes).toEqual(checkpoint.nodes);
|
||||
|
||||
const changed = await runtime.setModel({
|
||||
conversationId: left.id,
|
||||
@@ -204,11 +293,13 @@ describe('Pi Conversation runtime', () => {
|
||||
}));
|
||||
expect(workers.get(left.id)!.requests.map(({ type }) => type).sort()).toEqual([
|
||||
'get_entries',
|
||||
'get_session_stats',
|
||||
'get_state',
|
||||
]);
|
||||
expect(pool.getState(left.id)).toMatchObject({ generation: 2, state: 'ready' });
|
||||
expect(pool.getState(right.id)).toMatchObject({ generation: 1, state: 'ready' });
|
||||
expect((await runtime.getSnapshot(right.id)).conversation.model.model).toEqual(model);
|
||||
expect((await runtime.getSnapshot(left.id)).nodes).toEqual(settledNodes);
|
||||
|
||||
await expect(runtime.setModel({
|
||||
conversationId: left.id,
|
||||
@@ -264,7 +355,11 @@ describe('Pi Conversation runtime', () => {
|
||||
'abort',
|
||||
'abort',
|
||||
]);
|
||||
expect(workers.get(right.id)!.requests).toHaveLength(0);
|
||||
expect(workers.get(right.id)!.requests.map(({ type }) => type)).toEqual([
|
||||
'get_state',
|
||||
'get_entries',
|
||||
'get_session_stats',
|
||||
]);
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
expect((await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
|
||||
@@ -276,22 +371,62 @@ describe('Pi Conversation runtime', () => {
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
const rightDurable = {
|
||||
entries: {
|
||||
leafId: 'entry-right-user',
|
||||
entries: [{
|
||||
type: 'message',
|
||||
id: 'entry-right-user',
|
||||
parentId: null,
|
||||
timestamp: NOW,
|
||||
message: { role: 'user', content: 'Right history', timestamp: 1 },
|
||||
}],
|
||||
},
|
||||
};
|
||||
durableSessions.set(right.id, rightDurable);
|
||||
workers.get(right.id)!.setSessionData(rightDurable);
|
||||
workers.get(right.id)!.emit({ type: 'agent_end' });
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
workers.get(right.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||
|
||||
const leftBeforeFailedRecovery = await runtime.getSnapshot(left.id);
|
||||
const rightBeforeFailedRecovery = await runtime.getSnapshot(right.id);
|
||||
const corruptRightSession = {
|
||||
entries: {
|
||||
leafId: 'entry-corrupt',
|
||||
entries: [{
|
||||
type: 'message',
|
||||
id: 'entry-corrupt',
|
||||
parentId: 'missing-parent',
|
||||
timestamp: NOW,
|
||||
message: { role: 'user', content: 'must not replace history', timestamp: 1 },
|
||||
}],
|
||||
},
|
||||
};
|
||||
durableSessions.set(right.id, corruptRightSession);
|
||||
await expect(runtime.recover(right.id)).rejects.toBeInstanceOf(PiSessionProjectionError);
|
||||
expect((await runtime.getSnapshot(right.id)).nodes).toEqual(rightBeforeFailedRecovery.nodes);
|
||||
expect((await runtime.getSnapshot(right.id)).worker).toMatchObject({
|
||||
status: 'error',
|
||||
generation: 2,
|
||||
error: { code: 'CODING_SESSION_UNREADABLE', recoverable: true },
|
||||
});
|
||||
expect(await runtime.getSnapshot(left.id)).toEqual(leftBeforeFailedRecovery);
|
||||
|
||||
durableSessions.set(right.id, rightDurable);
|
||||
await expect(runtime.recover(right.id)).resolves.toMatchObject({
|
||||
conversationId: right.id,
|
||||
status: 'ready',
|
||||
workerGeneration: 2,
|
||||
workerGeneration: 3,
|
||||
});
|
||||
expect((await runtime.getSnapshot(right.id)).cursor).toMatchObject({
|
||||
workerGeneration: 2,
|
||||
workerGeneration: 3,
|
||||
seq: 0,
|
||||
});
|
||||
expect(workers.get(right.id)!.requests.map(({ type }) => type).sort()).toEqual([
|
||||
'get_entries',
|
||||
'get_session_stats',
|
||||
'get_state',
|
||||
]);
|
||||
expect(pool.getState(left.id)).toMatchObject({ generation: 2, state: 'idle' });
|
||||
|
||||
Reference in New Issue
Block a user