Skip to content

Commit 68c1b10

Browse files
committed
fix(forks): make the post-sync collector honor tool param visibility too
Greptile caught a real asymmetry, and corrected a wrong assumption in the first commit: `needsConfiguration` is NOT warning-only. `promote.ts:1035` skips the target's redeploy for any workflow in that list, and `:792` also withholds its chat-deployment carry-over. So with only the pre-sync collector fixed, an Agent block whose Jira `issueKey` was populated in the target and cleared by a credential remap would let the sync through (the modal correctly treats a model-supplied param as non-blocking) and then silently decline to redeploy that workflow - leaving the fork running its previous deployed version with no gate and no error. Both collectors now resolve `required` through one shared `resolveToolParamRequired`, so the pre-sync gate and the promote path cannot disagree about what a nested tool param means. The lookup (sub-block id, then canonical param id, then fail closed to the block-level rule) lives in one place instead of being duplicated. The `@/tools/params` mock in remap-references.test.ts is now overridable so a test can opt into an authoritative resolution; its defaults are unchanged and the other 74 tests pass untouched. The new case is verified to fail without the fix.
1 parent 3ca8d29 commit 68c1b10

4 files changed

Lines changed: 120 additions & 25 deletions

File tree

apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ import {
1515
isNonEmptyValue,
1616
scopeCanonicalModesForTool,
1717
} from '@/lib/workflows/subblocks/visibility'
18-
import {
19-
isSubBlockRequired,
20-
isToolParamUserRequired,
21-
} from '@/lib/workflows/tool-input/param-visibility'
18+
import { resolveToolParamRequired } from '@/lib/workflows/tool-input/param-visibility'
2219
import { getBlock } from '@/blocks/registry'
2320
import type { SubBlockConfig } from '@/blocks/types'
2421
import { getDependsOnFields } from '@/blocks/utils'
@@ -93,7 +90,7 @@ interface EmitAnchoredParams {
9390
* Present ONLY for the nested `tool-input` pass: each param's resolved
9491
* {@link ParameterVisibility}, keyed by sub-block id and by canonical param id. Its presence
9592
* is what marks a dependent as a tool param rather than a block sub-block, so `required`
96-
* can apply the tool-row rule (see {@link isToolParamUserRequired}).
93+
* can apply the tool-row rule (see {@link resolveToolParamRequired}).
9794
*
9895
* Two cases fall back to the block-level `required`, failing closed: a param absent from
9996
* the map (custom-tool / MCP generic fallback, or an unresolvable tool id), and a param
@@ -230,16 +227,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
230227
// Testing `rawSourceValue` directly is sound: the dormant guard above has already
231228
// returned for any pair in advanced mode, so the pair is basic-active here and
232229
// `rawSourceValue` IS the group's active canonical value.
233-
const paramVisibility = paramVisibilityById
234-
? (paramVisibilityById.get(dependent.id) ??
235-
(dependent.canonicalParamId
236-
? paramVisibilityById.get(dependent.canonicalParamId)
237-
: undefined))
238-
: undefined
239-
const configuredRequired =
240-
paramVisibility !== undefined
241-
? isToolParamUserRequired({ required: dependent.required, paramVisibility }, values)
242-
: isSubBlockRequired(dependent.required, values)
230+
const configuredRequired = resolveToolParamRequired(dependent, values, paramVisibilityById)
243231
out.push({
244232
parentKind: anchor.parentKind,
245233
parentSourceId,

apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,25 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
66

77
// The indexer resolves a tool's params via the tool registry; stub it so the
88
// injected blockConfigs subBlocks drive resolution deterministically in tests.
9+
// Exposed as vi.fn()s (with the historical defaults) so a test that needs an
10+
// AUTHORITATIVE resolution - i.e. one carrying `paramVisibility` - can opt in.
11+
const { mockGetToolIdForOperation, mockGetSubBlocksForToolInput } = vi.hoisted(() => ({
12+
mockGetToolIdForOperation: vi.fn((): string | undefined => undefined),
13+
mockGetSubBlocksForToolInput: vi.fn(
14+
(
15+
_toolId: string,
16+
_type: string,
17+
_values: unknown,
18+
_modes: unknown,
19+
provided?: { subBlocks?: SubBlockConfig[] }
20+
) => ({ subBlocks: provided?.subBlocks ?? [] })
21+
),
22+
}))
23+
924
vi.mock('@/tools/params', () => ({
10-
getToolIdForOperation: () => undefined,
25+
getToolIdForOperation: mockGetToolIdForOperation,
1126
getToolParametersConfig: () => null,
12-
getSubBlocksForToolInput: (
13-
_toolId: string,
14-
_type: string,
15-
_values: unknown,
16-
_modes: unknown,
17-
provided?: { subBlocks?: SubBlockConfig[] }
18-
) => ({ subBlocks: provided?.subBlocks ?? [] }),
27+
getSubBlocksForToolInput: mockGetSubBlocksForToolInput,
1928
formatParameterLabel: (label: string) => label,
2029
}))
2130

@@ -1005,6 +1014,52 @@ describe('collectClearedDependents', () => {
10051014
},
10061015
])
10071016
})
1017+
1018+
it('does not mark a cleared model-supplied tool param as required', () => {
1019+
// The pre-sync modal treats a `user-or-llm` param as non-blocking (the agent fills it at
1020+
// runtime). This collector must agree: a `required` entry here makes promote SKIP the
1021+
// target's redeploy, so disagreeing would let a sync through and then silently withhold
1022+
// the deployment.
1023+
mockGetToolIdForOperation.mockReturnValueOnce('gmail_read')
1024+
vi.mocked(getBlock).mockImplementation((type) => {
1025+
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
1026+
if (type === 'gmail')
1027+
return blockWith([
1028+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
1029+
{
1030+
id: 'folder',
1031+
title: 'Label',
1032+
type: 'folder-selector',
1033+
dependsOn: ['credential'],
1034+
required: true,
1035+
paramVisibility: 'user-or-llm',
1036+
},
1037+
])
1038+
return undefined as unknown as BlockConfig
1039+
})
1040+
const targetDraft: SubBlockRecord = {
1041+
tools: entry('tools', 'tool-input', [
1042+
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-target', folder: 'INBOX' } },
1043+
]),
1044+
}
1045+
const merged: SubBlockRecord = {
1046+
tools: entry('tools', 'tool-input', [
1047+
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
1048+
]),
1049+
}
1050+
const result = collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged)
1051+
// Still surfaced (the value really was cleared), just not gating the redeploy.
1052+
expect(result).toEqual([
1053+
{
1054+
blockId: 'b1',
1055+
blockName: 'Agent',
1056+
subBlockKey: 'tools[0].folder',
1057+
title: 'Label',
1058+
toolName: 'Gmail',
1059+
required: false,
1060+
},
1061+
])
1062+
})
10081063
})
10091064

10101065
describe('applyDependentOverrides', () => {

apps/sim/ee/workspace-forking/lib/remap/remap-references.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import {
2929
resolveCanonicalMode,
3030
scopeCanonicalModesForTool,
3131
} from '@/lib/workflows/subblocks/visibility'
32-
import { isSubBlockRequired } from '@/lib/workflows/tool-input/param-visibility'
32+
import {
33+
isSubBlockRequired,
34+
resolveToolParamRequired,
35+
} from '@/lib/workflows/tool-input/param-visibility'
3336
import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types'
3437
import { getBlock } from '@/blocks/registry'
3538
import type { SubBlockConfig } from '@/blocks/types'
@@ -39,6 +42,7 @@ import {
3942
remapForkFileUploadValue,
4043
} from '@/ee/workspace-forking/lib/remap/remap-files'
4144
import { isEnvVarReference, isReference } from '@/executor/constants'
45+
import type { ParameterVisibility } from '@/tools/types'
4246

4347
/**
4448
* Resource kinds the fork remapper rewrites across workspaces, derived from the
@@ -1232,6 +1236,27 @@ function collectClearedToolParamDependents(
12321236
scopeCanonicalModesForTool(parentCanonicalModes, index, tool.type)
12331237
)
12341238
const toolLabel = typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
1239+
// Resolved visibility per param, so `required` here means the same thing it means in the
1240+
// pre-sync modal. Without this the two paths disagree: the modal would let a sync through
1241+
// (a model-supplied param is not the user's to fill) and then this collector would mark it
1242+
// required, which SKIPS the target's redeploy in `promote.ts` - leaving the fork silently
1243+
// running its previous deployed version.
1244+
const paramVisibilityById = new Map<string, ParameterVisibility | undefined>()
1245+
for (const resolved of getToolInputParamConfigs({
1246+
tool: { ...tool, type: tool.type, params: mergedParams },
1247+
toolIndex: index,
1248+
parentCanonicalModes,
1249+
})) {
1250+
if (!resolved.authoritative) continue
1251+
const visibility = resolved.config.paramVisibility
1252+
paramVisibilityById.set(resolved.paramId, visibility)
1253+
if (
1254+
resolved.config.canonicalParamId &&
1255+
!paramVisibilityById.has(resolved.config.canonicalParamId)
1256+
) {
1257+
paramVisibilityById.set(resolved.config.canonicalParamId, visibility)
1258+
}
1259+
}
12351260
for (const cfg of toolConfig.subBlocks) {
12361261
if (!cfg.dependsOn || !cfg.id) continue
12371262
// Only flag a param the TARGET tool had configured (not one the source carried in).
@@ -1247,7 +1272,7 @@ function collectClearedToolParamDependents(
12471272
subBlockKey: `${toolInputKey}[${index}].${cfg.id}`,
12481273
title: cfg.title ?? cfg.id,
12491274
toolName: toolLabel,
1250-
required: isSubBlockRequired(cfg.required, mergedValues),
1275+
required: resolveToolParamRequired(cfg, mergedValues, paramVisibilityById),
12511276
})
12521277
}
12531278
}

apps/sim/lib/workflows/tool-input/param-visibility.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ export function isUserSuppliedToolParam(config: Pick<SubBlockConfig, 'paramVisib
4242
return (config.paramVisibility ?? DEFAULT_TOOL_PARAM_VISIBILITY) === 'user-only'
4343
}
4444

45+
/**
46+
* Whether a `tool-input` param must be supplied by the user, resolving its visibility from a
47+
* map keyed by sub-block id and canonical param id.
48+
*
49+
* Shared by fork sync's PRE-sync collector (which decides whether a row blocks the Sync
50+
* button) and its POST-sync collector (whose `required` entries make promote SKIP the
51+
* target's redeploy). Those two must agree: if the modal lets a sync through because a param
52+
* is the model's to fill, the promote path must not then withhold the deployment for the
53+
* same param.
54+
*
55+
* `paramVisibilityById` omitted means the caller is not in a tool-input context (a block's
56+
* own sub-blocks), so the plain block-level rule applies. A param absent from the map, or
57+
* present with an `undefined` value, falls back the same way — failing closed.
58+
*/
59+
export function resolveToolParamRequired(
60+
config: Pick<SubBlockConfig, 'id' | 'required' | 'canonicalParamId'>,
61+
values: Record<string, unknown>,
62+
paramVisibilityById?: ReadonlyMap<string, ParameterVisibility | undefined>
63+
): boolean {
64+
if (!paramVisibilityById) return isSubBlockRequired(config.required, values)
65+
const visibility =
66+
paramVisibilityById.get(config.id) ??
67+
(config.canonicalParamId ? paramVisibilityById.get(config.canonicalParamId) : undefined)
68+
if (visibility === undefined) return isSubBlockRequired(config.required, values)
69+
return isToolParamUserRequired({ required: config.required, paramVisibility: visibility }, values)
70+
}
71+
4572
/**
4673
* Resolve a sub-block's `required` declaration against the surrounding values. `true` is
4774
* unconditional; the object form is structurally a `SubBlockCondition` evaluated against the

0 commit comments

Comments
 (0)