Skip to content

Commit 3ca8d29

Browse files
committed
fix(forks): ask emptiness of the raw dependent value, not the coerced one
Pre-landing review caught a silent un-gating in the new predicate: it asked `isNonEmptyValue` about `rawSourceValue`, which flattens every non-string to `''` for the wire contract. A multi-select dependent selector stores an array, so a populated one reported blank and stopped gating the sync. `isNonEmptyValue` handles arrays and non-strings deliberately - give it the raw value. Reachable today via zoho-desk `departmentIds`, the one multi-select dependent selector with a `selectorKey` + `dependsOn`. Also from review: - the canonical id is an alias, so it no longer clobbers a param that owns that key as its own `paramId` (first write wins) - drop a redundant conjunct that implied a third state the code cannot reach - document the present-but-undefined visibility case, which the resolver's `buildToolInputSearchConfig` branch produces routinely - extract the placeholder-noun transform so a bare "Select" title falls back to the whole title instead of rendering "Select " / "No found", and test it Tests: non-string and empty-array source values, both verified to fail without the fix; the basic/advanced parity case now pins both shapes rather than calling `.every()` on an empty array; the indexer mock is reset per test so the new cases are not order-dependent.
1 parent 44e417a commit 3ca8d29

5 files changed

Lines changed: 239 additions & 15 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { dependentFieldNoun } from '@/ee/workspace-forking/components/fork-sync/dependent-field-noun'
6+
7+
describe('dependentFieldNoun', () => {
8+
it('strips a leading imperative verb so copy does not stutter', () => {
9+
// The defect this exists to prevent: `Select ${title.toLowerCase()}` on a title that
10+
// already reads as an instruction rendered "Select select issue".
11+
expect(dependentFieldNoun('Select Issue')).toBe('issue')
12+
expect(dependentFieldNoun('Select Project')).toBe('project')
13+
expect(dependentFieldNoun('Choose Document')).toBe('document')
14+
expect(dependentFieldNoun('Pick a Table')).toBe('a table')
15+
})
16+
17+
it('leaves a title that merely starts with those letters alone', () => {
18+
// The trailing `\s+` is what separates the verb from a word that begins with it.
19+
expect(dependentFieldNoun('Selected Files')).toBe('selected files')
20+
expect(dependentFieldNoun('Selection')).toBe('selection')
21+
})
22+
23+
it('falls back to the whole title when stripping would leave nothing', () => {
24+
// A bare verb has no noun to extract; an empty result would render "Select " and
25+
// "No found".
26+
expect(dependentFieldNoun('Select')).toBe('select')
27+
expect(dependentFieldNoun('Select ')).toBe('select ')
28+
})
29+
30+
it('passes a plain noun through lowercased', () => {
31+
expect(dependentFieldNoun('Label')).toBe('label')
32+
expect(dependentFieldNoun('Conflict Column')).toBe('conflict column')
33+
})
34+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Leading imperative verb on a field title. Titles are labels, and some already read as an
3+
* instruction ("Select Issue", "Choose Project"), so composing surrounding copy onto them
4+
* verbatim produces "Select select issue". The trailing `\s+` is load-bearing: it stops
5+
* "Selected Files" and "Selection" from being mangled into "ed Files" / "ion".
6+
*/
7+
const LEADING_IMPERATIVE_VERB = /^(?:select|choose|pick)\s+/i
8+
9+
/**
10+
* The bare noun of a dependent field's title, for copy that supplies its own verb
11+
* ("Select {noun}", "Search {noun}...", "No {noun} found").
12+
*
13+
* Falls back to the whole title when stripping would leave nothing — a title that is only a
14+
* verb has no noun to extract, and an empty noun would render "Select " and "No found".
15+
*/
16+
export function dependentFieldNoun(title: string): string {
17+
const stripped = title.replace(LEADING_IMPERATIVE_VERB, '').trim()
18+
return (stripped || title).toLowerCase()
19+
}

apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo } from 'react'
44
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
5+
import { dependentFieldNoun } from '@/ee/workspace-forking/components/fork-sync/dependent-field-noun'
56
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
67
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
78

@@ -46,10 +47,7 @@ export function DependentFieldSelector({
4647
[options]
4748
)
4849

49-
// A field title is a label, and some already read as an instruction ("Select Issue",
50-
// "Select Project"). Composing those directly produced "Select select issue", so strip a
51-
// leading verb to get the bare noun the surrounding copy supplies its own verb for.
52-
const noun = title.replace(/^select\s+/i, '').toLowerCase()
50+
const noun = dependentFieldNoun(title)
5351

5452
if (isLoading && enabled) {
5553
return (

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

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it, vi } from 'vitest'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const { mockGetToolInputParamConfigs } = vi.hoisted(() => ({
77
mockGetToolInputParamConfigs: vi.fn(() => [] as unknown[]),
@@ -60,6 +60,14 @@ const replaceItem = {
6060
// `deriveForkBlockId(...)` ids the expectations assert.
6161
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
6262

63+
// The indexer mock is module-scoped, so a `mockReturnValue` from one test would otherwise
64+
// leak into the next and make these order-dependent. Reset to the empty (no authoritative
65+
// visibility) default before each.
66+
beforeEach(() => {
67+
mockGetToolInputParamConfigs.mockReset()
68+
mockGetToolInputParamConfigs.mockReturnValue([])
69+
})
70+
6371
describe('collectForkDependentReconfigs', () => {
6472
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
6573
vi.mocked(getBlock).mockReturnValue(
@@ -837,6 +845,41 @@ describe('collectForkDependentReconfigs — blank source values never gate', ()
837845
expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: true })
838846
})
839847

848+
it('still gates a dependent whose source value is a non-string', () => {
849+
// A multi-select selector (e.g. zoho-desk `departmentIds`) stores an array. The wire
850+
// `sourceValue` coerces non-strings to '' - if the emptiness check read that coerced
851+
// value, a populated multi-select would report blank and silently stop gating.
852+
vi.mocked(getBlock).mockReturnValue(jiraProjectBlock())
853+
const states = new Map<string, WorkflowState>([
854+
[
855+
'wf-src',
856+
sourceState('jira', {
857+
credential: { value: 'cred-src' },
858+
operation: { value: 'write' },
859+
projectId: { value: ['PROJ-1'] as unknown as string },
860+
}),
861+
],
862+
])
863+
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
864+
expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: true })
865+
})
866+
867+
it('does not gate a dependent whose source value is an empty array', () => {
868+
vi.mocked(getBlock).mockReturnValue(jiraProjectBlock())
869+
const states = new Map<string, WorkflowState>([
870+
[
871+
'wf-src',
872+
sourceState('jira', {
873+
credential: { value: 'cred-src' },
874+
operation: { value: 'write' },
875+
projectId: { value: [] as unknown as string },
876+
}),
877+
],
878+
])
879+
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
880+
expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: false })
881+
})
882+
840883
it('reaches the same verdict in basic and advanced canonical mode', () => {
841884
vi.mocked(getBlock).mockReturnValue(jiraProjectBlock())
842885
const blankSubBlocks = {
@@ -861,10 +904,13 @@ describe('collectForkDependentReconfigs — blank source values never gate', ()
861904
new Map([['wf-src', advanced as unknown as WorkflowState]]),
862905
resolve
863906
)
864-
// Advanced drops the row entirely (dormant member); basic keeps it but non-blocking.
865-
// Neither may produce a required row from the same blank pair.
866-
expect(basicResult.every((f) => !f.required)).toBe(true)
867-
expect(advancedResult.every((f) => !f.required)).toBe(true)
907+
// Advanced drops the row entirely (the dormant-member guard), basic keeps it but
908+
// non-blocking. Pin BOTH shapes, not just `.every(...)`: over an empty array `.every`
909+
// is vacuously true, so an advanced path that regressed to emitting a required row
910+
// would still pass.
911+
expect(basicResult).toHaveLength(1)
912+
expect(basicResult[0]).toMatchObject({ subBlockKey: 'projectId', required: false })
913+
expect(advancedResult).toHaveLength(0)
868914
})
869915
})
870916

@@ -982,6 +1028,121 @@ describe('collectForkDependentReconfigs — nested tool params follow ParameterV
9821028
const issue = result.find((f) => f.subBlockKey === 'tools[0].issueKey')
9831029
expect(issue).toMatchObject({ required: true })
9841030
})
1031+
1032+
it('ignores a non-authoritative visibility rather than trusting it to un-gate', () => {
1033+
// A generic/inferred entry carries no reliable annotation, so it must not be able to
1034+
// turn a gating field into a non-gating one.
1035+
agentWithJiraTool()
1036+
mockGetToolInputParamConfigs.mockReturnValue([
1037+
{
1038+
paramId: 'issueKey',
1039+
authoritative: false,
1040+
config: { id: 'issueKey', type: 'short-input', paramVisibility: 'user-or-llm' },
1041+
value: undefined,
1042+
},
1043+
])
1044+
const result = collectForkDependentReconfigs(
1045+
[replaceItem],
1046+
stateWithIssueKey('ACME-999'),
1047+
resolve
1048+
)
1049+
expect(result.find((f) => f.subBlockKey === 'tools[0].issueKey')).toMatchObject({
1050+
required: true,
1051+
})
1052+
})
1053+
1054+
it('fails closed when an authoritative entry carries no visibility', () => {
1055+
// The real resolver's `uncoveredParams` branch builds its config via
1056+
// `buildToolInputSearchConfig`, which does NOT copy `paramVisibility` - so the map holds
1057+
// the key with an `undefined` value. That must fall back to the block-level `required`,
1058+
// not be read as "not user-only".
1059+
agentWithJiraTool()
1060+
mockGetToolInputParamConfigs.mockReturnValue([
1061+
{
1062+
paramId: 'issueKey',
1063+
authoritative: true,
1064+
config: { id: 'issueKey', type: 'short-input' },
1065+
value: undefined,
1066+
},
1067+
])
1068+
const result = collectForkDependentReconfigs(
1069+
[replaceItem],
1070+
stateWithIssueKey('ACME-999'),
1071+
resolve
1072+
)
1073+
expect(result.find((f) => f.subBlockKey === 'tools[0].issueKey')).toMatchObject({
1074+
required: true,
1075+
})
1076+
})
1077+
1078+
it('resolves visibility through the canonical param id when the sub-block id differs', () => {
1079+
// The resolver keys by its own paramId; a canonical pair's sub-block id can differ, so the
1080+
// map is double-keyed and the lookup falls back to `canonicalParamId`.
1081+
vi.mocked(getBlock).mockImplementation((type) => {
1082+
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
1083+
if (type === 'jira')
1084+
return blockWith([
1085+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
1086+
{
1087+
id: 'issueKeySelector',
1088+
title: 'Select Issue',
1089+
type: 'file-selector',
1090+
canonicalParamId: 'issueKey',
1091+
selectorKey: 'jira.issues',
1092+
dependsOn: ['credential'],
1093+
required: true,
1094+
},
1095+
])
1096+
return undefined as unknown as BlockConfig
1097+
})
1098+
mockGetToolInputParamConfigs.mockReturnValue([
1099+
{
1100+
paramId: 'issueKey',
1101+
authoritative: true,
1102+
config: { id: 'issueKey', type: 'file-selector', paramVisibility: 'user-or-llm' },
1103+
value: undefined,
1104+
},
1105+
])
1106+
const result = collectForkDependentReconfigs(
1107+
[replaceItem],
1108+
stateWithIssueKey('ACME-999'),
1109+
resolve
1110+
)
1111+
// Found via canonicalParamId -> user-or-llm -> not the user's to fill.
1112+
expect(result.find((f) => f.subBlockKey === 'tools[0].issueKeySelector')).toMatchObject({
1113+
required: false,
1114+
})
1115+
})
1116+
1117+
it('does not gate a blank user-only nested param', () => {
1118+
// Both invariants fire at once: user-only (so visibility would gate) but blank in the
1119+
// source (so there is nothing to carry across).
1120+
agentWithJiraTool()
1121+
mockGetToolInputParamConfigs.mockReturnValue(
1122+
resolvedParams({ issueKey: 'user-or-llm', domain: 'user-only' })
1123+
)
1124+
const states = new Map<string, WorkflowState>([
1125+
[
1126+
'wf-src',
1127+
sourceState('agent', {
1128+
tools: {
1129+
value: [
1130+
{
1131+
type: 'jira',
1132+
title: 'Jira',
1133+
operation: 'read',
1134+
params: { credential: 'cred-src', domain: '', issueKey: '' },
1135+
},
1136+
],
1137+
},
1138+
}),
1139+
],
1140+
])
1141+
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
1142+
expect(result.find((f) => f.subBlockKey === 'tools[0].domain')).toMatchObject({
1143+
required: false,
1144+
})
1145+
})
9851146
})
9861147

9871148
describe('collectForkResourceUsages', () => {

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,12 @@ interface EmitAnchoredParams {
9393
* Present ONLY for the nested `tool-input` pass: each param's resolved
9494
* {@link ParameterVisibility}, keyed by sub-block id and by canonical param id. Its presence
9595
* 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}). A param missing from
97-
* the map has no authoritative visibility (custom-tool / MCP generic fallback, or an
98-
* unresolvable tool id) and falls back to the block-level `required`, failing closed.
96+
* can apply the tool-row rule (see {@link isToolParamUserRequired}).
97+
*
98+
* Two cases fall back to the block-level `required`, failing closed: a param absent from
99+
* the map (custom-tool / MCP generic fallback, or an unresolvable tool id), and a param
100+
* present with an `undefined` value — the resolver's `buildToolInputSearchConfig` branch
101+
* does not copy `paramVisibility`, so an authoritative entry can still carry none.
99102
*/
100103
paramVisibilityById?: Map<string, ParameterVisibility | undefined>
101104
out: ForkDependentReconfig[]
@@ -234,7 +237,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
234237
: undefined))
235238
: undefined
236239
const configuredRequired =
237-
paramVisibilityById && paramVisibility !== undefined
240+
paramVisibility !== undefined
238241
? isToolParamUserRequired({ required: dependent.required, paramVisibility }, values)
239242
: isSubBlockRequired(dependent.required, values)
240243
out.push({
@@ -252,7 +255,11 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
252255
// The diff route overlays the stored/target-draft value onto `currentValue`;
253256
// `sourceValue` stays the raw source reference (the copy-resolved parent's seed).
254257
currentValue: rawSourceValue,
255-
required: configuredRequired && isNonEmptyValue(rawSourceValue),
258+
// Ask the emptiness question of the RAW value, not the string-coerced one:
259+
// `rawSourceValue` flattens every non-string (a multi-select selector stores an
260+
// array) to `''`, which would report a populated field as blank and silently
261+
// un-gate it. `isNonEmptyValue` handles arrays and non-strings on purpose.
262+
required: configuredRequired && isNonEmptyValue(rawDependentValue),
256263
providesContextKey,
257264
consumesContextKeys,
258265
context: dependentContext,
@@ -367,7 +374,12 @@ export function collectForkDependentReconfigs(
367374
if (!resolved.authoritative) continue
368375
const visibility = resolved.config.paramVisibility
369376
paramVisibilityById.set(resolved.paramId, visibility)
370-
if (resolved.config.canonicalParamId) {
377+
// The canonical id is an ALIAS, so it must never clobber a param that owns that
378+
// key as its own `paramId` - first (own-id) write wins.
379+
if (
380+
resolved.config.canonicalParamId &&
381+
!paramVisibilityById.has(resolved.config.canonicalParamId)
382+
) {
371383
paramVisibilityById.set(resolved.config.canonicalParamId, visibility)
372384
}
373385
}

0 commit comments

Comments
 (0)