import { connectorLabel } from '../features/connectors/connectorLabel'; import type { SuperagentToolCall } from '../types'; import type { DiffLine, ToolWidgetStatus } from './toolWidgetUtils'; import { diffLinesFromNew, getStringArg } from './toolWidgetUtils'; /** * Pure logic for the skills / identity / connection-state widgets, mirroring * the web builder's SuggestSkillInstallation, Request3rdPartyConnection, * SetSecrets and FunctionDisplay identity-card behavior. No RN imports — * unit-testable in the node environment (same rule as toolWidgetUtils.ts). */ // ── Shared ────────────────────────────────────────────────────────────────── /** * True when the user declined/cancelled an approval-style tool. Only `stopped` * means a user rejection (ToolRunner's rejected/abort paths); `error` can be an * approved call whose execution failed — the backend sets status="error" without * clearing requires_user_input (tools.py run_tool_call_and_update_results), so * reading error as a decline would hide real failures behind "dismissed". */ export function isDeclinedResolution(status: ToolWidgetStatus, toolCall: SuperagentToolCall): boolean { // requires_user_input reaches SuperagentToolCall via its index signature. return status === 'stopped' && Boolean(toolCall.requires_user_input); } // ToolRunner prepends this to results when resuming an approved call — strip it // so an approved-but-failed call renders just the failure reason. const APPROVED_RESULTS_PREFIX = 'tool call approved by the user. results: '; /** * Failure reason for an errored tool call, from results. Covers both shapes the * backend writes: plain strings (prepare_user_input prechecks, ToolRunner * exception paths) and `{message}` objects. Null for non-error statuses and * when no readable text exists. */ export function getFailureReason(status: ToolWidgetStatus, toolCall: SuperagentToolCall): string | null { if (status !== 'error') return null; const results = toolCall.results; let text: string | null = null; if (typeof results === 'string') { text = results; } else if (results && typeof results === 'object' && !Array.isArray(results)) { const message = (results as { message?: unknown }).message; if (typeof message === 'string') text = message; } if (!text) return null; if (text.startsWith(APPROVED_RESULTS_PREFIX)) text = text.slice(APPROVED_RESULTS_PREFIX.length); const trimmed = text.trim(); return trimmed || null; } // ── suggest_skill_installation ────────────────────────────────────────────── /** * Suggested skill's display name, from the skill info the backend's * prepare_user_input hook enriches into the tool's arguments * (skill_installation/tool.py writes arguments_string because * set_waiting_for_user_input overwrites results). */ export function getSuggestedSkillName(args: Record | null): string { const skill = args?.skill; if (!skill || typeof skill !== 'object') return ''; const displayName = (skill as Record).displayName; return typeof displayName === 'string' ? displayName.trim() : ''; } // ── activate_platform_skill ───────────────────────────────────────────────── /** Row title — stopped/error must not read as completed (automation-widget parity). */ export function getActivatePlatformSkillTitle(status: ToolWidgetStatus): string { if (status === 'running') return 'Loading skill'; if (status === 'error' || status === 'stopped') return 'Skill failed'; return 'Loaded skill'; } // ── request_oauth_authorization / suggest_payments_installation ──────────── export function isRetiredPaymentsByWixApproval( toolName: string, args: Record | null, ): boolean { return toolName === 'suggest_payments_installation' && getStringArg(args, ['provider']) === 'payments_by_wix'; } /** * Display name for a connection-style tool call — the native analog of the * web's getConnectionConfig(...).getName() in Request3rdPartyConnection.tsx. * Payment provider names match ToolApprovalCard's getPaymentName so the * waiting card and the settled card never disagree on a name. */ export function getConnectionDisplayName(toolName: string, args: Record | null): string { if (toolName === 'suggest_payments_installation') { const provider = getStringArg(args, ['provider']); if (provider === 'wix_payments') return 'Base44 Payments'; if (provider === 'stripe') return 'Stripe'; return 'payments'; } const integrationType = getStringArg(args, ['integration_type']); if (integrationType) return connectorLabel(integrationType); // BYO_SHARED calls may carry only an OrganizationConnector id. const connectorId = getStringArg(args, ['connector_id']); return connectorId ? connectorLabel(connectorId) : 'connector'; } // ── set_secrets ───────────────────────────────────────────────────────────── export type SecretSchemaItem = { description: string; name: string; }; /** * Secret NAMES + descriptions from the set_secrets args schema. This is the * only part of a set_secrets call that may ever be rendered — the user's * secret values travel in toolCall.user_input and must never be shown. */ export function getSecretSchemaItems(args: Record | null): SecretSchemaItem[] { let schema = args?.secrets_schema; if (typeof schema === 'string') { try { schema = JSON.parse(schema); } catch { return []; } } if (!Array.isArray(schema)) return []; const items: SecretSchemaItem[] = []; schema.forEach((entry) => { if (!entry || typeof entry !== 'object') return; const record = entry as Record; const name = String(record.secretName || record.name || '').trim(); if (!name) return; items.push({ description: typeof record.description === 'string' ? record.description : '', name, }); }); return items; } // ── show_channel_connection_options ───────────────────────────────────────── // Mirrors CHANNEL_CONNECTION_DISPLAY_NAMES in backend channel_tools.py. const CHANNEL_DISPLAY_NAMES: Record = { imessage: 'iMessage', slack: 'Slack', telegram: 'Telegram', whatsapp: 'WhatsApp', }; /** Channel name for the chip; null for 'all' / missing (generic options card). */ export function getChannelDisplayName(args: Record | null): string | null { const channel = getStringArg(args, ['channel']).toLowerCase(); return CHANNEL_DISPLAY_NAMES[channel] ?? null; } /** Row title — stopped/error must not read as completed (automation-widget parity). */ export function getChannelConnectionOptionsTitle(status: ToolWidgetStatus): string { if (status === 'running') return 'Showing connection options'; if (status === 'error' || status === 'stopped') return 'Failed to show connection options'; return 'Offered connection options'; } // ── update_identity ───────────────────────────────────────────────────────── // Per-file active labels from the web's IDENTITY_FRIENDLY_LABELS (FunctionDisplay.tsx). const IDENTITY_ACTIVE_LABELS: Record = { 'IDENTITY.md': 'Updating my profile', 'SOUL.md': 'Defining my personality', 'USER.md': 'Noting your details', }; export function getIdentityActiveLabel(fileName: string): string { return IDENTITY_ACTIVE_LABELS[fileName] || 'Saving to memory'; } /** Row title — stopped/error must not read as completed (automation-widget parity). */ export function getIdentityTitle(status: ToolWidgetStatus, fileName: string): string { if (status === 'running') return getIdentityActiveLabel(fileName); if (status === 'error') return 'Memory update failed'; if (status === 'stopped') return 'Memory update stopped'; return status === 'success' ? 'Updated' : 'Update'; } /** * Inline diff for an update_identity call. Native has no previous file content * (the web derives it from earlier messages), so this mirrors the web's * fallback: content lines as additions — plus removed rows for memory.md * delete operations, where content is empty and delete_entry_ids says what went. */ export function identityDiffLines(args: Record | null): DiffLine[] { if (getStringArg(args, ['operation']) === 'delete') { const ids = Array.isArray(args?.delete_entry_ids) ? args.delete_entry_ids : []; return ids .filter((id): id is string | number => typeof id === 'string' || typeof id === 'number') .map((id) => ({ text: `memory entry ${id}`, type: 'removed' as const })); } // Meaningful lines only — same filter as the web card (skips blank + heading // lines); diffLinesFromNew applies the shared bounded added-lines rendering. const lines = getStringArg(args, ['content']) .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); return diffLinesFromNew(lines.join('\n')); }