feat: project Pi conversation events
This commit is contained in:
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