697 lines
23 KiB
TypeScript
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-');
|
|
});
|
|
});
|