import type { SuperagentToolCall } from '../types'; import type { KeyValueItem } from './primitives/KeyValueList'; import { parseToolArgs } from './toolWidgetUtils'; /** * Pure logic backing the entity CRUD widgets. Mirrors the web builder's * entity tools-ui (frontend/apps/builder/.../tools-ui/Components/ * CreateEntities/ReadEntities/UpdateEntities/DeleteEntities.tsx, * utils/entitySummary.ts, ToolCallsContainer.tsx) with the English strings * from the builder's i18n catalog. */ /** Stale-turn note on non-actionable approval cards (web tools.previousApproval). */ export const PREVIOUS_APPROVAL_NOTE = 'This approval request is from a previous message'; /** Web ToolCallsContainer's ENTITY_APPROVAL_TOOLS — tools that batch-approve. */ export const ENTITY_APPROVAL_TOOLS: ReadonlySet = new Set(['update_entities', 'delete_entities']); /** Same predicate the web uses to route a call into the batch approval card. */ export function isPendingEntityApproval(toolCall: SuperagentToolCall): boolean { return ( toolCall.status === 'waiting_for_user_input' && !toolCall.grouped && Boolean(toolCall.id) && ENTITY_APPROVAL_TOOLS.has(toolCall.name || '') ); } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } /** args.query / args.data as a plain object ({} when missing or malformed). */ export function getRecordArg(args: Record | null, key: string): Record { return asRecord(args?.[key]); } export function getEntityName(args: Record | null, fallback = 'entities'): string { const name = args?.entity_name ?? args?.entityName; return typeof name === 'string' && name ? name : fallback; } /** Web KeyValueList formatting: strings JSON-quoted, primitives bare. */ export function formatEntityFieldValue(value: unknown): string { try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } } export function toKeyValueItems(record: Record): KeyValueItem[] { return Object.entries(record).map(([label, value]) => ({ label, value: formatEntityFieldValue(value) })); } /** * One-line description of an update/delete call — the LLM-provided `summary` * arg when present, otherwise the web entitySummary.ts fallbacks. */ export function getEntitySummary(toolName: string, args: Record | null): string { if (!args) return ''; const entityName = getEntityName(args); if (typeof args.summary === 'string' && args.summary) return args.summary; if (toolName === 'update_entities') { const entries = Object.entries(getRecordArg(args, 'data')); if (entries.length === 0) return `Update ${entityName}`; const [fieldName, fieldValue] = entries[0]; const displayValue = typeof fieldValue === 'string' ? `'${fieldValue}'` : formatEntityFieldValue(fieldValue); const truncated = displayValue.length > 25 ? `${displayValue.slice(0, 25)}...` : displayValue; const extra = entries.length - 1; return extra > 0 ? `Set ${fieldName} to ${truncated} (+${extra} more) on ${entityName}` : `Set ${fieldName} to ${truncated} on ${entityName}`; } if (toolName === 'delete_entities') return `Delete ${entityName} records`; return ''; } /** * entity name + record count for create_entity_records. * Prefers the backend-computed display_projection (survives socket truncation, * normalizes entityName/records vs entity_name/data), like the web widget. */ export function getCreateEntitiesInfo(toolCall: SuperagentToolCall): { entityName: string; recordCount: number | null } { const projection = asRecord(toolCall.display_projection); const args = parseToolArgs(toolCall); const entityName = typeof projection.entity_name === 'string' && projection.entity_name ? projection.entity_name : getEntityName(args); const fallbackItems = args?.data ?? args?.records; const recordCount = typeof projection.record_count === 'number' ? projection.record_count : Array.isArray(fallbackItems) ? fallbackItems.length : null; return { entityName, recordCount }; } /** read_entities result count — only when the results parse to a JSON array. */ export function countArrayResults(results: SuperagentToolCall['results']): number | null { if (!results) return null; let parsed: unknown = results; if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { return null; } } return Array.isArray(parsed) ? parsed.length : null; } /** Filter/Limit/Skip/Sort rows for read_entities (limit/skip hidden when falsy, as on web). */ export function getReadEntitiesFilterItems(args: Record | null): KeyValueItem[] { const items: KeyValueItem[] = []; const query = getRecordArg(args, 'query'); if (Object.keys(query).length > 0) items.push({ label: 'Filter', value: JSON.stringify(query) }); const limit = args?.limit; if (typeof limit === 'number' && limit) items.push({ label: 'Limit', value: String(limit) }); const skip = args?.skip; if (typeof skip === 'number' && skip) items.push({ label: 'Skip', value: String(skip) }); const sort = getRecordArg(args, 'sort'); if (Object.keys(sort).length > 0) items.push({ label: 'Sort', value: JSON.stringify(sort) }); return items; } // ── Batch approval ────────────────────────────────────────────────────────── export type BatchApprovalDecision = { toolCallId: string; approve: boolean }; /** * Per-call decisions for "Approve (n)": approve the checked calls, reject the * unchecked ones — the exact behavior of the web useBatchApproval submit. * Approvals go first so the turn resumes only after every call is resolved * (each submit resumes the message once no pending tool calls remain). */ export function buildBatchApprovalDecisions( pendingIds: string[], selectedIds: ReadonlySet, ): BatchApprovalDecision[] { const approved = pendingIds.filter((id) => selectedIds.has(id)); const rejected = pendingIds.filter((id) => !selectedIds.has(id)); return [ ...approved.map((toolCallId) => ({ toolCallId, approve: true })), ...rejected.map((toolCallId) => ({ toolCallId, approve: false })), ]; }