Skip to content

Commit a06f06f

Browse files
fix(workflows): merge responseFormat outputs with agent defaults
1 parent 5332614 commit a06f06f

File tree

3 files changed

+281
-10
lines changed

3 files changed

+281
-10
lines changed
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// @vitest-environment node
2+
import '@sim/testing/mocks/executor'
3+
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
import type { SerializedBlock } from '@/serializer/types'
7+
8+
import { getBlockSchema } from './block-data'
9+
10+
// Mock response-format utilities used transitively by getEffectiveBlockOutputs → getResponseFormatOutputs
11+
vi.mock('@/lib/core/utils/response-format', () => ({
12+
parseResponseFormatSafely: vi.fn(),
13+
extractFieldsFromSchema: vi.fn(),
14+
}))
15+
16+
// Mock @/blocks/registry (used by getBlockSchema in block-data.ts for hasTriggerCapability)
17+
vi.mock('@/blocks/registry', () => ({
18+
getBlock: vi.fn(),
19+
}))
20+
21+
// Import mocked functions so we can control return values per test
22+
import {
23+
extractFieldsFromSchema,
24+
parseResponseFormatSafely,
25+
} from '@/lib/core/utils/response-format'
26+
27+
// Import mocked getBlock from @/blocks (used by getEffectiveBlockOutputs in block-outputs.ts)
28+
import { getBlock } from '@/blocks'
29+
// Import mocked getBlock from @/blocks/registry (used by getBlockSchema in block-data.ts)
30+
import { getBlock as getBlockRegistry } from '@/blocks/registry'
31+
32+
const mockParseResponseFormat = vi.mocked(parseResponseFormatSafely)
33+
const mockExtractFields = vi.mocked(extractFieldsFromSchema)
34+
const mockGetBlock = vi.mocked(getBlock)
35+
const mockGetBlockRegistry = vi.mocked(getBlockRegistry)
36+
37+
// ---------------------------------------------------------------------------
38+
// Helpers
39+
// ---------------------------------------------------------------------------
40+
41+
/** Standard agent block outputs — mirrors what real agent blocks expose. */
42+
const AGENT_BASE_OUTPUTS: Record<string, { type: string }> = {
43+
content: { type: 'string' },
44+
model: { type: 'string' },
45+
tokens: { type: 'object' },
46+
toolCalls: { type: 'object' },
47+
providerTiming: { type: 'object' },
48+
}
49+
50+
/** Creates a minimal SerializedBlock for testing. */
51+
function createAgentBlock(overrides: Partial<SerializedBlock> = {}): SerializedBlock {
52+
return {
53+
id: 'agent-1',
54+
position: { x: 0, y: 0 },
55+
config: { tool: 'agent', params: {} },
56+
inputs: {},
57+
outputs: { ...AGENT_BASE_OUTPUTS },
58+
metadata: { id: 'agent', name: 'Agent' },
59+
enabled: true,
60+
...overrides,
61+
} as SerializedBlock
62+
}
63+
64+
// ---------------------------------------------------------------------------
65+
// Tests
66+
// ---------------------------------------------------------------------------
67+
68+
describe('getBlockSchema', () => {
69+
beforeEach(() => {
70+
vi.clearAllMocks()
71+
// Configure both getBlock mocks to return agent block config.
72+
// - @/blocks/registry: used by getBlockSchema for hasTriggerCapability
73+
// - @/blocks: used by getEffectiveBlockOutputs / getBlockOutputs for base outputs
74+
const agentBlockConfig = {
75+
type: 'agent',
76+
outputs: AGENT_BASE_OUTPUTS,
77+
subBlocks: [],
78+
category: 'ai',
79+
}
80+
const getBlockImpl = ((type: string) => {
81+
if (type === 'agent') return agentBlockConfig
82+
return undefined
83+
}) as any
84+
mockGetBlockRegistry.mockImplementation(getBlockImpl)
85+
mockGetBlock.mockImplementation(getBlockImpl)
86+
})
87+
88+
// -----------------------------------------------------------------------
89+
// responseFormat merging (the bug-fix under test)
90+
// -----------------------------------------------------------------------
91+
describe('with responseFormat', () => {
92+
it('should merge responseFormat fields with base agent outputs', () => {
93+
const block = createAgentBlock({
94+
config: {
95+
tool: 'agent',
96+
params: {
97+
responseFormat: '{"type":"object","properties":{"sentiment":{"type":"string"},"summary":{"type":"string"}}}',
98+
},
99+
},
100+
})
101+
102+
// Simulate the two parsing helpers returning valid data
103+
mockParseResponseFormat.mockReturnValue({
104+
type: 'object',
105+
properties: {
106+
sentiment: { type: 'string' },
107+
summary: { type: 'string' },
108+
},
109+
})
110+
mockExtractFields.mockReturnValue([
111+
{ name: 'sentiment', type: 'string' },
112+
{ name: 'summary', type: 'string' },
113+
])
114+
115+
const schema = getBlockSchema(block)
116+
117+
// Schema MUST contain ALL base outputs AND the responseFormat fields
118+
expect(schema).toBeDefined()
119+
120+
// Base outputs preserved
121+
expect(schema).toHaveProperty('content')
122+
expect(schema).toHaveProperty('model')
123+
expect(schema).toHaveProperty('tokens')
124+
expect(schema).toHaveProperty('toolCalls')
125+
expect(schema).toHaveProperty('providerTiming')
126+
127+
// responseFormat fields added
128+
expect(schema).toHaveProperty('sentiment')
129+
expect(schema).toHaveProperty('summary')
130+
131+
// Total keys = 5 base + 2 responseFormat = 7
132+
expect(Object.keys(schema!)).toHaveLength(7)
133+
})
134+
135+
it('should preserve toolCalls output when responseFormat is set', () => {
136+
// This is the exact bug that broke the "Kamatas PROD" workflow:
137+
// workflows referencing <agent.toolCalls.list> failed because
138+
// responseFormat replaced all base outputs instead of merging.
139+
const block = createAgentBlock({
140+
config: {
141+
tool: 'agent',
142+
params: {
143+
responseFormat: '{"type":"object","properties":{"analysis":{"type":"string"}}}',
144+
},
145+
},
146+
})
147+
148+
mockParseResponseFormat.mockReturnValue({
149+
type: 'object',
150+
properties: { analysis: { type: 'string' } },
151+
})
152+
mockExtractFields.mockReturnValue([{ name: 'analysis', type: 'string' }])
153+
154+
const schema = getBlockSchema(block)
155+
156+
expect(schema).toBeDefined()
157+
// The critical assertion: toolCalls must NOT be lost
158+
expect(schema).toHaveProperty('toolCalls')
159+
expect(schema!.toolCalls).toEqual({ type: 'object' })
160+
// responseFormat field must also be present
161+
expect(schema).toHaveProperty('analysis')
162+
})
163+
164+
it('should let responseFormat fields override base outputs of the same name', () => {
165+
const block = createAgentBlock({
166+
config: {
167+
tool: 'agent',
168+
params: {
169+
responseFormat: '{"type":"object","properties":{"content":{"type":"object"}}}',
170+
},
171+
},
172+
})
173+
174+
mockParseResponseFormat.mockReturnValue({
175+
type: 'object',
176+
properties: { content: { type: 'object' } },
177+
})
178+
mockExtractFields.mockReturnValue([{ name: 'content', type: 'object' }])
179+
180+
const schema = getBlockSchema(block)
181+
182+
expect(schema).toBeDefined()
183+
// responseFormat's "content" type should override the base "string" type
184+
expect(schema!.content).toEqual({ type: 'object', description: 'Field from Agent: content' })
185+
// Other base outputs still present
186+
expect(schema).toHaveProperty('toolCalls')
187+
expect(schema).toHaveProperty('model')
188+
})
189+
})
190+
191+
// -----------------------------------------------------------------------
192+
// responseFormat edge-cases that should NOT change behaviour
193+
// -----------------------------------------------------------------------
194+
describe('without responseFormat', () => {
195+
it('should return base outputs when responseFormat is not set', () => {
196+
const block = createAgentBlock()
197+
198+
// No responseFormat in config → parsing helpers never called
199+
const schema = getBlockSchema(block)
200+
201+
expect(schema).toBeDefined()
202+
expect(Object.keys(schema!)).toHaveLength(5)
203+
expect(schema).toHaveProperty('content')
204+
expect(schema).toHaveProperty('model')
205+
expect(schema).toHaveProperty('tokens')
206+
expect(schema).toHaveProperty('toolCalls')
207+
expect(schema).toHaveProperty('providerTiming')
208+
209+
// Parsing helpers should NOT be invoked
210+
expect(mockParseResponseFormat).not.toHaveBeenCalled()
211+
})
212+
213+
it('should return undefined when block has no outputs and no responseFormat', () => {
214+
// Override mock to return agent config with no outputs for this test
215+
const emptyConfig = { type: 'agent', outputs: {}, subBlocks: [], category: 'ai' }
216+
const emptyImpl = ((type: string) => (type === 'agent' ? emptyConfig : undefined)) as any
217+
mockGetBlockRegistry.mockImplementation(emptyImpl)
218+
mockGetBlock.mockImplementation(emptyImpl)
219+
220+
const block = createAgentBlock({ outputs: {} })
221+
222+
const schema = getBlockSchema(block)
223+
224+
expect(schema).toBeUndefined()
225+
})
226+
})
227+
228+
// -----------------------------------------------------------------------
229+
// responseFormat with invalid / empty parsed results
230+
// -----------------------------------------------------------------------
231+
describe('with invalid responseFormat', () => {
232+
it('should fall back to base outputs when parseResponseFormatSafely returns null', () => {
233+
const block = createAgentBlock({
234+
config: {
235+
tool: 'agent',
236+
params: { responseFormat: 'not-valid-json' },
237+
},
238+
})
239+
240+
mockParseResponseFormat.mockReturnValue(null)
241+
242+
const schema = getBlockSchema(block)
243+
244+
// Should fall through to base outputs (block.outputs)
245+
expect(schema).toBeDefined()
246+
expect(schema).toHaveProperty('content')
247+
expect(schema).toHaveProperty('toolCalls')
248+
expect(Object.keys(schema!)).toHaveLength(5)
249+
})
250+
251+
it('should fall back to base outputs when extractFieldsFromSchema returns empty array', () => {
252+
const block = createAgentBlock({
253+
config: {
254+
tool: 'agent',
255+
params: { responseFormat: '{"type":"object","properties":{}}' },
256+
},
257+
})
258+
259+
mockParseResponseFormat.mockReturnValue({ type: 'object', properties: {} })
260+
mockExtractFields.mockReturnValue([])
261+
262+
const schema = getBlockSchema(block)
263+
264+
// Empty fields → getResponseFormatOutputs returns undefined → fallback to base outputs
265+
expect(schema).toBeDefined()
266+
expect(schema).toHaveProperty('content')
267+
expect(schema).toHaveProperty('toolCalls')
268+
expect(Object.keys(schema!)).toHaveLength(5)
269+
})
270+
})
271+
})

apps/sim/executor/utils/block-data.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ function paramsToSubBlocks(
3333
return subBlocks
3434
}
3535

36-
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
36+
export function getBlockSchema(
37+
block: SerializedBlock
38+
): OutputSchema | undefined {
3739
const blockType = block.metadata?.id
3840
if (!blockType) return undefined
3941

@@ -53,10 +55,6 @@ function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
5355
return outputs
5456
}
5557

56-
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
57-
return getRegistrySchema(block)
58-
}
59-
6058
export function collectBlockData(
6159
ctx: ExecutionContext,
6260
currentNodeId?: string

apps/sim/lib/workflows/blocks/block-outputs.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,6 @@ export function getEffectiveBlockOutputs(
365365
const preferToolOutputs = options?.preferToolOutputs ?? !triggerMode
366366
const includeHidden = options?.includeHidden ?? false
367367

368-
if (blockType === 'agent') {
369-
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
370-
if (responseFormatOutputs) return responseFormatOutputs
371-
}
372-
373368
let baseOutputs: OutputDefinition
374369
if (triggerMode) {
375370
baseOutputs = getBlockOutputs(blockType, subBlocks, true, { includeHidden })
@@ -386,6 +381,13 @@ export function getEffectiveBlockOutputs(
386381
baseOutputs = getBlockOutputs(blockType, subBlocks, false, { includeHidden })
387382
}
388383

384+
if (blockType === 'agent') {
385+
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
386+
if (responseFormatOutputs) {
387+
return { ...baseOutputs, ...responseFormatOutputs }
388+
}
389+
}
390+
389391
if (blockType === 'evaluator') {
390392
const metricOutputs = getEvaluatorMetricOutputs(subBlocks)
391393
if (metricOutputs) {

0 commit comments

Comments
 (0)