@@ -1062,4 +1062,247 @@ describe('WorkflowOrchestrator P3 — agentType / model / isolation / schema', (
10621062 await dispatch ( 'extract' , { schema : { type : 'object' } } ) ;
10631063 expect ( calls [ 0 ] . eventEmitterAttached ) . toBe ( true ) ;
10641064 } ) ;
1065+
1066+ // R1 self-review (P3-T6 gap): the schema-mode state machine in
1067+ // createSchemaEventEmitter has a `state.result === null` guard that
1068+ // allows the model to RECOVER from earlier failed attempts. Only the
1069+ // 0-failure and 3+-failure boundaries were tested; the 1-failure and
1070+ // 2-failure recovery transitions had no coverage. A regression
1071+ // inverting the guard, or one where pendingArgs cleanup discards the
1072+ // recovered args, would slip past the previous tests.
1073+ it ( 'schema-mode: success on 2nd attempt (1 nudge then valid) captures round-2 args' , async ( ) => {
1074+ const { config } = fakeConfigWithMgr ( {
1075+ onCreate : async ( ) => ( {
1076+ finalText : '' ,
1077+ terminateMode : 'CANCELLED' ,
1078+ runWithEmitter : ( emitter ) => {
1079+ // Round 1: invalid args, validation fails.
1080+ emitter . emit ( 'tool_call' , {
1081+ subagentId : 'sub' ,
1082+ round : 1 ,
1083+ callId : 'c1' ,
1084+ name : 'structured_output' ,
1085+ args : { bad : 'shape' } ,
1086+ description : '' ,
1087+ isOutputMarkdown : false ,
1088+ timestamp : 1 ,
1089+ } ) ;
1090+ emitter . emit ( 'tool_result' , {
1091+ subagentId : 'sub' ,
1092+ round : 1 ,
1093+ callId : 'c1' ,
1094+ name : 'structured_output' ,
1095+ success : false ,
1096+ error : 'validation failed' ,
1097+ responseParts : [ ] ,
1098+ resultDisplay : '' ,
1099+ durationMs : 1 ,
1100+ timestamp : 1 ,
1101+ } ) ;
1102+ // Round 2: corrected args, validation passes. Must be captured
1103+ // as the result, not the round-1 args.
1104+ emitter . emit ( 'tool_call' , {
1105+ subagentId : 'sub' ,
1106+ round : 2 ,
1107+ callId : 'c2' ,
1108+ name : 'structured_output' ,
1109+ args : { ok : true , attempt : 2 } ,
1110+ description : '' ,
1111+ isOutputMarkdown : false ,
1112+ timestamp : 2 ,
1113+ } ) ;
1114+ emitter . emit ( 'tool_result' , {
1115+ subagentId : 'sub' ,
1116+ round : 2 ,
1117+ callId : 'c2' ,
1118+ name : 'structured_output' ,
1119+ success : true ,
1120+ responseParts : [ ] ,
1121+ resultDisplay : '' ,
1122+ durationMs : 1 ,
1123+ timestamp : 2 ,
1124+ } ) ;
1125+ } ,
1126+ } ) ,
1127+ } ) ;
1128+ const dispatch = createProductionDispatch ( config ) ;
1129+ const result = await dispatch ( 'extract' , {
1130+ schema : { type : 'object' } ,
1131+ } ) ;
1132+ expect ( result ) . toEqual ( { ok : true , attempt : 2 } ) ;
1133+ } ) ;
1134+
1135+ it ( 'schema-mode: success on 3rd attempt (2 nudges then valid) captures round-3 args' , async ( ) => {
1136+ const { config } = fakeConfigWithMgr ( {
1137+ onCreate : async ( ) => ( {
1138+ finalText : '' ,
1139+ terminateMode : 'CANCELLED' ,
1140+ runWithEmitter : ( emitter ) => {
1141+ for ( let r = 1 ; r <= 2 ; r ++ ) {
1142+ emitter . emit ( 'tool_call' , {
1143+ subagentId : 'sub' ,
1144+ round : r ,
1145+ callId : `c${ r } ` ,
1146+ name : 'structured_output' ,
1147+ args : { bad : r } ,
1148+ description : '' ,
1149+ isOutputMarkdown : false ,
1150+ timestamp : r ,
1151+ } ) ;
1152+ emitter . emit ( 'tool_result' , {
1153+ subagentId : 'sub' ,
1154+ round : r ,
1155+ callId : `c${ r } ` ,
1156+ name : 'structured_output' ,
1157+ success : false ,
1158+ error : 'validation failed' ,
1159+ responseParts : [ ] ,
1160+ resultDisplay : '' ,
1161+ durationMs : 1 ,
1162+ timestamp : r ,
1163+ } ) ;
1164+ }
1165+ emitter . emit ( 'tool_call' , {
1166+ subagentId : 'sub' ,
1167+ round : 3 ,
1168+ callId : 'c3' ,
1169+ name : 'structured_output' ,
1170+ args : { ok : true , attempt : 3 } ,
1171+ description : '' ,
1172+ isOutputMarkdown : false ,
1173+ timestamp : 3 ,
1174+ } ) ;
1175+ emitter . emit ( 'tool_result' , {
1176+ subagentId : 'sub' ,
1177+ round : 3 ,
1178+ callId : 'c3' ,
1179+ name : 'structured_output' ,
1180+ success : true ,
1181+ responseParts : [ ] ,
1182+ resultDisplay : '' ,
1183+ durationMs : 1 ,
1184+ timestamp : 3 ,
1185+ } ) ;
1186+ } ,
1187+ } ) ,
1188+ } ) ;
1189+ const dispatch = createProductionDispatch ( config ) ;
1190+ const result = await dispatch ( 'extract' , {
1191+ schema : { type : 'object' } ,
1192+ } ) ;
1193+ expect ( result ) . toEqual ( { ok : true , attempt : 3 } ) ;
1194+ } ) ;
1195+
1196+ // R1 self-review (P3-T6 gap): the disallowed-tool floor invariant
1197+ // declares "ALWAYS applies regardless of agentType". The
1198+ // single-option tests above exercise floor+agentType and schema
1199+ // separately, but not their composition. A regression making the
1200+ // floor conditional on schema being unset (e.g. mistakenly moving
1201+ // the union inside an `if (opts.schema === undefined)` branch)
1202+ // would pass the existing tests.
1203+ it ( 'schema-mode + agentType: floor disallowedTools still unioned' , async ( ) => {
1204+ const { config, calls } = fakeConfigWithMgr ( {
1205+ findSubagentByName : async ( ) => ( {
1206+ name : 'Permissive' ,
1207+ description : 'allows SendMessage explicitly' ,
1208+ systemPrompt : 'permissive' ,
1209+ level : 'project' ,
1210+ disallowedTools : [ 'Foo' ] ,
1211+ } ) ,
1212+ onCreate : async ( _call , _ee ) => ( {
1213+ finalText : '' ,
1214+ terminateMode : 'CANCELLED' ,
1215+ runWithEmitter : ( emitter ) => {
1216+ emitter . emit ( 'tool_call' , {
1217+ subagentId : 'sub' ,
1218+ round : 1 ,
1219+ callId : 'c1' ,
1220+ name : 'structured_output' ,
1221+ args : { ok : true } ,
1222+ description : '' ,
1223+ isOutputMarkdown : false ,
1224+ timestamp : 1 ,
1225+ } ) ;
1226+ emitter . emit ( 'tool_result' , {
1227+ subagentId : 'sub' ,
1228+ round : 1 ,
1229+ callId : 'c1' ,
1230+ name : 'structured_output' ,
1231+ success : true ,
1232+ responseParts : [ ] ,
1233+ resultDisplay : '' ,
1234+ durationMs : 1 ,
1235+ timestamp : 1 ,
1236+ } ) ;
1237+ } ,
1238+ } ) ,
1239+ } ) ;
1240+ const dispatch = createProductionDispatch ( config ) ;
1241+ await dispatch ( 'extract' , {
1242+ agentType : 'Permissive' ,
1243+ schema : { type : 'object' } ,
1244+ } ) ;
1245+ const disallowed = calls [ 0 ] . config . disallowedTools ?? [ ] ;
1246+ expect ( disallowed ) . toEqual (
1247+ expect . arrayContaining ( [ 'Foo' , 'send_message' , 'exit_plan_mode' ] ) ,
1248+ ) ;
1249+ } ) ;
1250+
1251+ // R1 self-review (P3-T6 gap): caller-abort taking priority over
1252+ // "completed without StructuredOutput" is a contract boundary the
1253+ // dispatch enforces at the explicit `if (signal?.aborted)` check.
1254+ // Without this test, a refactor removing the check would silently
1255+ // convert user-cancelled schema runs into schema-failure errors.
1256+ it ( 'schema-mode: caller abort takes priority over terminal "no structured_output" error' , async ( ) => {
1257+ const externalAbort = new AbortController ( ) ;
1258+ const { config } = fakeConfigWithMgr ( {
1259+ onCreate : async ( ) => ( {
1260+ finalText : '' ,
1261+ terminateMode : 'CANCELLED' ,
1262+ runWithEmitter : ( _emitter ) => {
1263+ // Caller-side abort fires while the subagent is in flight but
1264+ // before any structured_output call. After execute() returns,
1265+ // signal.aborted is true AND state.result is still null — the
1266+ // dispatch must throw AbortError, not the StructuredOutput
1267+ // terminal error.
1268+ externalAbort . abort ( ) ;
1269+ } ,
1270+ } ) ,
1271+ } ) ;
1272+ const dispatch = createProductionDispatch ( config , externalAbort . signal ) ;
1273+ await expect (
1274+ dispatch ( 'extract' , { schema : { type : 'object' } } ) ,
1275+ ) . rejects . toThrow ( / a b o r t e d / i) ;
1276+ } ) ;
1277+
1278+ // R1 self-review (P3-T6 gap): the override path's dispose() must run
1279+ // in a finally so per-agent MCP processes / hooks don't leak past the
1280+ // dispatch — including on the exception path. The test harness has a
1281+ // `disposed` counter that no test asserts on; this closes that gap on
1282+ // both the success and the thrown-from-execute paths.
1283+ it ( 'override path always calls dispose() on the success path' , async ( ) => {
1284+ // Use model-only override (no agentType) so we don't go through the
1285+ // SubagentManager resolution path. The ephemeral-default branch
1286+ // still routes through createAgentHeadless and therefore dispose().
1287+ const helper = fakeConfigWithMgr ( {
1288+ onCreate : async ( ) => ( { finalText : 'done' , terminateMode : 'GOAL' } ) ,
1289+ } ) ;
1290+ const dispatch = createProductionDispatch ( helper . config ) ;
1291+ await dispatch ( 'hi' , { model : 'qwen3-max' } ) ;
1292+ expect ( helper . disposed ) . toBeGreaterThanOrEqual ( 1 ) ;
1293+ } ) ;
1294+
1295+ it ( 'override path always calls dispose() even when terminateMode is non-GOAL' , async ( ) => {
1296+ const helper = fakeConfigWithMgr ( {
1297+ onCreate : async ( ) => ( {
1298+ finalText : '' ,
1299+ terminateMode : 'ERROR' , // non-GOAL → dispatch throws after execute
1300+ } ) ,
1301+ } ) ;
1302+ const dispatch = createProductionDispatch ( helper . config ) ;
1303+ await expect ( dispatch ( 'hi' , { model : 'qwen3-max' } ) ) . rejects . toThrow (
1304+ / t e r m i n a t e m o d e : E R R O R / ,
1305+ ) ;
1306+ expect ( helper . disposed ) . toBeGreaterThanOrEqual ( 1 ) ;
1307+ } ) ;
10651308} ) ;
0 commit comments