@librechat/agents 3.4.0 → 3.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +165 -57
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  14. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  15. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  16. package/dist/cjs/hooks/index.cjs.map +1 -1
  17. package/dist/cjs/hooks/types.cjs +2 -0
  18. package/dist/cjs/hooks/types.cjs.map +1 -1
  19. package/dist/cjs/llm/init.cjs +3 -3
  20. package/dist/cjs/llm/invoke.cjs +2 -2
  21. package/dist/cjs/main.cjs +34 -24
  22. package/dist/cjs/messages/format.cjs +40 -23
  23. package/dist/cjs/messages/format.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +130 -53
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/session/AgentSession.cjs +37 -2
  27. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  28. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  29. package/dist/cjs/stream.cjs +1 -1
  30. package/dist/cjs/summarization/node.cjs +6 -3
  31. package/dist/cjs/summarization/node.cjs.map +1 -1
  32. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  33. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  35. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  36. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +321 -95
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  40. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  41. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  42. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  43. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  44. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  45. package/dist/cjs/tools/search/tool.cjs +4 -1
  46. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
  52. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  53. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  54. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  55. package/dist/cjs/tools/subagent/index.cjs +1 -0
  56. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  57. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  58. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  59. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  60. package/dist/cjs/utils/index.cjs +2 -2
  61. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  62. package/dist/esm/graphs/Graph.mjs +165 -57
  63. package/dist/esm/graphs/Graph.mjs.map +1 -1
  64. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  65. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  66. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  67. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  68. package/dist/esm/graphs/createGraph.mjs +15 -0
  69. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  70. package/dist/esm/graphs/index.mjs +1 -0
  71. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  72. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  73. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  74. package/dist/esm/hooks/executeHooks.mjs +60 -14
  75. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  76. package/dist/esm/hooks/index.mjs.map +1 -1
  77. package/dist/esm/hooks/types.mjs +2 -1
  78. package/dist/esm/hooks/types.mjs.map +1 -1
  79. package/dist/esm/llm/init.mjs +1 -1
  80. package/dist/esm/llm/invoke.mjs +2 -2
  81. package/dist/esm/main.mjs +21 -19
  82. package/dist/esm/messages/format.mjs +40 -23
  83. package/dist/esm/messages/format.mjs.map +1 -1
  84. package/dist/esm/run.mjs +131 -54
  85. package/dist/esm/run.mjs.map +1 -1
  86. package/dist/esm/session/AgentSession.mjs +37 -2
  87. package/dist/esm/session/AgentSession.mjs.map +1 -1
  88. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  89. package/dist/esm/stream.mjs +1 -1
  90. package/dist/esm/summarization/node.mjs +6 -3
  91. package/dist/esm/summarization/node.mjs.map +1 -1
  92. package/dist/esm/tools/BashExecutor.mjs +1 -1
  93. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  94. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  95. package/dist/esm/tools/SubagentTool.mjs +4 -4
  96. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  97. package/dist/esm/tools/ToolNode.mjs +322 -96
  98. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  99. package/dist/esm/tools/ToolSearch.mjs +1 -1
  100. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  101. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  102. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  103. package/dist/esm/tools/search/rerankers.mjs +154 -1
  104. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  105. package/dist/esm/tools/search/tool.mjs +4 -1
  106. package/dist/esm/tools/search/tool.mjs.map +1 -1
  107. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  108. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  109. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  111. package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
  112. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  113. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  114. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  115. package/dist/esm/tools/subagent/index.mjs +1 -0
  116. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  117. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  118. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  119. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +2 -2
  121. package/dist/types/agents/AgentContext.d.ts +1 -1
  122. package/dist/types/graphs/Graph.d.ts +22 -2
  123. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  124. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  125. package/dist/types/graphs/createGraph.d.ts +13 -0
  126. package/dist/types/graphs/graphFactory.d.ts +13 -0
  127. package/dist/types/graphs/index.d.ts +2 -0
  128. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  129. package/dist/types/hooks/executeHooks.d.ts +5 -1
  130. package/dist/types/hooks/index.d.ts +2 -2
  131. package/dist/types/hooks/types.d.ts +12 -0
  132. package/dist/types/run.d.ts +6 -3
  133. package/dist/types/session/AgentSession.d.ts +1 -0
  134. package/dist/types/tools/SubagentTool.d.ts +1 -1
  135. package/dist/types/tools/ToolNode.d.ts +23 -15
  136. package/dist/types/tools/search/rerankers.d.ts +28 -0
  137. package/dist/types/tools/search/types.d.ts +38 -1
  138. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  139. package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
  140. package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
  141. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  142. package/dist/types/tools/subagent/index.d.ts +1 -1
  143. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  144. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  145. package/dist/types/types/graph.d.ts +189 -24
  146. package/dist/types/types/hitl.d.ts +15 -0
  147. package/package.json +8 -2
  148. package/src/agents/AgentContext.ts +1 -1
  149. package/src/graphs/Graph.ts +349 -104
  150. package/src/graphs/MultiAgentGraph.ts +149 -47
  151. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  152. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  153. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  154. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  155. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  156. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  157. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  158. package/src/graphs/createGraph.ts +32 -0
  159. package/src/graphs/graphFactory.ts +12 -0
  160. package/src/graphs/index.ts +2 -0
  161. package/src/hooks/HookRegistry.ts +151 -1
  162. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  163. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  164. package/src/hooks/executeHooks.ts +133 -15
  165. package/src/hooks/index.ts +3 -1
  166. package/src/hooks/types.ts +16 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +214 -56
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/graph-subagent.live.test.ts +242 -0
  175. package/src/specs/graph-subagent.test.ts +322 -0
  176. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  177. package/src/specs/subagent.test.ts +130 -4
  178. package/src/summarization/__tests__/node.test.ts +32 -14
  179. package/src/summarization/node.ts +15 -11
  180. package/src/tools/SubagentTool.ts +4 -4
  181. package/src/tools/ToolNode.ts +643 -157
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
  183. package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
  184. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  185. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
  186. package/src/tools/__tests__/hitl.test.ts +234 -5
  187. package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
  188. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  189. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  190. package/src/tools/search/rerankers.ts +260 -0
  191. package/src/tools/search/tool.ts +6 -0
  192. package/src/tools/search/types.ts +45 -1
  193. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  194. package/src/tools/subagent/SubagentExecutor.ts +2232 -312
  195. package/src/tools/subagent/SubagentReplay.ts +584 -0
  196. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  197. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  198. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  199. package/src/tools/subagent/childGraphConfig.ts +694 -0
  200. package/src/tools/subagent/index.ts +6 -0
  201. package/src/tools/subagent/runtimeLimits.ts +7 -0
  202. package/src/tools/toolOutputReferences.ts +40 -1
  203. package/src/types/graph.ts +222 -24
  204. package/src/types/hitl.ts +16 -0
@@ -1,9 +1,12 @@
1
1
  import { describe, it, expect, beforeEach } from '@jest/globals';
2
+ import { GraphInterrupt, MemorySaver } from '@langchain/langgraph';
2
3
  import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
3
4
  import type { BaseMessage } from '@langchain/core/messages';
4
5
  import type {
5
6
  AgentInputs,
7
+ GraphSubagentConfig,
6
8
  ResolvedSubagentConfig,
9
+ ResolvedSingleAgentSubagentConfig,
7
10
  StandardGraphInput,
8
11
  SubagentUpdateEvent,
9
12
  SubagentUsageEvent,
@@ -13,12 +16,14 @@ import type {
13
16
  import type { StandardGraph } from '@/graphs/Graph';
14
17
  import {
15
18
  SubagentExecutor,
19
+ filterGraphSubagentResult,
16
20
  filterSubagentResult,
17
21
  resolveSubagentConfigs,
18
22
  buildChildInputs,
19
23
  summarizeEvent,
20
24
  } from '../subagent';
21
25
  import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
26
+ import { SUBAGENT_PARENT_BATCH_CONFIG_KEY } from '../subagent/SubagentReplay';
22
27
  import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
23
28
  import { StreamLimitExceededError } from '@/llm/streamLimits';
24
29
  import { AgentContext } from '@/agents/AgentContext';
@@ -37,8 +42,8 @@ const makeChildInputs = (agentId = 'child-agent'): AgentInputs => ({
37
42
 
38
43
  const makeConfig = (
39
44
  type = 'researcher',
40
- overrides: Partial<ResolvedSubagentConfig> = {}
41
- ): ResolvedSubagentConfig => ({
45
+ overrides: Partial<ResolvedSingleAgentSubagentConfig> = {}
46
+ ): ResolvedSingleAgentSubagentConfig => ({
42
47
  type,
43
48
  name: 'Test Researcher',
44
49
  description: 'Researches things',
@@ -47,6 +52,18 @@ const makeConfig = (
47
52
  });
48
53
 
49
54
  describe('filterSubagentResult', () => {
55
+ it('treats a result member with no new AI message as textless completion', () => {
56
+ expect(
57
+ filterGraphSubagentResult(
58
+ {
59
+ messages: [new AIMessage('worker output')],
60
+ subagentResult: { agentId: 'result' },
61
+ },
62
+ 'result'
63
+ )
64
+ ).toBe('Task completed');
65
+ });
66
+
50
67
  it('extracts text from last AIMessage string content', () => {
51
68
  const messages: BaseMessage[] = [
52
69
  new HumanMessage('task'),
@@ -218,7 +235,8 @@ describe('resolveSubagentConfigs', () => {
218
235
  const parentContext = AgentContext.fromConfig(parentInputs);
219
236
  const resolved = resolveSubagentConfigs([config], parentContext);
220
237
  expect(resolved).toHaveLength(1);
221
- expect(resolved[0].agentInputs.agentId).toBe('child-agent');
238
+ const resolvedConfig = resolved[0];
239
+ expect(resolvedConfig.agentInputs.agentId).toBe('child-agent');
222
240
  });
223
241
 
224
242
  it('resolves self-spawn from parent _sourceInputs', () => {
@@ -231,8 +249,9 @@ describe('resolveSubagentConfigs', () => {
231
249
  const parentContext = AgentContext.fromConfig(parentInputs);
232
250
  const resolved = resolveSubagentConfigs([selfConfig], parentContext);
233
251
  expect(resolved).toHaveLength(1);
234
- expect(resolved[0].agentInputs.provider).toBe(Providers.OPENAI);
235
- expect(resolved[0].agentInputs.instructions).toBe(
252
+ const resolvedConfig = resolved[0];
253
+ expect(resolvedConfig.agentInputs.provider).toBe(Providers.OPENAI);
254
+ expect(resolvedConfig.agentInputs.instructions).toBe(
236
255
  'You are a parent agent.'
237
256
  );
238
257
  });
@@ -349,8 +368,7 @@ describe('buildChildInputs', () => {
349
368
  /**
350
369
  * Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
351
370
  * spread of the parent's `_sourceInputs`, so the parent-scoped direct
352
- * tool would leak into a checkpointer-less child graph and fail with
353
- * `No checkpointer set` — must be scrubbed.
371
+ * tool would leak into the child implicitly it must be scrubbed.
354
372
  */
355
373
  const selfConfig: ResolvedSubagentConfig = {
356
374
  type: 'self',
@@ -365,9 +383,8 @@ describe('buildChildInputs', () => {
365
383
 
366
384
  /**
367
385
  * Explicit child config: a host that deliberately attaches its own
368
- * in-process direct tools to a child keeps them (Codex #289 P2
369
- * interrupt-capable tools still fail in children, but non-interrupting
370
- * direct tools are legitimate there).
386
+ * in-process direct tools to a child keeps them (Codex #289 P2). With
387
+ * HITL enabled, interrupt-capable child tools use the shared checkpointer.
371
388
  */
372
389
  const explicitConfig: ResolvedSubagentConfig = {
373
390
  type: 'researcher',
@@ -496,6 +513,281 @@ describe('SubagentExecutor', () => {
496
513
  expect(result.messages).toEqual([]);
497
514
  });
498
515
 
516
+ it('fails graph HITL before factories, hooks, events, or usage can run', async () => {
517
+ const graphConfig: GraphSubagentConfig = {
518
+ kind: 'graph',
519
+ type: 'graph-team',
520
+ name: 'Graph Team',
521
+ description: 'Runs a graph.',
522
+ agents: [makeChildInputs('graph-member')],
523
+ edges: [],
524
+ entryAgentId: 'graph-member',
525
+ resultAgentId: 'graph-member',
526
+ };
527
+ const createChildGraph = jest.fn(makeNoopGraphFactory());
528
+ const createChildGraphByKind = jest.fn(
529
+ (): StandardGraph => makeNoopGraphFactory()()
530
+ );
531
+ const hook = jest.fn(async (): Promise<Record<string, never>> => ({}));
532
+ const hookRegistry = new HookRegistry();
533
+ hookRegistry.register('SubagentStart', { hooks: [hook] });
534
+ const update = jest.fn();
535
+ const handlerRegistry = new HandlerRegistry();
536
+ handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
537
+ handle: update,
538
+ });
539
+ const usageSink = jest.fn();
540
+ const executor = createExecutor({
541
+ configs: new Map([[graphConfig.type, graphConfig]]),
542
+ humanInTheLoop: { enabled: true },
543
+ createChildGraph,
544
+ createChildGraphByKind,
545
+ hookRegistry,
546
+ parentHandlerRegistry: handlerRegistry,
547
+ usageSink,
548
+ });
549
+
550
+ const result = await executor.execute({
551
+ description: 'Do not run.',
552
+ subagentType: graphConfig.type,
553
+ });
554
+
555
+ expect(result.content).toBe(
556
+ 'Error: Human-in-the-loop execution is not yet supported for graph subagents.'
557
+ );
558
+ expect(createChildGraph).not.toHaveBeenCalled();
559
+ expect(createChildGraphByKind).not.toHaveBeenCalled();
560
+ expect(hook).not.toHaveBeenCalled();
561
+ expect(update).not.toHaveBeenCalled();
562
+ expect(usageSink).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('fails closed when resumable execution has no parent tool call ID', async () => {
566
+ const createChildGraph = jest.fn(makeNoopGraphFactory());
567
+ const executor = createExecutor({
568
+ humanInTheLoop: { enabled: true },
569
+ createChildGraph,
570
+ });
571
+
572
+ const result = await executor.execute({
573
+ description: 'Do something',
574
+ subagentType: 'researcher',
575
+ threadId: 'durable-thread',
576
+ });
577
+
578
+ expect(result.content).toContain('requires a parent tool call ID');
579
+ expect(result.messages).toEqual([]);
580
+ expect(createChildGraph).not.toHaveBeenCalled();
581
+ });
582
+
583
+ it('persists terminal replay outcomes without an active child workflow', async () => {
584
+ const checkpointer = new MemorySaver();
585
+ const hookRegistry = new HookRegistry();
586
+ hookRegistry.registerSession('original-run', 'PreToolUse', {
587
+ hooks: [async (): Promise<Record<string, never>> => ({})],
588
+ });
589
+ const initialExecutor = createExecutor({
590
+ checkpointer,
591
+ hookRegistry,
592
+ humanInTheLoop: { enabled: true },
593
+ parentRunId: 'original-run',
594
+ });
595
+ const call = {
596
+ id: 'call_terminal',
597
+ name: Constants.SUBAGENT,
598
+ args: {
599
+ description: 'blocked before execution',
600
+ subagent_type: 'researcher',
601
+ },
602
+ type: 'tool_call' as const,
603
+ };
604
+ const originalConfig = {
605
+ configurable: {
606
+ thread_id: 'durable-parent',
607
+ checkpoint_id: 'parent-fork',
608
+ run_id: 'original-run',
609
+ },
610
+ };
611
+ const settled = {
612
+ output: new ToolMessage({
613
+ content: 'Blocked: policy',
614
+ status: 'error' as const,
615
+ name: Constants.SUBAGENT,
616
+ tool_call_id: call.id,
617
+ }),
618
+ additionalContexts: ['persisted context'],
619
+ resolvedArgs: { description: 'rewritten' },
620
+ };
621
+
622
+ await initialExecutor.persistSettledToolOutput(
623
+ call,
624
+ originalConfig,
625
+ settled
626
+ );
627
+
628
+ const rebuiltExecutor = createExecutor({
629
+ checkpointer,
630
+ hookRegistry,
631
+ humanInTheLoop: { enabled: true },
632
+ parentRunId: 'rebuilt-run',
633
+ });
634
+ const restored = await rebuiltExecutor.getSettledToolOutput(call, {
635
+ configurable: {
636
+ ...originalConfig.configurable,
637
+ run_id: 'rebuilt-run',
638
+ },
639
+ });
640
+
641
+ expect(restored?.output.content).toBe('Blocked: policy');
642
+ expect(restored?.additionalContexts).toEqual(['persisted context']);
643
+ expect(restored?.resolvedArgs).toEqual({ description: 'rewritten' });
644
+ expect(hookRegistry.hasHookFor('PreToolUse', 'original-run')).toBe(true);
645
+ expect(hookRegistry.hasHookFor('PreToolUse', 'rebuilt-run')).toBe(true);
646
+ });
647
+
648
+ it('persists an ordinary child error after the active workflow is released', async () => {
649
+ const checkpointer = new MemorySaver();
650
+ const invoke = jest.fn().mockRejectedValue(new Error('child failed'));
651
+ const executor = createExecutor({
652
+ checkpointer,
653
+ humanInTheLoop: { enabled: true },
654
+ createChildGraph: (): StandardGraph =>
655
+ ({
656
+ createWorkflow: () => ({
657
+ getState: jest.fn().mockResolvedValue({
658
+ values: {},
659
+ next: ['child-agent'],
660
+ tasks: [],
661
+ }),
662
+ invoke,
663
+ }),
664
+ clearHeavyState: jest.fn(),
665
+ }) as unknown as StandardGraph,
666
+ });
667
+ const call = {
668
+ id: 'call_error',
669
+ name: Constants.SUBAGENT,
670
+ args: { description: 'fail', subagent_type: 'researcher' },
671
+ type: 'tool_call' as const,
672
+ };
673
+ const runnableConfig = {
674
+ configurable: {
675
+ thread_id: 'durable-parent',
676
+ checkpoint_id: 'parent-fork',
677
+ run_id: 'original-run',
678
+ },
679
+ };
680
+ const result = await executor.execute({
681
+ description: 'fail',
682
+ subagentType: 'researcher',
683
+ threadId: 'durable-parent',
684
+ parentToolCallId: call.id,
685
+ parentConfigurable: runnableConfig.configurable,
686
+ });
687
+ expect(result.content).toBe('Subagent error: child failed');
688
+
689
+ await executor.persistSettledToolOutput(call, runnableConfig, {
690
+ output: new ToolMessage({
691
+ content: result.content,
692
+ name: Constants.SUBAGENT,
693
+ tool_call_id: call.id,
694
+ }),
695
+ additionalContexts: [],
696
+ });
697
+
698
+ const restored = await createExecutor({
699
+ checkpointer,
700
+ humanInTheLoop: { enabled: true },
701
+ }).getSettledToolOutput(call, runnableConfig);
702
+ expect(invoke).toHaveBeenCalledTimes(1);
703
+ expect(restored?.output.content).toBe('Subagent error: child failed');
704
+ });
705
+
706
+ it('continues a recovered in-progress child without reseeding its task', async () => {
707
+ const invoke = jest.fn().mockResolvedValue({
708
+ messages: [new AIMessage('continued')],
709
+ });
710
+ const executor = createExecutor({
711
+ checkpointer: new MemorySaver(),
712
+ humanInTheLoop: { enabled: true },
713
+ createChildGraph: (): StandardGraph =>
714
+ ({
715
+ createWorkflow: () => ({
716
+ getState: jest.fn().mockResolvedValue({
717
+ values: {},
718
+ next: ['child-agent'],
719
+ tasks: [],
720
+ }),
721
+ invoke,
722
+ }),
723
+ clearHeavyState: jest.fn(),
724
+ }) as unknown as StandardGraph,
725
+ });
726
+
727
+ const result = await executor.execute({
728
+ description: 'Do not duplicate this task',
729
+ subagentType: 'researcher',
730
+ threadId: 'durable-parent',
731
+ parentToolCallId: 'call_in_progress',
732
+ });
733
+
734
+ expect(result.content).toBe('continued');
735
+ expect(invoke).toHaveBeenCalledWith(null, expect.any(Object));
736
+ });
737
+
738
+ it('preserves an existing nested approval scope', async () => {
739
+ const nestedScope = {
740
+ run_id: 'grandchild-run',
741
+ agent_id: 'grandchild-agent',
742
+ subagent_type: 'grandchild',
743
+ parent_tool_call_id: 'call_grandchild',
744
+ };
745
+ const nestedInterrupt = new GraphInterrupt([
746
+ {
747
+ id: 'nested-interrupt',
748
+ value: {
749
+ type: 'tool_approval',
750
+ action_requests: [],
751
+ review_configs: [],
752
+ subagent: nestedScope,
753
+ },
754
+ },
755
+ ]);
756
+ const executor = createExecutor({
757
+ humanInTheLoop: { enabled: true },
758
+ createChildGraph: (): StandardGraph =>
759
+ ({
760
+ createWorkflow: () => ({
761
+ getState: jest.fn().mockResolvedValue({
762
+ values: {},
763
+ next: ['agent'],
764
+ tasks: [],
765
+ }),
766
+ invoke: jest.fn().mockRejectedValue(nestedInterrupt),
767
+ }),
768
+ clearHeavyState: jest.fn(),
769
+ }) as unknown as StandardGraph,
770
+ });
771
+
772
+ let received: GraphInterrupt | undefined;
773
+ try {
774
+ await executor.execute({
775
+ description: 'Delegate again',
776
+ subagentType: 'researcher',
777
+ threadId: 'durable-thread',
778
+ parentToolCallId: 'call_child',
779
+ });
780
+ } catch (error) {
781
+ if (error instanceof GraphInterrupt) {
782
+ received = error;
783
+ }
784
+ }
785
+
786
+ expect(received?.interrupts[0].value).toMatchObject({
787
+ subagent: nestedScope,
788
+ });
789
+ });
790
+
499
791
  it('rethrows a child stream-limit trip instead of converting it to a tool result', async () => {
500
792
  const executor = createExecutor({
501
793
  createChildGraph: (): StandardGraph =>
@@ -1393,6 +1685,7 @@ describe('SubagentExecutor', () => {
1393
1685
  checkpoint_id: 'parent-checkpoint-id',
1394
1686
  checkpoint_map: { parent: 'checkpoint' },
1395
1687
  checkpoint_ns: 'parent-checkpoint-ns',
1688
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch',
1396
1689
  requestBody: { messageId: 'msg-1' },
1397
1690
  thread_id: 'parent-thread',
1398
1691
  user: { id: 'user_abc' },
@@ -1409,11 +1702,260 @@ describe('SubagentExecutor', () => {
1409
1702
  expect(configurable.checkpoint_id).toBeUndefined();
1410
1703
  expect(configurable.checkpoint_map).toBeUndefined();
1411
1704
  expect(configurable.checkpoint_ns).toBeUndefined();
1705
+ expect(configurable[SUBAGENT_PARENT_BATCH_CONFIG_KEY]).toBeUndefined();
1412
1706
  expect(configurable.requestBody).toEqual({ messageId: 'msg-1' });
1413
1707
  expect(configurable.thread_id).toBe('parent-thread');
1414
1708
  expect(configurable.user).toEqual({ id: 'user_abc' });
1415
1709
  });
1416
1710
 
1711
+ it('isolates child checkpoints created from different parent forks', async () => {
1712
+ const invokedConfigs: Record<string, unknown>[] = [];
1713
+ const invoke = jest
1714
+ .fn()
1715
+ .mockImplementation(
1716
+ async (
1717
+ _input: unknown,
1718
+ config: Record<string, unknown>
1719
+ ): Promise<{ messages: BaseMessage[] }> => {
1720
+ invokedConfigs.push(config);
1721
+ return { messages: [new AIMessage('done')] };
1722
+ }
1723
+ );
1724
+ const executor = createExecutor({
1725
+ humanInTheLoop: { enabled: true },
1726
+ createChildGraph: (): StandardGraph =>
1727
+ ({
1728
+ createWorkflow: () => ({
1729
+ getState: jest.fn().mockResolvedValue({
1730
+ values: {},
1731
+ next: [],
1732
+ tasks: [],
1733
+ }),
1734
+ invoke,
1735
+ }),
1736
+ clearHeavyState: jest.fn(),
1737
+ }) as unknown as StandardGraph,
1738
+ });
1739
+ const common = {
1740
+ description: 'task',
1741
+ subagentType: 'researcher',
1742
+ threadId: 'parent-thread',
1743
+ parentToolCallId: 'call_shared',
1744
+ };
1745
+
1746
+ await executor.execute({
1747
+ ...common,
1748
+ parentConfigurable: {
1749
+ thread_id: 'parent-thread',
1750
+ checkpoint_id: 'fork-a',
1751
+ },
1752
+ });
1753
+ await executor.execute({
1754
+ ...common,
1755
+ parentConfigurable: {
1756
+ thread_id: 'parent-thread',
1757
+ checkpoint_id: 'fork-b',
1758
+ },
1759
+ });
1760
+
1761
+ expect(invoke).toHaveBeenCalledTimes(2);
1762
+ const childThreadIds = invokedConfigs.map(
1763
+ (config) =>
1764
+ (config.configurable as Record<string, unknown>).thread_id as string
1765
+ );
1766
+ expect(childThreadIds[0]).toMatch(/^subagent:/);
1767
+ expect(childThreadIds[1]).toMatch(/^subagent:/);
1768
+ expect(childThreadIds[0]).not.toBe(childThreadIds[1]);
1769
+ });
1770
+
1771
+ it('isolates reused tool-call IDs across assistant batches', async () => {
1772
+ const invokedThreadIds: string[] = [];
1773
+ const executor = createExecutor({
1774
+ humanInTheLoop: { enabled: true },
1775
+ createChildGraph: (): StandardGraph =>
1776
+ ({
1777
+ createWorkflow: () => ({
1778
+ getState: jest.fn().mockResolvedValue({
1779
+ values: {},
1780
+ next: [],
1781
+ tasks: [],
1782
+ }),
1783
+ invoke: jest
1784
+ .fn()
1785
+ .mockImplementation(
1786
+ async (
1787
+ _input: unknown,
1788
+ invokeConfig: Record<string, unknown>
1789
+ ): Promise<{ messages: BaseMessage[] }> => {
1790
+ invokedThreadIds.push(
1791
+ (invokeConfig.configurable as Record<string, unknown>)
1792
+ .thread_id as string
1793
+ );
1794
+ return { messages: [new AIMessage('done')] };
1795
+ }
1796
+ ),
1797
+ }),
1798
+ clearHeavyState: jest.fn(),
1799
+ }) as unknown as StandardGraph,
1800
+ });
1801
+ const common = {
1802
+ description: 'task',
1803
+ subagentType: 'researcher',
1804
+ threadId: 'parent-thread',
1805
+ parentToolCallId: 'call_reused',
1806
+ };
1807
+
1808
+ await executor.execute({
1809
+ ...common,
1810
+ parentConfigurable: {
1811
+ thread_id: 'parent-thread',
1812
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-1',
1813
+ },
1814
+ });
1815
+ await executor.execute({
1816
+ ...common,
1817
+ parentConfigurable: {
1818
+ thread_id: 'parent-thread',
1819
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-2',
1820
+ },
1821
+ });
1822
+
1823
+ expect(invokedThreadIds).toHaveLength(2);
1824
+ expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
1825
+ });
1826
+
1827
+ it('keeps ambiguous parent agent and tool-call components collision-free', async () => {
1828
+ const invokedThreadIds: string[] = [];
1829
+ const createChildGraph = (): StandardGraph =>
1830
+ ({
1831
+ createWorkflow: () => ({
1832
+ getState: jest.fn().mockResolvedValue({
1833
+ values: {},
1834
+ next: [],
1835
+ tasks: [],
1836
+ }),
1837
+ invoke: jest
1838
+ .fn()
1839
+ .mockImplementation(
1840
+ async (
1841
+ _input: unknown,
1842
+ invokeConfig: Record<string, unknown>
1843
+ ): Promise<{ messages: BaseMessage[] }> => {
1844
+ invokedThreadIds.push(
1845
+ (invokeConfig.configurable as Record<string, unknown>)
1846
+ .thread_id as string
1847
+ );
1848
+ return { messages: [new AIMessage('done')] };
1849
+ }
1850
+ ),
1851
+ }),
1852
+ clearHeavyState: jest.fn(),
1853
+ }) as unknown as StandardGraph;
1854
+ const common = {
1855
+ description: 'task',
1856
+ subagentType: 'researcher',
1857
+ threadId: 'parent',
1858
+ parentConfigurable: { thread_id: 'parent' },
1859
+ };
1860
+
1861
+ await createExecutor({
1862
+ checkpointer: new MemorySaver(),
1863
+ humanInTheLoop: { enabled: true },
1864
+ parentAgentId: 'a_b',
1865
+ createChildGraph,
1866
+ }).execute({ ...common, parentToolCallId: 'c' });
1867
+ await createExecutor({
1868
+ checkpointer: new MemorySaver(),
1869
+ humanInTheLoop: { enabled: true },
1870
+ parentAgentId: 'a',
1871
+ createChildGraph,
1872
+ }).execute({ ...common, parentToolCallId: 'b_c' });
1873
+
1874
+ expect(invokedThreadIds).toHaveLength(2);
1875
+ expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
1876
+ });
1877
+
1878
+ it('retains owned child checkpoint threads after heavy-state cleanup', async () => {
1879
+ const executor = createExecutor({
1880
+ checkpointer: new MemorySaver(),
1881
+ humanInTheLoop: { enabled: true },
1882
+ createChildGraph: (): StandardGraph =>
1883
+ ({
1884
+ createWorkflow: () => ({
1885
+ getState: jest.fn().mockResolvedValue({
1886
+ values: {},
1887
+ next: [],
1888
+ tasks: [],
1889
+ }),
1890
+ invoke: jest.fn().mockResolvedValue({
1891
+ messages: [new AIMessage('done')],
1892
+ }),
1893
+ }),
1894
+ clearHeavyState: jest.fn(),
1895
+ }) as unknown as StandardGraph,
1896
+ });
1897
+
1898
+ await executor.execute({
1899
+ description: 'task',
1900
+ subagentType: 'researcher',
1901
+ threadId: 'parent',
1902
+ parentToolCallId: 'call_owned',
1903
+ });
1904
+ const beforeCleanup = executor.getChildCheckpointThreadIds();
1905
+ executor.clearHeavyState();
1906
+
1907
+ expect(beforeCleanup).toHaveLength(1);
1908
+ expect(executor.getChildCheckpointThreadIds()).toEqual(beforeCleanup);
1909
+
1910
+ executor.resetCheckpointThreadIds();
1911
+ expect(executor.getChildCheckpointThreadIds()).toEqual([]);
1912
+ });
1913
+
1914
+ it('includes checkpoint threads from active descendant graphs', async () => {
1915
+ const grandchildThreadId = 'subagent:grandchild';
1916
+ const executor = createExecutor({
1917
+ checkpointer: new MemorySaver(),
1918
+ humanInTheLoop: { enabled: true },
1919
+ createChildGraph: (): StandardGraph =>
1920
+ ({
1921
+ createWorkflow: () => ({
1922
+ getState: jest.fn().mockResolvedValue({
1923
+ values: {},
1924
+ next: ['child-agent'],
1925
+ tasks: [],
1926
+ }),
1927
+ invoke: jest.fn().mockRejectedValue(
1928
+ new GraphInterrupt([
1929
+ {
1930
+ id: 'grandchild-interrupt',
1931
+ value: {
1932
+ type: 'tool_approval',
1933
+ action_requests: [],
1934
+ review_configs: [],
1935
+ },
1936
+ },
1937
+ ])
1938
+ ),
1939
+ }),
1940
+ getChildCheckpointThreadIds: jest.fn(() => [grandchildThreadId]),
1941
+ clearHeavyState: jest.fn(),
1942
+ }) as unknown as StandardGraph,
1943
+ });
1944
+
1945
+ await expect(
1946
+ executor.execute({
1947
+ description: 'nested task',
1948
+ subagentType: 'researcher',
1949
+ threadId: 'parent',
1950
+ parentToolCallId: 'call_child',
1951
+ })
1952
+ ).rejects.toBeInstanceOf(GraphInterrupt);
1953
+
1954
+ expect(executor.getChildCheckpointThreadIds()).toEqual(
1955
+ expect.arrayContaining([grandchildThreadId])
1956
+ );
1957
+ });
1958
+
1417
1959
  it('does not require parentConfigurable (back-compat with hosts that omit it)', async () => {
1418
1960
  const { factory, getInvokeConfig } = makeCapturingGraphFactory();
1419
1961
  const executor = createExecutor({ createChildGraph: factory });
@@ -1427,7 +1969,6 @@ describe('SubagentExecutor', () => {
1427
1969
  string,
1428
1970
  unknown
1429
1971
  >;
1430
- // Only thread_id (childRunId fallback) is set when no parent context is supplied.
1431
1972
  expect(Object.keys(configurable)).toEqual(['thread_id']);
1432
1973
  });
1433
1974
  });
@@ -1502,6 +2043,67 @@ describe('SubagentExecutor', () => {
1502
2043
  expect(input.agentType).toBe('researcher');
1503
2044
  });
1504
2045
 
2046
+ it('finishes missing child lifecycle after recovering a completed checkpoint', async () => {
2047
+ const stopHook = jest.fn().mockResolvedValue({});
2048
+ const hookRegistry = new HookRegistry();
2049
+ hookRegistry.register('SubagentStop', { hooks: [stopHook] });
2050
+ const updates: SubagentUpdateEvent[] = [];
2051
+ const handlerRegistry = new HandlerRegistry();
2052
+ handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2053
+ handle: (_event, data): void => {
2054
+ updates.push(data as SubagentUpdateEvent);
2055
+ },
2056
+ });
2057
+ const invoke = jest.fn();
2058
+ const updateState = jest.fn().mockResolvedValue({});
2059
+ const executor = createExecutor({
2060
+ humanInTheLoop: { enabled: true },
2061
+ hookRegistry,
2062
+ parentHandlerRegistry: handlerRegistry,
2063
+ createChildGraph: (): StandardGraph =>
2064
+ ({
2065
+ createWorkflow: () => ({
2066
+ getState: jest.fn().mockResolvedValue({
2067
+ values: { messages: [new AIMessage('persisted result')] },
2068
+ next: [],
2069
+ tasks: [],
2070
+ }),
2071
+ invoke,
2072
+ updateState,
2073
+ }),
2074
+ clearHeavyState: jest.fn(),
2075
+ }) as unknown as StandardGraph,
2076
+ });
2077
+
2078
+ const result = await executor.execute({
2079
+ description: 'recover me',
2080
+ subagentType: 'researcher',
2081
+ threadId: 'durable-thread',
2082
+ parentToolCallId: 'call_recovered',
2083
+ });
2084
+
2085
+ expect(result.content).toBe('persisted result');
2086
+ expect(invoke).not.toHaveBeenCalled();
2087
+ expect(stopHook).toHaveBeenCalledTimes(1);
2088
+ expect(updates.filter((update) => update.phase === 'stop')).toHaveLength(
2089
+ 1
2090
+ );
2091
+ expect(updateState).toHaveBeenCalledTimes(1);
2092
+ expect(updateState.mock.calls[0][1]).toMatchObject({
2093
+ messages: [
2094
+ {
2095
+ additional_kwargs: {
2096
+ __librechat_subagent_checkpoint: {
2097
+ version: 1,
2098
+ parentToolCallId: 'call_recovered',
2099
+ lifecycleComplete: true,
2100
+ },
2101
+ },
2102
+ },
2103
+ ],
2104
+ });
2105
+ });
2106
+
1505
2107
  it('SubagentStart deny blocks execution', async () => {
1506
2108
  const registry = new HookRegistry();
1507
2109
  registry.register('SubagentStart', {
@@ -2022,6 +2624,107 @@ describe('SubagentExecutor', () => {
2022
2624
  expect(phases[phases.length - 1]).toBe('stop');
2023
2625
  });
2024
2626
 
2627
+ it('does not let a stalled observational update handler block child completion', async () => {
2628
+ jest.useFakeTimers();
2629
+ try {
2630
+ const registry = new HandlerRegistry();
2631
+ const updateHandler = jest.fn(
2632
+ (): Promise<void> => new Promise<void>(() => {})
2633
+ );
2634
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2635
+ handle: updateHandler,
2636
+ });
2637
+ const { factory } = makeStubGraphFactory({
2638
+ messages: [new AIMessage('ok')],
2639
+ });
2640
+ const executor = createExecutor({
2641
+ createChildGraph: factory,
2642
+ parentHandlerRegistry: registry,
2643
+ });
2644
+
2645
+ const execution = executor.execute({
2646
+ description: 'Task',
2647
+ subagentType: 'researcher',
2648
+ });
2649
+ const outcome = Promise.race([
2650
+ execution.then(({ content }) => content),
2651
+ new Promise<string>((resolve) =>
2652
+ setTimeout(() => resolve('stalled'), 20_000)
2653
+ ),
2654
+ ]);
2655
+
2656
+ await jest.advanceTimersByTimeAsync(20_000);
2657
+
2658
+ await expect(outcome).resolves.toBe('ok');
2659
+ expect(updateHandler).toHaveBeenCalledTimes(2);
2660
+ } finally {
2661
+ jest.useRealTimers();
2662
+ }
2663
+ });
2664
+
2665
+ it('does not let a stalled queued update block the terminal stop envelope', async () => {
2666
+ jest.useFakeTimers();
2667
+ try {
2668
+ const phases: SubagentUpdateEvent['phase'][] = [];
2669
+ const registry = new HandlerRegistry();
2670
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2671
+ handle: (_event, rawData): void | Promise<void> => {
2672
+ const update = rawData as SubagentUpdateEvent;
2673
+ phases.push(update.phase);
2674
+ if (update.phase === 'run_step') {
2675
+ return new Promise<void>(() => {});
2676
+ }
2677
+ },
2678
+ });
2679
+ const factory: () => StandardGraph = (): StandardGraph =>
2680
+ ({
2681
+ createWorkflow: (): { invoke: jest.Mock } => ({
2682
+ invoke: jest.fn().mockImplementation(async (_state, options) => {
2683
+ const opts = options as { callbacks?: unknown[] };
2684
+ const forwarder = (opts.callbacks ?? [])[0] as {
2685
+ handleCustomEvent?: (
2686
+ eventName: string,
2687
+ data: unknown
2688
+ ) => Promise<void> | void;
2689
+ };
2690
+ for (let index = 0; index < 80; index++) {
2691
+ await forwarder.handleCustomEvent?.(GraphEvents.ON_RUN_STEP, {
2692
+ id: `step_${index}`,
2693
+ type: StepTypes.MESSAGE_CREATION,
2694
+ agentId: 'researcher',
2695
+ index,
2696
+ });
2697
+ }
2698
+ return { messages: [new AIMessage('ok')] };
2699
+ }),
2700
+ }),
2701
+ clearHeavyState: jest.fn(),
2702
+ }) as unknown as StandardGraph;
2703
+ const executor = createExecutor({
2704
+ createChildGraph: factory,
2705
+ parentHandlerRegistry: registry,
2706
+ });
2707
+
2708
+ const execution = executor.execute({
2709
+ description: 'Task',
2710
+ subagentType: 'researcher',
2711
+ });
2712
+ const outcome = Promise.race([
2713
+ execution.then(({ content }) => content),
2714
+ new Promise<string>((resolve) =>
2715
+ setTimeout(() => resolve('stalled'), 20_000)
2716
+ ),
2717
+ ]);
2718
+
2719
+ await jest.advanceTimersByTimeAsync(20_000);
2720
+
2721
+ await expect(outcome).resolves.toBe('ok');
2722
+ expect(phases).toEqual(['start', 'run_step', 'stop']);
2723
+ } finally {
2724
+ jest.useRealTimers();
2725
+ }
2726
+ });
2727
+
2025
2728
  it('allowlists forwarded run step payloads before wrapping them in ON_SUBAGENT_UPDATE', async () => {
2026
2729
  const subagentUpdates: SubagentUpdateEvent[] = [];
2027
2730
  const registry = new HandlerRegistry();
@@ -2149,18 +2852,32 @@ describe('SubagentExecutor', () => {
2149
2852
  expect(serialized).not.toContain('nested-completed-secret');
2150
2853
  });
2151
2854
 
2152
- it('does not drop non-droppable updates when the forwarding queue overflows', async () => {
2855
+ it('bounds queued updates under overload while preserving lifecycle envelopes', async () => {
2856
+ const phases: SubagentUpdateEvent['phase'][] = [];
2153
2857
  const completedIds: string[] = [];
2858
+ let releaseFirstCompleted!: () => void;
2859
+ const firstCompletedBlocked = new Promise<void>((resolve) => {
2860
+ releaseFirstCompleted = resolve;
2861
+ });
2862
+ let markAllEmitted!: () => void;
2863
+ const allEmitted = new Promise<void>((resolve) => {
2864
+ markAllEmitted = resolve;
2865
+ });
2866
+ let blockedFirstCompleted = false;
2154
2867
  const registry = new HandlerRegistry();
2155
2868
  registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2156
2869
  handle: async (_event, rawData): Promise<void> => {
2157
2870
  const update = rawData as SubagentUpdateEvent;
2871
+ phases.push(update.phase);
2158
2872
  if (update.phase === 'run_step_completed') {
2159
2873
  const data = update.data as { result?: { id?: string } };
2160
2874
  if (data.result?.id != null) {
2161
2875
  completedIds.push(data.result.id);
2162
2876
  }
2163
- await new Promise((resolve) => setTimeout(resolve, 1));
2877
+ if (!blockedFirstCompleted) {
2878
+ blockedFirstCompleted = true;
2879
+ await firstCompletedBlocked;
2880
+ }
2164
2881
  }
2165
2882
  },
2166
2883
  });
@@ -2195,6 +2912,7 @@ describe('SubagentExecutor', () => {
2195
2912
  }
2196
2913
  );
2197
2914
  }
2915
+ markAllEmitted();
2198
2916
  return { messages: [new AIMessage('ok')] };
2199
2917
  }),
2200
2918
  }),
@@ -2206,14 +2924,21 @@ describe('SubagentExecutor', () => {
2206
2924
  parentHandlerRegistry: registry,
2207
2925
  });
2208
2926
 
2209
- await executor.execute({
2927
+ const execution = executor.execute({
2210
2928
  description: 'Task',
2211
2929
  subagentType: 'researcher',
2212
2930
  });
2931
+ await allEmitted;
2932
+ releaseFirstCompleted();
2933
+ await execution;
2213
2934
 
2214
- expect(completedIds).toHaveLength(80);
2935
+ expect(completedIds).toHaveLength(65);
2215
2936
  expect(completedIds[0]).toBe('step_0');
2937
+ expect(completedIds).not.toContain('step_1');
2938
+ expect(completedIds).toContain('step_16');
2216
2939
  expect(completedIds[completedIds.length - 1]).toBe('step_79');
2940
+ expect(phases[0]).toBe('start');
2941
+ expect(phases[phases.length - 1]).toBe('stop');
2217
2942
  });
2218
2943
 
2219
2944
  it('does NOT forward ON_TOOL_EXECUTE when the parent registry has no handler (safe fallback)', async () => {