feat: project Pi conversation events
This commit is contained in:
1 parent
e79aeffffa
commit
3599064bc4
11 files changed
+2428
-32
No files matched your search
@@ -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-');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user