diff --git a/apps/sim/executor/handlers/api/api-handler.test.ts b/apps/sim/executor/handlers/api/api-handler.test.ts index e2eeca7f79..826b226f1d 100644 --- a/apps/sim/executor/handlers/api/api-handler.test.ts +++ b/apps/sim/executor/handlers/api/api-handler.test.ts @@ -195,6 +195,21 @@ describe('ApiBlockHandler', () => { ) }) + it('should handle malformed JSON body by keeping original string', async () => { + const inputs = { + url: 'https://example.com/api', + body: '{invalid json body', + } + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'http_request', + expect.objectContaining({ body: '{invalid json body' }), + { executionContext: mockContext } + ) + }) + it('should handle null body by converting to undefined', async () => { const inputs = { url: 'https://example.com/api', diff --git a/apps/sim/executor/handlers/api/api-handler.ts b/apps/sim/executor/handlers/api/api-handler.ts index b6a28517d7..f5c1b4ac54 100644 --- a/apps/sim/executor/handlers/api/api-handler.ts +++ b/apps/sim/executor/handlers/api/api-handler.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { BlockType, HTTP } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' +import { parseJSON } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' import { getTool } from '@/tools/utils' @@ -53,12 +54,10 @@ export class ApiBlockHandler implements BlockHandler { if (processedInputs.body !== undefined) { if (typeof processedInputs.body === 'string') { - try { - const trimmedBody = processedInputs.body.trim() - if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) { - processedInputs.body = JSON.parse(trimmedBody) - } - } catch (e) {} + const trimmedBody = processedInputs.body.trim() + if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) { + processedInputs.body = parseJSON(trimmedBody, processedInputs.body) + } } else if (processedInputs.body === null) { processedInputs.body = undefined } diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index be3baec9ae..2bf5188623 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -1,6 +1,7 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { getBlock } from '@/blocks/index' import { BlockType } from '@/executor/constants' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import type { ExecutionContext } from '@/executor/types' @@ -144,4 +145,38 @@ describe('GenericBlockHandler', () => { 'Block execution of Some Custom Tool failed with no error message' ) }) + + it('should handle malformed json field input by keeping original string', async () => { + const mockGetBlock = vi.mocked(getBlock) + mockGetBlock.mockReturnValue({ + type: 'custom-type', + name: 'Custom Block', + description: 'Test block', + category: 'tools', + bgColor: '#000', + icon: () => null, + subBlocks: [], + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (p: Record) => p, + }, + }, + inputs: { + data: { type: 'json' }, + }, + outputs: {}, + } as unknown as ReturnType) + + const inputs = { data: '{not valid json' } + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ data: '{not valid json' }), + { executionContext: mockContext } + ) + }) }) diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index c6ee89dd63..f07bf2c337 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { getBlock } from '@/blocks/index' import { isMcpTool } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' +import { parseJSON } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' import { getTool } from '@/tools/utils' @@ -45,13 +45,7 @@ export class GenericBlockHandler implements BlockHandler { if (typeof value === 'string' && value.trim().length > 0) { const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema if (inputType === 'json' || inputType === 'array') { - try { - finalInputs[key] = JSON.parse(value.trim()) - } catch (error) { - logger.warn(`Failed to parse ${inputType} field "${key}":`, { - error: toError(error).message, - }) - } + finalInputs[key] = parseJSON(value, value) } } } diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.test.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.test.ts new file mode 100644 index 0000000000..e0c22b6474 --- /dev/null +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.test.ts @@ -0,0 +1,176 @@ +import '@sim/testing/mocks/executor' + +import { urlsMock, urlsMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { BlockType } from '@/executor/constants' +import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' +import { executeTool } from '@/tools' + +vi.mock('@/lib/core/utils/urls', () => urlsMock) + +const { mockGeneratePauseContextId, mockMapNodeMetadataToPauseScopes } = vi.hoisted(() => ({ + mockGeneratePauseContextId: vi.fn(() => 'test-pause-context-id'), + mockMapNodeMetadataToPauseScopes: vi.fn(() => ({ + parallelScope: undefined, + loopScope: undefined, + })), +})) + +vi.mock('@/executor/human-in-the-loop/utils', () => ({ + generatePauseContextId: mockGeneratePauseContextId, + mapNodeMetadataToPauseScopes: mockMapNodeMetadataToPauseScopes, +})) + +vi.mock('@/executor/utils/builder-data', () => ({ + convertBuilderDataToJson: vi.fn(() => ({ key: 'value' })), + convertPropertyValue: vi.fn((prop: any) => prop.value), +})) + +vi.mock('@/executor/utils/block-data', () => ({ + collectBlockData: vi.fn(() => ({ + blockData: {}, + blockNameMapping: {}, + })), +})) + +const mockExecuteTool = executeTool as Mock + +describe('HumanInTheLoopBlockHandler', () => { + let handler: HumanInTheLoopBlockHandler + let mockBlock: SerializedBlock + let mockContext: ExecutionContext + + beforeEach(() => { + vi.clearAllMocks() + + handler = new HumanInTheLoopBlockHandler() + + mockBlock = { + id: 'hitl-block-1', + metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Test HITL Block' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + + mockContext = { + workflowId: 'test-workflow-id', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + } + + urlsMockFns.mockGetBaseUrl.mockReturnValue('http://localhost:3000') + mockExecuteTool.mockResolvedValue({ success: true, output: {} }) + mockGeneratePauseContextId.mockReturnValue('test-pause-context-id') + mockMapNodeMetadataToPauseScopes.mockReturnValue({ + parallelScope: undefined, + loopScope: undefined, + }) + }) + + it('should return true for human-in-the-loop blocks', () => { + expect(handler.canHandle(mockBlock)).toBe(true) + }) + + it('should return false for non-hitl blocks', () => { + const nonHitlBlock: SerializedBlock = { + ...mockBlock, + metadata: { id: 'other-block' }, + } + expect(handler.canHandle(nonHitlBlock)).toBe(false) + }) + + it('should execute with human operation and return correct response shape', async () => { + const inputs = { + operation: 'human', + inputFormat: [{ id: 'field-1', name: 'username', label: 'Username', type: 'string' }], + builderData: [{ id: '1', name: 'result', type: 'string', value: 'test' }], + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.response).toBeDefined() + expect(result.response.status).toBe(200) + expect(result.response.headers).toHaveProperty('Content-Type') + expect(result.response.data).toHaveProperty('operation', 'human') + expect(result.response.data).toHaveProperty('responseStructure') + expect(result.response.data).toHaveProperty('inputFormat') + expect(result.response.data).toHaveProperty('submission', null) + expect(result._pauseMetadata).toBeDefined() + expect(result._pauseMetadata.pauseKind).toBe('human') + }) + + it('should handle malformed JSON data in api operation mode', async () => { + const inputs = { + operation: 'api', + dataMode: 'json', + data: '{invalid json}', + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result).toBeDefined() + expect(result.response).toBeDefined() + expect(result.response.data).toBe('{invalid json}') + }) + + it('should handle valid JSON data in api operation mode', async () => { + const inputs = { + operation: 'api', + dataMode: 'json', + data: '{"message":"hello"}', + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.response.data).toMatchObject({ message: 'hello' }) + }) + + it('should return error response on execution failure', async () => { + const inputs = { + operation: 'human', + inputFormat: 'not-an-array', + builderData: 'not-an-array', + } + + mockMapNodeMetadataToPauseScopes.mockImplementation(() => { + throw new Error('Metadata mapping failed') + }) + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.response).toBeDefined() + expect(result.response.status).toBe(500) + expect(result.response.data).toHaveProperty('error') + expect(result.response.data.message).toBe('Metadata mapping failed') + }) + + it('should include resume links when executionId and workflowId exist', async () => { + const contextWithExecution: ExecutionContext = { + ...mockContext, + executionId: 'exec-123', + } + + const inputs = { + operation: 'human', + inputFormat: [], + } + + const result = await handler.execute(contextWithExecution, mockBlock, inputs) + + expect(result.response.data._resume).toBeDefined() + expect(result.url).toBeDefined() + expect(result.resumeEndpoint).toBeDefined() + }) +}) diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index 032640d1b7..5bc6938f24 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -17,7 +17,7 @@ import { import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data' -import { parseObjectStrings } from '@/executor/utils/json' +import { parseJSON, parseObjectStrings } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -253,13 +253,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { if (dataMode === 'json' && inputs.data) { if (typeof inputs.data === 'string') { - try { - return JSON.parse(inputs.data) - } catch (error) { - logger.warn('Failed to parse JSON data, returning as string:', error) - return inputs.data - } - } else if (typeof inputs.data === 'object' && inputs.data !== null) { + return parseJSON(inputs.data, inputs.data) + } + if (typeof inputs.data === 'object' && inputs.data !== null) { return inputs.data } return inputs.data diff --git a/apps/sim/executor/handlers/response/response-handler.test.ts b/apps/sim/executor/handlers/response/response-handler.test.ts new file mode 100644 index 0000000000..493bd07bf8 --- /dev/null +++ b/apps/sim/executor/handlers/response/response-handler.test.ts @@ -0,0 +1,139 @@ +import '@sim/testing/mocks/executor' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockType, HTTP } from '@/executor/constants' +import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +vi.mock('@/executor/utils/builder-data', () => ({ + convertBuilderDataToJson: vi.fn(() => ({ key: 'value' })), + convertBuilderDataToJsonString: vi.fn(() => '{"key":"value"}'), +})) + +describe('ResponseBlockHandler', () => { + let handler: ResponseBlockHandler + let mockBlock: SerializedBlock + let mockContext: ExecutionContext + + beforeEach(() => { + vi.clearAllMocks() + + handler = new ResponseBlockHandler() + + mockBlock = { + id: 'response-block-1', + metadata: { id: BlockType.RESPONSE, name: 'Test Response Block' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.RESPONSE, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + + mockContext = { + workflowId: 'test-workflow-id', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + } + }) + + it('should return true for response blocks', () => { + expect(handler.canHandle(mockBlock)).toBe(true) + }) + + it('should return false for non-response blocks', () => { + const nonResponseBlock: SerializedBlock = { + ...mockBlock, + metadata: { id: 'other-block' }, + } + expect(handler.canHandle(nonResponseBlock)).toBe(false) + }) + + it('should execute with structured data and return correct shape', async () => { + const inputs = { + dataMode: 'structured', + builderData: [{ id: '1', name: 'key', type: 'string', value: 'value' }], + status: '200', + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.status).toBe(200) + expect(result.data).toBeDefined() + expect(result.headers).toHaveProperty('Content-Type') + }) + + it('should execute with json data mode and valid JSON string', async () => { + const inputs = { + dataMode: 'json', + data: '{"message":"hello"}', + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.status).toBe(200) + expect(result.data).toEqual({ message: 'hello' }) + }) + + it('should execute with json data mode and object input', async () => { + const inputs = { + dataMode: 'json', + data: { message: 'hello' }, + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.data).toEqual({ message: 'hello' }) + }) + + it('should handle malformed JSON data by returning original string', async () => { + const inputs = { + dataMode: 'json', + data: '{invalid json}', + } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.status).toBe(200) + expect(result.data).toBe('{invalid json}') + }) + + it('should return default status 200 when no status provided', async () => { + const inputs = { dataMode: 'json', data: '{}' } + const result = await handler.execute(mockContext, mockBlock, inputs) + expect(result.status).toBe(HTTP.STATUS.OK) + }) + + it('should return default empty object when no data provided', async () => { + const inputs = {} + const result = await handler.execute(mockContext, mockBlock, inputs) + expect(result.data).toEqual({}) + }) + + it('should return error response object on execution failure', async () => { + const inputs = { + dataMode: 'structured', + builderData: [{ id: '1', name: 'key', type: 'string', value: 'value' }], + status: '200', + } + + const { convertBuilderDataToJson } = await import('@/executor/utils/builder-data') + vi.mocked(convertBuilderDataToJson).mockImplementation(() => { + throw new Error('Builder data conversion failed') + }) + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result.status).toBe(500) + expect(result.data).toHaveProperty('error') + expect(result.data.message).toBe('Builder data conversion failed') + }) +}) diff --git a/apps/sim/executor/handlers/response/response-handler.ts b/apps/sim/executor/handlers/response/response-handler.ts index 389ddda76b..0af93643eb 100644 --- a/apps/sim/executor/handlers/response/response-handler.ts +++ b/apps/sim/executor/handlers/response/response-handler.ts @@ -5,7 +5,7 @@ import { convertBuilderDataToJson, convertBuilderDataToJsonString, } from '@/executor/utils/builder-data' -import { parseObjectStrings } from '@/executor/utils/json' +import { parseJSON, parseObjectStrings } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('ResponseBlockHandler') @@ -56,13 +56,9 @@ export class ResponseBlockHandler implements BlockHandler { if (dataMode === 'json' && inputs.data) { if (typeof inputs.data === 'string') { - try { - return JSON.parse(inputs.data) - } catch (error) { - logger.warn('Failed to parse JSON data, returning as string:', error) - return inputs.data - } - } else if (typeof inputs.data === 'object' && inputs.data !== null) { + return parseJSON(inputs.data, inputs.data) + } + if (typeof inputs.data === 'object' && inputs.data !== null) { return inputs.data } return inputs.data diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 90e326c278..c053534385 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -232,6 +232,22 @@ describe('RouterBlockHandler', () => { }) }) + it('should handle non-JSON error response from provider', async () => { + const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' } + + mockFetch.mockImplementationOnce(() => { + return Promise.resolve({ + ok: false, + status: 502, + json: () => Promise.reject(new Error('Invalid JSON')), + }) + }) + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( + 'Provider API request failed with status 502' + ) + }) + it('should handle server error responses', async () => { const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' } @@ -572,6 +588,40 @@ describe('RouterBlockHandler V2', () => { ) }) + it('should handle non-JSON error response from provider in V2', async () => { + const inputs = { + context: 'Test context', + model: 'gpt-4o', + apiKey: 'test-api-key', + routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]), + } + + mockFetch.mockImplementationOnce(() => { + return Promise.resolve({ + ok: false, + status: 503, + json: () => Promise.reject(new Error('Not JSON')), + }) + }) + + await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow( + 'Provider API request failed with status 503' + ) + }) + + it('should throw when routes input is invalid JSON string', async () => { + const inputs = { + context: 'Test context', + model: 'gpt-4o', + apiKey: 'test-api-key', + routes: '{invalid json routes', + } + + await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow( + 'No routes defined for router' + ) + }) + it('should handle fallback when JSON parsing fails', async () => { const inputs = { context: 'Test context', diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 1f3c7ba574..2ba4341c92 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -12,6 +12,7 @@ import { } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' +import { parseJSON, parseJSONOrThrow } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -118,7 +119,9 @@ export class RouterBlockHandler implements BlockHandler { if (errorData.error) { errorMessage = errorData.error } - } catch (_e) {} + } catch (error) { + logger.warn('Failed to parse error response body', { error }) + } throw new Error(errorMessage) } @@ -267,7 +270,9 @@ export class RouterBlockHandler implements BlockHandler { if (errorData.error) { errorMessage = errorData.error } - } catch (_e) {} + } catch (error) { + logger.warn('Failed to parse error response body', { error }) + } throw new Error(errorMessage) } @@ -277,7 +282,7 @@ export class RouterBlockHandler implements BlockHandler { let reasoning = '' try { - const parsedResponse = JSON.parse(result.content) + const parsedResponse = parseJSONOrThrow(result.content) chosenRouteId = parsedResponse.route?.trim() || '' reasoning = parsedResponse.reasoning || '' } catch (_parseError) { @@ -367,18 +372,19 @@ export class RouterBlockHandler implements BlockHandler { * Parse routes from input (can be JSON string or array) */ private parseRoutes(input: any): RouteDefinition[] { - try { - if (typeof input === 'string') { - return JSON.parse(input) - } - if (Array.isArray(input)) { - return input - } + if (Array.isArray(input)) { + return input + } + const parsed = parseJSON(input, null) + if (parsed === null) { + logger.error('Failed to parse routes:', { input }) return [] - } catch (error) { - logger.error('Failed to parse routes:', { input, error }) + } + if (!Array.isArray(parsed)) { + logger.error('Routes parsed but is not an array:', { input, parsed }) return [] } + return parsed } private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {