@@ -17,6 +17,8 @@ const hoisted = vi.hoisted(() => ({
1717 pruneLegacyStoreKeysMock : vi . fn ( ) ,
1818 registerSubagentRunMock : vi . fn ( ) ,
1919 emitSessionLifecycleEventMock : vi . fn ( ) ,
20+ dispatchGatewayMethodInProcessMock : vi . fn ( ) ,
21+ hasInProcessGatewayContextMock : vi . fn ( ) ,
2022 resolveAgentConfigMock : vi . fn ( ) ,
2123 configOverride : { } as Record < string , unknown > ,
2224} ) ) ;
@@ -67,6 +69,8 @@ describe("spawnSubagentDirect seam flow", () => {
6769 beforeAll ( async ( ) => {
6870 ( { resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest ( {
6971 callGatewayMock : hoisted . callGatewayMock ,
72+ dispatchGatewayMethodInProcessMock : hoisted . dispatchGatewayMethodInProcessMock ,
73+ hasInProcessGatewayContextMock : hoisted . hasInProcessGatewayContextMock ,
7074 getRuntimeConfig : ( ) => hoisted . configOverride ,
7175 loadSessionStoreMock : hoisted . loadSessionStoreMock ,
7276 updateSessionStoreMock : hoisted . updateSessionStoreMock ,
@@ -88,6 +92,8 @@ describe("spawnSubagentDirect seam flow", () => {
8892 hoisted . pruneLegacyStoreKeysMock . mockReset ( ) ;
8993 hoisted . registerSubagentRunMock . mockReset ( ) ;
9094 hoisted . emitSessionLifecycleEventMock . mockReset ( ) ;
95+ hoisted . dispatchGatewayMethodInProcessMock . mockReset ( ) ;
96+ hoisted . hasInProcessGatewayContextMock . mockReset ( ) . mockReturnValue ( false ) ;
9197 hoisted . resolveAgentConfigMock . mockReset ( ) ;
9298 hoisted . resolveAgentConfigMock . mockImplementation (
9399 ( cfg : { agents ?: { list ?: Array < { id ?: string } > } } , agentId : string ) =>
@@ -266,6 +272,76 @@ describe("spawnSubagentDirect seam flow", () => {
266272 expect ( agentParams . cleanupBundleMcpOnRunEnd ) . toBe ( true ) ;
267273 } ) ;
268274
275+ it ( "dispatches spawned agent runs in process when a gateway context is available" , async ( ) => {
276+ hoisted . hasInProcessGatewayContextMock . mockReturnValue ( true ) ;
277+ hoisted . callGatewayMock . mockRejectedValue ( new Error ( "unexpected websocket gateway call" ) ) ;
278+ hoisted . dispatchGatewayMethodInProcessMock . mockImplementation ( async ( method : string ) => {
279+ if ( method === "agent" ) {
280+ return { runId : "run-in-process" } ;
281+ }
282+ return { ok : true } ;
283+ } ) ;
284+
285+ const result = await spawnSubagentDirect (
286+ {
287+ task : "spawn without websocket self-connection" ,
288+ } ,
289+ {
290+ agentSessionKey : "agent:main:main" ,
291+ } ,
292+ ) ;
293+
294+ expect ( result . status ) . toBe ( "accepted" ) ;
295+ expect ( result . runId ) . toBe ( "run-in-process" ) ;
296+ expect ( hoisted . callGatewayMock ) . not . toHaveBeenCalled ( ) ;
297+ expect ( hoisted . dispatchGatewayMethodInProcessMock ) . toHaveBeenCalledWith (
298+ "agent" ,
299+ expect . objectContaining ( {
300+ message : expect . stringContaining ( "spawn without websocket self-connection" ) ,
301+ sessionKey : result . childSessionKey ,
302+ } ) ,
303+ expect . objectContaining ( {
304+ timeoutMs : expect . any ( Number ) ,
305+ } ) ,
306+ ) ;
307+ } ) ;
308+
309+ it ( "keeps admin-scoped cleanup on in-process spawn failure" , async ( ) => {
310+ hoisted . hasInProcessGatewayContextMock . mockReturnValue ( true ) ;
311+ hoisted . callGatewayMock . mockRejectedValue ( new Error ( "unexpected websocket gateway call" ) ) ;
312+ hoisted . dispatchGatewayMethodInProcessMock . mockImplementation ( async ( method : string ) => {
313+ if ( method === "agent" ) {
314+ throw new Error ( "spawn failed" ) ;
315+ }
316+ return { ok : true } ;
317+ } ) ;
318+
319+ const result = await spawnSubagentDirect (
320+ {
321+ task : "spawn failure cleanup" ,
322+ } ,
323+ {
324+ agentSessionKey : "agent:main:main" ,
325+ } ,
326+ ) ;
327+
328+ expect ( result . status ) . toBe ( "error" ) ;
329+ expect ( result . error ) . toContain ( "spawn failed" ) ;
330+ expect ( hoisted . callGatewayMock ) . not . toHaveBeenCalled ( ) ;
331+ expect ( hoisted . dispatchGatewayMethodInProcessMock ) . toHaveBeenCalledWith (
332+ "sessions.delete" ,
333+ expect . objectContaining ( {
334+ key : result . childSessionKey ,
335+ deleteTranscript : true ,
336+ } ) ,
337+ expect . objectContaining ( {
338+ forceSyntheticClient : true ,
339+ syntheticScopes : [ "operator.admin" ] ,
340+ timeoutMs : 60_000 ,
341+ } ) ,
342+ ) ;
343+ } ) ;
344+
269345 it ( "inherits requester thinking level when no spawn or subagent default is configured" , async ( ) => {
270346 let persistedStore : Record < string , Record < string , unknown > > | undefined ;
271347 hoisted . loadSessionStoreMock . mockReturnValue ( {
0 commit comments