import type { ConversationContentBlock, ConversationMessageNode, ConversationPatch, ConversationSnapshot, ConversationToolNode, PublicUsage, } from '../contracts'; import type { PiRpcEvent } from './rpc-client'; import { subagentDetailsOfResult } from '../subagent-protocol'; import { isProductToolName, productToolDetailsOfResult, } from '../product-tool-protocol'; export interface PiEventProjectorOptions { createId(): string; now?: () => number; projectImage?(image: PiImageProjectionInput): Promise; } export interface PiImageProjectionInput { conversationId: string; data: string; mime: string; source: 'live' | 'session'; } export interface PiProjectedAttachment { attachmentId: string; mime: string; } export interface PiProjectionDiagnostic { eventType: string; reason: 'unsupported-event'; } function retryFailure(message: string) { return { code: 'CODING_RUNTIME_START_FAILED' as const, message, recoverable: true, }; } function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function usageOf(value: unknown): PublicUsage | undefined { const usage = asRecord(value); if (!usage || typeof usage.input !== 'number' || typeof usage.output !== 'number') return undefined; return { inputTokens: usage.input, outputTokens: usage.output, ...(typeof usage.cacheRead === 'number' ? { cacheReadTokens: usage.cacheRead } : {}), ...(typeof usage.cacheWrite === 'number' ? { cacheWriteTokens: usage.cacheWrite } : {}), }; } function stopReasonOf(value: unknown): ConversationMessageNode['stopReason'] | undefined { if (value === 'toolUse') return 'tool-use'; if (value === 'stop' || value === 'length' || value === 'error' || value === 'aborted') { return value; } return undefined; } function currentAssistant(snapshot: ConversationSnapshot): ConversationMessageNode | undefined { return snapshot.nodes.findLast( (node): node is ConversationMessageNode => node.kind === 'message' && node.role === 'assistant' && node.status === 'streaming', ); } function outputBlocks(toolId: string, value: unknown): ConversationContentBlock[] { const result = asRecord(value); if (!result || !Array.isArray(result.content)) return []; return result.content.flatMap((item, contentIndex) => { const block = asRecord(item); if (!block || block.type !== 'text' || typeof block.text !== 'string') return []; return [{ kind: 'text' as const, id: `${toolId}:output:${contentIndex}`, text: block.text, status: 'complete' as const, }]; }); } function projectToolResult( snapshot: ConversationSnapshot, tool: ConversationToolNode, status: ConversationToolNode['status'], value: unknown, ): ConversationPatch[] { const subagentDetails = subagentDetailsOfResult(value); const details = subagentDetails ?? productToolDetailsOfResult(value); const result = asRecord(value); const unknownProductDetails = (tool.toolName === 'subagent' || isProductToolName(tool.toolName)) && result?.details !== undefined && !details; const output = unknownProductDetails ? [{ kind: 'text' as const, id: `${tool.id}:output:unavailable`, text: tool.toolName === 'subagent' ? 'Subagent details are unavailable for this version.' : 'Tool details are unavailable for this version.', status: 'complete' as const, }] : outputBlocks(tool.id, value); const patches: ConversationPatch[] = [{ op: 'tool.upsert', node: { ...tool, status, output, ...(details ? { details } : {}), }, }]; const runId = snapshot.run.runId; if (subagentDetails && runId) { patches.push({ op: 'subagent.upsert', node: { kind: 'subagent', id: `subagent:${subagentDetails.dispatchId}`, runId, details: subagentDetails, }, }); } return patches; } function currentTool(snapshot: ConversationSnapshot, toolCallId: string): ConversationToolNode | undefined { return snapshot.nodes.find( (node): node is ConversationToolNode => node.kind === 'tool' && node.toolCallId === toolCallId, ); } async function liveContentBlocks( messageId: string, content: unknown, conversationId: string, projectImage: PiEventProjectorOptions['projectImage'], ): Promise { const values = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : []; const blocks: ConversationContentBlock[] = []; for (let contentIndex = 0; contentIndex < values.length; contentIndex += 1) { const block = asRecord(values[contentIndex]); if (block?.type === 'text' && typeof block.text === 'string') { blocks.push({ kind: 'text', id: `${messageId}:content:${contentIndex}`, text: block.text, status: 'complete', }); } else if (block?.type === 'thinking' && typeof block.thinking === 'string') { blocks.push({ kind: 'thinking', id: `${messageId}:content:${contentIndex}`, text: block.thinking, status: 'complete', }); } else if (block?.type === 'image' && typeof block.data === 'string' && typeof block.mimeType === 'string' && projectImage) { const attachment = await projectImage({ conversationId, data: block.data, mime: block.mimeType, source: 'live', }); blocks.push({ kind: 'image', id: `${messageId}:content:${contentIndex}`, attachmentId: attachment.attachmentId, mime: attachment.mime, }); } } return blocks; } export class PiEventProjector { private readonly createId: () => string; private readonly now: () => number; private readonly projectImage: PiEventProjectorOptions['projectImage']; private assistantMessageId: string | null = null; private readonly toolDrafts = new Map(); private compactionId: string | null = null; private summarizationRetryBaseRun: ConversationSnapshot['run'] | null = null; private readonly diagnostics: PiProjectionDiagnostic[] = []; constructor(options: PiEventProjectorOptions) { this.createId = options.createId; this.now = options.now ?? Date.now; this.projectImage = options.projectImage; } getDiagnostics(): PiProjectionDiagnostic[] { return structuredClone(this.diagnostics); } async project( snapshot: ConversationSnapshot, event: PiRpcEvent, ): Promise { if (event.type === 'agent_start') { return [{ op: 'run.state', run: { ...snapshot.run, status: 'running', runId: snapshot.run.runId ?? this.createId(), startedAt: snapshot.run.startedAt ?? this.now(), }, }]; } if (event.type === 'turn_start' || event.type === 'turn_end') { const runId = snapshot.run.runId ?? this.createId(); return [{ op: 'boundary.upsert', node: { kind: 'boundary', id: this.createId(), runId, boundary: event.type === 'turn_start' ? 'turn-start' : 'turn-end', }, }]; } if (event.type === 'auto_retry_start' && typeof event.attempt === 'number' && typeof event.delayMs === 'number') { const runId = snapshot.run.runId ?? this.createId(); return [ { op: 'boundary.upsert', node: { kind: 'boundary', id: this.createId(), runId, boundary: 'retry', attempt: event.attempt, delayMs: event.delayMs, }, }, { op: 'run.state', run: { ...snapshot.run, status: 'retrying', runId, retry: { attempt: event.attempt, delayMs: event.delayMs }, }, }, ]; } if (event.type === 'auto_retry_end' && typeof event.success === 'boolean') { const { retry: _retry, ...run } = snapshot.run; if (event.success) { return [{ op: 'run.state', run: { ...run, status: 'running' } }]; } return [{ op: 'run.state', run: { ...run, status: 'error', terminalReason: 'failed', error: retryFailure('The local Agent retry failed'), }, }]; } if (event.type === 'summarization_retry_scheduled' && typeof event.attempt === 'number' && typeof event.delayMs === 'number') { const runId = snapshot.run.runId ?? this.createId(); this.summarizationRetryBaseRun ??= { ...snapshot.run, runId }; return [ { op: 'boundary.upsert', node: { kind: 'boundary', id: this.createId(), runId, boundary: 'retry', attempt: event.attempt, delayMs: event.delayMs, }, }, { op: 'run.state', run: { ...snapshot.run, status: 'retrying', runId, retry: { attempt: event.attempt, delayMs: event.delayMs }, }, }, ]; } if (event.type === 'summarization_retry_attempt_start' && (event.source === 'branchSummary' || event.source === 'compaction')) { const base = this.summarizationRetryBaseRun ?? snapshot.run; const { retry: _retry, ...run } = base; return [ ...(event.source === 'compaction' ? [{ op: 'context.replace' as const, context: { ...snapshot.context, compaction: 'running' as const }, }] : []), { op: 'run.state', run: { ...run, status: event.source === 'compaction' ? 'compacting' : run.status, }, }, ]; } if (event.type === 'summarization_retry_finished') { const base = this.summarizationRetryBaseRun ?? snapshot.run; this.summarizationRetryBaseRun = null; const { retry: _retry, ...run } = base; return [{ op: 'run.state', run }]; } if (event.type === 'compaction_start') { const runId = snapshot.run.runId ?? this.createId(); const id = this.createId(); this.compactionId = id; return [ { op: 'compaction.upsert', node: { kind: 'compaction', id, runId, source: event.reason === 'manual' ? 'manual' : 'automatic', status: 'running', willRetry: false, }, }, { op: 'context.replace', context: { ...snapshot.context, compaction: 'running', lastCompactionId: id }, }, { op: 'run.state', run: { ...snapshot.run, status: 'compacting', runId } }, ]; } if (event.type === 'compaction_end') { const id = this.compactionId ?? this.createId(); const runId = snapshot.run.runId ?? this.createId(); this.compactionId = null; const compactionFailed = typeof event.errorMessage === 'string'; const { retry: _retry, ...run } = snapshot.run; return [ { op: 'compaction.upsert', node: { kind: 'compaction', id, runId, source: event.reason === 'manual' ? 'manual' : 'automatic', status: event.aborted === true || typeof event.errorMessage === 'string' ? 'error' : 'complete', willRetry: event.willRetry === true, }, }, { op: 'context.replace', context: { ...snapshot.context, compaction: 'idle', lastCompactionId: id }, }, { op: 'run.state', run: { ...run, status: event.willRetry === true ? 'running' : compactionFailed ? 'error' : snapshot.run.status, runId, ...(compactionFailed ? { terminalReason: 'failed' as const, error: retryFailure('The local Agent summarization failed'), } : {}), }, }, ]; } if (event.type === 'queue_update' && Array.isArray(event.steering) && Array.isArray(event.followUp)) { const existing = [...snapshot.queue.items]; const item = (mode: 'steer' | 'follow-up', text: string) => { const matchIndex = existing.findIndex((candidate) => candidate.mode === mode && candidate.text === text); if (matchIndex >= 0) return existing.splice(matchIndex, 1)[0]!; const id = this.createId(); return { id, clientRequestId: id, mode, text, attachmentIds: [] }; }; return [{ op: 'queue.replace', queue: { items: [ ...event.steering.filter((value): value is string => typeof value === 'string') .map((text) => item('steer', text)), ...event.followUp.filter((value): value is string => typeof value === 'string') .map((text) => item('follow-up', text)), ], }, }]; } if (event.type === 'agent_settled') { const terminalReason = snapshot.run.status === 'aborting' ? 'aborted' as const : snapshot.run.status === 'error' || snapshot.run.terminalReason === 'failed' ? 'failed' as const : 'completed' as const; this.summarizationRetryBaseRun = null; return [ { op: 'run.state', run: { status: 'idle', ...(snapshot.run.runId ? { runId: snapshot.run.runId } : {}), settledAt: this.now(), terminalReason, ...(terminalReason === 'failed' && snapshot.run.error ? { error: snapshot.run.error } : {}), }, }, { op: 'queue.replace', queue: { items: [] } }, ]; } if (event.type === 'extension_ui_request' && typeof event.id === 'string' && typeof event.method === 'string' && typeof event.title === 'string' && ['select', 'confirm', 'input', 'editor'].includes(event.method)) { const kind = event.method as 'select' | 'confirm' | 'input' | 'editor'; return [{ op: 'interaction.upsert', interaction: { id: event.id, conversationId: snapshot.conversation.id, runId: snapshot.run.runId ?? `interaction:${event.id}`, kind, title: event.title, ...(typeof event.message === 'string' ? { message: event.message } : {}), ...(kind === 'select' && Array.isArray(event.options) ? { options: event.options .filter((option): option is string => typeof option === 'string') .map((label, index) => ({ id: `${event.id}:option:${index}`, label, })), } : {}), status: 'pending', }, }]; } if (event.type === 'message_start') { const message = asRecord(event.message); if (message?.role === 'user') { const optimistic = snapshot.nodes.findLast( (node): node is ConversationMessageNode => node.kind === 'message' && node.role === 'user' && node.status === 'optimistic', ); const id = optimistic?.id ?? this.createId(); return [{ op: 'message.upsert', node: { kind: 'message', id, ...(optimistic?.clientRequestId ? { clientRequestId: optimistic.clientRequestId } : {}), role: 'user', status: 'complete', blocks: await liveContentBlocks( id, message.content, snapshot.conversation.id, this.projectImage, ), }, }]; } if (message?.role !== 'assistant') return []; const id = this.createId(); this.assistantMessageId = id; return [{ op: 'message.upsert', node: { kind: 'message', id, role: 'assistant', status: 'streaming', blocks: [], ...(usageOf(message.usage) ? { usage: usageOf(message.usage) } : {}), }, }]; } if (event.type === 'message_update') { const update = asRecord(event.assistantMessageEvent); const message = currentAssistant(snapshot); if (!update || !message || typeof update.contentIndex !== 'number') return []; const blockId = `${message.id}:content:${update.contentIndex}`; if (update.type === 'text_start') { return [{ op: 'message.upsert', node: { ...message, blocks: [ ...message.blocks.filter((block) => block.id !== blockId), { kind: 'text', id: blockId, text: '', status: 'streaming' }, ], ...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}), }, }]; } if (update.type === 'text_delta' && typeof update.delta === 'string') { return [{ op: 'message.block-delta', messageId: message.id, blockId, delta: update.delta, }]; } if (update.type === 'text_end' && typeof update.content === 'string') { return [{ op: 'message.upsert', node: { ...message, blocks: message.blocks.map((block) => block.id === blockId && block.kind === 'text' ? { ...block, text: update.content as string, status: 'complete' } : block), ...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}), }, }]; } if (update.type === 'thinking_start') { return [{ op: 'message.upsert', node: { ...message, blocks: [ ...message.blocks.filter((block) => block.id !== blockId), { kind: 'thinking', id: blockId, text: '', status: 'streaming' }, ], ...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}), }, }]; } if (update.type === 'thinking_delta' && typeof update.delta === 'string') { return [{ op: 'message.block-delta', messageId: message.id, blockId, delta: update.delta, }]; } if (update.type === 'thinking_end' && typeof update.content === 'string') { return [{ op: 'message.upsert', node: { ...message, blocks: message.blocks.map((block) => block.id === blockId && block.kind === 'thinking' ? { ...block, text: update.content as string, status: 'complete' } : block), ...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}), }, }]; } if (update.type === 'toolcall_start') { const id = this.createId(); this.toolDrafts.set(update.contentIndex, { id, argumentsText: '' }); return [{ op: 'tool.upsert', node: { kind: 'tool', id, toolCallId: `pending:${id}`, toolName: 'tool', title: 'tool', inputText: '', status: 'declared', output: [], }, }]; } if (update.type === 'toolcall_delta' && typeof update.delta === 'string') { const draft = this.toolDrafts.get(update.contentIndex); if (draft) draft.argumentsText += update.delta; return []; } if (update.type === 'toolcall_end') { const toolCall = asRecord(update.toolCall); if (!toolCall || typeof toolCall.id !== 'string' || typeof toolCall.name !== 'string') return []; const draft = this.toolDrafts.get(update.contentIndex) ?? { id: this.createId(), argumentsText: '', }; this.toolDrafts.delete(update.contentIndex); return [{ op: 'tool.upsert', node: { kind: 'tool', id: draft.id, toolCallId: toolCall.id, toolName: toolCall.name, title: toolCall.name, inputText: asRecord(toolCall.arguments) ? JSON.stringify(toolCall.arguments) : draft.argumentsText, status: 'declared', output: [], }, }]; } return []; } if (event.type === 'tool_execution_start') { if (typeof event.toolCallId !== 'string') return []; const tool = currentTool(snapshot, event.toolCallId); if (!tool) return []; return [{ op: 'tool.upsert', node: { ...tool, status: 'running' } }]; } if (event.type === 'tool_execution_update') { if (typeof event.toolCallId !== 'string') return []; const tool = currentTool(snapshot, event.toolCallId); if (!tool) return []; return projectToolResult(snapshot, tool, 'running', event.partialResult); } if (event.type === 'tool_execution_end') { if (typeof event.toolCallId !== 'string') return []; const tool = currentTool(snapshot, event.toolCallId); if (!tool) return []; return projectToolResult( snapshot, tool, event.isError === true ? 'error' : 'complete', event.result, ); } if (event.type === 'message_end') { const message = asRecord(event.message); if (message?.role === 'toolResult' && typeof message.toolCallId === 'string') { const tool = currentTool(snapshot, message.toolCallId); if (!tool) return []; return projectToolResult( snapshot, tool, message.isError === true ? 'error' : 'complete', message, ); } if (message?.role !== 'assistant') return []; const current = currentAssistant(snapshot); const id = current?.id ?? this.assistantMessageId ?? this.createId(); this.assistantMessageId = null; return [{ op: 'message.upsert', node: { kind: 'message', id, role: 'assistant', status: message.stopReason === 'aborted' ? 'aborted' : message.stopReason === 'error' ? 'error' : 'complete', blocks: await liveContentBlocks( id, message.content, snapshot.conversation.id, this.projectImage, ), ...(usageOf(message.usage) ? { usage: usageOf(message.usage) } : {}), ...(stopReasonOf(message.stopReason) ? { stopReason: stopReasonOf(message.stopReason) } : {}), }, }]; } if (event.type !== 'agent_end') { this.diagnostics.push({ eventType: event.type, reason: 'unsupported-event' }); if (this.diagnostics.length > 32) this.diagnostics.shift(); } return []; } }