Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/sim/app/api/help/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()

try {
// Get user session
const session = await getSession()
if (!session?.user?.email) {
logger.warn(`[${requestId}] Unauthorized help request attempt`)
Expand All @@ -30,20 +29,20 @@ export async function POST(req: NextRequest) {

const email = session.user.email

// Handle multipart form data
const formData = await req.formData()

// Extract form fields
const subject = formData.get('subject') as string
const message = formData.get('message') as string
const type = formData.get('type') as string
const workflowId = formData.get('workflowId') as string | null
const workspaceId = formData.get('workspaceId') as string
const userAgent = formData.get('userAgent') as string | null

logger.info(`[${requestId}] Processing help request`, {
type,
email: `${email.substring(0, 3)}***`, // Log partial email for privacy
})

// Validate the form data
const validationResult = helpFormSchema.safeParse({
subject,
message,
Expand All @@ -60,7 +59,6 @@ export async function POST(req: NextRequest) {
)
}

// Extract images
const images: { filename: string; content: Buffer; contentType: string }[] = []

for (const [key, value] of formData.entries()) {
Expand All @@ -81,10 +79,14 @@ export async function POST(req: NextRequest) {

logger.debug(`[${requestId}] Help request includes ${images.length} images`)

// Prepare email content
const userId = session.user.id
let emailText = `
Type: ${type}
From: ${email}
User ID: ${userId}
Workspace ID: ${workspaceId ?? 'N/A'}
Workflow ID: ${workflowId ?? 'N/A'}
Browser: ${userAgent ?? 'N/A'}

${message}
`
Expand Down Expand Up @@ -115,7 +117,6 @@ ${message}

logger.info(`[${requestId}] Help request email sent successfully`)

// Send confirmation email to the user
try {
const confirmationHtml = await renderHelpConfirmationEmail(
type as 'bug' | 'feedback' | 'feature_request' | 'other',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ interface ImageWithPreview extends File {
interface HelpModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId?: string
workspaceId: string
}

export function HelpModal({ open, onOpenChange }: HelpModalProps) {
export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)

Expand Down Expand Up @@ -370,18 +372,20 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
setSubmitStatus(null)

try {
// Prepare form data with images
const formData = new FormData()
formData.append('subject', data.subject)
formData.append('message', data.message)
formData.append('type', data.type)
formData.append('workspaceId', workspaceId)
formData.append('userAgent', navigator.userAgent)
if (workflowId) {
formData.append('workflowId', workflowId)
}

// Attach all images to form data
images.forEach((image, index) => {
formData.append(`image_${index}`, image)
})

// Submit to API
const response = await fetch('/api/help', {
method: 'POST',
body: formData,
Expand All @@ -392,11 +396,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
throw new Error(errorData.error || 'Failed to submit help request')
}

// Handle success
setSubmitStatus('success')
reset()

// Clean up resources
images.forEach((image) => URL.revokeObjectURL(image.preview))
setImages([])
} catch (error) {
Expand All @@ -406,7 +408,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
setIsSubmitting(false)
}
},
[images, reset]
[images, reset, workflowId, workspaceId]
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,12 @@ export function Sidebar() {
/>

{/* Footer Navigation Modals */}
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
<HelpModal
open={isHelpModalOpen}
onOpenChange={setIsHelpModalOpen}
workflowId={workflowId}
workspaceId={workspaceId}
/>
<SettingsModal
open={isSettingsModalOpen}
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
Expand Down
240 changes: 240 additions & 0 deletions apps/sim/providers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
MODELS_WITH_TEMPERATURE_SUPPORT,
MODELS_WITH_VERBOSITY,
PROVIDERS_WITH_TOOL_USAGE_CONTROL,
prepareToolExecution,
prepareToolsWithUsageControl,
shouldBillModelUsage,
supportsTemperature,
Expand Down Expand Up @@ -979,6 +980,245 @@ describe('Tool Management', () => {
})
})

describe('prepareToolExecution', () => {
describe('basic parameter merging', () => {
it.concurrent('should merge LLM args with user params', () => {
const tool = {
params: { apiKey: 'user-key', channel: '#general' },
}
const llmArgs = { message: 'Hello world', channel: '#random' }
const request = { workflowId: 'wf-123' }

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') // User value wins
expect(toolParams.message).toBe('Hello world')
})

it.concurrent('should filter out empty string user params', () => {
const tool = {
params: { apiKey: 'user-key', channel: '' }, // Empty channel
}
const llmArgs = { message: 'Hello', channel: '#llm-channel' }
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#llm-channel') // LLM value used since user is empty
expect(toolParams.message).toBe('Hello')
})
})

describe('inputMapping deep merge for workflow tools', () => {
it.concurrent('should deep merge inputMapping when user provides empty object', () => {
const tool = {
params: {
workflowId: 'child-workflow-123',
inputMapping: '{}', // Empty JSON string from UI
},
}
const llmArgs = {
inputMapping: { query: 'search term', limit: 10 },
}
const request = { workflowId: 'parent-workflow' }

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// LLM values should be used since user object is empty
expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 })
expect(toolParams.workflowId).toBe('child-workflow-123')
})

it.concurrent('should deep merge inputMapping with partial user values', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: '{"query": "", "customField": "user-value"}', // Partial values
},
}
const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10 },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// LLM fills empty query, user's customField preserved, LLM's limit included
expect(toolParams.inputMapping).toEqual({
query: 'llm-search',
limit: 10,
customField: 'user-value',
})
})

it.concurrent('should preserve non-empty user inputMapping values', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: '{"query": "user-search", "limit": 5}',
},
}
const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10, extra: 'field' },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// User values win, but LLM's extra field is included
expect(toolParams.inputMapping).toEqual({
query: 'user-search',
limit: 5,
extra: 'field',
})
})

it.concurrent('should handle inputMapping as object (not JSON string)', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: { query: '', customField: 'user-value' }, // Object, not string
},
}
const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10 },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

expect(toolParams.inputMapping).toEqual({
query: 'llm-search',
limit: 10,
customField: 'user-value',
})
})

it.concurrent('should use LLM inputMapping when user does not provide it', () => {
const tool = {
params: { workflowId: 'child-workflow' }, // No inputMapping
}
const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10 },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

expect(toolParams.inputMapping).toEqual({ query: 'llm-search', limit: 10 })
})

it.concurrent('should use user inputMapping when LLM does not provide it', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: '{"query": "user-search"}',
},
}
const llmArgs = {} // No inputMapping from LLM
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

expect(toolParams.inputMapping).toEqual({ query: 'user-search' })
})

it.concurrent('should handle invalid JSON in user inputMapping gracefully', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: 'not valid json {',
},
}
const llmArgs = {
inputMapping: { query: 'llm-search' },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// Should use LLM values since user JSON is invalid
expect(toolParams.inputMapping).toEqual({ query: 'llm-search' })
})

it.concurrent('should not affect other parameters - normal override behavior', () => {
const tool = {
params: { apiKey: 'user-key', channel: '#general' },
}
const llmArgs = { message: 'Hello', channel: '#random' }
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// Normal behavior: user values override LLM values
expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') // User value wins
expect(toolParams.message).toBe('Hello')
})

it.concurrent('should preserve 0 and false as valid user values in inputMapping', () => {
const tool = {
params: {
workflowId: 'child-workflow',
inputMapping: '{"limit": 0, "enabled": false, "query": ""}',
},
}
const llmArgs = {
inputMapping: { limit: 10, enabled: true, query: 'llm-search' },
}
const request = {}

const { toolParams } = prepareToolExecution(tool, llmArgs, request)

// 0 and false should be preserved (they're valid values)
// empty string should be filled by LLM
expect(toolParams.inputMapping).toEqual({
limit: 0,
enabled: false,
query: 'llm-search',
})
})
})

describe('execution params context', () => {
it.concurrent('should include workflow context in executionParams', () => {
const tool = { params: { message: 'test' } }
const llmArgs = {}
const request = {
workflowId: 'wf-123',
workspaceId: 'ws-456',
chatId: 'chat-789',
userId: 'user-abc',
}

const { executionParams } = prepareToolExecution(tool, llmArgs, request)

expect(executionParams._context).toEqual({
workflowId: 'wf-123',
workspaceId: 'ws-456',
chatId: 'chat-789',
userId: 'user-abc',
})
})

it.concurrent('should include environment and workflow variables', () => {
const tool = { params: {} }
const llmArgs = {}
const request = {
environmentVariables: { API_KEY: 'secret' },
workflowVariables: { counter: 42 },
}

const { executionParams } = prepareToolExecution(tool, llmArgs, request)

expect(executionParams.envVars).toEqual({ API_KEY: 'secret' })
expect(executionParams.workflowVariables).toEqual({ counter: 42 })
})
})
})

describe('Provider/Model Blacklist', () => {
describe('isProviderBlacklisted', () => {
it.concurrent('should return false when no providers are blacklisted', () => {
Expand Down
Loading