// @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) => { 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) => { 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', mode: 'prompt', startedAt: 10, settledAt: expect.any(Number), 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) => { 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-'); }); });