// @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 stable subagent nodes and bounds unknown detail versions', async () => { const snapshot = await projectPiSessionSnapshot({ snapshot: baseSnapshot(), workerGeneration: 1, state: { sessionId: 'session-a', isStreaming: false, isCompacting: false }, entries: { leafId: 'entry-unknown-result', entries: [ { type: 'message', id: 'entry-assistant', parentId: null, message: { role: 'assistant', stopReason: 'toolUse', usage: { input: 1, output: 1 }, content: [ { type: 'toolCall', id: 'call-known', name: 'subagent', arguments: {} }, { type: 'toolCall', id: 'call-unknown', name: 'subagent', arguments: {} }, ], }, }, { type: 'message', id: 'entry-known-result', parentId: 'entry-assistant', message: { role: 'toolResult', toolCallId: 'call-known', toolName: 'subagent', content: [], details: { schema: 'subagent.v1', dispatchId: 'dispatch-a', mode: 'single', tasks: [{ taskId: 'task-a', agentId: 'agent-a', toolProfile: 'coding', status: 'complete', }], }, }, }, { type: 'message', id: 'entry-unknown-result', parentId: 'entry-known-result', message: { role: 'toolResult', toolCallId: 'call-unknown', toolName: 'subagent', content: [], details: { schema: 'subagent.v9', raw: 'RAW_SESSION_SECRET' }, }, }, ], }, }); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ kind: 'subagent', id: 'subagent:dispatch-a', details: expect.objectContaining({ schema: 'subagent.v1' }), })); expect(JSON.stringify(snapshot)).not.toContain('RAW_SESSION_SECRET'); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ kind: 'tool', toolCallId: 'call-unknown', output: [expect.objectContaining({ text: 'Subagent details are unavailable for this version.' })], })); }); it('hydrates product-tool details and never restores unknown raw details', async () => { const snapshot = await projectPiSessionSnapshot({ snapshot: baseSnapshot(), workerGeneration: 1, state: { sessionId: 'session-a', isStreaming: false, isCompacting: false }, entries: { leafId: 'entry-unknown-result', entries: [ { type: 'message', id: 'entry-assistant', parentId: null, message: { role: 'assistant', stopReason: 'toolUse', usage: { input: 1, output: 1 }, content: [ { type: 'toolCall', id: 'call-known', name: 'changed_file', arguments: {} }, { type: 'toolCall', id: 'call-unknown', name: 'runtime_context', arguments: {} }, ], }, }, { type: 'message', id: 'entry-known-result', parentId: 'entry-assistant', message: { role: 'toolResult', toolCallId: 'call-known', toolName: 'changed_file', content: [], details: { schema: 'changed-file.v1', paths: ['src/example.ts'] }, }, }, { type: 'message', id: 'entry-unknown-result', parentId: 'entry-known-result', message: { role: 'toolResult', toolCallId: 'call-unknown', toolName: 'runtime_context', content: [], details: { schema: 'runtime-context.v9', raw: 'RAW_CONTEXT_SECRET' }, }, }, ], }, }); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ kind: 'tool', toolCallId: 'call-known', details: { schema: 'changed-file.v1', paths: ['src/example.ts'] }, })); expect(JSON.stringify(snapshot)).not.toContain('RAW_CONTEXT_SECRET'); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ kind: 'tool', toolCallId: 'call-unknown', output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })], })); }); 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('preserves seq for same-generation checkpoint hydration', async () => { const live = baseSnapshot(); live.cursor.seq = 7; const snapshot = await projectPiSessionSnapshot({ snapshot: live, workerGeneration: 1, state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false }, entries: { entries: [], leafId: null }, }); expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 }); }); 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); }); });