@librechat/agents 3.1.85 → 3.1.87

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 (166) hide show
  1. package/README.md +69 -0
  2. package/dist/cjs/agents/AgentContext.cjs +7 -2
  3. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  4. package/dist/cjs/events.cjs +23 -0
  5. package/dist/cjs/events.cjs.map +1 -1
  6. package/dist/cjs/graphs/Graph.cjs +133 -18
  7. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +251 -53
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/init.cjs +1 -5
  13. package/dist/cjs/llm/init.cjs.map +1 -1
  14. package/dist/cjs/llm/openai/index.cjs +113 -24
  15. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openrouter/index.cjs +3 -1
  18. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  19. package/dist/cjs/main.cjs +18 -5
  20. package/dist/cjs/main.cjs.map +1 -1
  21. package/dist/cjs/openai/index.cjs +253 -0
  22. package/dist/cjs/openai/index.cjs.map +1 -0
  23. package/dist/cjs/responses/index.cjs +448 -0
  24. package/dist/cjs/responses/index.cjs.map +1 -0
  25. package/dist/cjs/run.cjs +108 -7
  26. package/dist/cjs/run.cjs.map +1 -1
  27. package/dist/cjs/session/AgentSession.cjs +1057 -0
  28. package/dist/cjs/session/AgentSession.cjs.map +1 -0
  29. package/dist/cjs/session/JsonlSessionStore.cjs +425 -0
  30. package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -0
  31. package/dist/cjs/session/handlers.cjs +221 -0
  32. package/dist/cjs/session/handlers.cjs.map +1 -0
  33. package/dist/cjs/session/ids.cjs +22 -0
  34. package/dist/cjs/session/ids.cjs.map +1 -0
  35. package/dist/cjs/session/messageSerialization.cjs +179 -0
  36. package/dist/cjs/session/messageSerialization.cjs.map +1 -0
  37. package/dist/cjs/stream.cjs +472 -11
  38. package/dist/cjs/stream.cjs.map +1 -1
  39. package/dist/cjs/summarization/node.cjs +1 -1
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +177 -59
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/eagerEventExecution.cjs +113 -0
  44. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -0
  45. package/dist/cjs/tools/handlers.cjs +1 -1
  46. package/dist/cjs/tools/handlers.cjs.map +1 -1
  47. package/dist/cjs/tools/streamedToolCallSeals.cjs +42 -0
  48. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -0
  49. package/dist/esm/agents/AgentContext.mjs +7 -2
  50. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  51. package/dist/esm/events.mjs +23 -1
  52. package/dist/esm/events.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +133 -18
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  56. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  57. package/dist/esm/llm/anthropic/index.mjs +251 -53
  58. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  59. package/dist/esm/llm/init.mjs +1 -5
  60. package/dist/esm/llm/init.mjs.map +1 -1
  61. package/dist/esm/llm/openai/index.mjs +113 -25
  62. package/dist/esm/llm/openai/index.mjs.map +1 -1
  63. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  64. package/dist/esm/llm/openrouter/index.mjs +4 -2
  65. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  66. package/dist/esm/main.mjs +5 -1
  67. package/dist/esm/main.mjs.map +1 -1
  68. package/dist/esm/openai/index.mjs +246 -0
  69. package/dist/esm/openai/index.mjs.map +1 -0
  70. package/dist/esm/responses/index.mjs +440 -0
  71. package/dist/esm/responses/index.mjs.map +1 -0
  72. package/dist/esm/run.mjs +108 -7
  73. package/dist/esm/run.mjs.map +1 -1
  74. package/dist/esm/session/AgentSession.mjs +1054 -0
  75. package/dist/esm/session/AgentSession.mjs.map +1 -0
  76. package/dist/esm/session/JsonlSessionStore.mjs +422 -0
  77. package/dist/esm/session/JsonlSessionStore.mjs.map +1 -0
  78. package/dist/esm/session/handlers.mjs +219 -0
  79. package/dist/esm/session/handlers.mjs.map +1 -0
  80. package/dist/esm/session/ids.mjs +17 -0
  81. package/dist/esm/session/ids.mjs.map +1 -0
  82. package/dist/esm/session/messageSerialization.mjs +173 -0
  83. package/dist/esm/session/messageSerialization.mjs.map +1 -0
  84. package/dist/esm/stream.mjs +473 -12
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +1 -1
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/ToolNode.mjs +177 -59
  89. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  90. package/dist/esm/tools/eagerEventExecution.mjs +107 -0
  91. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -0
  92. package/dist/esm/tools/handlers.mjs +1 -1
  93. package/dist/esm/tools/handlers.mjs.map +1 -1
  94. package/dist/esm/tools/streamedToolCallSeals.mjs +36 -0
  95. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -0
  96. package/dist/types/events.d.ts +1 -0
  97. package/dist/types/graphs/Graph.d.ts +24 -9
  98. package/dist/types/index.d.ts +1 -0
  99. package/dist/types/llm/openai/index.d.ts +1 -0
  100. package/dist/types/openai/index.d.ts +75 -0
  101. package/dist/types/responses/index.d.ts +97 -0
  102. package/dist/types/run.d.ts +2 -0
  103. package/dist/types/session/AgentSession.d.ts +32 -0
  104. package/dist/types/session/JsonlSessionStore.d.ts +67 -0
  105. package/dist/types/session/handlers.d.ts +8 -0
  106. package/dist/types/session/ids.d.ts +4 -0
  107. package/dist/types/session/index.d.ts +5 -0
  108. package/dist/types/session/messageSerialization.d.ts +7 -0
  109. package/dist/types/session/types.d.ts +191 -0
  110. package/dist/types/tools/ToolNode.d.ts +12 -1
  111. package/dist/types/tools/eagerEventExecution.d.ts +23 -0
  112. package/dist/types/tools/streamedToolCallSeals.d.ts +13 -0
  113. package/dist/types/types/hitl.d.ts +4 -0
  114. package/dist/types/types/run.d.ts +11 -1
  115. package/dist/types/types/tools.d.ts +36 -0
  116. package/package.json +19 -2
  117. package/src/__tests__/stream.eagerEventExecution.test.ts +2458 -0
  118. package/src/agents/AgentContext.ts +7 -2
  119. package/src/agents/__tests__/AgentContext.test.ts +254 -5
  120. package/src/events.ts +29 -0
  121. package/src/graphs/Graph.ts +224 -50
  122. package/src/graphs/MultiAgentGraph.ts +1 -1
  123. package/src/graphs/__tests__/composition.smoke.test.ts +30 -0
  124. package/src/index.ts +3 -0
  125. package/src/llm/anthropic/index.ts +356 -84
  126. package/src/llm/anthropic/llm.spec.ts +64 -0
  127. package/src/llm/custom-chat-models.smoke.test.ts +175 -4
  128. package/src/llm/openai/contentBlocks.test.ts +35 -0
  129. package/src/llm/openai/deepseek.test.ts +201 -2
  130. package/src/llm/openai/index.ts +171 -26
  131. package/src/llm/openai/utils/index.ts +22 -0
  132. package/src/llm/openrouter/index.ts +4 -2
  133. package/src/openai/__tests__/openai.test.ts +337 -0
  134. package/src/openai/index.ts +404 -0
  135. package/src/responses/__tests__/responses.test.ts +652 -0
  136. package/src/responses/index.ts +677 -0
  137. package/src/run.ts +158 -8
  138. package/src/scripts/compare_pi_vs_ours.ts +592 -173
  139. package/src/scripts/session_live.ts +548 -0
  140. package/src/session/AgentSession.ts +1432 -0
  141. package/src/session/JsonlSessionStore.ts +572 -0
  142. package/src/session/__tests__/JsonlSessionStore.test.ts +1410 -0
  143. package/src/session/__tests__/handlers.test.ts +161 -0
  144. package/src/session/handlers.ts +272 -0
  145. package/src/session/ids.ts +17 -0
  146. package/src/session/index.ts +44 -0
  147. package/src/session/messageSerialization.ts +207 -0
  148. package/src/session/types.ts +275 -0
  149. package/src/specs/custom-event-await.test.ts +89 -0
  150. package/src/specs/summarization.test.ts +1 -1
  151. package/src/stream.ts +755 -48
  152. package/src/summarization/node.ts +1 -1
  153. package/src/tools/ToolNode.ts +299 -126
  154. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +373 -0
  155. package/src/tools/__tests__/handlers.test.ts +2 -1
  156. package/src/tools/__tests__/hitl.test.ts +206 -110
  157. package/src/tools/eagerEventExecution.ts +153 -0
  158. package/src/tools/handlers.ts +8 -4
  159. package/src/tools/streamedToolCallSeals.ts +57 -0
  160. package/src/types/hitl.ts +4 -0
  161. package/src/types/run.ts +11 -0
  162. package/src/types/tools.ts +36 -0
  163. package/dist/cjs/llm/text.cjs +0 -69
  164. package/dist/cjs/llm/text.cjs.map +0 -1
  165. package/dist/esm/llm/text.mjs +0 -67
  166. package/dist/esm/llm/text.mjs.map +0 -1
@@ -0,0 +1,2458 @@
1
+ import { describe, it, expect, jest, afterEach } from '@jest/globals';
2
+ import type { AgentContext } from '@/agents/AgentContext';
3
+ import type { StandardGraph } from '@/graphs';
4
+ import type * as t from '@/types';
5
+ import {
6
+ Constants,
7
+ ContentTypes,
8
+ GraphEvents,
9
+ Providers,
10
+ StepTypes,
11
+ } from '@/common';
12
+ import { HandlerRegistry } from '@/events';
13
+ import * as events from '@/utils/events';
14
+ import { ChatModelStreamHandler } from '@/stream';
15
+ import {
16
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
17
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
18
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
19
+ } from '@/tools/streamedToolCallSeals';
20
+
21
+ function createGraph(overrides: Partial<StandardGraph> = {}): StandardGraph {
22
+ const runSteps = new Map<string, t.RunStep>();
23
+ const stepIdsByKey = new Map<string, string>();
24
+ let stepCounter = 0;
25
+ const handlerRegistry = new HandlerRegistry();
26
+ handlerRegistry.register(GraphEvents.ON_TOOL_EXECUTE, {
27
+ handle: async () => undefined,
28
+ });
29
+ const eagerUsageCount = new Map<string, number>();
30
+
31
+ const graph = {
32
+ config: {
33
+ configurable: { user_id: 'user_1' },
34
+ metadata: { run_id: 'run_1' },
35
+ },
36
+ eagerEventToolExecution: { enabled: true },
37
+ eagerEventToolExecutions: new Map(),
38
+ eagerEventToolUsageCount: eagerUsageCount,
39
+ getEagerEventToolUsageCount: jest.fn(() => eagerUsageCount),
40
+ eagerEventToolCallChunks: new Map(),
41
+ handlerRegistry,
42
+ hookRegistry: undefined,
43
+ humanInTheLoop: undefined,
44
+ toolOutputReferences: undefined,
45
+ sessions: new Map(),
46
+ toolCallStepIds: new Map(),
47
+ messageIdsByStepKey: new Map(),
48
+ messageStepHasToolCalls: new Map(),
49
+ prelimMessageIdsByStepKey: new Map(),
50
+ getAgentContext: jest.fn(
51
+ (): Partial<AgentContext> => ({
52
+ provider: Providers.ANTHROPIC,
53
+ reasoningKey: 'reasoning',
54
+ toolDefinitions: [{ name: 'weather' }],
55
+ graphTools: [],
56
+ agentId: 'agent_1',
57
+ })
58
+ ),
59
+ getStepKey: jest.fn(() => 'step-key'),
60
+ getStepIdByKey: jest.fn((stepKey: string) => {
61
+ const stepId = stepIdsByKey.get(stepKey);
62
+ if (stepId == null) {
63
+ throw new Error('no current step');
64
+ }
65
+ return stepId;
66
+ }),
67
+ getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
68
+ dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
69
+ const id = `step_${++stepCounter}`;
70
+ if (
71
+ (details as t.StepDetails).type === StepTypes.TOOL_CALLS &&
72
+ Array.isArray((details as t.ToolCallsDetails).tool_calls)
73
+ ) {
74
+ for (const toolCall of (details as t.ToolCallsDetails).tool_calls ?? []) {
75
+ if (toolCall.id != null && toolCall.id !== '') {
76
+ graph.toolCallStepIds.set(toolCall.id, id);
77
+ }
78
+ }
79
+ }
80
+ stepIdsByKey.set(stepKey, id);
81
+ runSteps.set(id, {
82
+ id,
83
+ type: (details as { type: t.RunStep['type'] }).type,
84
+ stepDetails: details as t.RunStep['stepDetails'],
85
+ } as t.RunStep);
86
+ return id;
87
+ }),
88
+ dispatchRunStepDelta: jest.fn(async () => undefined),
89
+ ...overrides,
90
+ };
91
+
92
+ return graph as unknown as StandardGraph;
93
+ }
94
+
95
+ function chunkStateKey(stepKey: string, chunkKey: string | number): string {
96
+ return `${stepKey}\u0000${String(chunkKey)}`;
97
+ }
98
+
99
+ const finalToolCallResponseMetadata = { finish_reason: 'tool_calls' };
100
+ const openAIResponsesToolCallMetadata = {
101
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
102
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
103
+ };
104
+
105
+ describe('ChatModelStreamHandler eager event tool execution', () => {
106
+ afterEach(() => {
107
+ jest.restoreAllMocks();
108
+ });
109
+
110
+ it('prestarts a complete event-driven tool call from the stream', async () => {
111
+ const graph = createGraph();
112
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
113
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
114
+ async (event, data): Promise<void> => {
115
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
116
+ return;
117
+ }
118
+ const batch = data as t.ToolExecuteBatchRequest;
119
+ toolExecuteCalls.push(batch);
120
+ batch.resolve([
121
+ {
122
+ toolCallId: 'call_weather',
123
+ status: 'success',
124
+ content: 'sunny',
125
+ },
126
+ ]);
127
+ }
128
+ );
129
+
130
+ await new ChatModelStreamHandler().handle(
131
+ GraphEvents.CHAT_MODEL_STREAM,
132
+ {
133
+ chunk: {
134
+ content: '',
135
+ tool_calls: [
136
+ {
137
+ id: 'call_weather',
138
+ name: 'weather',
139
+ args: { city: 'NYC' },
140
+ },
141
+ ],
142
+ response_metadata: finalToolCallResponseMetadata,
143
+ } as unknown as t.StreamChunk,
144
+ },
145
+ { langgraph_node: 'agent' },
146
+ graph
147
+ );
148
+
149
+ expect(toolExecuteCalls).toHaveLength(1);
150
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
151
+ id: 'call_weather',
152
+ name: 'weather',
153
+ args: { city: 'NYC' },
154
+ stepId: expect.stringMatching(/^step_/),
155
+ turn: 0,
156
+ });
157
+ expect(graph.eagerEventToolExecutions.get('call_weather')).toMatchObject({
158
+ toolCallId: 'call_weather',
159
+ toolName: 'weather',
160
+ args: { city: 'NYC' },
161
+ });
162
+ expect(graph.toolCallStepIds.has('call_weather')).toBe(true);
163
+ });
164
+
165
+ it('does not prestart parseable tool calls before a final tool-call signal', async () => {
166
+ const graph = createGraph();
167
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
168
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
169
+ async (event, data): Promise<void> => {
170
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
171
+ return;
172
+ }
173
+ const batch = data as t.ToolExecuteBatchRequest;
174
+ toolExecuteCalls.push(batch);
175
+ batch.resolve([
176
+ {
177
+ toolCallId: 'call_weather',
178
+ status: 'success',
179
+ content: 'sunny',
180
+ },
181
+ ]);
182
+ }
183
+ );
184
+
185
+ const handler = new ChatModelStreamHandler();
186
+ const metadata = { langgraph_node: 'agent' };
187
+
188
+ await handler.handle(
189
+ GraphEvents.CHAT_MODEL_STREAM,
190
+ {
191
+ chunk: {
192
+ content: '',
193
+ tool_calls: [
194
+ {
195
+ id: 'call_weather',
196
+ name: 'weather',
197
+ args: { city: 'N' },
198
+ },
199
+ ],
200
+ } as unknown as t.StreamChunk,
201
+ },
202
+ metadata,
203
+ graph
204
+ );
205
+
206
+ expect(toolExecuteCalls).toHaveLength(0);
207
+ expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(false);
208
+
209
+ await handler.handle(
210
+ GraphEvents.CHAT_MODEL_STREAM,
211
+ {
212
+ chunk: {
213
+ content: '',
214
+ tool_calls: [
215
+ {
216
+ id: 'call_weather',
217
+ name: 'weather',
218
+ args: { city: 'NYC' },
219
+ },
220
+ ],
221
+ response_metadata: finalToolCallResponseMetadata,
222
+ } as unknown as t.StreamChunk,
223
+ },
224
+ metadata,
225
+ graph
226
+ );
227
+
228
+ expect(toolExecuteCalls).toHaveLength(1);
229
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
230
+ id: 'call_weather',
231
+ name: 'weather',
232
+ args: { city: 'NYC' },
233
+ stepId: expect.stringMatching(/^step_/),
234
+ turn: 0,
235
+ });
236
+ });
237
+
238
+ it('prestarts multiple complete event-driven tool calls as one batch', async () => {
239
+ const graph = createGraph({
240
+ getAgentContext: jest.fn(
241
+ (): Partial<AgentContext> => ({
242
+ provider: Providers.ANTHROPIC,
243
+ reasoningKey: 'reasoning',
244
+ toolDefinitions: [{ name: 'weather' }, { name: 'calendar' }],
245
+ graphTools: [],
246
+ agentId: 'agent_1',
247
+ })
248
+ ) as unknown as StandardGraph['getAgentContext'],
249
+ });
250
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
251
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
252
+ async (event, data): Promise<void> => {
253
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
254
+ return;
255
+ }
256
+ const batch = data as t.ToolExecuteBatchRequest;
257
+ toolExecuteCalls.push(batch);
258
+ batch.resolve(
259
+ batch.toolCalls.map((request) => ({
260
+ toolCallId: request.id,
261
+ status: 'success' as const,
262
+ content: `${request.name} result`,
263
+ }))
264
+ );
265
+ }
266
+ );
267
+
268
+ await new ChatModelStreamHandler().handle(
269
+ GraphEvents.CHAT_MODEL_STREAM,
270
+ {
271
+ chunk: {
272
+ content: '',
273
+ tool_calls: [
274
+ {
275
+ id: 'call_weather',
276
+ name: 'weather',
277
+ args: { city: 'NYC' },
278
+ },
279
+ {
280
+ id: 'call_calendar',
281
+ name: 'calendar',
282
+ args: { date: 'today' },
283
+ },
284
+ ],
285
+ response_metadata: finalToolCallResponseMetadata,
286
+ } as unknown as t.StreamChunk,
287
+ },
288
+ { langgraph_node: 'agent' },
289
+ graph
290
+ );
291
+
292
+ expect(toolExecuteCalls).toHaveLength(1);
293
+ expect(toolExecuteCalls[0].toolCalls).toHaveLength(2);
294
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
295
+ expect.objectContaining({
296
+ id: 'call_weather',
297
+ name: 'weather',
298
+ args: { city: 'NYC' },
299
+ stepId: expect.stringMatching(/^step_/),
300
+ turn: 0,
301
+ }),
302
+ expect.objectContaining({
303
+ id: 'call_calendar',
304
+ name: 'calendar',
305
+ args: { date: 'today' },
306
+ stepId: expect.stringMatching(/^step_/),
307
+ turn: 0,
308
+ }),
309
+ ]);
310
+ const weatherExecution = graph.eagerEventToolExecutions.get('call_weather');
311
+ const calendarExecution = graph.eagerEventToolExecutions.get('call_calendar');
312
+ expect(weatherExecution).toMatchObject({
313
+ toolCallId: 'call_weather',
314
+ toolName: 'weather',
315
+ args: { city: 'NYC' },
316
+ });
317
+ expect(calendarExecution).toMatchObject({
318
+ toolCallId: 'call_calendar',
319
+ toolName: 'calendar',
320
+ args: { date: 'today' },
321
+ });
322
+ expect(weatherExecution?.promise).toBe(calendarExecution?.promise);
323
+ });
324
+
325
+ it('assigns same-tool eager turns in model emission order', async () => {
326
+ const graph = createGraph();
327
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
328
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
329
+ async (event, data): Promise<void> => {
330
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
331
+ return;
332
+ }
333
+ const batch = data as t.ToolExecuteBatchRequest;
334
+ toolExecuteCalls.push(batch);
335
+ batch.resolve(
336
+ batch.toolCalls.map((request) => ({
337
+ toolCallId: request.id,
338
+ status: 'success' as const,
339
+ content: `${request.args.city} weather`,
340
+ }))
341
+ );
342
+ }
343
+ );
344
+
345
+ await new ChatModelStreamHandler().handle(
346
+ GraphEvents.CHAT_MODEL_STREAM,
347
+ {
348
+ chunk: {
349
+ content: '',
350
+ tool_calls: [
351
+ {
352
+ id: 'call_weather_1',
353
+ name: 'weather',
354
+ args: { city: 'NYC' },
355
+ },
356
+ {
357
+ id: 'call_weather_2',
358
+ name: 'weather',
359
+ args: { city: 'Boston' },
360
+ },
361
+ ],
362
+ response_metadata: finalToolCallResponseMetadata,
363
+ } as unknown as t.StreamChunk,
364
+ },
365
+ { langgraph_node: 'agent' },
366
+ graph
367
+ );
368
+
369
+ expect(toolExecuteCalls).toHaveLength(1);
370
+ expect(toolExecuteCalls[0].toolCalls.map((call) => call.turn)).toEqual([
371
+ 0, 1,
372
+ ]);
373
+ expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
374
+ expect(graph.eagerEventToolExecutions.get('call_weather_1')?.request.turn)
375
+ .toBe(0);
376
+ expect(graph.eagerEventToolExecutions.get('call_weather_2')?.request.turn)
377
+ .toBe(1);
378
+ });
379
+
380
+ it('scopes eager turn reservation by agent', async () => {
381
+ const usageByAgent = new Map<string, Map<string, number>>();
382
+ const getUsageCount = (agentId?: string): Map<string, number> => {
383
+ const key = agentId ?? 'default';
384
+ let usage = usageByAgent.get(key);
385
+ if (usage == null) {
386
+ usage = new Map<string, number>();
387
+ usageByAgent.set(key, usage);
388
+ }
389
+ return usage;
390
+ };
391
+ const graph = createGraph({
392
+ getEagerEventToolUsageCount: jest.fn(getUsageCount),
393
+ getAgentContext: jest.fn(
394
+ (metadata?: Record<string, unknown>): AgentContext => ({
395
+ provider: Providers.ANTHROPIC,
396
+ reasoningKey: 'reasoning',
397
+ toolDefinitions: [{ name: 'weather' }],
398
+ graphTools: [],
399
+ agentId:
400
+ metadata?.langgraph_node === 'agent_2' ? 'agent_2' : 'agent_1',
401
+ }) as unknown as AgentContext
402
+ ),
403
+ });
404
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
405
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
406
+ async (event, data): Promise<void> => {
407
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
408
+ return;
409
+ }
410
+ const batch = data as t.ToolExecuteBatchRequest;
411
+ toolExecuteCalls.push(batch);
412
+ batch.resolve(
413
+ batch.toolCalls.map((request) => ({
414
+ toolCallId: request.id,
415
+ status: 'success' as const,
416
+ content: 'ok',
417
+ }))
418
+ );
419
+ }
420
+ );
421
+
422
+ const handler = new ChatModelStreamHandler();
423
+ await handler.handle(
424
+ GraphEvents.CHAT_MODEL_STREAM,
425
+ {
426
+ chunk: {
427
+ content: '',
428
+ tool_calls: [
429
+ {
430
+ id: 'call_agent_1_weather',
431
+ name: 'weather',
432
+ args: { city: 'NYC' },
433
+ },
434
+ ],
435
+ response_metadata: finalToolCallResponseMetadata,
436
+ } as unknown as t.StreamChunk,
437
+ },
438
+ { langgraph_node: 'agent_1' },
439
+ graph
440
+ );
441
+ await handler.handle(
442
+ GraphEvents.CHAT_MODEL_STREAM,
443
+ {
444
+ chunk: {
445
+ content: '',
446
+ tool_calls: [
447
+ {
448
+ id: 'call_agent_2_weather',
449
+ name: 'weather',
450
+ args: { city: 'Boston' },
451
+ },
452
+ ],
453
+ response_metadata: finalToolCallResponseMetadata,
454
+ } as unknown as t.StreamChunk,
455
+ },
456
+ { langgraph_node: 'agent_2' },
457
+ graph
458
+ );
459
+
460
+ expect(toolExecuteCalls).toHaveLength(2);
461
+ expect(toolExecuteCalls.map((call) => call.toolCalls[0].turn)).toEqual([
462
+ 0, 0,
463
+ ]);
464
+ expect(usageByAgent.get('agent_1')?.get('weather')).toBe(1);
465
+ expect(usageByAgent.get('agent_2')?.get('weather')).toBe(1);
466
+ expect(graph.eagerEventToolExecutions.get('call_agent_1_weather')?.request.turn)
467
+ .toBe(0);
468
+ expect(graph.eagerEventToolExecutions.get('call_agent_2_weather')?.request.turn)
469
+ .toBe(0);
470
+ });
471
+
472
+ it('skips eager for the whole batch if any call is not request-plannable', async () => {
473
+ const graph = createGraph();
474
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
475
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
476
+ async (event, data): Promise<void> => {
477
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
478
+ return;
479
+ }
480
+ const batch = data as t.ToolExecuteBatchRequest;
481
+ toolExecuteCalls.push(batch);
482
+ batch.resolve([]);
483
+ }
484
+ );
485
+
486
+ await new ChatModelStreamHandler().handle(
487
+ GraphEvents.CHAT_MODEL_STREAM,
488
+ {
489
+ chunk: {
490
+ content: '',
491
+ tool_calls: [
492
+ {
493
+ id: 'call_weather_bad',
494
+ name: 'weather',
495
+ args: '{"city":',
496
+ },
497
+ {
498
+ id: 'call_weather_good',
499
+ name: 'weather',
500
+ args: { city: 'Boston' },
501
+ },
502
+ ],
503
+ response_metadata: finalToolCallResponseMetadata,
504
+ } as unknown as t.StreamChunk,
505
+ },
506
+ { langgraph_node: 'agent' },
507
+ graph
508
+ );
509
+
510
+ expect(toolExecuteCalls).toHaveLength(0);
511
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
512
+ expect(graph.eagerEventToolUsageCount.size).toBe(0);
513
+ });
514
+
515
+ it('records complete chunk-only tool calls after creating a tool step', async () => {
516
+ const graph = createGraph();
517
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
518
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
519
+ async (event, data): Promise<void> => {
520
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
521
+ return;
522
+ }
523
+ const batch = data as t.ToolExecuteBatchRequest;
524
+ toolExecuteCalls.push(batch);
525
+ batch.resolve([
526
+ {
527
+ toolCallId: 'call_weather',
528
+ status: 'success',
529
+ content: 'sunny',
530
+ },
531
+ ]);
532
+ }
533
+ );
534
+
535
+ await new ChatModelStreamHandler().handle(
536
+ GraphEvents.CHAT_MODEL_STREAM,
537
+ {
538
+ chunk: {
539
+ content: '',
540
+ tool_call_chunks: [
541
+ {
542
+ id: 'call_weather',
543
+ name: 'weather',
544
+ args: '{"city":"NYC"}',
545
+ index: 0,
546
+ },
547
+ ],
548
+ } as unknown as t.StreamChunk,
549
+ },
550
+ { langgraph_node: 'agent' },
551
+ graph
552
+ );
553
+
554
+ expect(toolExecuteCalls).toHaveLength(0);
555
+ expect(graph.toolCallStepIds.has('call_weather')).toBe(true);
556
+ expect(
557
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
558
+ ?.argsText
559
+ ).toBe('{"city":"NYC"}');
560
+ });
561
+
562
+ it('prestarts OpenAI Responses streamed tool calls on explicit arguments done', async () => {
563
+ const graph = createGraph({
564
+ getAgentContext: jest.fn(
565
+ (): Partial<AgentContext> => ({
566
+ provider: Providers.OPENAI,
567
+ reasoningKey: 'reasoning_content',
568
+ toolDefinitions: [{ name: 'weather' }],
569
+ graphTools: [],
570
+ agentId: 'agent_1',
571
+ })
572
+ ) as unknown as StandardGraph['getAgentContext'],
573
+ });
574
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
575
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
576
+ async (event, data): Promise<void> => {
577
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
578
+ return;
579
+ }
580
+ const batch = data as t.ToolExecuteBatchRequest;
581
+ toolExecuteCalls.push(batch);
582
+ batch.resolve([
583
+ {
584
+ toolCallId: 'call_weather',
585
+ status: 'success',
586
+ content: 'sunny',
587
+ },
588
+ ]);
589
+ }
590
+ );
591
+
592
+ const handler = new ChatModelStreamHandler();
593
+ const metadata = { langgraph_node: 'agent' };
594
+
595
+ await handler.handle(
596
+ GraphEvents.CHAT_MODEL_STREAM,
597
+ {
598
+ chunk: {
599
+ content: '',
600
+ tool_call_chunks: [
601
+ {
602
+ id: 'call_weather',
603
+ name: 'weather',
604
+ args: '',
605
+ index: 0,
606
+ },
607
+ ],
608
+ response_metadata: openAIResponsesToolCallMetadata,
609
+ } as unknown as t.StreamChunk,
610
+ },
611
+ metadata,
612
+ graph
613
+ );
614
+
615
+ await handler.handle(
616
+ GraphEvents.CHAT_MODEL_STREAM,
617
+ {
618
+ chunk: {
619
+ content: '',
620
+ tool_call_chunks: [
621
+ {
622
+ args: '{"city":"N',
623
+ index: 0,
624
+ },
625
+ ],
626
+ response_metadata: openAIResponsesToolCallMetadata,
627
+ } as unknown as t.StreamChunk,
628
+ },
629
+ metadata,
630
+ graph
631
+ );
632
+
633
+ expect(toolExecuteCalls).toHaveLength(0);
634
+
635
+ await handler.handle(
636
+ GraphEvents.CHAT_MODEL_STREAM,
637
+ {
638
+ chunk: {
639
+ content: '',
640
+ tool_call_chunks: [
641
+ {
642
+ id: 'call_weather',
643
+ args: '{"city":"NYC"}',
644
+ index: 0,
645
+ },
646
+ ],
647
+ response_metadata: {
648
+ ...openAIResponsesToolCallMetadata,
649
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: {
650
+ kind: 'single',
651
+ id: 'call_weather',
652
+ index: 0,
653
+ },
654
+ },
655
+ } as unknown as t.StreamChunk,
656
+ },
657
+ metadata,
658
+ graph
659
+ );
660
+
661
+ expect(toolExecuteCalls).toHaveLength(1);
662
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
663
+ id: 'call_weather',
664
+ name: 'weather',
665
+ args: { city: 'NYC' },
666
+ stepId: expect.stringMatching(/^step_/),
667
+ turn: 0,
668
+ });
669
+ expect(graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))).toBe(
670
+ false
671
+ );
672
+ });
673
+
674
+ it('keeps OpenAI Chat Completions streamed chunks on the final tool_calls path', async () => {
675
+ const graph = createGraph({
676
+ getAgentContext: jest.fn(
677
+ (): Partial<AgentContext> => ({
678
+ provider: Providers.OPENAI,
679
+ reasoningKey: 'reasoning_content',
680
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
681
+ graphTools: [],
682
+ agentId: 'agent_1',
683
+ })
684
+ ) as unknown as StandardGraph['getAgentContext'],
685
+ });
686
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
687
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
688
+ async (event, data): Promise<void> => {
689
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
690
+ return;
691
+ }
692
+ const batch = data as t.ToolExecuteBatchRequest;
693
+ toolExecuteCalls.push(batch);
694
+ batch.resolve(
695
+ batch.toolCalls.map((call) => ({
696
+ toolCallId: call.id,
697
+ status: 'success',
698
+ content: `ok ${call.name}`,
699
+ }))
700
+ );
701
+ }
702
+ );
703
+
704
+ const handler = new ChatModelStreamHandler();
705
+ const metadata = { langgraph_node: 'agent' };
706
+
707
+ await handler.handle(
708
+ GraphEvents.CHAT_MODEL_STREAM,
709
+ {
710
+ chunk: {
711
+ content: '',
712
+ tool_call_chunks: [
713
+ {
714
+ id: 'call_weather',
715
+ name: 'weather',
716
+ args: '{"city":"NYC"}',
717
+ index: 0,
718
+ },
719
+ ],
720
+ } as unknown as t.StreamChunk,
721
+ },
722
+ metadata,
723
+ graph
724
+ );
725
+ await handler.handle(
726
+ GraphEvents.CHAT_MODEL_STREAM,
727
+ {
728
+ chunk: {
729
+ content: '',
730
+ tool_call_chunks: [
731
+ {
732
+ id: 'call_stock',
733
+ name: 'stock',
734
+ args: '{"ticker":"CH"}',
735
+ index: 1,
736
+ },
737
+ ],
738
+ } as unknown as t.StreamChunk,
739
+ },
740
+ metadata,
741
+ graph
742
+ );
743
+
744
+ expect(toolExecuteCalls).toHaveLength(0);
745
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
746
+
747
+ await handler.handle(
748
+ GraphEvents.CHAT_MODEL_STREAM,
749
+ {
750
+ chunk: {
751
+ content: '',
752
+ tool_calls: [
753
+ {
754
+ id: 'call_weather',
755
+ name: 'weather',
756
+ args: { city: 'NYC' },
757
+ },
758
+ {
759
+ id: 'call_stock',
760
+ name: 'stock',
761
+ args: { ticker: 'CH' },
762
+ },
763
+ ],
764
+ response_metadata: finalToolCallResponseMetadata,
765
+ } as unknown as t.StreamChunk,
766
+ },
767
+ metadata,
768
+ graph
769
+ );
770
+
771
+ expect(toolExecuteCalls).toHaveLength(1);
772
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
773
+ expect.objectContaining({
774
+ id: 'call_weather',
775
+ args: { city: 'NYC' },
776
+ turn: 0,
777
+ }),
778
+ expect.objectContaining({
779
+ id: 'call_stock',
780
+ args: { ticker: 'CH' },
781
+ turn: 0,
782
+ }),
783
+ ]);
784
+ });
785
+
786
+ it('prestarts final tool calls even when the final chunk also has tool-call chunks', async () => {
787
+ const graph = createGraph();
788
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
789
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
790
+ async (event, data): Promise<void> => {
791
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
792
+ return;
793
+ }
794
+ const batch = data as t.ToolExecuteBatchRequest;
795
+ toolExecuteCalls.push(batch);
796
+ batch.resolve([
797
+ {
798
+ toolCallId: 'call_weather',
799
+ status: 'success',
800
+ content: 'sunny',
801
+ },
802
+ ]);
803
+ }
804
+ );
805
+
806
+ await new ChatModelStreamHandler().handle(
807
+ GraphEvents.CHAT_MODEL_STREAM,
808
+ {
809
+ chunk: {
810
+ content: '',
811
+ tool_calls: [
812
+ {
813
+ id: 'call_weather',
814
+ name: 'weather',
815
+ args: { city: 'NYC' },
816
+ },
817
+ ],
818
+ tool_call_chunks: [
819
+ {
820
+ index: 0,
821
+ id: 'call_weather',
822
+ name: 'weather',
823
+ args: '{"city":"NYC"}',
824
+ },
825
+ ],
826
+ response_metadata: finalToolCallResponseMetadata,
827
+ } as unknown as t.StreamChunk,
828
+ },
829
+ { langgraph_node: 'agent' },
830
+ graph
831
+ );
832
+
833
+ expect(toolExecuteCalls).toHaveLength(1);
834
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
835
+ id: 'call_weather',
836
+ name: 'weather',
837
+ args: { city: 'NYC' },
838
+ turn: 0,
839
+ });
840
+ expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(true);
841
+ });
842
+
843
+ it('waits for final tool calls before prestarting streamed chunk calls', async () => {
844
+ const graph = createGraph();
845
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
846
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
847
+ async (event, data): Promise<void> => {
848
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
849
+ return;
850
+ }
851
+ const batch = data as t.ToolExecuteBatchRequest;
852
+ toolExecuteCalls.push(batch);
853
+ batch.resolve([
854
+ {
855
+ toolCallId: 'call_weather',
856
+ status: 'success',
857
+ content: 'sunny',
858
+ },
859
+ ]);
860
+ }
861
+ );
862
+
863
+ const handler = new ChatModelStreamHandler();
864
+
865
+ await handler.handle(
866
+ GraphEvents.CHAT_MODEL_STREAM,
867
+ {
868
+ chunk: {
869
+ content: '',
870
+ tool_calls: [
871
+ {
872
+ id: 'call_weather',
873
+ name: 'weather',
874
+ args: {},
875
+ },
876
+ ],
877
+ tool_call_chunks: [
878
+ {
879
+ id: 'call_weather',
880
+ name: 'weather',
881
+ args: '',
882
+ index: 0,
883
+ },
884
+ ],
885
+ } as unknown as t.StreamChunk,
886
+ },
887
+ { langgraph_node: 'agent' },
888
+ graph
889
+ );
890
+
891
+ await handler.handle(
892
+ GraphEvents.CHAT_MODEL_STREAM,
893
+ {
894
+ chunk: {
895
+ content: '',
896
+ tool_call_chunks: [
897
+ {
898
+ args: '{"city"',
899
+ index: 0,
900
+ },
901
+ ],
902
+ } as unknown as t.StreamChunk,
903
+ },
904
+ { langgraph_node: 'agent' },
905
+ graph
906
+ );
907
+
908
+ expect(toolExecuteCalls).toHaveLength(0);
909
+
910
+ await handler.handle(
911
+ GraphEvents.CHAT_MODEL_STREAM,
912
+ {
913
+ chunk: {
914
+ content: '',
915
+ tool_call_chunks: [
916
+ {
917
+ args: ':"NYC"}',
918
+ index: 0,
919
+ },
920
+ ],
921
+ } as unknown as t.StreamChunk,
922
+ },
923
+ { langgraph_node: 'agent' },
924
+ graph
925
+ );
926
+
927
+ expect(toolExecuteCalls).toHaveLength(0);
928
+
929
+ await handler.handle(
930
+ GraphEvents.CHAT_MODEL_STREAM,
931
+ {
932
+ chunk: {
933
+ content: '',
934
+ tool_calls: [
935
+ {
936
+ id: 'call_weather',
937
+ name: 'weather',
938
+ args: { city: 'NYC' },
939
+ },
940
+ ],
941
+ response_metadata: finalToolCallResponseMetadata,
942
+ } as unknown as t.StreamChunk,
943
+ },
944
+ { langgraph_node: 'agent' },
945
+ graph
946
+ );
947
+
948
+ expect(toolExecuteCalls).toHaveLength(1);
949
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
950
+ id: 'call_weather',
951
+ name: 'weather',
952
+ args: { city: 'NYC' },
953
+ stepId: expect.stringMatching(/^step_/),
954
+ turn: 0,
955
+ });
956
+
957
+ await handler.handle(
958
+ GraphEvents.CHAT_MODEL_STREAM,
959
+ {
960
+ chunk: {
961
+ content: '',
962
+ tool_calls: [
963
+ {
964
+ id: 'call_stock',
965
+ name: 'stock',
966
+ args: {},
967
+ },
968
+ ],
969
+ tool_call_chunks: [
970
+ {
971
+ id: 'call_stock',
972
+ name: 'stock',
973
+ args: '',
974
+ index: 0,
975
+ },
976
+ ],
977
+ } as unknown as t.StreamChunk,
978
+ },
979
+ { langgraph_node: 'agent' },
980
+ graph
981
+ );
982
+
983
+ expect(toolExecuteCalls).toHaveLength(1);
984
+
985
+ await handler.handle(
986
+ GraphEvents.CHAT_MODEL_STREAM,
987
+ {
988
+ chunk: {
989
+ content: '',
990
+ tool_call_chunks: [
991
+ {
992
+ args: '{"ticker":"CH"}',
993
+ index: 0,
994
+ },
995
+ ],
996
+ } as unknown as t.StreamChunk,
997
+ },
998
+ { langgraph_node: 'agent' },
999
+ graph
1000
+ );
1001
+
1002
+ expect(toolExecuteCalls).toHaveLength(1);
1003
+
1004
+ await handler.handle(
1005
+ GraphEvents.CHAT_MODEL_STREAM,
1006
+ {
1007
+ chunk: {
1008
+ content: '',
1009
+ tool_calls: [
1010
+ {
1011
+ id: 'call_stock',
1012
+ name: 'stock',
1013
+ args: { ticker: 'CH' },
1014
+ },
1015
+ ],
1016
+ response_metadata: finalToolCallResponseMetadata,
1017
+ } as unknown as t.StreamChunk,
1018
+ },
1019
+ { langgraph_node: 'agent' },
1020
+ graph
1021
+ );
1022
+
1023
+ expect(toolExecuteCalls).toHaveLength(2);
1024
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
1025
+ id: 'call_stock',
1026
+ name: 'stock',
1027
+ args: { ticker: 'CH' },
1028
+ stepId: expect.stringMatching(/^step_/),
1029
+ turn: 0,
1030
+ });
1031
+ });
1032
+
1033
+ it('preserves repeated adjacent argument deltas', async () => {
1034
+ const graph = createGraph();
1035
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1036
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1037
+ async (event, data): Promise<void> => {
1038
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1039
+ return;
1040
+ }
1041
+ const batch = data as t.ToolExecuteBatchRequest;
1042
+ toolExecuteCalls.push(batch);
1043
+ batch.resolve([
1044
+ {
1045
+ toolCallId: 'call_repeat',
1046
+ status: 'success',
1047
+ content: 'ok',
1048
+ },
1049
+ ]);
1050
+ }
1051
+ );
1052
+
1053
+ const handler = new ChatModelStreamHandler();
1054
+ const metadata = { langgraph_node: 'agent' };
1055
+
1056
+ await handler.handle(
1057
+ GraphEvents.CHAT_MODEL_STREAM,
1058
+ {
1059
+ chunk: {
1060
+ content: '',
1061
+ tool_call_chunks: [
1062
+ {
1063
+ id: 'call_repeat',
1064
+ name: 'weather',
1065
+ args: '{"word":"b',
1066
+ index: 0,
1067
+ },
1068
+ ],
1069
+ } as unknown as t.StreamChunk,
1070
+ },
1071
+ metadata,
1072
+ graph
1073
+ );
1074
+
1075
+ await handler.handle(
1076
+ GraphEvents.CHAT_MODEL_STREAM,
1077
+ {
1078
+ chunk: {
1079
+ content: '',
1080
+ tool_call_chunks: [
1081
+ {
1082
+ args: 'o',
1083
+ index: 0,
1084
+ },
1085
+ ],
1086
+ } as unknown as t.StreamChunk,
1087
+ },
1088
+ metadata,
1089
+ graph
1090
+ );
1091
+
1092
+ await handler.handle(
1093
+ GraphEvents.CHAT_MODEL_STREAM,
1094
+ {
1095
+ chunk: {
1096
+ content: '',
1097
+ tool_call_chunks: [
1098
+ {
1099
+ args: 'o',
1100
+ index: 0,
1101
+ },
1102
+ ],
1103
+ } as unknown as t.StreamChunk,
1104
+ },
1105
+ metadata,
1106
+ graph
1107
+ );
1108
+
1109
+ expect(toolExecuteCalls).toHaveLength(0);
1110
+
1111
+ await handler.handle(
1112
+ GraphEvents.CHAT_MODEL_STREAM,
1113
+ {
1114
+ chunk: {
1115
+ content: '',
1116
+ tool_call_chunks: [
1117
+ {
1118
+ args: 'k"}',
1119
+ index: 0,
1120
+ },
1121
+ ],
1122
+ } as unknown as t.StreamChunk,
1123
+ },
1124
+ metadata,
1125
+ graph
1126
+ );
1127
+
1128
+ expect(toolExecuteCalls).toHaveLength(0);
1129
+ expect(
1130
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1131
+ ?.argsText
1132
+ ).toBe('{"word":"book"}');
1133
+
1134
+ await handler.handle(
1135
+ GraphEvents.CHAT_MODEL_STREAM,
1136
+ {
1137
+ chunk: {
1138
+ content: '',
1139
+ tool_calls: [
1140
+ {
1141
+ id: 'call_repeat',
1142
+ name: 'weather',
1143
+ args: { word: 'book' },
1144
+ },
1145
+ ],
1146
+ response_metadata: finalToolCallResponseMetadata,
1147
+ } as unknown as t.StreamChunk,
1148
+ },
1149
+ metadata,
1150
+ graph
1151
+ );
1152
+
1153
+ expect(toolExecuteCalls).toHaveLength(1);
1154
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1155
+ id: 'call_repeat',
1156
+ name: 'weather',
1157
+ args: { word: 'book' },
1158
+ stepId: expect.stringMatching(/^step_/),
1159
+ turn: 0,
1160
+ });
1161
+ });
1162
+
1163
+ it('preserves identical incremental argument fragments', async () => {
1164
+ const graph = createGraph();
1165
+ const handler = new ChatModelStreamHandler();
1166
+ const metadata = { langgraph_node: 'agent' };
1167
+
1168
+ await handler.handle(
1169
+ GraphEvents.CHAT_MODEL_STREAM,
1170
+ {
1171
+ chunk: {
1172
+ content: '',
1173
+ tool_call_chunks: [
1174
+ {
1175
+ id: 'call_repeat',
1176
+ name: 'weather',
1177
+ args: 'o',
1178
+ index: 0,
1179
+ },
1180
+ ],
1181
+ } as unknown as t.StreamChunk,
1182
+ },
1183
+ metadata,
1184
+ graph
1185
+ );
1186
+
1187
+ await handler.handle(
1188
+ GraphEvents.CHAT_MODEL_STREAM,
1189
+ {
1190
+ chunk: {
1191
+ content: '',
1192
+ tool_call_chunks: [
1193
+ {
1194
+ args: 'o',
1195
+ index: 0,
1196
+ },
1197
+ ],
1198
+ } as unknown as t.StreamChunk,
1199
+ },
1200
+ metadata,
1201
+ graph
1202
+ );
1203
+
1204
+ expect(
1205
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1206
+ ?.argsText
1207
+ ).toBe('oo');
1208
+ });
1209
+
1210
+ it('deduplicates repeated observed multi-character fragments', async () => {
1211
+ const graph = createGraph();
1212
+ const handler = new ChatModelStreamHandler();
1213
+ const metadata = { langgraph_node: 'agent' };
1214
+
1215
+ await handler.handle(
1216
+ GraphEvents.CHAT_MODEL_STREAM,
1217
+ {
1218
+ chunk: {
1219
+ content: '',
1220
+ tool_call_chunks: [
1221
+ {
1222
+ id: 'call_repeat',
1223
+ name: 'weather',
1224
+ args: '{"titl',
1225
+ index: 0,
1226
+ },
1227
+ ],
1228
+ } as unknown as t.StreamChunk,
1229
+ },
1230
+ metadata,
1231
+ graph
1232
+ );
1233
+
1234
+ await handler.handle(
1235
+ GraphEvents.CHAT_MODEL_STREAM,
1236
+ {
1237
+ chunk: {
1238
+ content: '',
1239
+ tool_call_chunks: [
1240
+ {
1241
+ args: '{"titl',
1242
+ index: 0,
1243
+ },
1244
+ ],
1245
+ } as unknown as t.StreamChunk,
1246
+ },
1247
+ metadata,
1248
+ graph
1249
+ );
1250
+
1251
+ expect(
1252
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1253
+ ?.argsText
1254
+ ).toBe('{"titl');
1255
+ });
1256
+
1257
+ it('does not prestart from cumulative streamed args before final tool calls', async () => {
1258
+ const graph = createGraph();
1259
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1260
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1261
+ async (event, data): Promise<void> => {
1262
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1263
+ return;
1264
+ }
1265
+ const batch = data as t.ToolExecuteBatchRequest;
1266
+ toolExecuteCalls.push(batch);
1267
+ batch.resolve([
1268
+ {
1269
+ toolCallId: 'call_weather',
1270
+ status: 'success',
1271
+ content: 'sunny',
1272
+ },
1273
+ ]);
1274
+ }
1275
+ );
1276
+
1277
+ const handler = new ChatModelStreamHandler();
1278
+ const metadata = { langgraph_node: 'agent' };
1279
+
1280
+ await handler.handle(
1281
+ GraphEvents.CHAT_MODEL_STREAM,
1282
+ {
1283
+ chunk: {
1284
+ content: '',
1285
+ tool_call_chunks: [
1286
+ {
1287
+ id: 'call_weather',
1288
+ name: 'weather',
1289
+ args: '{"ci',
1290
+ index: 0,
1291
+ },
1292
+ ],
1293
+ } as unknown as t.StreamChunk,
1294
+ },
1295
+ metadata,
1296
+ graph
1297
+ );
1298
+
1299
+ await handler.handle(
1300
+ GraphEvents.CHAT_MODEL_STREAM,
1301
+ {
1302
+ chunk: {
1303
+ content: '',
1304
+ tool_call_chunks: [
1305
+ {
1306
+ args: '{"city":"N',
1307
+ index: 0,
1308
+ },
1309
+ ],
1310
+ } as unknown as t.StreamChunk,
1311
+ },
1312
+ metadata,
1313
+ graph
1314
+ );
1315
+
1316
+ expect(toolExecuteCalls).toHaveLength(0);
1317
+
1318
+ await handler.handle(
1319
+ GraphEvents.CHAT_MODEL_STREAM,
1320
+ {
1321
+ chunk: {
1322
+ content: '',
1323
+ tool_call_chunks: [
1324
+ {
1325
+ args: '{"city":"NYC"}',
1326
+ index: 0,
1327
+ },
1328
+ ],
1329
+ } as unknown as t.StreamChunk,
1330
+ },
1331
+ metadata,
1332
+ graph
1333
+ );
1334
+
1335
+ expect(toolExecuteCalls).toHaveLength(0);
1336
+
1337
+ await handler.handle(
1338
+ GraphEvents.CHAT_MODEL_STREAM,
1339
+ {
1340
+ chunk: {
1341
+ content: '',
1342
+ tool_call_chunks: [
1343
+ {
1344
+ args: '{"city":"NYC","unit":"C"}',
1345
+ index: 0,
1346
+ },
1347
+ ],
1348
+ } as unknown as t.StreamChunk,
1349
+ },
1350
+ metadata,
1351
+ graph
1352
+ );
1353
+
1354
+ expect(toolExecuteCalls).toHaveLength(0);
1355
+ expect(
1356
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1357
+ ?.argsText
1358
+ ).toBe('{"city":"NYC","unit":"C"}');
1359
+
1360
+ await handler.handle(
1361
+ GraphEvents.CHAT_MODEL_STREAM,
1362
+ {
1363
+ chunk: {
1364
+ content: '',
1365
+ tool_calls: [
1366
+ {
1367
+ id: 'call_weather',
1368
+ name: 'weather',
1369
+ args: { city: 'NYC', unit: 'C' },
1370
+ },
1371
+ ],
1372
+ response_metadata: finalToolCallResponseMetadata,
1373
+ } as unknown as t.StreamChunk,
1374
+ },
1375
+ metadata,
1376
+ graph
1377
+ );
1378
+
1379
+ expect(toolExecuteCalls).toHaveLength(1);
1380
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1381
+ id: 'call_weather',
1382
+ name: 'weather',
1383
+ args: { city: 'NYC', unit: 'C' },
1384
+ stepId: expect.stringMatching(/^step_/),
1385
+ turn: 0,
1386
+ });
1387
+ });
1388
+
1389
+ it('merges overlapping cumulative streamed args without duplicating suffixes', async () => {
1390
+ const graph = createGraph();
1391
+ const handler = new ChatModelStreamHandler();
1392
+ const metadata = { langgraph_node: 'agent' };
1393
+
1394
+ await handler.handle(
1395
+ GraphEvents.CHAT_MODEL_STREAM,
1396
+ {
1397
+ chunk: {
1398
+ content: '',
1399
+ tool_call_chunks: [
1400
+ {
1401
+ id: 'call_weather',
1402
+ name: 'weather',
1403
+ args: '{"tit',
1404
+ index: 0,
1405
+ },
1406
+ ],
1407
+ } as unknown as t.StreamChunk,
1408
+ },
1409
+ metadata,
1410
+ graph
1411
+ );
1412
+
1413
+ await handler.handle(
1414
+ GraphEvents.CHAT_MODEL_STREAM,
1415
+ {
1416
+ chunk: {
1417
+ content: '',
1418
+ tool_call_chunks: [
1419
+ {
1420
+ args: '{"title":"alpha"',
1421
+ index: 0,
1422
+ },
1423
+ ],
1424
+ } as unknown as t.StreamChunk,
1425
+ },
1426
+ metadata,
1427
+ graph
1428
+ );
1429
+
1430
+ await handler.handle(
1431
+ GraphEvents.CHAT_MODEL_STREAM,
1432
+ {
1433
+ chunk: {
1434
+ content: '',
1435
+ tool_call_chunks: [
1436
+ {
1437
+ args: 'le":"alpha","city":"NYC"}',
1438
+ index: 0,
1439
+ },
1440
+ ],
1441
+ } as unknown as t.StreamChunk,
1442
+ },
1443
+ metadata,
1444
+ graph
1445
+ );
1446
+
1447
+ expect(
1448
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1449
+ ?.argsText
1450
+ ).toBe('{"title":"alpha","city":"NYC"}');
1451
+ });
1452
+
1453
+ it('preserves repeated deltas from a reused stream chunk object', async () => {
1454
+ const graph = createGraph();
1455
+ const handler = new ChatModelStreamHandler();
1456
+ const metadata = { langgraph_node: 'agent' };
1457
+ const reusableToolChunk: {
1458
+ id?: string;
1459
+ name?: string;
1460
+ args: string;
1461
+ index: number;
1462
+ } = {
1463
+ id: 'call_repeat',
1464
+ name: 'weather',
1465
+ args: '{"word":"b',
1466
+ index: 0,
1467
+ };
1468
+ const reusableChunk = {
1469
+ content: '',
1470
+ tool_call_chunks: [reusableToolChunk],
1471
+ } as unknown as t.StreamChunk;
1472
+
1473
+ await handler.handle(
1474
+ GraphEvents.CHAT_MODEL_STREAM,
1475
+ { chunk: reusableChunk },
1476
+ metadata,
1477
+ graph
1478
+ );
1479
+
1480
+ reusableToolChunk.id = undefined;
1481
+ reusableToolChunk.name = undefined;
1482
+ reusableToolChunk.args = 'o';
1483
+
1484
+ await handler.handle(
1485
+ GraphEvents.CHAT_MODEL_STREAM,
1486
+ { chunk: reusableChunk },
1487
+ metadata,
1488
+ graph
1489
+ );
1490
+ await handler.handle(
1491
+ GraphEvents.CHAT_MODEL_STREAM,
1492
+ { chunk: reusableChunk },
1493
+ metadata,
1494
+ graph
1495
+ );
1496
+
1497
+ reusableToolChunk.args = 'k"}';
1498
+
1499
+ await handler.handle(
1500
+ GraphEvents.CHAT_MODEL_STREAM,
1501
+ { chunk: reusableChunk },
1502
+ metadata,
1503
+ graph
1504
+ );
1505
+
1506
+ expect(
1507
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1508
+ ?.argsText
1509
+ ).toBe('{"word":"book"}');
1510
+ });
1511
+
1512
+ it('preserves repeated text deltas from a reused stream chunk object', async () => {
1513
+ const dispatchMessageDelta = jest.fn<StandardGraph['dispatchMessageDelta']>(
1514
+ async () => undefined
1515
+ );
1516
+ const graph = createGraph({
1517
+ dispatchMessageDelta,
1518
+ getAgentContext: jest.fn(
1519
+ (): Partial<AgentContext> => ({
1520
+ provider: Providers.OPENAI,
1521
+ reasoningKey: 'reasoning_content',
1522
+ currentTokenType: ContentTypes.TEXT,
1523
+ toolDefinitions: [],
1524
+ graphTools: [],
1525
+ agentId: 'agent_1',
1526
+ })
1527
+ ) as unknown as StandardGraph['getAgentContext'],
1528
+ });
1529
+ const handler = new ChatModelStreamHandler();
1530
+ const metadata = { langgraph_node: 'agent' };
1531
+ const reusableChunk = { content: 'ha' } as unknown as t.StreamChunk;
1532
+
1533
+ await handler.handle(
1534
+ GraphEvents.CHAT_MODEL_STREAM,
1535
+ { chunk: reusableChunk },
1536
+ metadata,
1537
+ graph
1538
+ );
1539
+ await handler.handle(
1540
+ GraphEvents.CHAT_MODEL_STREAM,
1541
+ { chunk: reusableChunk },
1542
+ metadata,
1543
+ graph
1544
+ );
1545
+
1546
+ expect(dispatchMessageDelta).toHaveBeenCalledTimes(2);
1547
+ expect(dispatchMessageDelta).toHaveBeenNthCalledWith(
1548
+ 1,
1549
+ expect.stringMatching(/^step_/),
1550
+ { content: [{ type: ContentTypes.TEXT, text: 'ha' }] },
1551
+ metadata
1552
+ );
1553
+ expect(dispatchMessageDelta).toHaveBeenNthCalledWith(
1554
+ 2,
1555
+ expect.stringMatching(/^step_/),
1556
+ { content: [{ type: ContentTypes.TEXT, text: 'ha' }] },
1557
+ metadata
1558
+ );
1559
+ });
1560
+
1561
+ it('processes a reused chunk object when its streamed payload changes', async () => {
1562
+ const graph = createGraph();
1563
+ const handler = new ChatModelStreamHandler();
1564
+ const metadata = { langgraph_node: 'agent' };
1565
+ const reusableToolChunk = {
1566
+ id: 'call_weather',
1567
+ name: 'weather',
1568
+ args: '{"city"',
1569
+ index: 0,
1570
+ };
1571
+ const reusableChunk = {
1572
+ content: '',
1573
+ tool_call_chunks: [reusableToolChunk],
1574
+ } as unknown as t.StreamChunk;
1575
+
1576
+ await handler.handle(
1577
+ GraphEvents.CHAT_MODEL_STREAM,
1578
+ { chunk: reusableChunk },
1579
+ metadata,
1580
+ graph
1581
+ );
1582
+
1583
+ reusableToolChunk.args = ':"NYC"}';
1584
+
1585
+ await handler.handle(
1586
+ GraphEvents.CHAT_MODEL_STREAM,
1587
+ { chunk: reusableChunk },
1588
+ metadata,
1589
+ graph
1590
+ );
1591
+
1592
+ expect(
1593
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1594
+ ?.argsText
1595
+ ).toBe('{"city":"NYC"}');
1596
+ });
1597
+
1598
+ it('does not share chunk object de-duplication across graphs', async () => {
1599
+ const graphA = createGraph();
1600
+ const graphB = createGraph();
1601
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1602
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1603
+ async (event, data): Promise<void> => {
1604
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1605
+ return;
1606
+ }
1607
+ const batch = data as t.ToolExecuteBatchRequest;
1608
+ toolExecuteCalls.push(batch);
1609
+ batch.resolve(
1610
+ batch.toolCalls.map((request) => ({
1611
+ toolCallId: request.id,
1612
+ status: 'success' as const,
1613
+ content: `${request.id} result`,
1614
+ }))
1615
+ );
1616
+ }
1617
+ );
1618
+
1619
+ const handler = new ChatModelStreamHandler();
1620
+ const sharedChunk = {
1621
+ content: '',
1622
+ tool_calls: [
1623
+ {
1624
+ id: 'call_weather',
1625
+ name: 'weather',
1626
+ args: { city: 'NYC' },
1627
+ },
1628
+ ],
1629
+ response_metadata: finalToolCallResponseMetadata,
1630
+ } as unknown as t.StreamChunk;
1631
+
1632
+ await handler.handle(
1633
+ GraphEvents.CHAT_MODEL_STREAM,
1634
+ { chunk: sharedChunk },
1635
+ { langgraph_node: 'agent' },
1636
+ graphA
1637
+ );
1638
+ await handler.handle(
1639
+ GraphEvents.CHAT_MODEL_STREAM,
1640
+ { chunk: sharedChunk },
1641
+ { langgraph_node: 'agent' },
1642
+ graphB
1643
+ );
1644
+
1645
+ expect(toolExecuteCalls).toHaveLength(2);
1646
+ expect(graphA.eagerEventToolExecutions.has('call_weather')).toBe(true);
1647
+ expect(graphB.eagerEventToolExecutions.has('call_weather')).toBe(true);
1648
+ });
1649
+
1650
+ it('prestarts a completed streamed tool when a later tool call begins', async () => {
1651
+ const graph = createGraph({
1652
+ getAgentContext: jest.fn(
1653
+ (): Partial<AgentContext> => ({
1654
+ provider: Providers.ANTHROPIC,
1655
+ reasoningKey: 'reasoning',
1656
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
1657
+ graphTools: [],
1658
+ agentId: 'agent_1',
1659
+ })
1660
+ ) as unknown as StandardGraph['getAgentContext'],
1661
+ });
1662
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1663
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1664
+ async (event, data): Promise<void> => {
1665
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1666
+ return;
1667
+ }
1668
+ const batch = data as t.ToolExecuteBatchRequest;
1669
+ toolExecuteCalls.push(batch);
1670
+ batch.resolve(
1671
+ batch.toolCalls.map((call) => ({
1672
+ toolCallId: call.id,
1673
+ status: 'success',
1674
+ content: `ok ${call.name}`,
1675
+ }))
1676
+ );
1677
+ }
1678
+ );
1679
+
1680
+ const handler = new ChatModelStreamHandler();
1681
+ const metadata = { langgraph_node: 'agent' };
1682
+
1683
+ await handler.handle(
1684
+ GraphEvents.CHAT_MODEL_STREAM,
1685
+ {
1686
+ chunk: {
1687
+ content: '',
1688
+ tool_call_chunks: [
1689
+ {
1690
+ id: 'call_weather',
1691
+ name: 'weather',
1692
+ args: '{"city":"NYC"}',
1693
+ index: 0,
1694
+ },
1695
+ ],
1696
+ } as unknown as t.StreamChunk,
1697
+ },
1698
+ metadata,
1699
+ graph
1700
+ );
1701
+
1702
+ expect(toolExecuteCalls).toHaveLength(0);
1703
+
1704
+ await handler.handle(
1705
+ GraphEvents.CHAT_MODEL_STREAM,
1706
+ {
1707
+ chunk: {
1708
+ content: '',
1709
+ tool_call_chunks: [
1710
+ {
1711
+ id: 'call_stock',
1712
+ name: 'stock',
1713
+ args: '{"ticker":"C',
1714
+ index: 1,
1715
+ },
1716
+ ],
1717
+ } as unknown as t.StreamChunk,
1718
+ },
1719
+ metadata,
1720
+ graph
1721
+ );
1722
+
1723
+ expect(toolExecuteCalls).toHaveLength(1);
1724
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
1725
+ expect.objectContaining({
1726
+ id: 'call_weather',
1727
+ name: 'weather',
1728
+ args: { city: 'NYC' },
1729
+ stepId: expect.stringMatching(/^step_/),
1730
+ turn: 0,
1731
+ }),
1732
+ ]);
1733
+ expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(true);
1734
+ expect(graph.eagerEventToolExecutions.has('call_stock')).toBe(false);
1735
+ expect(graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))).toBe(
1736
+ false
1737
+ );
1738
+ expect(
1739
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 1))?.argsText
1740
+ ).toBe('{"ticker":"C');
1741
+
1742
+ await handler.handle(
1743
+ GraphEvents.CHAT_MODEL_STREAM,
1744
+ {
1745
+ chunk: {
1746
+ content: '',
1747
+ tool_call_chunks: [
1748
+ {
1749
+ args: 'H"}',
1750
+ index: 1,
1751
+ },
1752
+ ],
1753
+ } as unknown as t.StreamChunk,
1754
+ },
1755
+ metadata,
1756
+ graph
1757
+ );
1758
+
1759
+ expect(toolExecuteCalls).toHaveLength(1);
1760
+
1761
+ await handler.handle(
1762
+ GraphEvents.CHAT_MODEL_STREAM,
1763
+ {
1764
+ chunk: {
1765
+ content: '',
1766
+ tool_calls: [
1767
+ {
1768
+ id: 'call_weather',
1769
+ name: 'weather',
1770
+ args: { city: 'NYC' },
1771
+ },
1772
+ {
1773
+ id: 'call_stock',
1774
+ name: 'stock',
1775
+ args: { ticker: 'CH' },
1776
+ },
1777
+ ],
1778
+ response_metadata: finalToolCallResponseMetadata,
1779
+ } as unknown as t.StreamChunk,
1780
+ },
1781
+ metadata,
1782
+ graph
1783
+ );
1784
+
1785
+ expect(toolExecuteCalls).toHaveLength(2);
1786
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
1787
+ id: 'call_stock',
1788
+ name: 'stock',
1789
+ args: { ticker: 'CH' },
1790
+ stepId: expect.stringMatching(/^step_/),
1791
+ turn: 0,
1792
+ });
1793
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
1794
+ });
1795
+
1796
+ it('does not seal a streamed tool when the same chunk also carries its own index', async () => {
1797
+ const graph = createGraph({
1798
+ getAgentContext: jest.fn(
1799
+ (): Partial<AgentContext> => ({
1800
+ provider: Providers.ANTHROPIC,
1801
+ reasoningKey: 'reasoning',
1802
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
1803
+ graphTools: [],
1804
+ agentId: 'agent_1',
1805
+ })
1806
+ ) as unknown as StandardGraph['getAgentContext'],
1807
+ });
1808
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1809
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1810
+ async (event, data): Promise<void> => {
1811
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1812
+ return;
1813
+ }
1814
+ const batch = data as t.ToolExecuteBatchRequest;
1815
+ toolExecuteCalls.push(batch);
1816
+ batch.resolve(
1817
+ batch.toolCalls.map((call) => ({
1818
+ toolCallId: call.id,
1819
+ status: 'success',
1820
+ content: `ok ${call.name}`,
1821
+ }))
1822
+ );
1823
+ }
1824
+ );
1825
+
1826
+ const handler = new ChatModelStreamHandler();
1827
+ const metadata = { langgraph_node: 'agent' };
1828
+
1829
+ await handler.handle(
1830
+ GraphEvents.CHAT_MODEL_STREAM,
1831
+ {
1832
+ chunk: {
1833
+ content: '',
1834
+ tool_call_chunks: [
1835
+ {
1836
+ id: 'call_weather',
1837
+ name: 'weather',
1838
+ args: '{"city":"NYC"}',
1839
+ index: 0,
1840
+ },
1841
+ ],
1842
+ } as unknown as t.StreamChunk,
1843
+ },
1844
+ metadata,
1845
+ graph
1846
+ );
1847
+
1848
+ await handler.handle(
1849
+ GraphEvents.CHAT_MODEL_STREAM,
1850
+ {
1851
+ chunk: {
1852
+ content: '',
1853
+ tool_call_chunks: [
1854
+ {
1855
+ args: '',
1856
+ index: 0,
1857
+ },
1858
+ {
1859
+ id: 'call_stock',
1860
+ name: 'stock',
1861
+ args: '{"ticker":"C',
1862
+ index: 1,
1863
+ },
1864
+ ],
1865
+ } as unknown as t.StreamChunk,
1866
+ },
1867
+ metadata,
1868
+ graph
1869
+ );
1870
+
1871
+ expect(toolExecuteCalls).toHaveLength(0);
1872
+
1873
+ await handler.handle(
1874
+ GraphEvents.CHAT_MODEL_STREAM,
1875
+ {
1876
+ chunk: {
1877
+ content: '',
1878
+ tool_call_chunks: [
1879
+ {
1880
+ args: 'H"}',
1881
+ index: 1,
1882
+ },
1883
+ ],
1884
+ } as unknown as t.StreamChunk,
1885
+ },
1886
+ metadata,
1887
+ graph
1888
+ );
1889
+
1890
+ expect(toolExecuteCalls).toHaveLength(1);
1891
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1892
+ id: 'call_weather',
1893
+ name: 'weather',
1894
+ args: { city: 'NYC' },
1895
+ });
1896
+ });
1897
+
1898
+ it('preserves same-tool turns across per-call streamed eager starts', async () => {
1899
+ const graph = createGraph();
1900
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1901
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1902
+ async (event, data): Promise<void> => {
1903
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1904
+ return;
1905
+ }
1906
+ const batch = data as t.ToolExecuteBatchRequest;
1907
+ toolExecuteCalls.push(batch);
1908
+ batch.resolve(
1909
+ batch.toolCalls.map((call) => ({
1910
+ toolCallId: call.id,
1911
+ status: 'success',
1912
+ content: `ok ${call.args.city}`,
1913
+ }))
1914
+ );
1915
+ }
1916
+ );
1917
+
1918
+ const handler = new ChatModelStreamHandler();
1919
+ const metadata = { langgraph_node: 'agent' };
1920
+
1921
+ await handler.handle(
1922
+ GraphEvents.CHAT_MODEL_STREAM,
1923
+ {
1924
+ chunk: {
1925
+ content: '',
1926
+ tool_call_chunks: [
1927
+ {
1928
+ id: 'call_weather_1',
1929
+ name: 'weather',
1930
+ args: '{"city":"NYC"}',
1931
+ index: 0,
1932
+ },
1933
+ ],
1934
+ } as unknown as t.StreamChunk,
1935
+ },
1936
+ metadata,
1937
+ graph
1938
+ );
1939
+
1940
+ await handler.handle(
1941
+ GraphEvents.CHAT_MODEL_STREAM,
1942
+ {
1943
+ chunk: {
1944
+ content: '',
1945
+ tool_call_chunks: [
1946
+ {
1947
+ id: 'call_weather_2',
1948
+ name: 'weather',
1949
+ args: '{"city":"B',
1950
+ index: 1,
1951
+ },
1952
+ ],
1953
+ } as unknown as t.StreamChunk,
1954
+ },
1955
+ metadata,
1956
+ graph
1957
+ );
1958
+
1959
+ expect(toolExecuteCalls).toHaveLength(1);
1960
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1961
+ id: 'call_weather_1',
1962
+ name: 'weather',
1963
+ args: { city: 'NYC' },
1964
+ turn: 0,
1965
+ });
1966
+
1967
+ await handler.handle(
1968
+ GraphEvents.CHAT_MODEL_STREAM,
1969
+ {
1970
+ chunk: {
1971
+ content: '',
1972
+ tool_call_chunks: [
1973
+ {
1974
+ args: 'oston"}',
1975
+ index: 1,
1976
+ },
1977
+ ],
1978
+ } as unknown as t.StreamChunk,
1979
+ },
1980
+ metadata,
1981
+ graph
1982
+ );
1983
+
1984
+ await handler.handle(
1985
+ GraphEvents.CHAT_MODEL_STREAM,
1986
+ {
1987
+ chunk: {
1988
+ content: '',
1989
+ tool_calls: [
1990
+ {
1991
+ id: 'call_weather_1',
1992
+ name: 'weather',
1993
+ args: { city: 'NYC' },
1994
+ },
1995
+ {
1996
+ id: 'call_weather_2',
1997
+ name: 'weather',
1998
+ args: { city: 'Boston' },
1999
+ },
2000
+ ],
2001
+ response_metadata: finalToolCallResponseMetadata,
2002
+ } as unknown as t.StreamChunk,
2003
+ },
2004
+ metadata,
2005
+ graph
2006
+ );
2007
+
2008
+ expect(toolExecuteCalls).toHaveLength(2);
2009
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2010
+ id: 'call_weather_2',
2011
+ name: 'weather',
2012
+ args: { city: 'Boston' },
2013
+ turn: 1,
2014
+ });
2015
+ expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
2016
+ });
2017
+
2018
+ it('scopes streamed chunk accumulation by step key', async () => {
2019
+ const graph = createGraph({
2020
+ getStepKey: jest.fn((metadata?: Record<string, unknown>) =>
2021
+ String(metadata?.langgraph_node ?? 'step-key')
2022
+ ),
2023
+ });
2024
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2025
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
2026
+ async (event, data): Promise<void> => {
2027
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2028
+ return;
2029
+ }
2030
+ const batch = data as t.ToolExecuteBatchRequest;
2031
+ toolExecuteCalls.push(batch);
2032
+ batch.resolve(
2033
+ batch.toolCalls.map((call) => ({
2034
+ toolCallId: call.id,
2035
+ status: 'success',
2036
+ content: `ok ${call.name}`,
2037
+ }))
2038
+ );
2039
+ }
2040
+ );
2041
+
2042
+ const handler = new ChatModelStreamHandler();
2043
+
2044
+ await handler.handle(
2045
+ GraphEvents.CHAT_MODEL_STREAM,
2046
+ {
2047
+ chunk: {
2048
+ content: '',
2049
+ tool_call_chunks: [
2050
+ {
2051
+ id: 'call_agent_a',
2052
+ name: 'weather',
2053
+ args: '{"city":"N',
2054
+ index: 0,
2055
+ },
2056
+ ],
2057
+ } as unknown as t.StreamChunk,
2058
+ },
2059
+ { langgraph_node: 'agent_a' },
2060
+ graph
2061
+ );
2062
+
2063
+ await handler.handle(
2064
+ GraphEvents.CHAT_MODEL_STREAM,
2065
+ {
2066
+ chunk: {
2067
+ content: '',
2068
+ tool_call_chunks: [
2069
+ {
2070
+ id: 'call_agent_b',
2071
+ name: 'weather',
2072
+ args: '{"city":"S',
2073
+ index: 0,
2074
+ },
2075
+ ],
2076
+ } as unknown as t.StreamChunk,
2077
+ },
2078
+ { langgraph_node: 'agent_b' },
2079
+ graph
2080
+ );
2081
+
2082
+ await handler.handle(
2083
+ GraphEvents.CHAT_MODEL_STREAM,
2084
+ {
2085
+ chunk: {
2086
+ content: '',
2087
+ tool_call_chunks: [
2088
+ {
2089
+ args: 'F"}',
2090
+ index: 0,
2091
+ },
2092
+ ],
2093
+ } as unknown as t.StreamChunk,
2094
+ },
2095
+ { langgraph_node: 'agent_b' },
2096
+ graph
2097
+ );
2098
+
2099
+ expect(toolExecuteCalls).toHaveLength(0);
2100
+
2101
+ await handler.handle(
2102
+ GraphEvents.CHAT_MODEL_STREAM,
2103
+ {
2104
+ chunk: {
2105
+ content: '',
2106
+ tool_call_chunks: [
2107
+ {
2108
+ args: 'YC"}',
2109
+ index: 0,
2110
+ },
2111
+ ],
2112
+ } as unknown as t.StreamChunk,
2113
+ },
2114
+ { langgraph_node: 'agent_a' },
2115
+ graph
2116
+ );
2117
+
2118
+ expect(toolExecuteCalls).toHaveLength(0);
2119
+ expect(
2120
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2121
+ ).toBe('{"city":"NYC"}');
2122
+ expect(
2123
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_b', 0))?.argsText
2124
+ ).toBe('{"city":"SF"}');
2125
+
2126
+ await handler.handle(
2127
+ GraphEvents.CHAT_MODEL_STREAM,
2128
+ {
2129
+ chunk: {
2130
+ content: '',
2131
+ tool_calls: [
2132
+ {
2133
+ id: 'call_agent_b',
2134
+ name: 'weather',
2135
+ args: { city: 'SF' },
2136
+ },
2137
+ ],
2138
+ response_metadata: finalToolCallResponseMetadata,
2139
+ } as unknown as t.StreamChunk,
2140
+ },
2141
+ { langgraph_node: 'agent_b' },
2142
+ graph
2143
+ );
2144
+
2145
+ expect(graph.eagerEventToolCallChunks.has(chunkStateKey('agent_b', 0))).toBe(
2146
+ false
2147
+ );
2148
+ expect(
2149
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2150
+ ).toBe('{"city":"NYC"}');
2151
+
2152
+ await handler.handle(
2153
+ GraphEvents.CHAT_MODEL_STREAM,
2154
+ {
2155
+ chunk: {
2156
+ content: '',
2157
+ tool_calls: [
2158
+ {
2159
+ id: 'call_agent_a',
2160
+ name: 'weather',
2161
+ args: { city: 'NYC' },
2162
+ },
2163
+ ],
2164
+ response_metadata: finalToolCallResponseMetadata,
2165
+ } as unknown as t.StreamChunk,
2166
+ },
2167
+ { langgraph_node: 'agent_a' },
2168
+ graph
2169
+ );
2170
+
2171
+ expect(toolExecuteCalls).toHaveLength(2);
2172
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2173
+ id: 'call_agent_b',
2174
+ name: 'weather',
2175
+ args: { city: 'SF' },
2176
+ });
2177
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2178
+ id: 'call_agent_a',
2179
+ name: 'weather',
2180
+ args: { city: 'NYC' },
2181
+ });
2182
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2183
+ });
2184
+
2185
+ it('does not prestart when batch-sensitive hooks are configured', async () => {
2186
+ const graph = createGraph({ hookRegistry: {} as StandardGraph['hookRegistry'] });
2187
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2188
+
2189
+ await new ChatModelStreamHandler().handle(
2190
+ GraphEvents.CHAT_MODEL_STREAM,
2191
+ {
2192
+ chunk: {
2193
+ content: '',
2194
+ tool_calls: [
2195
+ {
2196
+ id: 'call_weather',
2197
+ name: 'weather',
2198
+ args: { city: 'NYC' },
2199
+ },
2200
+ ],
2201
+ } as unknown as t.StreamChunk,
2202
+ },
2203
+ { langgraph_node: 'agent' },
2204
+ graph
2205
+ );
2206
+
2207
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2208
+ GraphEvents.ON_TOOL_EXECUTE,
2209
+ expect.anything(),
2210
+ expect.anything()
2211
+ );
2212
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2213
+ });
2214
+
2215
+ it('does not buffer streamed chunks when eager execution is disabled', async () => {
2216
+ const graph = createGraph({
2217
+ eagerEventToolExecution: { enabled: false },
2218
+ } as Partial<StandardGraph>);
2219
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2220
+
2221
+ await new ChatModelStreamHandler().handle(
2222
+ GraphEvents.CHAT_MODEL_STREAM,
2223
+ {
2224
+ chunk: {
2225
+ content: '',
2226
+ tool_call_chunks: [
2227
+ {
2228
+ id: 'call_weather',
2229
+ name: 'weather',
2230
+ args: '{"city":"NYC"}',
2231
+ index: 0,
2232
+ },
2233
+ ],
2234
+ } as unknown as t.StreamChunk,
2235
+ },
2236
+ { langgraph_node: 'agent' },
2237
+ graph
2238
+ );
2239
+
2240
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2241
+ GraphEvents.ON_TOOL_EXECUTE,
2242
+ expect.anything(),
2243
+ expect.anything()
2244
+ );
2245
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2246
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2247
+ });
2248
+
2249
+ it('does not prestart local-engine direct coding tools', async () => {
2250
+ const graph = createGraph({
2251
+ toolExecution: {
2252
+ engine: 'local',
2253
+ } as StandardGraph['toolExecution'],
2254
+ getAgentContext: jest.fn(
2255
+ (): Partial<AgentContext> => ({
2256
+ provider: Providers.OPENAI,
2257
+ reasoningKey: 'reasoning_content',
2258
+ toolDefinitions: [{ name: Constants.EXECUTE_CODE }],
2259
+ graphTools: [],
2260
+ agentId: 'agent_1',
2261
+ })
2262
+ ) as unknown as StandardGraph['getAgentContext'],
2263
+ });
2264
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2265
+
2266
+ await new ChatModelStreamHandler().handle(
2267
+ GraphEvents.CHAT_MODEL_STREAM,
2268
+ {
2269
+ chunk: {
2270
+ content: '',
2271
+ tool_calls: [
2272
+ {
2273
+ id: 'call_code',
2274
+ name: Constants.EXECUTE_CODE,
2275
+ args: { code: 'print(1)' },
2276
+ },
2277
+ ],
2278
+ } as unknown as t.StreamChunk,
2279
+ },
2280
+ { langgraph_node: 'agent' },
2281
+ graph
2282
+ );
2283
+
2284
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2285
+ GraphEvents.ON_TOOL_EXECUTE,
2286
+ expect.anything(),
2287
+ expect.anything()
2288
+ );
2289
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2290
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2291
+ });
2292
+
2293
+ it('does not prestart streamed local-engine direct coding tools', async () => {
2294
+ const graph = createGraph({
2295
+ toolExecution: {
2296
+ engine: 'local',
2297
+ } as StandardGraph['toolExecution'],
2298
+ getAgentContext: jest.fn(
2299
+ (): Partial<AgentContext> => ({
2300
+ provider: Providers.OPENAI,
2301
+ reasoningKey: 'reasoning_content',
2302
+ toolDefinitions: [{ name: Constants.EXECUTE_CODE }, { name: 'weather' }],
2303
+ graphTools: [],
2304
+ agentId: 'agent_1',
2305
+ })
2306
+ ) as unknown as StandardGraph['getAgentContext'],
2307
+ });
2308
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2309
+ const handler = new ChatModelStreamHandler();
2310
+ const metadata = { langgraph_node: 'agent' };
2311
+
2312
+ await handler.handle(
2313
+ GraphEvents.CHAT_MODEL_STREAM,
2314
+ {
2315
+ chunk: {
2316
+ content: '',
2317
+ tool_call_chunks: [
2318
+ {
2319
+ id: 'call_weather',
2320
+ name: 'weather',
2321
+ args: '{"city":"NYC"}',
2322
+ index: 0,
2323
+ },
2324
+ ],
2325
+ } as unknown as t.StreamChunk,
2326
+ },
2327
+ metadata,
2328
+ graph
2329
+ );
2330
+ await handler.handle(
2331
+ GraphEvents.CHAT_MODEL_STREAM,
2332
+ {
2333
+ chunk: {
2334
+ content: '',
2335
+ tool_call_chunks: [
2336
+ {
2337
+ id: 'call_code',
2338
+ name: Constants.EXECUTE_CODE,
2339
+ args: '{"code":"print(1)"}',
2340
+ index: 1,
2341
+ },
2342
+ ],
2343
+ } as unknown as t.StreamChunk,
2344
+ },
2345
+ metadata,
2346
+ graph
2347
+ );
2348
+
2349
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2350
+ GraphEvents.ON_TOOL_EXECUTE,
2351
+ expect.anything(),
2352
+ expect.anything()
2353
+ );
2354
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2355
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2356
+ });
2357
+
2358
+ it('does not prestart event tools in a mixed direct-tool batch', async () => {
2359
+ const graph = createGraph({
2360
+ toolExecution: {
2361
+ engine: 'local',
2362
+ } as StandardGraph['toolExecution'],
2363
+ getAgentContext: jest.fn(
2364
+ (): Partial<AgentContext> => ({
2365
+ provider: Providers.OPENAI,
2366
+ reasoningKey: 'reasoning_content',
2367
+ toolDefinitions: [
2368
+ { name: Constants.EXECUTE_CODE },
2369
+ { name: 'weather' },
2370
+ ],
2371
+ graphTools: [],
2372
+ agentId: 'agent_1',
2373
+ })
2374
+ ) as unknown as StandardGraph['getAgentContext'],
2375
+ });
2376
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2377
+
2378
+ await new ChatModelStreamHandler().handle(
2379
+ GraphEvents.CHAT_MODEL_STREAM,
2380
+ {
2381
+ chunk: {
2382
+ content: '',
2383
+ tool_calls: [
2384
+ {
2385
+ id: 'call_code',
2386
+ name: Constants.EXECUTE_CODE,
2387
+ args: { code: 'print(1)' },
2388
+ },
2389
+ {
2390
+ id: 'call_weather',
2391
+ name: 'weather',
2392
+ args: { city: 'NYC' },
2393
+ },
2394
+ ],
2395
+ response_metadata: finalToolCallResponseMetadata,
2396
+ } as unknown as t.StreamChunk,
2397
+ },
2398
+ { langgraph_node: 'agent' },
2399
+ graph
2400
+ );
2401
+
2402
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2403
+ GraphEvents.ON_TOOL_EXECUTE,
2404
+ expect.anything(),
2405
+ expect.anything()
2406
+ );
2407
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2408
+ });
2409
+
2410
+ it('continues eager turns after normal event-dispatch usage', async () => {
2411
+ const graph = createGraph();
2412
+ graph.eagerEventToolUsageCount.set('weather', 1);
2413
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2414
+ jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
2415
+ async (event, data): Promise<void> => {
2416
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2417
+ return;
2418
+ }
2419
+ const batch = data as t.ToolExecuteBatchRequest;
2420
+ toolExecuteCalls.push(batch);
2421
+ batch.resolve([
2422
+ {
2423
+ toolCallId: 'call_weather_2',
2424
+ status: 'success',
2425
+ content: 'sunny',
2426
+ },
2427
+ ]);
2428
+ }
2429
+ );
2430
+
2431
+ await new ChatModelStreamHandler().handle(
2432
+ GraphEvents.CHAT_MODEL_STREAM,
2433
+ {
2434
+ chunk: {
2435
+ content: '',
2436
+ tool_calls: [
2437
+ {
2438
+ id: 'call_weather_2',
2439
+ name: 'weather',
2440
+ args: { city: 'NYC' },
2441
+ },
2442
+ ],
2443
+ response_metadata: finalToolCallResponseMetadata,
2444
+ } as unknown as t.StreamChunk,
2445
+ },
2446
+ { langgraph_node: 'agent' },
2447
+ graph
2448
+ );
2449
+
2450
+ expect(toolExecuteCalls).toHaveLength(1);
2451
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2452
+ id: 'call_weather_2',
2453
+ name: 'weather',
2454
+ turn: 1,
2455
+ });
2456
+ expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
2457
+ });
2458
+ });