feat: project Pi conversation events
This commit is contained in:
123
tests/fixtures/pi-0.84.2-projector-fixtures.ts
vendored
Normal file
123
tests/fixtures/pi-0.84.2-projector-fixtures.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
// Captured against @earendil-works/pi-coding-agent@0.84.2 RPC/session shapes.
|
||||
export const PI_084_TEXT_TURN = {
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: 'message_start',
|
||||
message: { role: 'user', content: 'Implement the change', timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: 'message_start',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_start', contentIndex: 0 },
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Implemented' },
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: 'Implemented' },
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message_end',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Implemented' }],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
entries: {
|
||||
leafId: 'entry-assistant-a',
|
||||
entries: [
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-user-a',
|
||||
parentId: null,
|
||||
timestamp: '2026-08-23T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'Implement the change', timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-assistant-a',
|
||||
parentId: 'entry-user-a',
|
||||
timestamp: '2026-08-23T00:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Implemented' }],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
contextUsage: { tokens: 18, contextWindow: 100_000, percent: 0.018 },
|
||||
tokens: { input: 12, output: 4, cacheRead: 2, cacheWrite: 0, total: 18 },
|
||||
},
|
||||
} as const;
|
||||
@@ -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' });
|
||||
|
||||
470
tests/unit/pi-event-projector.test.ts
Normal file
470
tests/unit/pi-event-projector.test.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ConversationPatch,
|
||||
ConversationSnapshot,
|
||||
} from '../../electron/coding-runtime/contracts';
|
||||
import {
|
||||
createConversationReducerState,
|
||||
reduceConversationPatch,
|
||||
} from '../../electron/coding-runtime/conversation-reducer';
|
||||
import { PiEventProjector } from '../../electron/coding-runtime/pi/event-projector';
|
||||
|
||||
function emptySnapshot(): ConversationSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: 'conversation-a',
|
||||
projectId: 'project-a',
|
||||
agentId: 'agent-a',
|
||||
title: 'Conversation A',
|
||||
model: {
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
},
|
||||
},
|
||||
nodes: [],
|
||||
run: { status: 'running', runId: 'run-a', mode: 'prompt', startedAt: 10 },
|
||||
queue: { items: [] },
|
||||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function apply(
|
||||
snapshot: ConversationSnapshot,
|
||||
patches: ConversationPatch[],
|
||||
): ConversationSnapshot {
|
||||
let state = createConversationReducerState(snapshot);
|
||||
for (const patch of patches) {
|
||||
state = reduceConversationPatch(state, {
|
||||
conversationId: 'conversation-a',
|
||||
workerGeneration: 1,
|
||||
runId: 'run-a',
|
||||
seq: (state.snapshot?.cursor.seq ?? 0) + 1,
|
||||
at: 20,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
if (!state.snapshot || state.invalidation) throw new Error(state.invalidation?.reason);
|
||||
return state.snapshot;
|
||||
}
|
||||
|
||||
describe('Pi event projector', () => {
|
||||
it('keeps one assistant UI identity while content-index deltas become an authoritative message', async () => {
|
||||
const projector = new PiEventProjector({
|
||||
createId: () => 'assistant-ui-a',
|
||||
});
|
||||
let snapshot = emptySnapshot();
|
||||
const initialAssistant = {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
api: 'openai-completions',
|
||||
provider: 'managed-account-a',
|
||||
model: 'model-a',
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: 'pending',
|
||||
timestamp: 10,
|
||||
};
|
||||
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_start',
|
||||
message: initialAssistant,
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_update',
|
||||
usage: { input: 11, output: 1, cacheRead: 2, cacheWrite: 3, totalTokens: 17 },
|
||||
assistantMessageEvent: { type: 'text_start', contentIndex: 0 },
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_update',
|
||||
usage: { input: 11, output: 2, cacheRead: 2, cacheWrite: 3, totalTokens: 18 },
|
||||
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Hel' },
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_update',
|
||||
usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 3, totalTokens: 19 },
|
||||
assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: 'Hello' },
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_end',
|
||||
message: {
|
||||
...initialAssistant,
|
||||
content: [{ type: 'text', text: 'Hello, world.' }],
|
||||
usage: {
|
||||
input: 11,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 3,
|
||||
totalTokens: 20,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(snapshot.nodes).toEqual([{
|
||||
kind: 'message',
|
||||
id: 'assistant-ui-a',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [{
|
||||
kind: 'text',
|
||||
id: 'assistant-ui-a:content:0',
|
||||
text: 'Hello, world.',
|
||||
status: 'complete',
|
||||
}],
|
||||
usage: {
|
||||
inputTokens: 11,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 2,
|
||||
cacheWriteTokens: 3,
|
||||
},
|
||||
stopReason: 'stop',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('replaces cumulative tool output and attaches camel-case toolResult to one tool card', async () => {
|
||||
const ids = ['assistant-ui-a', 'tool-ui-a'];
|
||||
const projector = new PiEventProjector({
|
||||
createId: () => ids.shift() as string,
|
||||
});
|
||||
let snapshot = emptySnapshot();
|
||||
const project = async (event: Record<string, unknown>) => {
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, event as { type: string }));
|
||||
};
|
||||
const assistant = {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
usage: { input: 5, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: 'pending',
|
||||
timestamp: 10,
|
||||
};
|
||||
|
||||
await project({ type: 'message_start', message: assistant });
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'toolcall_start', contentIndex: 0 },
|
||||
});
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'toolcall_delta', contentIndex: 0, delta: '{"path":' },
|
||||
});
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'toolcall_delta', contentIndex: 0, delta: '"a.txt"}' },
|
||||
});
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: {
|
||||
type: 'toolcall_end',
|
||||
contentIndex: 0,
|
||||
toolCall: { type: 'toolCall', id: 'call-a', name: 'read', arguments: { path: 'a.txt' } },
|
||||
},
|
||||
});
|
||||
await project({
|
||||
type: 'tool_execution_start',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
args: { path: 'a.txt' },
|
||||
});
|
||||
await project({
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
args: { path: 'a.txt' },
|
||||
partialResult: { content: [{ type: 'text', text: 'one' }], details: {} },
|
||||
});
|
||||
await project({
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
args: { path: 'a.txt' },
|
||||
partialResult: { content: [{ type: 'text', text: 'one two' }], details: {} },
|
||||
});
|
||||
await project({
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
result: { content: [{ type: 'text', text: 'final' }], details: {} },
|
||||
isError: false,
|
||||
});
|
||||
await project({
|
||||
type: 'message_end',
|
||||
message: {
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
content: [{ type: 'text', text: 'final authoritative' }],
|
||||
details: {},
|
||||
isError: false,
|
||||
timestamp: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.nodes.filter(({ kind }) => kind === 'tool')).toEqual([{
|
||||
kind: 'tool',
|
||||
id: 'tool-ui-a',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
title: 'read',
|
||||
inputText: '{"path":"a.txt"}',
|
||||
status: 'complete',
|
||||
output: [{
|
||||
kind: 'text',
|
||||
id: 'tool-ui-a:output:0',
|
||||
text: 'final authoritative',
|
||||
status: 'complete',
|
||||
}],
|
||||
}]);
|
||||
expect(snapshot.nodes.filter(({ kind }) => kind === 'message')).toEqual([{
|
||||
kind: 'message',
|
||||
id: 'assistant-ui-a',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [],
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
it('keeps retries and compaction active until agent_settled is authoritative idle', async () => {
|
||||
const ids = ['turn-start-a', 'retry-a', 'compaction-a', 'queue-a', 'queue-b'];
|
||||
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
|
||||
let snapshot = emptySnapshot();
|
||||
const project = async (event: Record<string, unknown>) => {
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, event as { type: string }));
|
||||
};
|
||||
|
||||
await project({ type: 'agent_start' });
|
||||
await project({ type: 'turn_start', turnIndex: 0, timestamp: 10 });
|
||||
await project({
|
||||
type: 'auto_retry_start',
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
delayMs: 250,
|
||||
errorMessage: 'sensitive upstream detail',
|
||||
});
|
||||
expect(snapshot.run).toMatchObject({
|
||||
status: 'retrying',
|
||||
runId: 'run-a',
|
||||
retry: { attempt: 1, delayMs: 250 },
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain('sensitive upstream detail');
|
||||
|
||||
await project({ type: 'auto_retry_end', success: true, attempt: 1 });
|
||||
await project({ type: 'compaction_start', reason: 'threshold' });
|
||||
expect(snapshot.run.status).toBe('compacting');
|
||||
expect(snapshot.context.compaction).toBe('running');
|
||||
await project({
|
||||
type: 'compaction_end',
|
||||
reason: 'threshold',
|
||||
result: { summary: 'hidden summary', firstKeptEntryId: 'entry-kept', tokensBefore: 5000 },
|
||||
aborted: false,
|
||||
willRetry: true,
|
||||
});
|
||||
expect(snapshot.run.status).toBe('running');
|
||||
expect(snapshot.context.compaction).toBe('idle');
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'compaction',
|
||||
id: 'compaction-a',
|
||||
source: 'automatic',
|
||||
status: 'complete',
|
||||
willRetry: true,
|
||||
}));
|
||||
expect(JSON.stringify(snapshot)).not.toContain('hidden summary');
|
||||
|
||||
await project({ type: 'agent_end', messages: [], willRetry: true });
|
||||
expect(snapshot.run.status).toBe('running');
|
||||
await project({
|
||||
type: 'queue_update',
|
||||
steering: ['Steer once'],
|
||||
followUp: ['Follow later'],
|
||||
});
|
||||
expect(snapshot.queue.items.map(({ mode, text }) => ({ mode, text }))).toEqual([
|
||||
{ mode: 'steer', text: 'Steer once' },
|
||||
{ mode: 'follow-up', text: 'Follow later' },
|
||||
]);
|
||||
await project({ type: 'agent_settled' });
|
||||
expect(snapshot.run).toMatchObject({
|
||||
status: 'idle',
|
||||
runId: 'run-a',
|
||||
terminalReason: 'completed',
|
||||
});
|
||||
expect(snapshot.queue.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('reconciles the optimistic user node and projects image bytes through an attachment hook', async () => {
|
||||
const projectedImages: unknown[] = [];
|
||||
const projector = new PiEventProjector({
|
||||
createId: () => 'unexpected-id',
|
||||
projectImage: async (image) => {
|
||||
projectedImages.push(image);
|
||||
return { attachmentId: 'attachment-a', mime: image.mime };
|
||||
},
|
||||
});
|
||||
let snapshot: ConversationSnapshot = {
|
||||
...emptySnapshot(),
|
||||
nodes: [{
|
||||
kind: 'message',
|
||||
id: 'user-ui-a',
|
||||
clientRequestId: 'request-a',
|
||||
role: 'user',
|
||||
status: 'optimistic',
|
||||
blocks: [{
|
||||
kind: 'text',
|
||||
id: 'user-ui-a:content:0',
|
||||
text: 'Inspect this',
|
||||
status: 'complete',
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const rawImage = 'base64-image-bytes-that-must-not-enter-product-state';
|
||||
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Inspect this' },
|
||||
{ type: 'image', data: rawImage, mimeType: 'image/png' },
|
||||
],
|
||||
timestamp: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(projectedImages).toEqual([{
|
||||
conversationId: 'conversation-a',
|
||||
data: rawImage,
|
||||
mime: 'image/png',
|
||||
source: 'live',
|
||||
}]);
|
||||
expect(snapshot.nodes).toEqual([{
|
||||
kind: 'message',
|
||||
id: 'user-ui-a',
|
||||
clientRequestId: 'request-a',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [
|
||||
{
|
||||
kind: 'text',
|
||||
id: 'user-ui-a:content:0',
|
||||
text: 'Inspect this',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
kind: 'image',
|
||||
id: 'user-ui-a:content:1',
|
||||
attachmentId: 'attachment-a',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
}]);
|
||||
expect(JSON.stringify(snapshot)).not.toContain(rawImage);
|
||||
});
|
||||
|
||||
it('assembles thinking by contentIndex and lets message_end replace the draft', async () => {
|
||||
const projector = new PiEventProjector({ createId: () => 'assistant-thinking-a' });
|
||||
let snapshot = emptySnapshot();
|
||||
const assistant = {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
usage: { input: 7, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: 'pending',
|
||||
timestamp: 10,
|
||||
};
|
||||
const project = async (event: Record<string, unknown>) => {
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, event as { type: string }));
|
||||
};
|
||||
|
||||
await project({ type: 'message_start', message: assistant });
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 },
|
||||
});
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'draft' },
|
||||
});
|
||||
await project({
|
||||
type: 'message_update',
|
||||
usage: assistant.usage,
|
||||
assistantMessageEvent: { type: 'thinking_end', contentIndex: 0, content: 'draft done' },
|
||||
});
|
||||
await project({
|
||||
type: 'message_end',
|
||||
message: {
|
||||
...assistant,
|
||||
content: [{ type: 'thinking', thinking: 'authoritative thinking' }],
|
||||
stopReason: 'stop',
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
id: 'assistant-thinking-a',
|
||||
blocks: [{
|
||||
kind: 'thinking',
|
||||
id: 'assistant-thinking-a:content:0',
|
||||
text: 'authoritative thinking',
|
||||
status: 'complete',
|
||||
}],
|
||||
}));
|
||||
});
|
||||
|
||||
it('projects extension dialogs and bounds unknown-event diagnostics without raw details', async () => {
|
||||
const projector = new PiEventProjector({ createId: () => 'unused-id' });
|
||||
let snapshot = emptySnapshot();
|
||||
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'extension_ui_request',
|
||||
id: 'interaction-a',
|
||||
method: 'select',
|
||||
title: 'Choose an action',
|
||||
options: ['Keep', 'Discard'],
|
||||
}));
|
||||
expect(snapshot.pendingInteractions).toEqual([{
|
||||
id: 'interaction-a',
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
kind: 'select',
|
||||
title: 'Choose an action',
|
||||
options: [
|
||||
{ id: 'interaction-a:option:0', label: 'Keep' },
|
||||
{ id: 'interaction-a:option:1', label: 'Discard' },
|
||||
],
|
||||
status: 'pending',
|
||||
}]);
|
||||
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
expect(await projector.project(snapshot, {
|
||||
type: `unknown_${index}`,
|
||||
rawSecret: `secret-${index}`,
|
||||
})).toEqual([]);
|
||||
}
|
||||
const diagnostics = projector.getDiagnostics();
|
||||
expect(diagnostics).toHaveLength(32);
|
||||
expect(diagnostics[0]).toEqual({ eventType: 'unknown_8', reason: 'unsupported-event' });
|
||||
expect(JSON.stringify(diagnostics)).not.toContain('secret-');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,22 @@ class AuthFailureWorker implements PiConversationWorker {
|
||||
if (command.type === 'prompt') {
|
||||
throw new PiProcessError('PI_RPC_RESPONSE_ERROR', '401 provider authentication failed');
|
||||
}
|
||||
return { type: 'response', id: 'fake', success: true };
|
||||
const data = command.type === 'get_state'
|
||||
? { sessionId: `session-${this.id}`, isStreaming: false, isCompacting: false }
|
||||
: command.type === 'get_entries'
|
||||
? { entries: [], leafId: null }
|
||||
: command.type === 'get_session_stats'
|
||||
? {
|
||||
contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 },
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
type: 'response',
|
||||
id: 'fake',
|
||||
success: true,
|
||||
...(data === undefined ? {} : { data: data as T }),
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(_listener: (event: PiRpcEvent) => void): () => void { return () => undefined; }
|
||||
|
||||
363
tests/unit/pi-session-projector.test.ts
Normal file
363
tests/unit/pi-session-projector.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ConversationSnapshot } from '../../electron/coding-runtime/contracts';
|
||||
import {
|
||||
PiSessionProjectionError,
|
||||
projectPiSessionSnapshot,
|
||||
} from '../../electron/coding-runtime/pi/session-projector';
|
||||
|
||||
function baseSnapshot(): ConversationSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: 'conversation-a',
|
||||
projectId: 'project-a',
|
||||
agentId: 'agent-a',
|
||||
title: 'Conversation A',
|
||||
model: {
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
},
|
||||
},
|
||||
nodes: [],
|
||||
run: { status: 'idle' },
|
||||
queue: { items: [] },
|
||||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
||||
|
||||
describe('Pi session projector', () => {
|
||||
it('hydrates only the authoritative active leaf path', async () => {
|
||||
const snapshot = await projectPiSessionSnapshot({
|
||||
snapshot: baseSnapshot(),
|
||||
workerGeneration: 2,
|
||||
state: {
|
||||
sessionId: 'pi-session-a',
|
||||
thinkingLevel: 'medium',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
},
|
||||
entries: {
|
||||
leafId: 'entry-right-user',
|
||||
entries: [
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-root-user',
|
||||
parentId: null,
|
||||
timestamp: '2026-08-23T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'Root question', timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-left-assistant',
|
||||
parentId: 'entry-root-user',
|
||||
timestamp: '2026-08-23T00:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Active answer' }],
|
||||
usage: {
|
||||
input: 4,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 6,
|
||||
cost: zeroCost,
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-abandoned-assistant',
|
||||
parentId: 'entry-root-user',
|
||||
timestamp: '2026-08-23T00:00:02.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Abandoned answer' }],
|
||||
usage: {
|
||||
input: 4,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 6,
|
||||
cost: zeroCost,
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-right-user',
|
||||
parentId: 'entry-left-assistant',
|
||||
timestamp: '2026-08-23T00:00:03.000Z',
|
||||
message: { role: 'user', content: 'Continue active branch', timestamp: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.cursor).toEqual({
|
||||
workerGeneration: 2,
|
||||
seq: 0,
|
||||
leafEntryId: 'entry-right-user',
|
||||
});
|
||||
expect(snapshot.nodes.filter(({ kind }) => kind === 'message')).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'entry:entry-root-user',
|
||||
sourceEntryId: 'entry-root-user',
|
||||
role: 'user',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'entry:entry-left-assistant',
|
||||
sourceEntryId: 'entry-left-assistant',
|
||||
role: 'assistant',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'entry:entry-right-user',
|
||||
sourceEntryId: 'entry-right-user',
|
||||
role: 'user',
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(snapshot)).not.toContain('Abandoned answer');
|
||||
});
|
||||
|
||||
it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => {
|
||||
const live: ConversationSnapshot = {
|
||||
...baseSnapshot(),
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'live-user-a',
|
||||
clientRequestId: 'request-a',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', id: 'live-user-a:content:0', text: 'Kept question', status: 'complete' }],
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'live-assistant-a',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', id: 'live-assistant-a:content:0', text: 'Kept answer', status: 'complete' }],
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
id: 'live-tool-a',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
title: 'read',
|
||||
inputText: '{"path":"a.txt"}',
|
||||
status: 'complete',
|
||||
output: [{ kind: 'text', id: 'live-tool-a:output:0', text: 'final', status: 'complete' }],
|
||||
},
|
||||
{
|
||||
kind: 'compaction',
|
||||
id: 'live-compaction-a',
|
||||
runId: 'run-a',
|
||||
source: 'automatic',
|
||||
status: 'complete',
|
||||
willRetry: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot = await projectPiSessionSnapshot({
|
||||
snapshot: live,
|
||||
workerGeneration: 2,
|
||||
state: {
|
||||
sessionId: 'pi-session-a',
|
||||
thinkingLevel: 'medium',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
},
|
||||
stats: {
|
||||
contextUsage: { tokens: null, contextWindow: 100_000, percent: null },
|
||||
tokens: { input: 20, output: 10, cacheRead: 5, cacheWrite: 0, total: 35 },
|
||||
},
|
||||
entries: {
|
||||
leafId: 'entry-after-user',
|
||||
entries: [
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-old-user',
|
||||
parentId: null,
|
||||
timestamp: '2026-08-23T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'Summarized old question', timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-kept-user',
|
||||
parentId: 'entry-old-user',
|
||||
timestamp: '2026-08-23T00:00:01.000Z',
|
||||
message: { role: 'user', content: 'Kept question', timestamp: 2 },
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-kept-assistant',
|
||||
parentId: 'entry-kept-user',
|
||||
timestamp: '2026-08-23T00:00:02.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Kept answer' },
|
||||
{ type: 'toolCall', id: 'call-a', name: 'read', arguments: { path: 'a.txt' } },
|
||||
],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 4,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 18,
|
||||
cost: zeroCost,
|
||||
},
|
||||
stopReason: 'toolUse',
|
||||
timestamp: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-tool-result',
|
||||
parentId: 'entry-kept-assistant',
|
||||
timestamp: '2026-08-23T00:00:03.000Z',
|
||||
message: {
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-a',
|
||||
toolName: 'read',
|
||||
content: [{ type: 'text', text: 'final authoritative' }],
|
||||
details: {},
|
||||
isError: false,
|
||||
timestamp: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'entry-compaction',
|
||||
parentId: 'entry-tool-result',
|
||||
timestamp: '2026-08-23T00:00:04.000Z',
|
||||
summary: 'summary must stay hidden',
|
||||
firstKeptEntryId: 'entry-kept-user',
|
||||
tokensBefore: 5000,
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 'entry-after-user',
|
||||
parentId: 'entry-compaction',
|
||||
timestamp: '2026-08-23T00:00:05.000Z',
|
||||
message: { role: 'user', content: 'After compaction', timestamp: 5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.nodes.map(({ kind, id }) => ({ kind, id }))).toEqual([
|
||||
{ kind: 'compaction', id: 'live-compaction-a' },
|
||||
{ kind: 'message', id: 'live-user-a' },
|
||||
{ kind: 'message', id: 'live-assistant-a' },
|
||||
{ kind: 'tool', id: 'live-tool-a' },
|
||||
{ kind: 'message', id: 'entry:entry-after-user' },
|
||||
]);
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
id: 'live-user-a',
|
||||
sourceEntryId: 'entry-kept-user',
|
||||
clientRequestId: 'request-a',
|
||||
}));
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
id: 'live-assistant-a',
|
||||
sourceEntryId: 'entry-kept-assistant',
|
||||
}));
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'tool',
|
||||
id: 'live-tool-a',
|
||||
toolCallId: 'call-a',
|
||||
status: 'complete',
|
||||
output: [expect.objectContaining({ text: 'final authoritative' })],
|
||||
}));
|
||||
expect(snapshot.context).toEqual({
|
||||
usedTokens: 0,
|
||||
contextWindow: 100_000,
|
||||
compaction: 'idle',
|
||||
recalculating: true,
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain('Summarized old question');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('summary must stay hidden');
|
||||
});
|
||||
|
||||
it('projects persisted images through attachment storage without retaining base64', async () => {
|
||||
const rawImage = 'A'.repeat(1024 * 1024);
|
||||
const projected = await projectPiSessionSnapshot({
|
||||
snapshot: baseSnapshot(),
|
||||
workerGeneration: 2,
|
||||
state: {
|
||||
sessionId: 'pi-session-a',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
},
|
||||
entries: {
|
||||
leafId: 'entry-image',
|
||||
entries: [{
|
||||
type: 'message',
|
||||
id: 'entry-image',
|
||||
parentId: null,
|
||||
timestamp: '2026-08-23T00:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'image', data: rawImage, mimeType: 'image/png' }],
|
||||
timestamp: 1,
|
||||
},
|
||||
}],
|
||||
},
|
||||
projectImage: async (image) => {
|
||||
expect(image).toMatchObject({
|
||||
conversationId: 'conversation-a',
|
||||
mime: 'image/png',
|
||||
source: 'session',
|
||||
});
|
||||
expect(image.data).toBe(rawImage);
|
||||
return { attachmentId: 'attachment-session-a', mime: image.mime };
|
||||
},
|
||||
});
|
||||
|
||||
expect(projected.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'message',
|
||||
blocks: [{
|
||||
kind: 'image',
|
||||
id: 'entry:entry-image:content:0',
|
||||
attachmentId: 'attachment-session-a',
|
||||
mime: 'image/png',
|
||||
}],
|
||||
}));
|
||||
expect(JSON.stringify(projected)).not.toContain(rawImage);
|
||||
});
|
||||
|
||||
it('fails closed on an unreadable active path without mutating the last good snapshot', async () => {
|
||||
const lastGood = baseSnapshot();
|
||||
const before = structuredClone(lastGood);
|
||||
await expect(projectPiSessionSnapshot({
|
||||
snapshot: lastGood,
|
||||
workerGeneration: 2,
|
||||
state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false },
|
||||
entries: {
|
||||
leafId: 'entry-missing-parent',
|
||||
entries: [{
|
||||
type: 'message',
|
||||
id: 'entry-missing-parent',
|
||||
parentId: 'not-present',
|
||||
timestamp: '2026-08-23T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'Unreadable', timestamp: 1 },
|
||||
}],
|
||||
},
|
||||
})).rejects.toBeInstanceOf(PiSessionProjectionError);
|
||||
expect(lastGood).toEqual(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user