Files
makelore/tests/unit/pi-event-projector.test.ts

697 lines
23 KiB
TypeScript

// @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';
import {
PI_084_AUTO_RETRY_FAILURE_EVENTS,
PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS,
} from '../fixtures/pi-0.84.2-projector-fixtures';
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('projects only subagent.v1 details and never exposes unknown raw details', async () => {
const projector = new PiEventProjector({ createId: () => 'unused' });
let snapshot = emptySnapshot();
snapshot.nodes.push({
kind: 'tool', id: 'tool-subagent', toolCallId: 'call-subagent', toolName: 'subagent',
title: 'subagent', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_update',
toolCallId: 'call-subagent',
partialResult: {
content: [],
details: {
schema: 'subagent.v1', dispatchId: 'dispatch-a', mode: 'parallel',
tasks: [{
taskId: 'task-a', agentId: 'agent-a', toolProfile: 'read-only',
status: 'complete', summary: 'done', usage: { inputTokens: 3, outputTokens: 5 },
}],
},
},
}));
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'subagent', id: 'subagent:dispatch-a', runId: 'run-a',
details: expect.objectContaining({ schema: 'subagent.v1' }),
}));
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool', id: 'tool-subagent',
details: expect.objectContaining({ dispatchId: 'dispatch-a' }),
}));
snapshot.nodes.push({
kind: 'tool', id: 'tool-unknown', toolCallId: 'call-unknown', toolName: 'subagent',
title: 'subagent', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_end',
toolCallId: 'call-unknown', isError: false,
result: { details: { schema: 'subagent.v2', raw: 'RAW_SECRET_DETAILS' } },
}));
expect(JSON.stringify(snapshot)).not.toContain('RAW_SECRET_DETAILS');
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool', id: 'tool-unknown',
output: [expect.objectContaining({ text: 'Subagent details are unavailable for this version.' })],
}));
});
it('projects versioned product-tool details and suppresses unknown versions', async () => {
const projector = new PiEventProjector({ createId: () => 'unused' });
let snapshot = emptySnapshot();
snapshot.nodes.push({
kind: 'tool', id: 'tool-task-state', toolCallId: 'call-task-state', toolName: 'task_state',
title: 'task_state', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_end',
toolCallId: 'call-task-state',
isError: false,
result: {
content: [{ type: 'text', text: 'done' }],
details: {
schema: 'task-state.v1',
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
},
},
}));
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
id: 'tool-task-state',
details: {
schema: 'task-state.v1',
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
},
}));
snapshot.nodes.push({
kind: 'tool', id: 'tool-browser', toolCallId: 'call-browser', toolName: 'agent_browser',
title: 'agent_browser', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_end',
toolCallId: 'call-browser',
isError: false,
result: {
content: [{ type: 'text', text: 'RAW_BROWSER_OUTPUT' }],
details: { schema: 'agent-browser.v2', attachmentId: 'RAW_ATTACHMENT' },
},
}));
expect(JSON.stringify(snapshot)).not.toContain('RAW_BROWSER_OUTPUT');
expect(JSON.stringify(snapshot)).not.toContain('RAW_ATTACHMENT');
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
id: 'tool-browser',
output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })],
}));
});
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('preserves a redacted failed auto-retry terminal through agent_settled', async () => {
const ids = ['retry-boundary-a'];
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
let snapshot = emptySnapshot();
for (const event of PI_084_AUTO_RETRY_FAILURE_EVENTS) {
snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event)));
}
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: {
code: 'CODING_RUNTIME_START_FAILED',
recoverable: true,
},
});
expect(JSON.stringify(snapshot)).not.toContain('sensitive');
expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({
eventType: 'auto_retry_end',
}));
});
it('projects a non-retryable Works user-context failure as Provider auth required', async () => {
const projector = new PiEventProjector({ createId: () => 'provider-error-a' });
let snapshot = emptySnapshot();
const providerFailure = {
role: 'assistant',
content: [],
stopReason: 'error',
errorMessage: '401: works square AI gateway did not return one-api user context; secret request detail',
timestamp: 10,
};
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'message_end',
message: providerFailure,
}));
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'agent_end',
messages: [providerFailure],
willRetry: false,
}));
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' }));
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: {
code: 'CODING_PROVIDER_AUTH_REQUIRED',
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
recoverable: true,
},
});
expect(JSON.stringify(snapshot)).not.toContain('secret request detail');
});
it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => {
const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' });
let snapshot = emptySnapshot();
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'auto_retry_start',
attempt: 3,
maxAttempts: 3,
delayMs: 1_000,
errorMessage: 'sensitive earlier failure',
}));
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'auto_retry_end',
success: false,
attempt: 3,
finalError: '502: works square AI gateway did not return one-api user context; secret final detail',
}));
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' }));
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: {
code: 'CODING_PROVIDER_AUTH_REQUIRED',
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
recoverable: true,
},
});
expect(JSON.stringify(snapshot)).not.toContain('secret');
});
it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => {
const ids = ['compaction-a', 'summary-retry-boundary-a'];
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
let snapshot = emptySnapshot();
for (const event of PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS) {
snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event)));
if (event.type === 'summarization_retry_scheduled') {
expect(snapshot.run).toMatchObject({
status: 'retrying',
retry: { attempt: 1, delayMs: 500 },
});
}
if (event.type === 'summarization_retry_attempt_start'
|| event.type === 'summarization_retry_finished') {
expect(snapshot.run.status).toBe('compacting');
expect(snapshot.run.retry).toBeUndefined();
}
}
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'boundary',
id: 'summary-retry-boundary-a',
boundary: 'retry',
attempt: 1,
delayMs: 500,
}));
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: { code: 'CODING_RUNTIME_START_FAILED', recoverable: true },
});
expect(JSON.stringify(snapshot)).not.toContain('sensitive');
expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({
eventType: expect.stringMatching(/^summarization_retry_/),
}));
});
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-');
});
});