import type { SuperagentToolCall } from '../types'; /** * Pure logic backing the built-in tool widgets (src/toolWidgets) and the tool * timeline's argument previews (ToolCallSummary.tsx). Mirrors the * web builder's tools-ui helpers (frontend/apps/builder/.../tools-ui/utils.ts) * so the native widgets read the same statuses, labels and arguments as their * web counterparts. No RN imports here — everything is unit-testable in the * node environment. */ // ── Status ───────────────────────────────────────────────────────────────── export type ToolWidgetStatus = 'running' | 'success' | 'error' | 'stopped' | 'waiting'; export function getWidgetStatus(status?: string): ToolWidgetStatus { const normalized = (status || '').toLowerCase(); if (normalized === 'running' || normalized === 'in_progress' || normalized === 'pending') return 'running'; if (normalized.includes('waiting')) return 'waiting'; if (normalized === 'error' || normalized === 'failed' || normalized === 'rejected') return 'error'; if (normalized === 'stopped') return 'stopped'; return 'success'; } /** * Widget status with the web card widgets' staleness correction * (CreateAutomationWidget / CreateWorkflowWidget / AgentBlueprint): a * running/pending status with results already present — or on a message that * is no longer the latest assistant turn (`isLastAssistantMessage === false`, * the turn ended without a status update) — means the call finished but the * status update was lost. Treat it as success so the card renders instead of * a stuck spinner. `undefined` chat position settles on results only * (backward compatible for hosts that don't supply it). */ export function getSettledWidgetStatus( toolCall: SuperagentToolCall, isLastAssistantMessage?: boolean, ): ToolWidgetStatus { const status = getWidgetStatus(toolCall.status); if (status !== 'running') return status; const hasResults = toolCall.results != null && toolCall.results !== ''; if (hasResults || isLastAssistantMessage === false) return 'success'; return status; } /** * Whether an approval-capable widget may act for its chat position. Only an * explicit `false` (an older assistant turn — the submit endpoint resumes by * tool_call_id, so a stale card could resume out of order) blocks acting; * `undefined` means the host didn't supply chat position and is treated as * actionable for backward compatibility (web isLastAssistantMessage parity). */ export function isActionableTurn(isLastAssistantMessage?: boolean): boolean { return isLastAssistantMessage !== false; } // ── Grouping ─────────────────────────────────────────────────────────────── /** * Raw combined status for a synthetic grouped call — port of the web * computeGroupStatus (tool-call-preprocessing.ts): waiting/running win * immediately, then error beats success. Stays in the raw backend vocabulary * so downstream consumers (approval detection, getWidgetStatus) keep working. */ export function computeGroupStatus(calls: SuperagentToolCall[]): string { let status = 'pending'; for (const call of calls) { if (call.status === 'waiting_for_user_input') return 'waiting_for_user_input'; if (call.status === 'running') return 'running'; if (call.status === 'error' || call.status === 'failed') status = 'error'; else if (call.status === 'success' && status !== 'error') status = 'success'; } return status; } /** * Collapse runs of *consecutive* tool calls whose name is in `groupableNames` * into one synthetic `grouped: true` call (children under `toolCalls`), * preserving the order of everything else — the native mirror of the web's * groupConsecutive preprocessing (used there for grep and generated-image * batches). Grouped calls flow through flattenToolCall, so the timeline and * approval handling see the individual children unchanged. */ export function groupConsecutiveToolCalls( toolCalls: SuperagentToolCall[], groupableNames: ReadonlySet, ): SuperagentToolCall[] { const result: SuperagentToolCall[] = []; let buffer: SuperagentToolCall[] = []; const flush = () => { if (buffer.length === 0) return; result.push({ // Keyed off the first child so React keys stay stable as the run grows. id: buffer[0].id != null ? `group-${buffer[0].id}` : undefined, name: buffer[0].name, grouped: true, toolCalls: buffer, status: computeGroupStatus(buffer), }); buffer = []; }; for (const toolCall of toolCalls) { // Already-grouped calls (host preprocessing) pass through untouched. if (!toolCall.grouped && groupableNames.has(toolCall.name)) { if (buffer.length > 0 && buffer[0].name !== toolCall.name) flush(); buffer.push(toolCall); } else { flush(); result.push(toolCall); } } flush(); return result; } // ── Arguments ────────────────────────────────────────────────────────────── /** Parsed tool arguments (arguments_string wins, matching the web parser). */ export function parseToolArgs(toolCall: SuperagentToolCall): Record | null { const raw = toolCall.arguments_string ?? toolCall.arguments ?? null; if (!raw) return null; if (typeof raw !== 'string') return raw as Record; return parseArgsString(raw) ?? parseTruncatedArgsString(raw); } function parseArgsString(raw: string): Record | null { try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? (parsed as Record) : null; } catch { return null; } } /** * Live socket emits truncate big tool args (file tools are rewritten to * `{"file_path": …}` with a literal `...` suffix — see the backend's * _truncate_arguments_string_for_socket). Strip that suffix and retry so the * file-path chip survives the live turn; the full args arrive on the post-turn * REST refresh. */ function parseTruncatedArgsString(raw: string): Record | null { return raw.endsWith('...') ? parseArgsString(raw.slice(0, -3)) : null; } export function getStringArg(args: Record | null, keys: string[]): string { if (!args) return ''; for (const key of keys) { const value = args[key]; if (typeof value === 'string' && value.trim()) return value; } return ''; } /** File path for file-op tools — same fallbacks as the web widgets. */ export function getToolFilePath(args: Record | null): string { return getStringArg(args, ['file_path', 'target_file', 'path']).trim(); } export function truncateSingleLine(value: string, maxLength: number): string | null { const singleLine = value.replace(/\s+/g, ' ').trim(); if (!singleLine) return null; return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength - 3)}...` : singleLine; } // ── Line diff ────────────────────────────────────────────────────────────── export type DiffLineType = 'added' | 'removed' | 'context'; export type DiffLine = { text: string; type: DiffLineType; }; // Keep the diff bounded: more lines than this would be unreadable in chat // anyway; excess lines collapse into a summary row. const MAX_DIFF_LINES = 300; // Also bound each line: a single minified/base64 line can be megabytes, and // DiffView renders each line as one in a horizontal ScrollView. const MAX_DIFF_LINE_CHARS = 500; function clampDiffLine(text: string): string { if (text.length <= MAX_DIFF_LINE_CHARS) return text; return `${text.slice(0, MAX_DIFF_LINE_CHARS)} … (+${text.length - MAX_DIFF_LINE_CHARS} chars)`; } function splitLines(text: string): string[] { if (!text) return []; return text.replace(/\n$/, '').split('\n'); } function truncateLines(lines: string[]): { lines: string[]; truncated: number } { if (lines.length <= MAX_DIFF_LINES) return { lines, truncated: 0 }; return { lines: lines.slice(0, MAX_DIFF_LINES), truncated: lines.length - MAX_DIFF_LINES }; } /** All-added diff for freshly written content (write_file has no old text). */ export function diffLinesFromNew(newText: string): DiffLine[] { const { lines, truncated } = truncateLines(splitLines(newText)); const out: DiffLine[] = lines.map((text) => ({ text: clampDiffLine(text), type: 'added' as const })); if (truncated > 0) out.push({ text: `… ${truncated} more lines`, type: 'context' }); return out; } // ── Registry resolution ──────────────────────────────────────────────────── /** * Exact-name registry lookup with ordered fallback matchers, for tool families * that can't be exact-name-keyed (mcp_* prefixes, artifact aliases). Generic * over the renderer type so the ordering logic stays unit-testable without * importing React Native components. */ export function resolveFromRegistry( registry: Record, resolvers: ReadonlyArray<(toolCall: SuperagentToolCall) => R | undefined>, toolCall: SuperagentToolCall, ): R | undefined { // Own-key lookup: tool names come from the payload, so a name like // `constructor` must not resolve to an inherited Object.prototype member // (same hardening as automationWidgetUtils / fileTreeUtils). const exact = Object.prototype.hasOwnProperty.call(registry, toolCall.name) ? registry[toolCall.name] : undefined; if (exact) return exact; for (const resolve of resolvers) { const match = resolve(toolCall); if (match) return match; } return undefined; } // ── MCP tool names ────────────────────────────────────────────────────────── // The backend wraps MCP tools as `mcp__` (build_tool_name in // backend/app/mcp_connections/tool_wrapper.py). Parsing mirrors the web // MCPToolUI: split at the FIRST underscore after the prefix — best-effort, // since a sanitized multi-word server name can itself contain underscores. export const MCP_TOOL_PREFIX = 'mcp_'; export function isMcpToolName(name: string): boolean { return name.startsWith(MCP_TOOL_PREFIX) && name.length > MCP_TOOL_PREFIX.length; } export function parseMcpToolName(fullName: string): { serverName: string; toolName: string } { const withoutPrefix = fullName.startsWith(MCP_TOOL_PREFIX) ? fullName.slice(MCP_TOOL_PREFIX.length) : fullName; const splitIndex = withoutPrefix.indexOf('_'); if (splitIndex === -1) return { serverName: '', toolName: withoutPrefix }; return { serverName: withoutPrefix.slice(0, splitIndex), toolName: withoutPrefix.slice(splitIndex + 1), }; } /** snake_case → Title Case, same as the web MCPToolUI formatDisplayName. */ export function formatMcpDisplayName(name: string): string { return name .split('_') .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * MCP row title — web MCPToolUI's Running/Failed/Run verbs. Stopped renders * as failed so an interrupted call never reads as a completed run * (skills/automation widget parity). */ export function getMcpToolRowTitle(status: ToolWidgetStatus): string { if (status === 'running') return 'Running'; if (status === 'error' || status === 'stopped') return 'Failed'; return 'Run'; } // ── Value formatting ─────────────────────────────────────────────────────── export function formatUnknownValue(value: unknown): string { if (typeof value === 'string') return value; try { return JSON.stringify(value, null, 2) ?? String(value); } catch { return String(value); } } // ── get_backend_function_logs ────────────────────────────────────────────── /** * Entry count for a get_backend_function_logs call. The tool answers with a * human-readable message (backend_function_tools.py `_format_logs`): one * "[LEVEL] timestamp - message" line per entry, or "No logs found.". * Deliberately never returns the payload itself: function logs carry the * function's console output, which the web confines to an admin-only debug * affordance. */ export function countBackendFunctionLogEntries(results: SuperagentToolCall['results']): number | null { let text: string | null = null; if (typeof results === 'string') { text = results; } else if (results && typeof results === 'object') { const message = (results as { message?: unknown }).message; if (typeof message === 'string') text = message; } if (!text || !text.trim()) return null; if (/^no logs found/i.test(text.trim())) return 0; const entries = text.split('\n').filter((line) => /^\[\s*\w+\s*\]/.test(line.trim())); return entries.length > 0 ? entries.length : null; } // ── File path humanization ───────────────────────────────────────────────── // Port of the web tools-ui humanizeFilePath (frontend/apps/builder/.../tools-ui/ // utils.ts) so file chips read the same on both platforms: friendly data-class // labels ("Todo entity"), "Home Page", "users API", and camelCase splitting. const DATA_CLASS_FRIENDLY_SUFFIX: Record = { agents: 'agent', entities: 'entity', functions: 'function', workflows: 'workflow', }; /** TodoItem -> "Todo Item", some-name_x -> "some name x". */ function formatSegment(segment: string): string { return segment .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[-_]/g, ' ') .trim(); } /** * base44/entities/Todo.jsonc, entities/Todo, base44/functions/Send/entry.ts … * → { category, name }. Client app code under src// is NOT a * data-class path (matches the web src/ leak guard). */ function matchDataClassPath(filePath: string): { category: string; name: string } | null { if (/^src\/(?:entities|agents|workflows|functions)\//i.test(filePath)) return null; const withoutBase = filePath.replace(/^base44\//, ''); const match = withoutBase.match(/^(entities|agents|workflows|functions)\/(.+)$/); if (!match) return null; const category = match[1]; let name = match[2]; if (category === 'functions') name = name.replace(/\/entry\.(tsx?|jsx?)$/i, ''); // Strip only known code/schema extensions so dotted logical names // (SendInvoice.v2, support.bot) keep their suffix. name = name.replace(/\.(jsonc?|tsx?|jsx?)$/i, ''); return { category, name }; } export function humanizeFilePath(filePath: string): string { if (!filePath) return 'file'; // URLs (e.g. storage links) → decoded file name without the hash prefix. if (filePath.startsWith('http://') || filePath.startsWith('https://')) { let raw: string; try { raw = decodeURIComponent(new URL(filePath).pathname.split('/').pop() || 'file'); } catch { raw = filePath.split('/').pop() || 'file'; } return raw.replace(/^[a-f0-9]{7,}_/i, ''); } const dataClass = matchDataClassPath(filePath); if (dataClass) { const formatted = dataClass.name.split('/').map(formatSegment).join('/'); return `${formatted} ${DATA_CLASS_FRIENDLY_SUFFIX[dataClass.category]}`; } // Strip exactly one common leading segment. let path = filePath.replace(/^(src\/|app\/|components\/|lib\/|utils\/)/, ''); if (path === 'index.css') return 'styles'; path = path.replace(/\.(jsx?|tsx?|css|scss|jsonc?|md|html|py|java|go|rb|php)$/i, ''); if (filePath.match(/pages?\/.*\.jsx?$/i)) { const pageName = path.split('/').pop(); const formatted = pageName ? formatSegment(pageName) : ''; return `${formatted.charAt(0).toUpperCase() + formatted.slice(1)} Page`; } if (filePath.match(/api\/.*\.jsx?$/i)) { const routeName = path.split('/').slice(0, -1).pop() || path.split('/').pop(); const formatted = routeName ? formatSegment(routeName) : ''; return `${formatted.charAt(0).toUpperCase() + formatted.slice(1)} API`; } return path.split('/').map(formatSegment).join('/').trim() || 'file'; }