11/**
22 * @vitest -environment node
33 */
4- import { describe , expect , it , vi } from 'vitest'
4+ import { beforeEach , describe , expect , it , vi } from 'vitest'
55
66const { mockGetToolInputParamConfigs } = vi . hoisted ( ( ) => ( {
77 mockGetToolInputParamConfigs : vi . fn ( ( ) => [ ] as unknown [ ] ) ,
@@ -60,6 +60,14 @@ const replaceItem = {
6060// `deriveForkBlockId(...)` ids the expectations assert.
6161const 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+
6371describe ( '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
9871148describe ( 'collectForkResourceUsages' , ( ) => {
0 commit comments