Skip to content

Commit 12d4512

Browse files
committed
fix(buffer): preserve unset fields and enforce response contracts
1 parent 023f5b5 commit 12d4512

5 files changed

Lines changed: 206 additions & 22 deletions

File tree

‎apps/sim/blocks/blocks/buffer.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,8 +666,7 @@ export const BufferBlock: BlockConfig = {
666666
key === 'operation'
667667
)
668668
continue
669-
if (value === undefined || value === null) continue
670-
if (value === '') {
669+
if (value === undefined || value === null || value === '') {
671670
result[key] = undefined
672671
continue
673672
}

‎apps/sim/executor/handlers/generic/generic-handler.test.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import type { ExecutionContext } from '@/executor/types'
1111
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1212
import type { SerializedBlock } from '@/serializer/types'
1313
import { executeTool } from '@/tools'
14+
import bufferPostFixture from '@/tools/buffer/__fixtures__/post.json'
15+
import { bufferCreatePostTool } from '@/tools/buffer/create_post'
1416
import { bufferEditPostTool } from '@/tools/buffer/edit_post'
15-
import type { BufferEditPostParams } from '@/tools/buffer/types'
17+
import type { BufferCreatePostParams, BufferEditPostParams } from '@/tools/buffer/types'
1618
import { mcpRunOperationTool } from '@/tools/mcp/run-operation'
1719
import type { ToolConfig } from '@/tools/types'
1820
import { getTool } from '@/tools/utils'
@@ -86,6 +88,10 @@ describe('GenericBlockHandler', () => {
8688
optional: { mode: 'default', schedulingType: 'default', approvalChange: 'default' },
8789
},
8890
{ selection: 'cleared structured editors', optional: { assets: '', metadata: '' } },
91+
{
92+
selection: 'untouched advanced fields',
93+
optional: { assets: null, metadata: null, mediaType: null },
94+
},
8995
])('preserves existing Buffer state for a caption edit with $selection', async ({ optional }) => {
9096
mockGetBlock.mockReturnValue(BufferBlock)
9197
mockGetTool.mockReturnValue(bufferEditPostTool)
@@ -94,7 +100,7 @@ describe('GenericBlockHandler', () => {
94100
const body: { variables: { input: Record<string, unknown> } } = JSON.parse(String(init?.body))
95101
providerInputs.push(body.variables.input)
96102
return Response.json({
97-
data: { editPost: { __typename: 'PostActionSuccess', post: { id: 'post-1' } } },
103+
data: { editPost: { __typename: 'PostActionSuccess', post: bufferPostFixture } },
98104
})
99105
})
100106
mockExecuteTool.mockImplementation(async (toolId: string, params: BufferEditPostParams) => {
@@ -128,6 +134,93 @@ describe('GenericBlockHandler', () => {
128134
expect(providerInputs).toEqual([{ id: 'post-1', text: 'Updated caption' }])
129135
})
130136

137+
it.each([
138+
{
139+
operation: 'create_post',
140+
description: 'create defaults with untouched advanced fields',
141+
input: {
142+
channelId: 'channel-1',
143+
text: 'Caption',
144+
mode: 'addToQueue',
145+
schedulingType: null,
146+
mediaType: null,
147+
needsApproval: null,
148+
},
149+
expected: {
150+
channelId: 'channel-1',
151+
text: 'Caption',
152+
mode: 'addToQueue',
153+
schedulingType: 'automatic',
154+
assets: [],
155+
needsApproval: false,
156+
},
157+
},
158+
{
159+
operation: 'edit_post',
160+
description: 'media-only edit with an untouched caption',
161+
input: {
162+
postId: 'post-1',
163+
text: null,
164+
assets: [{ image: { url: 'https://example.com/image.png' } }],
165+
},
166+
expected: {
167+
id: 'post-1',
168+
assets: [{ image: { url: 'https://example.com/image.png' } }],
169+
},
170+
},
171+
{
172+
operation: 'edit_post',
173+
description: 'intentional caption clearing',
174+
input: { postId: 'post-1', text: null, clearText: true },
175+
expected: { id: 'post-1', text: null },
176+
},
177+
])(
178+
'sends only requested Buffer changes: $description',
179+
async ({ operation, input, expected }) => {
180+
mockGetBlock.mockReturnValue(BufferBlock)
181+
const tool = operation === 'create_post' ? bufferCreatePostTool : bufferEditPostTool
182+
mockGetTool.mockReturnValue(tool)
183+
const providerInputs: Record<string, unknown>[] = []
184+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
185+
const body: { variables: { input: Record<string, unknown> } } = JSON.parse(
186+
String(init?.body)
187+
)
188+
providerInputs.push(body.variables.input)
189+
return Response.json({
190+
data: {
191+
[operation === 'create_post' ? 'createPost' : 'editPost']: {
192+
__typename: 'PostActionSuccess',
193+
post: bufferPostFixture,
194+
},
195+
},
196+
})
197+
})
198+
mockExecuteTool.mockImplementation(
199+
async (toolId: string, params: BufferCreatePostParams & BufferEditPostParams) => {
200+
const response = await executeBufferTool({
201+
toolId,
202+
input: tool.operation.input(params),
203+
headers: new Headers(),
204+
context: { workflowId: mockContext.workflowId, userId: 'user-1' },
205+
requestId: 'request-1',
206+
})
207+
if (!tool.transformResponse) throw new Error('Missing response transformer')
208+
return tool.transformResponse(response)
209+
}
210+
)
211+
await handler.execute(
212+
mockContext,
213+
{
214+
...mockBlock,
215+
metadata: { id: 'buffer', name: 'Buffer' },
216+
config: { tool: tool.id, params: {} },
217+
},
218+
{ operation, apiKey: 'buffer-key', ...input }
219+
)
220+
expect(providerInputs).toEqual([expected])
221+
}
222+
)
223+
131224
it('executes the standalone stable MCP action after argument resolution with trusted block scope', async () => {
132225
mockGetBlock.mockReturnValue(McpBlock)
133226
mockGetTool.mockReturnValue(mcpRunOperationTool)

‎apps/sim/lib/internal/buffer/operations.test.ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { mockResolveFileInputToUrl } = fileUtilsServerMockFns
1010

1111
import { executeBufferTool } from '@/lib/internal/buffer/execute-tool'
1212
import { createBufferPost } from '@/lib/internal/buffer/operations'
13+
import bufferPostFixture from '@/tools/buffer/__fixtures__/post.json'
1314

1415
describe('Buffer operations', () => {
1516
it('creates a text post when optional assets are null', async () => {
@@ -18,7 +19,7 @@ describe('Buffer operations', () => {
1819
const body: { variables: { input: Record<string, unknown> } } = JSON.parse(String(init?.body))
1920
providerInputs.push(body.variables.input)
2021
return Response.json({
21-
data: { createPost: { __typename: 'PostActionSuccess', post: { id: 'post-1' } } },
22+
data: { createPost: { __typename: 'PostActionSuccess', post: bufferPostFixture } },
2223
})
2324
})
2425

@@ -66,7 +67,7 @@ describe('Buffer operations', () => {
6667
const body: { variables: { input: Record<string, unknown> } } = JSON.parse(String(init?.body))
6768
providerInputs.push(body.variables.input)
6869
return Response.json({
69-
data: { editPost: { __typename: 'PostActionSuccess', post: { id: 'post-1' } } },
70+
data: { editPost: { __typename: 'PostActionSuccess', post: bufferPostFixture } },
7071
})
7172
})
7273

@@ -98,7 +99,7 @@ describe('Buffer operations', () => {
9899
)
99100
providerInputs.push(body.variables.input)
100101
return Response.json({
101-
data: { editPost: { __typename: 'PostActionSuccess', post: { id: 'post-1' } } },
102+
data: { editPost: { __typename: 'PostActionSuccess', post: bufferPostFixture } },
102103
})
103104
})
104105

@@ -124,14 +125,47 @@ describe('Buffer operations', () => {
124125
}
125126
)
126127

128+
it.each([
129+
{ field: 'channel', value: null, path: 'Post.channel' },
130+
{ field: 'text', value: null, path: 'Post.text' },
131+
{ field: 'assets', value: null, path: 'Post.assets' },
132+
{ field: 'tags', value: [null], path: 'Post.tags[]' },
133+
])(
134+
'rejects null in required provider response positions: $path',
135+
async ({ field, value, path }) => {
136+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
137+
Response.json({
138+
data: {
139+
editPost: {
140+
__typename: 'PostActionSuccess',
141+
post: { ...bufferPostFixture, [field]: value },
142+
},
143+
},
144+
})
145+
)
146+
const response = await executeBufferTool({
147+
toolId: 'buffer_edit_post',
148+
input: { apiKey: 'buffer-key', postId: 'post-1', text: 'Updated caption' },
149+
headers: new Headers(),
150+
context: { workflowId: 'workflow-1', userId: 'user-1' },
151+
requestId: 'request-1',
152+
})
153+
expect(response.status).toBe(500)
154+
expect(await response.json()).toEqual({
155+
success: false,
156+
error: `Buffer returned null for ${path}`,
157+
})
158+
}
159+
)
160+
127161
it('resolves stored media with trusted user context before the provider call', async () => {
128162
mockResolveFileInputToUrl.mockResolvedValue({ fileUrl: 'https://files.example/image.png' })
129163
const fetchMock = vi.fn().mockResolvedValue(
130164
Response.json({
131165
data: {
132166
createPost: {
133167
__typename: 'PostActionSuccess',
134-
post: { id: 'post-1' },
168+
post: bufferPostFixture,
135169
},
136170
},
137171
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"id": "post-1",
3+
"allowedActions": [],
4+
"assets": [],
5+
"author": null,
6+
"channel": {
7+
"id": "channel-1",
8+
"allowedActions": [],
9+
"avatar": "https://example.com/avatar.png",
10+
"descriptor": "Example channel",
11+
"displayName": null,
12+
"externalLink": null,
13+
"hasActiveMemberDevice": false,
14+
"isDisconnected": false,
15+
"isLocked": false,
16+
"isNew": false,
17+
"isQueuePaused": false,
18+
"linkShortening": {
19+
"config": null,
20+
"isEnabled": false
21+
},
22+
"metadata": null,
23+
"name": "Example channel",
24+
"organizationId": "organization-1",
25+
"postingGoal": null,
26+
"postingSchedule": [],
27+
"products": ["publish"],
28+
"scopes": [],
29+
"service": "linkedin",
30+
"serviceId": "service-1",
31+
"showTrendingTopicSuggestions": false,
32+
"timezone": "UTC",
33+
"type": "page",
34+
"weeklyPostingLimit": null,
35+
"createdAt": "2026-01-01T00:00:00Z",
36+
"updatedAt": "2026-01-01T00:00:00Z"
37+
},
38+
"channelId": "channel-1",
39+
"channelService": "linkedin",
40+
"contentItemId": null,
41+
"dueAt": null,
42+
"error": null,
43+
"externalLink": null,
44+
"ideaId": null,
45+
"isCustomScheduled": false,
46+
"metadata": null,
47+
"metrics": null,
48+
"metricsUpdatedAt": null,
49+
"notes": [],
50+
"notificationStatus": null,
51+
"schedulingType": "automatic",
52+
"sentAt": null,
53+
"sharedNow": false,
54+
"shareMode": "addToQueue",
55+
"status": "draft",
56+
"tags": [],
57+
"text": "Caption",
58+
"via": "api",
59+
"createdAt": "2026-01-01T00:00:00Z",
60+
"updatedAt": "2026-01-01T00:00:00Z"
61+
}

‎apps/sim/tools/buffer/schema.ts‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -120,22 +120,19 @@ export function projectBufferObject(type: string, value: unknown): unknown {
120120
const nested = BUFFER_SCHEMA[nestedType]
121121
const object = Boolean(nested?.fields || nested?.members)
122122
if (candidate == null) {
123-
result[field] = wireType.endsWith('!')
124-
? wireType.startsWith('[')
125-
? []
126-
: object
127-
? null
128-
: scalarType(nestedType) === 'boolean'
129-
? false
130-
: scalarType(nestedType) === 'number'
131-
? 0
132-
: ''
133-
: null
123+
if (wireType.endsWith('!')) throw new Error(`Buffer returned null for ${type}.${field}`)
124+
result[field] = null
134125
} else if (wireType.startsWith('[')) {
135126
if (!Array.isArray(candidate)) throw new Error(`Buffer returned an invalid ${type}.${field}`)
136-
result[field] = object
137-
? candidate.map((entry) => (entry == null ? null : projectBufferObject(nestedType, entry)))
138-
: candidate
127+
const listType = wireType.endsWith('!') ? wireType.slice(0, -1) : wireType
128+
const itemRequired = listType.slice(1, -1).endsWith('!')
129+
result[field] = candidate.map((entry) => {
130+
if (entry == null) {
131+
if (itemRequired) throw new Error(`Buffer returned null for ${type}.${field}[]`)
132+
return null
133+
}
134+
return object ? projectBufferObject(nestedType, entry) : entry
135+
})
139136
} else {
140137
result[field] = object ? projectBufferObject(nestedType, candidate) : candidate
141138
}

0 commit comments

Comments
 (0)