feat: project Pi conversation events
This commit is contained in:
589
electron/coding-runtime/pi/event-projector.ts
Normal file
589
electron/coding-runtime/pi/event-projector.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type {
|
||||
ConversationContentBlock,
|
||||
ConversationMessageNode,
|
||||
ConversationPatch,
|
||||
ConversationSnapshot,
|
||||
ConversationToolNode,
|
||||
PublicUsage,
|
||||
} from '../contracts';
|
||||
import type { PiRpcEvent } from './rpc-client';
|
||||
|
||||
export interface PiEventProjectorOptions {
|
||||
createId(): string;
|
||||
now?: () => number;
|
||||
projectImage?(image: PiImageProjectionInput): Promise<PiProjectedAttachment>;
|
||||
}
|
||||
|
||||
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 asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: 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 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<ConversationContentBlock[]> {
|
||||
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<number, { id: string; argumentsText: string }>();
|
||||
private compactionId: string | 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<ConversationPatch[]> {
|
||||
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' && event.success === true) {
|
||||
const { retry: _retry, ...run } = snapshot.run;
|
||||
return [{ op: 'run.state', run: { ...run, status: 'running' } }];
|
||||
}
|
||||
|
||||
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;
|
||||
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: {
|
||||
...snapshot.run,
|
||||
status: event.willRetry === true ? 'running' : snapshot.run.status,
|
||||
runId,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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') {
|
||||
return [
|
||||
{
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'idle',
|
||||
...(snapshot.run.runId ? { runId: snapshot.run.runId } : {}),
|
||||
settledAt: this.now(),
|
||||
terminalReason: snapshot.run.status === 'aborting' ? 'aborted' : 'completed',
|
||||
},
|
||||
},
|
||||
{ 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 [{
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
...tool,
|
||||
status: 'running',
|
||||
output: outputBlocks(tool.id, event.partialResult),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
if (event.type === 'tool_execution_end') {
|
||||
if (typeof event.toolCallId !== 'string') return [];
|
||||
const tool = currentTool(snapshot, event.toolCallId);
|
||||
if (!tool) return [];
|
||||
return [{
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
...tool,
|
||||
status: event.isError === true ? 'error' : 'complete',
|
||||
output: outputBlocks(tool.id, 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 [{
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
...tool,
|
||||
status: message.isError === true ? 'error' : 'complete',
|
||||
output: outputBlocks(tool.id, 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user