Skip to content

Commit 3282fd8

Browse files
fix(custom-blocks): reserve system output names and hide own block from command modal (#5667)
* fix(custom-blocks): reserve system output names and hide own block from command modal * fix(mothership): constrain custom block image icons in subagent tool rows * improvement(custom-blocks): allow result as an exposed output name
1 parent 53bdbf4 commit 3282fd8

13 files changed

Lines changed: 208 additions & 13 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ export function ToolCallItem({
9393
return (
9494
<div className='flex items-center gap-[6px] pl-6'>
9595
{BlockIcon && (
96-
<BlockIcon className='size-[14px] flex-shrink-0' style={getBareIconStyle(BlockIcon)} />
96+
// Size via inline style: a custom block's image icon carries a trailing
97+
// `size-full` that defeats size *classes* (it fills tiled surfaces), so a
98+
// class-only size renders the uploaded icon at natural size here.
99+
<BlockIcon
100+
className='size-[14px] flex-shrink-0'
101+
style={{ width: 14, height: 14, ...getBareIconStyle(BlockIcon) }}
102+
/>
97103
)}
98104
{isExecuting ? (
99105
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export function SearchModal({
9797
const params = useParams()
9898
const router = useRouter()
9999
const workspaceId = params.workspaceId as string
100+
const currentWorkflowId = params.workflowId as string | undefined
100101
const inputRef = useRef<HTMLInputElement>(null)
101102
const [mounted, setMounted] = useState(false)
102103
const { navigateToSettings } = useSettingsNavigation()
@@ -581,23 +582,25 @@ export function SearchModal({
581582
*/
582583
const filteredBlocks = useMemo(() => {
583584
if (!isOnWorkflowPage) return []
585+
// A custom block is hidden on its own source workflow's canvas — placing it
586+
// there recurses (same exclusion as the toolbar).
584587
return filterAndCap(
585-
blocks,
588+
blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId),
586589
(b) => b.name,
587590
deferredSearch,
588591
(b) => b.searchValue
589592
)
590-
}, [isOnWorkflowPage, blocks, deferredSearch])
593+
}, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId])
591594

592595
const filteredTools = useMemo(() => {
593596
if (!isOnWorkflowPage) return []
594597
return filterAndCap(
595-
tools,
598+
tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId),
596599
(t) => t.name,
597600
deferredSearch,
598601
(t) => t.searchValue
599602
)
600-
}, [isOnWorkflowPage, tools, deferredSearch])
603+
}, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId])
601604

602605
const filteredTriggers = useMemo(() => {
603606
if (!isOnWorkflowPage) return []

apps/sim/blocks/custom/build-config.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
CUSTOM_BLOCK_TILE_COLOR,
99
type CustomBlockRow,
1010
isCustomBlockType,
11+
isReservedOutputName,
1112
} from '@/blocks/custom/build-config'
1213
import type { BlockIcon } from '@/blocks/types'
1314

@@ -33,6 +34,18 @@ describe('isCustomBlockType', () => {
3334
})
3435
})
3536

37+
describe('isReservedOutputName', () => {
38+
it('rejects the system output fields case-insensitively', () => {
39+
expect(isReservedOutputName('cost')).toBe(true)
40+
expect(isReservedOutputName('Cost')).toBe(true)
41+
expect(isReservedOutputName(' success ')).toBe(true)
42+
expect(isReservedOutputName('error')).toBe(true)
43+
expect(isReservedOutputName('result')).toBe(false)
44+
expect(isReservedOutputName('cost_2')).toBe(false)
45+
expect(isReservedOutputName('summary')).toBe(false)
46+
})
47+
})
48+
3649
describe('buildCustomBlockConfig', () => {
3750
const fields: WorkflowInputField[] = [
3851
{ name: 'title', type: 'string' },
@@ -47,6 +60,7 @@ describe('buildCustomBlockConfig', () => {
4760
const config = buildCustomBlockConfig(row, fields, { icon })
4861
expect(config.type).toBe('custom_block_abc123')
4962
expect(config.name).toBe('Invoice Parser')
63+
expect(config.sourceWorkflowId).toBe('wf-1')
5064
expect(config.category).toBe('tools')
5165
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
5266
expect(config.hideFromToolbar).toBeUndefined()

apps/sim/blocks/custom/build-config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ export const RESERVED_PARAMS = new Set([
6262
'advancedMode',
6363
])
6464

65+
/**
66+
* Output names the block projects itself (`success`/`error` from `buildOutputs`,
67+
* `cost` from the executor's billing aggregation). A user-named exposed output
68+
* must never shadow these — an output literally named `cost` would clobber the
69+
* billed cost. `result` is deliberately NOT reserved: it only exists as a system
70+
* field when no outputs are curated, which cannot co-occur with a named output.
71+
*/
72+
export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost'])
73+
74+
/** Whether an exposed-output name collides with a system output field. */
75+
export function isReservedOutputName(name: string): boolean {
76+
return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase())
77+
}
78+
6579
/** Map a Start input field type to the editor sub-block type used to collect it. */
6680
function subBlockTypeForField(fieldType: string): SubBlockType {
6781
switch (fieldType) {
@@ -122,6 +136,7 @@ export function buildCustomBlockConfig(
122136
type: row.type,
123137
name: row.name,
124138
description: row.description,
139+
sourceWorkflowId: row.workflowId,
125140
category: 'tools',
126141
longDescription:
127142
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +

apps/sim/blocks/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,12 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
468468
}
469469
}
470470
hideFromToolbar?: boolean
471+
/**
472+
* For published custom blocks only: the bound source workflow's id. Discovery
473+
* surfaces use it to hide a workflow's own block on that workflow's canvas
474+
* (placing it would recurse).
475+
*/
476+
sourceWorkflowId?: string
471477
/**
472478
* Marks an unreleased block. Preview blocks are hidden from every discovery
473479
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —

apps/sim/ee/custom-blocks/components/custom-block-detail.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/compo
3232
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
3333
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
3434
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
35-
import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-config'
35+
import {
36+
type CustomBlockInput,
37+
type CustomBlockOutput,
38+
isReservedOutputName,
39+
} from '@/blocks/custom/build-config'
3640
import { SettingRow } from '@/ee/components/setting-row'
3741
import {
3842
useCustomBlocks,
@@ -56,12 +60,12 @@ const decodeOutput = (value: string) => {
5660
: { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) }
5761
}
5862

59-
/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */
63+
/** Derive a unique, friendly output name from a dot-path, avoiding collisions and reserved names. */
6064
function deriveOutputName(path: string, taken: Set<string>): string {
6165
const base = (path.split('.').pop() || path).replace(/[^a-zA-Z0-9_]/g, '_')
6266
let name = base
6367
let n = 2
64-
while (taken.has(name)) name = `${base}_${n++}`
68+
while (taken.has(name) || isReservedOutputName(name)) name = `${base}_${n++}`
6569
taken.add(name)
6670
return name
6771
}
@@ -360,6 +364,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
360364
setError('Output names must be unique')
361365
return
362366
}
367+
const reserved = exposedOutputs.find((o) => isReservedOutputName(o.name))
368+
if (reserved) {
369+
setError(`"${reserved.name}" is a reserved output name (success, error, cost)`)
370+
return
371+
}
363372
// Only the placeholder and required flag are authored; the field set/name/type
364373
// are always derived from the deployed Start. Persist only non-empty overrides.
365374
const inputPlaceholders = visibleInputs

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,45 @@ describe('WorkflowBlockHandler', () => {
518518
})
519519
})
520520
})
521+
522+
describe('projectCustomBlockOutput', () => {
523+
const childResult = {
524+
success: true,
525+
output: { data: 'whole result' },
526+
logs: [{ blockId: 'b1', success: true, output: { data: { x: 42 }, price: 999 } }],
527+
}
528+
529+
it('maps each curated output to its named field plus system fields', () => {
530+
const result = (handler as any).projectCustomBlockOutput(
531+
childResult,
532+
[{ blockId: 'b1', path: 'data.x', name: 'answer' }],
533+
0.5
534+
)
535+
536+
expect(result).toEqual({ answer: 42, success: true, cost: { total: 0.5 } })
537+
})
538+
539+
it('never lets an exposed output named cost clobber the billed cost', () => {
540+
const result = (handler as any).projectCustomBlockOutput(
541+
childResult,
542+
[{ blockId: 'b1', path: 'price', name: 'cost' }],
543+
0.5
544+
)
545+
546+
expect(result.cost).toEqual({ total: 0.5 })
547+
expect(result.success).toBe(true)
548+
})
549+
550+
it('exposes the whole child result when no outputs are curated', () => {
551+
const result = (handler as any).projectCustomBlockOutput(childResult, [], 0.5)
552+
553+
expect(result).toEqual({
554+
success: true,
555+
result: { data: 'whole result' },
556+
cost: { total: 0.5 },
557+
})
558+
})
559+
})
521560
})
522561

523562
describe('remapCustomBlockInputKeys', () => {

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -888,14 +888,15 @@ export class WorkflowBlockHandler implements BlockHandler {
888888
return { success: true, result: executionResult.output ?? {}, ...cost }
889889
}
890890
const logs = executionResult.logs ?? []
891-
const output: Record<string, unknown> = { success: true, ...cost }
891+
const output: Record<string, unknown> = {}
892892
for (const { blockId, path, name } of exposedOutputs) {
893893
const log =
894894
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
895895
[...logs].reverse().find((l) => l.blockId === blockId)
896896
output[name] = log ? getValueAtPath(log.output, path) : undefined
897897
}
898-
return output as BlockOutput
898+
// System fields spread last — pre-validation rows may still name an output cost/success.
899+
return { ...output, success: true, ...cost } as BlockOutput
899900
}
900901

901902
private mapChildOutputToParent(

apps/sim/lib/api/contracts/custom-blocks.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22
import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
33
import { defineRouteContract } from '@/lib/api/contracts/types'
4+
import { isReservedOutputName } from '@/blocks/custom/build-config'
45

56
const inputFieldSchema = z.object({
67
/** Stable per-field id — preserved so client block configs key sub-blocks on it
@@ -36,6 +37,21 @@ const exposedOutputSchema = z.object({
3637
name: z.string().min(1).max(60),
3738
})
3839

40+
/**
41+
* Publish/update variant: rejects reserved system output names (`success`,
42+
* `error`, `cost`) that would shadow the block's own projected fields. The
43+
* read schema stays lenient so rows that predate this validation still parse.
44+
*/
45+
const exposedOutputWriteSchema = exposedOutputSchema.extend({
46+
name: z
47+
.string()
48+
.min(1)
49+
.max(60)
50+
.refine((name) => !isReservedOutputName(name), {
51+
message: 'Output name is reserved (success, error, cost)',
52+
}),
53+
})
54+
3955
export const customBlockSchema = z.object({
4056
id: z.string(),
4157
organizationId: z.string(),
@@ -87,7 +103,7 @@ export const publishCustomBlockBodySchema = z.object({
87103
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
88104
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
89105
/** Curated outputs; omit/empty to expose the child's whole result. */
90-
exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
106+
exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
91107
})
92108

93109
export type PublishCustomBlockBody = z.input<typeof publishCustomBlockBodySchema>
@@ -104,7 +120,7 @@ export const updateCustomBlockBodySchema = z
104120
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
105121
iconUrl: iconUrlSchema.nullable().optional(),
106122
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
107-
exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
123+
exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
108124
})
109125
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
110126

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/billing/core/subscription', () => ({
7+
isOrganizationOnEnterprisePlan: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/core/config/feature-flags', () => ({
11+
isFeatureEnabled: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/workflows/input-format', () => ({
15+
extractInputFieldsFromBlocks: vi.fn(),
16+
}))
17+
18+
vi.mock('@/lib/workflows/persistence/utils', () => ({
19+
loadDeployedWorkflowState: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
23+
getWorkspaceWithOwner: vi.fn(),
24+
}))
25+
26+
import {
27+
CustomBlockValidationError,
28+
publishCustomBlock,
29+
updateCustomBlock,
30+
} from '@/lib/workflows/custom-blocks/operations'
31+
32+
const publishParams = {
33+
organizationId: 'org-1',
34+
workspaceId: 'ws-1',
35+
workflowId: 'wf-1',
36+
userId: 'user-1',
37+
name: 'Enrich Lead',
38+
description: '',
39+
}
40+
41+
describe('reserved exposed-output names', () => {
42+
it('publishCustomBlock rejects an output named cost', async () => {
43+
await expect(
44+
publishCustomBlock({
45+
...publishParams,
46+
exposedOutputs: [{ blockId: 'b1', path: 'price', name: 'cost' }],
47+
})
48+
).rejects.toThrow(CustomBlockValidationError)
49+
})
50+
51+
it('publishCustomBlock rejects reserved names case-insensitively', async () => {
52+
await expect(
53+
publishCustomBlock({
54+
...publishParams,
55+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'Success' }],
56+
})
57+
).rejects.toThrow('"Success" is a reserved output name (success, error, cost)')
58+
})
59+
60+
it('updateCustomBlock rejects a reserved output name', async () => {
61+
await expect(
62+
updateCustomBlock('cb-1', {
63+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'error' }],
64+
})
65+
).rejects.toThrow(CustomBlockValidationError)
66+
})
67+
})

0 commit comments

Comments
 (0)