Skip to content

Commit 1183020

Browse files
committed
fix(servicenow): stop one subblock id from carrying two value spaces
Subblock values are stored per block keyed by id, so an id reused across operations keeps its value when the operation changes. Incident and change shared `state`, and `closeCode`, `closeNotes`, `comments`, and the knowledge search phrase were each reused for a different value space — so an incident state could be written onto a change request, an incident close code sent as a change close code, or an encoded query searched as knowledge text. Give each value space its own subblock and republish it to the tool param from the operation that owns it, the way targetState and approvalState already work. The generic Table API ids stay exactly as they are, since renaming one would orphan the stored value of every workflow already using those shipped tools. The previous guard only compared seeded defaults, which is why this class stayed hidden; the new one asserts against the merged params a tool actually receives.
1 parent 62094cc commit 1183020

2 files changed

Lines changed: 166 additions & 13 deletions

File tree

apps/sim/blocks/blocks/servicenow.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,29 @@ const SEMANTIC_DISPLAY_VALUE_OPS: ReadonlySet<string> = new Set([
100100
...SEMANTIC_WRITE_OPS,
101101
])
102102

103+
/** Operations whose tool takes a `state`, from whichever control owns that state model. */
104+
const SEMANTIC_STATE_OPS: ReadonlySet<string> = new Set([
105+
'servicenow_create_incident',
106+
'servicenow_list_incidents',
107+
'servicenow_update_incident',
108+
'servicenow_list_change_requests',
109+
'servicenow_update_change_request',
110+
'servicenow_update_change_state',
111+
'servicenow_list_approvals',
112+
])
113+
114+
/** Operations whose `state` comes from the change model rather than the incident one. */
115+
const CHANGE_STATE_OPS: ReadonlySet<string> = new Set([
116+
'servicenow_list_change_requests',
117+
'servicenow_update_change_request',
118+
])
119+
120+
/** Operations whose close code and notes are the change model's, not an incident's. */
121+
const CHANGE_CLOSE_OPS: ReadonlySet<string> = new Set([
122+
'servicenow_update_change_request',
123+
'servicenow_update_change_state',
124+
])
125+
103126
const OPTIONAL_CHOICE = { label: 'Any (not set)', id: '' } as const
104127

105128
const optionalChoices = <T extends { label: string; id: string }>(options: readonly T[]) => [
@@ -771,7 +794,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
771794
},
772795
// State dropdowns — one per state model
773796
{
774-
id: 'state',
797+
id: 'incidentState',
775798
title: 'Incident State',
776799
type: 'combobox',
777800
options: optionalChoices(INCIDENT_STATE_OPTIONS),
@@ -788,7 +811,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
788811
'Base-system incident states. Instances with a customized state model can use different coded values. Moving to On Hold also needs an On hold reason (Awaiting Caller, Awaiting Change, Awaiting Problem, or Awaiting Vendor), which you set through Additional Fields; Awaiting Caller additionally requires Additional Comments.',
789812
},
790813
{
791-
id: 'state',
814+
id: 'changeState',
792815
title: 'Change State',
793816
type: 'combobox',
794817
options: optionalChoices(CHANGE_STATE_OPTIONS),
@@ -1027,7 +1050,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10271050
mode: 'advanced',
10281051
},
10291052
{
1030-
id: 'comments',
1053+
id: 'incidentComments',
10311054
title: 'Additional Comments',
10321055
type: 'long-input',
10331056
placeholder: 'Customer-visible comment',
@@ -1038,7 +1061,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10381061
mode: 'advanced',
10391062
},
10401063
{
1041-
id: 'comments',
1064+
id: 'approvalComments',
10421065
title: 'Approval Comments',
10431066
type: 'long-input',
10441067
placeholder: 'Reason for the decision',
@@ -1062,7 +1085,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10621085
},
10631086
// Resolution and closure
10641087
{
1065-
id: 'closeCode',
1088+
id: 'resolutionCode',
10661089
title: 'Resolution Code',
10671090
type: 'short-input',
10681091
placeholder: 'A close_code choice configured on your instance',
@@ -1074,7 +1097,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10741097
description: 'The incident close_code choice list is configured per instance.',
10751098
},
10761099
{
1077-
id: 'closeCode',
1100+
id: 'changeCloseCode',
10781101
title: 'Close Code',
10791102
type: 'combobox',
10801103
options: optionalChoices(CHANGE_CLOSE_CODE_OPTIONS),
@@ -1086,7 +1109,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10861109
description: 'Required when moving a change request to Closed.',
10871110
},
10881111
{
1089-
id: 'closeNotes',
1112+
id: 'resolutionNotes',
10901113
title: 'Resolution Notes',
10911114
type: 'long-input',
10921115
placeholder: 'How the record was resolved',
@@ -1097,7 +1120,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
10971120
required: true,
10981121
},
10991122
{
1100-
id: 'closeNotes',
1123+
id: 'changeCloseNotes',
11011124
title: 'Close Notes',
11021125
type: 'long-input',
11031126
placeholder: 'Outcome of the change',
@@ -1378,7 +1401,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
13781401
},
13791402
// Knowledge
13801403
{
1381-
id: 'query',
1404+
id: 'knowledgeQuery',
13821405
title: 'Search Text',
13831406
type: 'short-input',
13841407
placeholder: 'vpn setup',
@@ -1554,6 +1577,15 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
15541577
semanticDisplayValue,
15551578
targetState,
15561579
approvalState,
1580+
incidentState,
1581+
changeState,
1582+
incidentComments,
1583+
approvalComments,
1584+
resolutionCode,
1585+
changeCloseCode,
1586+
resolutionNotes,
1587+
changeCloseNotes,
1588+
knowledgeQuery,
15571589
...rest
15581590
} = params
15591591
const isCreateOrUpdate =
@@ -1562,8 +1594,31 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
15621594
if (SEMANTIC_DISPLAY_VALUE_OPS.has(operation)) {
15631595
rest.displayValue = semanticDisplayValue
15641596
}
1565-
if (operation === 'servicenow_update_change_state') rest.state = targetState
1566-
if (operation === 'servicenow_list_approvals') rest.state = approvalState
1597+
1598+
/**
1599+
* Subblock values are stored per block keyed by subblock id, so an id
1600+
* reused across operations carries one value between them. Each of these
1601+
* tool params has more than one value space — an incident `state` is not
1602+
* a change `state`, and a knowledge search phrase is not an encoded
1603+
* query — so each gets its own subblock and is republished here for the
1604+
* operations that own it. Assign explicitly rather than conditionally:
1605+
* `finalInputs` merges this result over the raw inputs, so a key left
1606+
* off is not dropped, it keeps whatever the raw input held.
1607+
*/
1608+
const stateSource =
1609+
operation === 'servicenow_update_change_state'
1610+
? targetState
1611+
: operation === 'servicenow_list_approvals'
1612+
? approvalState
1613+
: CHANGE_STATE_OPS.has(operation)
1614+
? changeState
1615+
: incidentState
1616+
rest.state = SEMANTIC_STATE_OPS.has(operation) ? stateSource : undefined
1617+
rest.comments =
1618+
operation === 'servicenow_update_approval' ? approvalComments : incidentComments
1619+
rest.closeCode = CHANGE_CLOSE_OPS.has(operation) ? changeCloseCode : resolutionCode
1620+
rest.closeNotes = CHANGE_CLOSE_OPS.has(operation) ? changeCloseNotes : resolutionNotes
1621+
if (operation === 'servicenow_search_knowledge') rest.query = knowledgeQuery
15671622

15681623
if (attachmentLimit != null && attachmentLimit !== '') rest.limit = Number(attachmentLimit)
15691624
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
@@ -1645,6 +1700,15 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
16451700
state: { type: 'string', description: 'State coded value' },
16461701
targetState: { type: 'string', description: 'Target state for a change request transition' },
16471702
approvalState: { type: 'string', description: 'Approval state filter' },
1703+
incidentState: { type: 'string', description: 'Incident state coded value' },
1704+
changeState: { type: 'string', description: 'Change request state coded value' },
1705+
incidentComments: { type: 'string', description: 'Customer-visible incident comment' },
1706+
approvalComments: { type: 'string', description: 'Comment recorded with an approval decision' },
1707+
resolutionCode: { type: 'string', description: 'Incident resolution (close) code' },
1708+
changeCloseCode: { type: 'string', description: 'Change request close code' },
1709+
resolutionNotes: { type: 'string', description: 'Incident resolution notes' },
1710+
changeCloseNotes: { type: 'string', description: 'Change request close notes' },
1711+
knowledgeQuery: { type: 'string', description: 'Knowledge article search text' },
16481712
impact: { type: 'string', description: 'Impact coded value' },
16491713
urgency: { type: 'string', description: 'Urgency coded value' },
16501714
priority: { type: 'string', description: 'Priority coded value' },

apps/sim/tools/servicenow/servicenow.test.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ describe('block params mapping keeps per-operation defaults from colliding', ()
184184
return seeded
185185
}
186186

187+
/**
188+
* The block's mapping is merged over the raw inputs as
189+
* `{ ...inputs, ...mapped }`, so a key the mapper leaves off is not dropped —
190+
* it keeps whatever the subBlock store held. Assertions therefore have to be
191+
* made against the merged result, not the mapper's return value.
192+
*/
193+
function mergedParams(stored: Record<string, unknown>): Record<string, unknown> {
194+
const inputs = { ...seededDefaults(), ...auth, ...stored }
195+
const mapped = (mapParams?.(inputs as never) ?? {}) as Record<string, unknown>
196+
return { ...inputs, ...mapped }
197+
}
198+
187199
/**
188200
* Standing guard for the whole bug class: a subBlock id may legitimately be
189201
* reused across operations that feed the same tool param, but the definitions
@@ -275,6 +287,81 @@ describe('block params mapping keeps per-operation defaults from colliding', ()
275287
})
276288
})
277289

290+
describe('one subBlock id never carries two different value spaces', () => {
291+
const mapParams = ServiceNowBlock.tools.config?.params
292+
293+
function seededDefaults(): Record<string, unknown> {
294+
const seeded: Record<string, unknown> = {}
295+
for (const subBlock of ServiceNowBlock.subBlocks) {
296+
if (typeof subBlock.value === 'function') {
297+
seeded[subBlock.id] = (subBlock.value as (p: Record<string, never>) => unknown)({})
298+
}
299+
}
300+
return seeded
301+
}
302+
303+
function mergedParams(stored: Record<string, unknown>): Record<string, unknown> {
304+
const inputs = { ...seededDefaults(), ...auth, ...stored }
305+
const mapped = (mapParams?.(inputs as never) ?? {}) as Record<string, unknown>
306+
return { ...inputs, ...mapped }
307+
}
308+
309+
/**
310+
* Subblock values are stored per block keyed by id, so switching operations
311+
* leaves the previous operation's value in place. Where two operations mean
312+
* different things by the same tool param — an incident state versus a change
313+
* state, a close code from two different choice lists, a search phrase versus
314+
* an encoded query — they must not share a subBlock id, or the stale value
315+
* rides along and is written to the wrong record.
316+
*/
317+
it.each([
318+
['incidentState', 'changeState', 'state', 'servicenow_update_change_request', '6', '-2'],
319+
['changeState', 'incidentState', 'state', 'servicenow_update_incident', '-2', '6'],
320+
[
321+
'resolutionCode',
322+
'changeCloseCode',
323+
'closeCode',
324+
'servicenow_update_change_request',
325+
'Solved (Permanently)',
326+
'successful',
327+
],
328+
[
329+
'incidentComments',
330+
'approvalComments',
331+
'comments',
332+
'servicenow_update_approval',
333+
'visible to the caller',
334+
'approved by change board',
335+
],
336+
[
337+
'query',
338+
'knowledgeQuery',
339+
'query',
340+
'servicenow_search_knowledge',
341+
'active=true^priority=1',
342+
'vpn setup',
343+
],
344+
])(
345+
'a stale %s never reaches %s of the wrong operation',
346+
(staleId, ownId, param, operation, staleValue, ownValue) => {
347+
const leaked = mergedParams({ operation, [staleId]: staleValue })
348+
expect(leaked[param]).not.toBe(staleValue)
349+
350+
const kept = mergedParams({ operation, [staleId]: staleValue, [ownId]: ownValue })
351+
expect(kept[param]).toBe(ownValue)
352+
}
353+
)
354+
355+
it('leaves the generic Table API operations without a semantic state', () => {
356+
const merged = mergedParams({
357+
operation: 'servicenow_read_record',
358+
tableName: 'incident',
359+
incidentState: '6',
360+
})
361+
expect(merged.state).toBeUndefined()
362+
})
363+
})
364+
278365
describe('every subBlock a tool reads is one the tool actually declares', () => {
279366
const toolsById = new Map(Object.values(servicenowTools).map((tool) => [tool.id, tool] as const))
280367

@@ -323,14 +410,16 @@ describe('coded-value controls stay reachable on a customized instance', () => {
323410
* select-only.
324411
*/
325412
it.each([
326-
'state',
413+
'incidentState',
414+
'changeState',
327415
'targetState',
328416
'approvalState',
329417
'impact',
330418
'urgency',
331419
'priority',
332420
'type',
333-
'closeCode',
421+
'resolutionCode',
422+
'changeCloseCode',
334423
])('accepts a raw value for %s', (subBlockId) => {
335424
const matches = ServiceNowBlock.subBlocks.filter((subBlock) => subBlock.id === subBlockId)
336425
expect(matches.length).toBeGreaterThan(0)

0 commit comments

Comments
 (0)