From ccdd42639f55ede6eb1b2dfa29503d14089b00b8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 11 Feb 2026 14:33:03 -0800 Subject: [PATCH] fix(variables): fix tag dropdown and cursor alignment in variables block (#3199) --- apps/sim/app/api/copilot/chat/route.ts | 52 +++- .../orchestrator/tool-executor/index.ts | 259 +++++++++++++++++- apps/sim/stores/panel/copilot/store.ts | 81 +++++- 3 files changed, 371 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 513c0798d..25349e914 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -113,6 +113,7 @@ const ChatMessageSchema = z.object({ workflowId: z.string().optional(), knowledgeId: z.string().optional(), blockId: z.string().optional(), + blockIds: z.array(z.string()).optional(), templateId: z.string().optional(), executionId: z.string().optional(), // For workflow_block, provide both workflowId and blockId @@ -159,6 +160,20 @@ export async function POST(req: NextRequest) { commands, } = ChatMessageSchema.parse(body) + const normalizedContexts = Array.isArray(contexts) + ? contexts.map((ctx) => { + if (ctx.kind !== 'blocks') return ctx + if (Array.isArray(ctx.blockIds) && ctx.blockIds.length > 0) return ctx + if (ctx.blockId) { + return { + ...ctx, + blockIds: [ctx.blockId], + } + } + return ctx + }) + : contexts + // Resolve workflowId - if not provided, use first workflow or find by name const resolved = await resolveWorkflowIdForUser( authenticatedUserId, @@ -176,10 +191,10 @@ export async function POST(req: NextRequest) { const userMessageIdToUse = userMessageId || crypto.randomUUID() try { logger.info(`[${tracker.requestId}] Received chat POST`, { - hasContexts: Array.isArray(contexts), - contextsCount: Array.isArray(contexts) ? contexts.length : 0, - contextsPreview: Array.isArray(contexts) - ? contexts.map((c: any) => ({ + hasContexts: Array.isArray(normalizedContexts), + contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0, + contextsPreview: Array.isArray(normalizedContexts) + ? normalizedContexts.map((c: any) => ({ kind: c?.kind, chatId: c?.chatId, workflowId: c?.workflowId, @@ -191,17 +206,25 @@ export async function POST(req: NextRequest) { } catch {} // Preprocess contexts server-side let agentContexts: Array<{ type: string; content: string }> = [] - if (Array.isArray(contexts) && contexts.length > 0) { + if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) { try { const { processContextsServer } = await import('@/lib/copilot/process-contents') - const processed = await processContextsServer(contexts as any, authenticatedUserId, message) + const processed = await processContextsServer( + normalizedContexts as any, + authenticatedUserId, + message + ) agentContexts = processed logger.info(`[${tracker.requestId}] Contexts processed for request`, { processedCount: agentContexts.length, kinds: agentContexts.map((c) => c.type), lengthPreview: agentContexts.map((c) => c.content?.length ?? 0), }) - if (Array.isArray(contexts) && contexts.length > 0 && agentContexts.length === 0) { + if ( + Array.isArray(normalizedContexts) && + normalizedContexts.length > 0 && + agentContexts.length === 0 + ) { logger.warn( `[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.` ) @@ -246,11 +269,13 @@ export async function POST(req: NextRequest) { mode, model: selectedModel, provider, + conversationId: effectiveConversationId, conversationHistory, contexts: agentContexts, fileAttachments, commands, chatId: actualChatId, + prefetch, implicitFeedback, }, { @@ -432,10 +457,15 @@ export async function POST(req: NextRequest) { content: message, timestamp: new Date().toISOString(), ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), - ...(Array.isArray(contexts) && contexts.length > 0 && { contexts }), - ...(Array.isArray(contexts) && - contexts.length > 0 && { - contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }], + ...(Array.isArray(normalizedContexts) && + normalizedContexts.length > 0 && { + contexts: normalizedContexts, + }), + ...(Array.isArray(normalizedContexts) && + normalizedContexts.length > 0 && { + contentBlocks: [ + { type: 'contexts', contexts: normalizedContexts as any, timestamp: Date.now() }, + ], }), } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index 2bd0e6611..829a57a62 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import { customTools, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, desc, eq, isNull, or } from 'drizzle-orm' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import type { ExecutionContext, @@ -12,6 +12,7 @@ import { routeExecution } from '@/lib/copilot/tools/server/router' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' import { getTool, resolveToolId } from '@/tools/utils' import { executeCheckDeploymentStatus, @@ -76,6 +77,247 @@ import { const logger = createLogger('CopilotToolExecutor') +type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list' + +interface ManageCustomToolSchema { + type: 'function' + function: { + name: string + description?: string + parameters: Record + } +} + +interface ManageCustomToolParams { + operation?: string + toolId?: string + schema?: ManageCustomToolSchema + code?: string + title?: string + workspaceId?: string +} + +async function executeManageCustomTool( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageCustomToolParams + const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation + const workspaceId = params.workspaceId || context.workspaceId + + if (!operation) { + return { success: false, error: "Missing required 'operation' argument" } + } + + try { + if (operation === 'list') { + const toolsForUser = workspaceId + ? await db + .select() + .from(customTools) + .where( + or( + eq(customTools.workspaceId, workspaceId), + and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId)) + ) + ) + .orderBy(desc(customTools.createdAt)) + : await db + .select() + .from(customTools) + .where(and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId))) + .orderBy(desc(customTools.createdAt)) + + return { + success: true, + output: { + success: true, + operation, + tools: toolsForUser, + count: toolsForUser.length, + }, + } + } + + if (operation === 'add') { + if (!workspaceId) { + return { + success: false, + error: "workspaceId is required for operation 'add'", + } + } + if (!params.schema || !params.code) { + return { + success: false, + error: "Both 'schema' and 'code' are required for operation 'add'", + } + } + + const title = params.title || params.schema.function?.name + if (!title) { + return { success: false, error: "Missing tool title or schema.function.name for 'add'" } + } + + const resultTools = await upsertCustomTools({ + tools: [ + { + title, + schema: params.schema, + code: params.code, + }, + ], + workspaceId, + userId: context.userId, + }) + const created = resultTools.find((tool) => tool.title === title) + + return { + success: true, + output: { + success: true, + operation, + toolId: created?.id, + title, + message: `Created custom tool "${title}"`, + }, + } + } + + if (operation === 'edit') { + if (!workspaceId) { + return { + success: false, + error: "workspaceId is required for operation 'edit'", + } + } + if (!params.toolId) { + return { success: false, error: "'toolId' is required for operation 'edit'" } + } + if (!params.schema && !params.code) { + return { + success: false, + error: "At least one of 'schema' or 'code' is required for operation 'edit'", + } + } + + const workspaceTool = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId))) + .limit(1) + + const legacyTool = + workspaceTool.length === 0 + ? await db + .select() + .from(customTools) + .where( + and( + eq(customTools.id, params.toolId), + isNull(customTools.workspaceId), + eq(customTools.userId, context.userId) + ) + ) + .limit(1) + : [] + + const existing = workspaceTool[0] || legacyTool[0] + if (!existing) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + + const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema) + const mergedCode = params.code || existing.code + const title = params.title || mergedSchema.function?.name || existing.title + + await upsertCustomTools({ + tools: [ + { + id: params.toolId, + title, + schema: mergedSchema, + code: mergedCode, + }, + ], + workspaceId, + userId: context.userId, + }) + + return { + success: true, + output: { + success: true, + operation, + toolId: params.toolId, + title, + message: `Updated custom tool "${title}"`, + }, + } + } + + if (operation === 'delete') { + if (!params.toolId) { + return { success: false, error: "'toolId' is required for operation 'delete'" } + } + + const workspaceDelete = + workspaceId != null + ? await db + .delete(customTools) + .where( + and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId)) + ) + .returning({ id: customTools.id }) + : [] + + const legacyDelete = + workspaceDelete.length === 0 + ? await db + .delete(customTools) + .where( + and( + eq(customTools.id, params.toolId), + isNull(customTools.workspaceId), + eq(customTools.userId, context.userId) + ) + ) + .returning({ id: customTools.id }) + : [] + + const deleted = workspaceDelete[0] || legacyDelete[0] + if (!deleted) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + + return { + success: true, + output: { + success: true, + operation, + toolId: params.toolId, + message: 'Deleted custom tool', + }, + } + } + + return { + success: false, + error: `Unsupported operation for manage_custom_tool: ${operation}`, + } + } catch (error) { + logger.error('manage_custom_tool execution failed', { + operation, + workspaceId, + userId: context.userId, + error: error instanceof Error ? error.message : String(error), + }) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to manage custom tool', + } + } +} + const SERVER_TOOLS = new Set([ 'get_blocks_and_tools', 'get_blocks_metadata', @@ -161,6 +403,19 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< } } }, + oauth_request_access: async (p, _c) => { + const providerName = (p.providerName || p.provider_name || 'the provider') as string + return { + success: true, + output: { + success: true, + status: 'requested', + providerName, + message: `Requested ${providerName} OAuth connection. The user should complete the OAuth modal in the UI, then retry credential-dependent actions.`, + }, + } + }, + manage_custom_tool: (p, c) => executeManageCustomTool(p, c), } /** diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index e7261a229..44f17df10 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -310,6 +310,50 @@ function parseModelKey(compositeKey: string): { provider: string; modelId: strin return { provider: compositeKey.slice(0, slashIdx), modelId: compositeKey.slice(slashIdx + 1) } } +/** + * Convert legacy/variant Claude IDs into the canonical ID shape used by the model catalog. + * + * Examples: + * - claude-4.5-opus -> claude-opus-4-5 + * - claude-opus-4.6 -> claude-opus-4-6 + * - anthropic.claude-opus-4-5-20251101-v1:0 -> claude-opus-4-5 (match key only) + */ +function canonicalizeModelMatchKey(modelId: string): string { + if (!modelId) return modelId + const normalized = modelId.trim().toLowerCase() + + const toCanonicalClaude = (tier: string, version: string): string => { + const normalizedVersion = version.replace(/\./g, '-') + return `claude-${tier}-${normalizedVersion}` + } + + const tierFirstExact = normalized.match(/^claude-(opus|sonnet|haiku)-(\d+(?:[.-]\d+)?)$/) + if (tierFirstExact) { + const [, tier, version] = tierFirstExact + return toCanonicalClaude(tier, version) + } + + const versionFirstExact = normalized.match(/^claude-(\d+(?:[.-]\d+)?)-(opus|sonnet|haiku)$/) + if (versionFirstExact) { + const [, version, tier] = versionFirstExact + return toCanonicalClaude(tier, version) + } + + const tierFirstEmbedded = normalized.match(/claude-(opus|sonnet|haiku)-(\d+(?:[.-]\d+)?)/) + if (tierFirstEmbedded) { + const [, tier, version] = tierFirstEmbedded + return toCanonicalClaude(tier, version) + } + + const versionFirstEmbedded = normalized.match(/claude-(\d+(?:[.-]\d+)?)-(opus|sonnet|haiku)/) + if (versionFirstEmbedded) { + const [, version, tier] = versionFirstEmbedded + return toCanonicalClaude(tier, version) + } + + return normalized +} + const MODEL_PROVIDER_PRIORITY = [ 'anthropic', 'bedrock', @@ -350,12 +394,23 @@ function normalizeSelectedModelKey(selectedModel: string, models: AvailableModel const { provider, modelId } = parseModelKey(selectedModel) const targetModelId = modelId || selectedModel + const targetMatchKey = canonicalizeModelMatchKey(targetModelId) - const matches = models.filter((m) => m.id.endsWith(`/${targetModelId}`)) + const matches = models.filter((m) => { + const candidateModelId = parseModelKey(m.id).modelId || m.id + const candidateMatchKey = canonicalizeModelMatchKey(candidateModelId) + return ( + candidateModelId === targetModelId || + m.id.endsWith(`/${targetModelId}`) || + candidateMatchKey === targetMatchKey + ) + }) if (matches.length === 0) return selectedModel if (provider) { - const sameProvider = matches.find((m) => m.provider === provider) + const sameProvider = matches.find( + (m) => m.provider === provider || m.id.startsWith(`${provider}/`) + ) if (sameProvider) return sameProvider.id } @@ -1093,11 +1148,12 @@ export const useCopilotStore = create()( const chatConfig = chat.config ?? {} const chatMode = chatConfig.mode || get().mode const chatModel = chatConfig.model || get().selectedModel + const normalizedChatModel = normalizeSelectedModelKey(chatModel, get().availableModels) logger.debug('[Chat] Restoring chat config', { chatId: chat.id, mode: chatMode, - model: chatModel, + model: normalizedChatModel, hasPlanArtifact: !!planArtifact, }) @@ -1119,7 +1175,7 @@ export const useCopilotStore = create()( showPlanTodos: false, streamingPlanContent: planArtifact, mode: chatMode, - selectedModel: chatModel as CopilotStore['selectedModel'], + selectedModel: normalizedChatModel as CopilotStore['selectedModel'], suppressAutoSelect: false, }) @@ -1292,6 +1348,10 @@ export const useCopilotStore = create()( const refreshedConfig = updatedCurrentChat.config ?? {} const refreshedMode = refreshedConfig.mode || get().mode const refreshedModel = refreshedConfig.model || get().selectedModel + const normalizedRefreshedModel = normalizeSelectedModelKey( + refreshedModel, + get().availableModels + ) const toolCallsById = buildToolCallsById(normalizedMessages) set({ @@ -1300,7 +1360,7 @@ export const useCopilotStore = create()( toolCallsById, streamingPlanContent: refreshedPlanArtifact, mode: refreshedMode, - selectedModel: refreshedModel as CopilotStore['selectedModel'], + selectedModel: normalizedRefreshedModel as CopilotStore['selectedModel'], }) } try { @@ -1320,11 +1380,15 @@ export const useCopilotStore = create()( const chatConfig = mostRecentChat.config ?? {} const chatMode = chatConfig.mode || get().mode const chatModel = chatConfig.model || get().selectedModel + const normalizedChatModel = normalizeSelectedModelKey( + chatModel, + get().availableModels + ) logger.info('[Chat] Auto-selecting most recent chat with config', { chatId: mostRecentChat.id, mode: chatMode, - model: chatModel, + model: normalizedChatModel, hasPlanArtifact: !!planArtifact, }) @@ -1336,7 +1400,7 @@ export const useCopilotStore = create()( toolCallsById, streamingPlanContent: planArtifact, mode: chatMode, - selectedModel: chatModel as CopilotStore['selectedModel'], + selectedModel: normalizedChatModel as CopilotStore['selectedModel'], }) try { await get().loadMessageCheckpoints(mostRecentChat.id) @@ -2268,7 +2332,8 @@ export const useCopilotStore = create()( }, setSelectedModel: async (model) => { - set({ selectedModel: model }) + const normalizedModel = normalizeSelectedModelKey(model, get().availableModels) + set({ selectedModel: normalizedModel as CopilotStore['selectedModel'] }) }, setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), loadAvailableModels: async () => {