364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
// @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);
|
|
});
|
|
});
|