Skip to content

Commit b4f42b3

Browse files
committed
fix(workflows): remap all tool canonical modes across API edits
1 parent 4039a47 commit b4f42b3

5 files changed

Lines changed: 389 additions & 5 deletions

File tree

apps/sim/lib/workflows/editing/builders.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { generateId, isValidUuid } from '@sim/utils/id'
3-
import { sortObjectKeysDeep } from '@sim/utils/object'
3+
import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object'
44
import {
55
type BlockRetryConfig,
66
normalizeBlockRetryTries,
@@ -19,8 +19,10 @@ import {
1919
} from '@/lib/permission-groups/operation-access'
2020
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
2121
import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility'
22+
import { remapToolCanonicalModes } from '@/lib/workflows/editing/tool-canonical-modes'
2223
import {
2324
buildCanonicalIndex,
25+
buildCanonicalIndexForSurface,
2426
buildDefaultCanonicalModes,
2527
isCanonicalPair,
2628
} from '@/lib/workflows/subblocks/visibility'
@@ -277,9 +279,13 @@ export function createBlockFromParams(
277279
}
278280

279281
export function updateCanonicalModesForInputs(
280-
block: { data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> } },
282+
block: {
283+
data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> }
284+
subBlocks?: Record<string, { value?: unknown }>
285+
},
281286
inputKeys: string[],
282-
blockConfig: BlockConfig
287+
blockConfig: BlockConfig,
288+
previousTools?: unknown
283289
): void {
284290
if (!blockConfig.subBlocks?.length) return
285291

@@ -308,6 +314,42 @@ export function updateCanonicalModesForInputs(
308314
if (!block.data.canonicalModes) block.data.canonicalModes = {}
309315
Object.assign(block.data.canonicalModes, canonicalModeUpdates)
310316
}
317+
318+
if (blockConfig.type === 'agent' && inputKeys.includes('tools')) {
319+
const tools = block.subBlocks?.tools?.value
320+
if (Array.isArray(tools)) {
321+
const canonicalModes = remapToolCanonicalModes(
322+
Array.isArray(previousTools) ? normalizeTools(previousTools) : [],
323+
tools,
324+
block.data?.canonicalModes ?? {},
325+
collectExplicitToolCanonicalModes(tools)
326+
)
327+
block.data = { ...block.data, canonicalModes }
328+
}
329+
}
330+
}
331+
332+
function collectExplicitToolCanonicalModes(tools: unknown[]) {
333+
const modes = new Map<number, Record<string, 'basic' | 'advanced'>>()
334+
tools.forEach((tool, index) => {
335+
if (!isRecordLike(tool)) return
336+
const choices: Record<string, 'basic' | 'advanced'> = {}
337+
const config = typeof tool.type === 'string' ? getBlock(tool.type) : undefined
338+
if (config && isRecordLike(tool.params)) {
339+
const params = tool.params
340+
const canonicalIndex = buildCanonicalIndexForSurface(config.subBlocks, false)
341+
for (const group of Object.values(canonicalIndex.groupsById)) {
342+
if (!isCanonicalPair(group) || !group.basicId) continue
343+
const hasBasic = params[group.basicId] !== undefined
344+
const hasAdvanced = group.advancedIds.some((id) => params[id] !== undefined)
345+
if (hasBasic !== hasAdvanced) {
346+
choices[group.canonicalId] = hasAdvanced ? 'advanced' : 'basic'
347+
}
348+
}
349+
}
350+
if (Object.keys(choices).length) modes.set(index, choices)
351+
})
352+
return modes
311353
}
312354

313355
/**

apps/sim/lib/workflows/editing/operations.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,3 +1169,89 @@ describe('permission-group tool access', () => {
11691169
)
11701170
})
11711171
})
1172+
1173+
describe('API tool canonical mode remapping', () => {
1174+
const first = {
1175+
type: 'jira',
1176+
operation: 'get_issue',
1177+
params: { projectId: 'project-a', manualProjectId: '<Start.projectA>' },
1178+
}
1179+
const second = {
1180+
type: 'jira',
1181+
operation: 'get_issue',
1182+
params: { projectId: 'project-b', manualProjectId: '<Start.projectB>' },
1183+
}
1184+
1185+
it.each([
1186+
{ operation_type: 'edit', explicit: false },
1187+
{ operation_type: 'insert_into_subflow', explicit: false },
1188+
{ operation_type: 'edit', explicit: true },
1189+
{ operation_type: 'insert_into_subflow', explicit: true },
1190+
] as const)(
1191+
'moves selector modes during $operation_type (explicit choice: $explicit)',
1192+
({ operation_type, explicit }) => {
1193+
const blockId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
1194+
const workflow = {
1195+
blocks: {
1196+
[blockId]: {
1197+
id: blockId,
1198+
type: 'agent',
1199+
name: 'Agent',
1200+
position: { x: 0, y: 0 },
1201+
enabled: true,
1202+
outputs: {},
1203+
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [first, second] } },
1204+
data: {
1205+
canonicalModes: {
1206+
'0:projectId': 'advanced' as const,
1207+
'1:projectId': 'basic' as const,
1208+
'0:issueKey': 'basic' as const,
1209+
model: 'advanced' as const,
1210+
},
1211+
},
1212+
},
1213+
loop: {
1214+
id: 'loop',
1215+
type: 'loop',
1216+
name: 'Loop',
1217+
position: { x: 0, y: 0 },
1218+
enabled: true,
1219+
outputs: {},
1220+
subBlocks: {},
1221+
data: { loopType: 'for', count: 2 },
1222+
},
1223+
},
1224+
edges: [],
1225+
loops: {},
1226+
parallels: {},
1227+
}
1228+
const result = applyOperationsToWorkflowState(workflow, [
1229+
{
1230+
operation_type,
1231+
block_id: blockId,
1232+
params: {
1233+
...(operation_type === 'insert_into_subflow'
1234+
? { subflowId: 'loop', type: 'agent', name: 'Agent' }
1235+
: {}),
1236+
inputs: {
1237+
tools: structuredClone([
1238+
second,
1239+
explicit ? { ...first, params: { projectId: 'edited-project' } } : first,
1240+
]),
1241+
},
1242+
},
1243+
},
1244+
])
1245+
expect(result.validationErrors).toEqual([])
1246+
expect(result.state.blocks[blockId].subBlocks.tools.value[0].params.projectId).toBe(
1247+
'project-b'
1248+
)
1249+
expect(result.state.blocks[blockId].data.canonicalModes).toEqual({
1250+
'0:projectId': 'basic',
1251+
'1:projectId': explicit ? 'basic' : 'advanced',
1252+
'1:issueKey': 'basic',
1253+
model: 'advanced',
1254+
})
1255+
}
1256+
)
1257+
})

apps/sim/lib/workflows/editing/operations.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,12 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
572572

573573
const editBlockConfig = getBlock(block.type)
574574
if (editBlockConfig) {
575-
updateCanonicalModesForInputs(block, [...explicitInputKeys], editBlockConfig)
575+
updateCanonicalModesForInputs(
576+
block,
577+
[...explicitInputKeys],
578+
editBlockConfig,
579+
previousSubBlockValues.get('tools')
580+
)
576581

577582
const changedInputKeys = editBlockConfig.subBlocks
578583
.filter((subBlock) => {
@@ -957,6 +962,7 @@ export function handleInsertIntoSubflowOperation(
957962

958963
// Update inputs if provided (with validation)
959964
if (params.inputs) {
965+
const previousTools = existingBlock.subBlocks?.tools?.value
960966
// Validate inputs against block configuration
961967
const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id)
962968
validationErrors.push(...validationResult.errors)
@@ -1014,7 +1020,8 @@ export function handleInsertIntoSubflowOperation(
10141020
updateCanonicalModesForInputs(
10151021
existingBlock,
10161022
Object.keys(validationResult.validInputs),
1017-
existingBlockConfig
1023+
existingBlockConfig,
1024+
previousTools
10181025
)
10191026
}
10201027
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it } from 'vitest'
3+
import { remapToolCanonicalModes } from '@/lib/workflows/editing/tool-canonical-modes'
4+
5+
const first = { type: 'jira', operation: 'get_issue', params: { projectId: 'first' } }
6+
const second = { type: 'jira', operation: 'get_issue', params: { projectId: 'second' } }
7+
8+
describe('remapToolCanonicalModes', () => {
9+
it('moves every indexed field and preserves block-level and legacy keys', () => {
10+
expect(
11+
remapToolCanonicalModes([first, second], structuredClone([second, first]), {
12+
'0:projectId': 'advanced',
13+
'0:issueKey': 'basic',
14+
'1:projectId': 'basic',
15+
model: 'advanced',
16+
'jira:issueKey': 'advanced',
17+
})
18+
).toEqual({
19+
'1:projectId': 'advanced',
20+
'1:issueKey': 'basic',
21+
'0:projectId': 'basic',
22+
model: 'advanced',
23+
'jira:issueKey': 'advanced',
24+
})
25+
})
26+
27+
it('reserves exact matches before matching an edited duplicate by callable identity', () => {
28+
const edited = { ...first, params: { projectId: 'edited' } }
29+
expect(
30+
remapToolCanonicalModes([first, second], [edited, first], {
31+
'0:projectId': 'basic',
32+
'1:projectId': 'advanced',
33+
})
34+
).toEqual({ '1:projectId': 'basic', '0:projectId': 'advanced' })
35+
})
36+
37+
it('drops removed and stale indexes so replacements do not inherit settings', () => {
38+
expect(
39+
remapToolCanonicalModes([first], [{ type: 'slack' }], {
40+
'0:projectId': 'advanced',
41+
'9:channel': 'basic',
42+
model: 'basic',
43+
})
44+
).toEqual({ model: 'basic' })
45+
})
46+
47+
it('clears indexed modes for an empty list', () => {
48+
expect(
49+
remapToolCanonicalModes([first], [], {
50+
'0:projectId': 'advanced',
51+
model: 'basic',
52+
})
53+
).toEqual({ model: 'basic' })
54+
})
55+
56+
it('preserves separate modes for identical tools when the array is unchanged', () => {
57+
const modes = { '0:projectId': 'basic', '1:projectId': 'advanced' } as const
58+
expect(remapToolCanonicalModes([first, first], structuredClone([first, first]), modes)).toEqual(
59+
modes
60+
)
61+
})
62+
63+
it('ignores visual-only changes on an unchanged duplicate list', () => {
64+
expect(
65+
remapToolCanonicalModes(
66+
[first, first],
67+
[{ ...first, isExpanded: true, title: 'Renamed' }, first],
68+
{ '1:projectId': 'advanced' }
69+
)
70+
).toEqual({ '1:projectId': 'advanced' })
71+
})
72+
73+
it('rejects indistinguishable duplicates with different saved modes', () => {
74+
expect(() =>
75+
remapToolCanonicalModes([first, first], [first], {
76+
'0:projectId': 'basic',
77+
'1:projectId': 'advanced',
78+
})
79+
).toThrow('ambiguous canonical modes')
80+
})
81+
82+
it('permits ambiguous duplicates when their saved modes agree', () => {
83+
expect(
84+
remapToolCanonicalModes([first, first], [first], {
85+
'0:projectId': 'advanced',
86+
'1:projectId': 'advanced',
87+
})
88+
).toEqual({ '0:projectId': 'advanced' })
89+
})
90+
91+
it('uses an explicit field selection to resolve ambiguity', () => {
92+
expect(
93+
remapToolCanonicalModes(
94+
[first, first],
95+
[first],
96+
{
97+
'0:projectId': 'basic',
98+
'1:projectId': 'advanced',
99+
},
100+
new Map([[0, { projectId: 'basic' }]])
101+
)
102+
).toEqual({ '0:projectId': 'basic' })
103+
})
104+
105+
it('does not let one explicit selection hide a conflict in another field', () => {
106+
expect(() =>
107+
remapToolCanonicalModes(
108+
[first, first],
109+
[first],
110+
{
111+
'0:projectId': 'basic',
112+
'1:projectId': 'advanced',
113+
'1:issueKey': 'advanced',
114+
},
115+
new Map([[0, { projectId: 'basic' }]])
116+
)
117+
).toThrow('ambiguous canonical modes')
118+
})
119+
120+
it.each([
121+
[
122+
{ type: 'custom-tool', customToolId: 'one' },
123+
{ type: 'custom-tool', customToolId: 'two' },
124+
],
125+
[
126+
{ type: 'mcp', params: { serverId: 'one', toolName: 'search' } },
127+
{ type: 'mcp', params: { serverId: 'two', toolName: 'search' } },
128+
],
129+
[
130+
{ type: 'mcp-server-advanced', params: { serverId: 'one' } },
131+
{ type: 'mcp-server-advanced', params: { serverId: 'two' } },
132+
],
133+
[
134+
{ type: 'workflow', params: { workflowId: 'one' } },
135+
{ type: 'workflow', params: { workflowId: 'two' } },
136+
],
137+
])('keeps callable and target identities distinct: %j', (a, b) => {
138+
expect(remapToolCanonicalModes([a, b], [b, a], { '0:field': 'advanced' })).toEqual({
139+
'1:field': 'advanced',
140+
})
141+
})
142+
143+
it('preserves all modes when reversing the maximum API tool count', () => {
144+
const tools = Array.from({ length: 100 }, (_, index) => ({
145+
...first,
146+
params: { projectId: `${index}` },
147+
}))
148+
const modes = Object.fromEntries(
149+
tools.map((_, index) => [`${index}:projectId`, index % 2 ? 'basic' : 'advanced'] as const)
150+
)
151+
const expected = Object.fromEntries(
152+
tools.map(
153+
(_, index) => [`${99 - index}:projectId`, index % 2 ? 'basic' : 'advanced'] as const
154+
)
155+
)
156+
expect(remapToolCanonicalModes(tools, [...tools].reverse(), modes)).toEqual(expected)
157+
})
158+
})

0 commit comments

Comments
 (0)