/** * ACP tool snapshot merge + wire mapping — lifted from Paseo acp-agent.ts patterns. * Stable toolCallId, merge on tool_call_update, status lifecycle for UI + DB. */ import type { ToolCall, ToolCallUpdate, ToolCallStatus, ToolKind } from '@agentclientprotocol/sdk'; export type AcpToolLifecycleStatus = 'running' | 'completed' | 'failed' | 'canceled'; export interface AcpToolSnapshot { toolCallId: string; title: string; kind?: ToolKind | null; status?: ToolCallStatus | null; rawInput?: unknown; rawOutput?: unknown; } export interface AcpWireMeta { status: AcpToolLifecycleStatus; kind?: string | null; title?: string; output?: unknown; error?: string; } function coalesceDefined(next: T | null | undefined, previous: T | null | undefined, fallback: T | null): T | null { if (next !== undefined && next !== null) return next; if (previous !== undefined && previous !== null) return previous; return fallback; } export function mergeToolSnapshot( toolCallId: string, update: ToolCall | ToolCallUpdate, previous?: AcpToolSnapshot, ): AcpToolSnapshot { return { toolCallId, title: update.title ?? previous?.title ?? toolCallId, kind: update.kind ?? previous?.kind ?? null, status: update.status ?? previous?.status ?? null, rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput, rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput, }; } export function mapToolLifecycleStatus( status: ToolCallStatus | null | undefined, rawOutput?: unknown, ): AcpToolLifecycleStatus { if (rawOutput === 'canceled') return 'canceled'; switch (status) { case 'completed': return 'completed'; case 'failed': return 'failed'; case 'pending': case 'in_progress': default: return 'running'; } } function readErrorMessage(rawOutput: unknown): string | undefined { if (typeof rawOutput === 'string' && rawOutput.trim()) return rawOutput; if (rawOutput && typeof rawOutput === 'object' && !Array.isArray(rawOutput)) { const rec = rawOutput as Record; const msg = rec.message ?? rec.error ?? rec.reason; if (typeof msg === 'string' && msg.trim()) return msg; } return undefined; } function asRecord(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } export function snapshotToWireToolCall(snapshot: AcpToolSnapshot): { id: string; name: string; args: Record; } { const lifecycle = mapToolLifecycleStatus(snapshot.status, snapshot.rawOutput); const input = asRecord(snapshot.rawInput); const error = lifecycle === 'failed' ? readErrorMessage(snapshot.rawOutput) : undefined; const meta: AcpWireMeta = { status: lifecycle, kind: snapshot.kind ?? null, title: snapshot.title, ...(snapshot.rawOutput !== undefined ? { output: snapshot.rawOutput } : {}), ...(error ? { error } : {}), }; return { id: snapshot.toolCallId, name: String(snapshot.kind ?? snapshot.title), args: { ...input, _acp: meta }, }; } export function snapshotToPartPayload(snapshot: AcpToolSnapshot): { id: string; name: string; args: Record; } { const wire = snapshotToWireToolCall(snapshot); return { id: wire.id, name: wire.name, args: wire.args }; } export function synthesizeCanceledSnapshots(snapshots: Iterable): AcpToolSnapshot[] { const out: AcpToolSnapshot[] = []; for (const snapshot of snapshots) { if (mapToolLifecycleStatus(snapshot.status) === 'running') { out.push({ ...snapshot, status: 'failed', rawOutput: snapshot.rawOutput ?? 'canceled' }); } } return out; }