@ai-sdk/langchain 0.0.0-1c33ba03-20260114162300 → 0.0.0-4caafb2a-20260122145312
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.
- package/CHANGELOG.md +83 -2
- package/package.json +4 -3
- package/src/__fixtures__/langgraph.ts +16543 -0
- package/src/__snapshots__/langgraph-hitl-request-1.json +508 -0
- package/src/__snapshots__/langgraph-hitl-request-2.json +173 -0
- package/src/__snapshots__/react-agent-tool-calling.json +859 -0
- package/src/adapter.test.ts +2043 -0
- package/src/adapter.ts +520 -0
- package/src/index.ts +12 -0
- package/src/stream-callbacks.test.ts +196 -0
- package/src/stream-callbacks.ts +65 -0
- package/src/transport.test.ts +41 -0
- package/src/transport.ts +88 -0
- package/src/types.ts +75 -0
- package/src/utils.test.ts +1982 -0
- package/src/utils.ts +1587 -0
|
@@ -0,0 +1,1982 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
AIMessage,
|
|
4
|
+
AIMessageChunk,
|
|
5
|
+
HumanMessage,
|
|
6
|
+
ToolMessage,
|
|
7
|
+
} from '@langchain/core/messages';
|
|
8
|
+
import type {
|
|
9
|
+
ToolResultPart,
|
|
10
|
+
AssistantContent,
|
|
11
|
+
UserContent,
|
|
12
|
+
UIMessageChunk,
|
|
13
|
+
} from 'ai';
|
|
14
|
+
import {
|
|
15
|
+
convertToolResultPart,
|
|
16
|
+
convertAssistantContent,
|
|
17
|
+
convertUserContent,
|
|
18
|
+
isToolResultPart,
|
|
19
|
+
processModelChunk,
|
|
20
|
+
isPlainMessageObject,
|
|
21
|
+
isAIMessageChunk,
|
|
22
|
+
isToolMessageType,
|
|
23
|
+
getMessageText,
|
|
24
|
+
isImageGenerationOutput,
|
|
25
|
+
extractImageOutputs,
|
|
26
|
+
processLangGraphEvent,
|
|
27
|
+
} from './utils';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a mock ReadableStreamDefaultController for testing
|
|
31
|
+
*/
|
|
32
|
+
function createMockController(
|
|
33
|
+
chunks: unknown[],
|
|
34
|
+
): ReadableStreamDefaultController<UIMessageChunk> {
|
|
35
|
+
return {
|
|
36
|
+
enqueue: (c: unknown) => {
|
|
37
|
+
chunks.push(c);
|
|
38
|
+
},
|
|
39
|
+
close: () => {},
|
|
40
|
+
error: () => {},
|
|
41
|
+
desiredSize: 1,
|
|
42
|
+
} as ReadableStreamDefaultController<UIMessageChunk>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('convertToolResultPart', () => {
|
|
46
|
+
it('should convert text output', () => {
|
|
47
|
+
const part: ToolResultPart = {
|
|
48
|
+
type: 'tool-result',
|
|
49
|
+
toolCallId: 'call-1',
|
|
50
|
+
toolName: 'get_weather',
|
|
51
|
+
output: { type: 'text', value: 'Sunny, 72°F' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = convertToolResultPart(part);
|
|
55
|
+
|
|
56
|
+
expect(result).toBeInstanceOf(ToolMessage);
|
|
57
|
+
expect(result.tool_call_id).toBe('call-1');
|
|
58
|
+
expect(result.content).toBe('Sunny, 72°F');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should convert error-text output', () => {
|
|
62
|
+
const part: ToolResultPart = {
|
|
63
|
+
type: 'tool-result',
|
|
64
|
+
toolCallId: 'call-1',
|
|
65
|
+
toolName: 'failing_tool',
|
|
66
|
+
output: { type: 'error-text', value: 'Something went wrong' },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = convertToolResultPart(part);
|
|
70
|
+
|
|
71
|
+
expect(result.content).toBe('Something went wrong');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should convert json output', () => {
|
|
75
|
+
const part: ToolResultPart = {
|
|
76
|
+
type: 'tool-result',
|
|
77
|
+
toolCallId: 'call-1',
|
|
78
|
+
toolName: 'get_data',
|
|
79
|
+
output: { type: 'json', value: { temperature: 72 } },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = convertToolResultPart(part);
|
|
83
|
+
|
|
84
|
+
expect(result.content).toBe('{"temperature":72}');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should convert error-json output', () => {
|
|
88
|
+
const part: ToolResultPart = {
|
|
89
|
+
type: 'tool-result',
|
|
90
|
+
toolCallId: 'call-1',
|
|
91
|
+
toolName: 'failing_tool',
|
|
92
|
+
output: { type: 'error-json', value: { error: 'Failed' } },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const result = convertToolResultPart(part);
|
|
96
|
+
|
|
97
|
+
expect(result.content).toBe('{"error":"Failed"}');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should convert content output with text blocks', () => {
|
|
101
|
+
const part: ToolResultPart = {
|
|
102
|
+
type: 'tool-result',
|
|
103
|
+
toolCallId: 'call-1',
|
|
104
|
+
toolName: 'multi_output',
|
|
105
|
+
output: {
|
|
106
|
+
type: 'content',
|
|
107
|
+
value: [
|
|
108
|
+
{ type: 'text', text: 'First part ' },
|
|
109
|
+
{ type: 'text', text: 'Second part' },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = convertToolResultPart(part);
|
|
115
|
+
|
|
116
|
+
expect(result.content).toBe('First part Second part');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should handle content output with non-text blocks', () => {
|
|
120
|
+
const part: ToolResultPart = {
|
|
121
|
+
type: 'tool-result',
|
|
122
|
+
toolCallId: 'call-1',
|
|
123
|
+
toolName: 'mixed_output',
|
|
124
|
+
output: {
|
|
125
|
+
type: 'content',
|
|
126
|
+
value: [
|
|
127
|
+
{ type: 'text', text: 'Hello' },
|
|
128
|
+
{ type: 'image-data', data: 'base64data', mediaType: 'image/png' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = convertToolResultPart(part);
|
|
134
|
+
|
|
135
|
+
expect(result.content).toBe('Hello');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('convertAssistantContent', () => {
|
|
140
|
+
it('should convert string content', () => {
|
|
141
|
+
const content: AssistantContent = 'Hello, how can I help?';
|
|
142
|
+
|
|
143
|
+
const result = convertAssistantContent(content);
|
|
144
|
+
|
|
145
|
+
expect(result).toBeInstanceOf(AIMessage);
|
|
146
|
+
expect(result.content).toBe('Hello, how can I help?');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should convert array content with text parts', () => {
|
|
150
|
+
const content: AssistantContent = [
|
|
151
|
+
{ type: 'text', text: 'Hello ' },
|
|
152
|
+
{ type: 'text', text: 'World' },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const result = convertAssistantContent(content);
|
|
156
|
+
|
|
157
|
+
expect(result.content).toBe('Hello World');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should convert array content with tool calls', () => {
|
|
161
|
+
const content: AssistantContent = [
|
|
162
|
+
{
|
|
163
|
+
type: 'tool-call',
|
|
164
|
+
toolCallId: 'call-1',
|
|
165
|
+
toolName: 'get_weather',
|
|
166
|
+
input: { location: 'NYC' },
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const result = convertAssistantContent(content);
|
|
171
|
+
|
|
172
|
+
expect(result.tool_calls).toHaveLength(1);
|
|
173
|
+
expect(result.tool_calls?.[0]).toEqual({
|
|
174
|
+
id: 'call-1',
|
|
175
|
+
name: 'get_weather',
|
|
176
|
+
args: { location: 'NYC' },
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle mixed text and tool calls', () => {
|
|
181
|
+
const content: AssistantContent = [
|
|
182
|
+
{ type: 'text', text: "I'll check the weather" },
|
|
183
|
+
{
|
|
184
|
+
type: 'tool-call',
|
|
185
|
+
toolCallId: 'call-1',
|
|
186
|
+
toolName: 'get_weather',
|
|
187
|
+
input: { location: 'NYC' },
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const result = convertAssistantContent(content);
|
|
192
|
+
|
|
193
|
+
expect(result.content).toBe("I'll check the weather");
|
|
194
|
+
expect(result.tool_calls).toHaveLength(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should have no tool_calls when none present', () => {
|
|
198
|
+
const content: AssistantContent = [{ type: 'text', text: 'Just text' }];
|
|
199
|
+
|
|
200
|
+
const result = convertAssistantContent(content);
|
|
201
|
+
|
|
202
|
+
// AIMessage normalizes undefined to empty array
|
|
203
|
+
expect(result.tool_calls).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('convertUserContent', () => {
|
|
208
|
+
it('should convert string content', () => {
|
|
209
|
+
const content: UserContent = 'Hello!';
|
|
210
|
+
|
|
211
|
+
const result = convertUserContent(content);
|
|
212
|
+
|
|
213
|
+
expect(result).toBeInstanceOf(HumanMessage);
|
|
214
|
+
expect(result.content).toBe('Hello!');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should convert array content with text parts', () => {
|
|
218
|
+
const content: UserContent = [
|
|
219
|
+
{ type: 'text', text: 'Part 1 ' },
|
|
220
|
+
{ type: 'text', text: 'Part 2' },
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const result = convertUserContent(content);
|
|
224
|
+
|
|
225
|
+
expect(result.content).toBe('Part 1 Part 2');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should include image parts with binary data using OpenAI image_url format', () => {
|
|
229
|
+
const content: UserContent = [
|
|
230
|
+
{ type: 'text', text: 'Describe this image' },
|
|
231
|
+
{
|
|
232
|
+
type: 'image',
|
|
233
|
+
image: new Uint8Array([1, 2, 3]),
|
|
234
|
+
mediaType: 'image/png',
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const result = convertUserContent(content);
|
|
239
|
+
|
|
240
|
+
expect(result.content).toEqual([
|
|
241
|
+
{ type: 'text', text: 'Describe this image' },
|
|
242
|
+
{
|
|
243
|
+
type: 'image_url',
|
|
244
|
+
image_url: { url: 'data:image/png;base64,AQID' }, // base64 of [1, 2, 3]
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should include image parts with URL using OpenAI image_url format', () => {
|
|
250
|
+
const content: UserContent = [
|
|
251
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
252
|
+
{
|
|
253
|
+
type: 'image',
|
|
254
|
+
image: 'https://example.com/image.jpg',
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const result = convertUserContent(content);
|
|
259
|
+
|
|
260
|
+
expect(result.content).toEqual([
|
|
261
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
262
|
+
{
|
|
263
|
+
type: 'image_url',
|
|
264
|
+
image_url: { url: 'https://example.com/image.jpg' },
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should include non-image file parts using file format', () => {
|
|
270
|
+
const content: UserContent = [
|
|
271
|
+
{ type: 'text', text: 'Summarize this document' },
|
|
272
|
+
{
|
|
273
|
+
type: 'file',
|
|
274
|
+
data: 'https://example.com/doc.pdf',
|
|
275
|
+
mediaType: 'application/pdf',
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const result = convertUserContent(content);
|
|
280
|
+
|
|
281
|
+
expect(result.content).toEqual([
|
|
282
|
+
{ type: 'text', text: 'Summarize this document' },
|
|
283
|
+
{
|
|
284
|
+
type: 'file',
|
|
285
|
+
url: 'https://example.com/doc.pdf',
|
|
286
|
+
mimeType: 'application/pdf',
|
|
287
|
+
filename: 'file.pdf',
|
|
288
|
+
},
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should handle URL objects for images using OpenAI image_url format', () => {
|
|
293
|
+
const content: UserContent = [
|
|
294
|
+
{ type: 'text', text: 'Describe' },
|
|
295
|
+
{
|
|
296
|
+
type: 'image',
|
|
297
|
+
image: new URL('https://example.com/photo.png'),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const result = convertUserContent(content);
|
|
302
|
+
|
|
303
|
+
expect(result.content).toEqual([
|
|
304
|
+
{ type: 'text', text: 'Describe' },
|
|
305
|
+
{
|
|
306
|
+
type: 'image_url',
|
|
307
|
+
image_url: { url: 'https://example.com/photo.png' },
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should handle data URLs for images using OpenAI image_url format', () => {
|
|
313
|
+
const content: UserContent = [
|
|
314
|
+
{ type: 'text', text: 'Analyze' },
|
|
315
|
+
{
|
|
316
|
+
type: 'image',
|
|
317
|
+
image: 'data:image/png;base64,abc123',
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const result = convertUserContent(content);
|
|
322
|
+
|
|
323
|
+
expect(result.content).toEqual([
|
|
324
|
+
{ type: 'text', text: 'Analyze' },
|
|
325
|
+
{
|
|
326
|
+
type: 'image_url',
|
|
327
|
+
image_url: { url: 'data:image/png;base64,abc123' },
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should handle image files (file type with image mediaType) using OpenAI image_url format', () => {
|
|
333
|
+
const content: UserContent = [
|
|
334
|
+
{ type: 'text', text: 'What is this?' },
|
|
335
|
+
{
|
|
336
|
+
type: 'file',
|
|
337
|
+
data: 'https://example.com/photo.jpg',
|
|
338
|
+
mediaType: 'image/jpeg',
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const result = convertUserContent(content);
|
|
343
|
+
|
|
344
|
+
expect(result.content).toEqual([
|
|
345
|
+
{ type: 'text', text: 'What is this?' },
|
|
346
|
+
{
|
|
347
|
+
type: 'image_url',
|
|
348
|
+
image_url: { url: 'https://example.com/photo.jpg' },
|
|
349
|
+
},
|
|
350
|
+
]);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('isToolResultPart', () => {
|
|
355
|
+
it('should return true for valid tool result parts', () => {
|
|
356
|
+
const part = {
|
|
357
|
+
type: 'tool-result',
|
|
358
|
+
toolCallId: 'call-1',
|
|
359
|
+
toolName: 'test',
|
|
360
|
+
output: { type: 'text', value: 'result' },
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
expect(isToolResultPart(part)).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should return false for null', () => {
|
|
367
|
+
expect(isToolResultPart(null)).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should return false for undefined', () => {
|
|
371
|
+
expect(isToolResultPart(undefined)).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should return false for non-objects', () => {
|
|
375
|
+
expect(isToolResultPart('string')).toBe(false);
|
|
376
|
+
expect(isToolResultPart(123)).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should return false for objects without type', () => {
|
|
380
|
+
expect(isToolResultPart({ toolCallId: 'call-1' })).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should return false for objects with wrong type', () => {
|
|
384
|
+
expect(isToolResultPart({ type: 'text' })).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('processModelChunk', () => {
|
|
389
|
+
it('should emit text-start and text-delta for first chunk', () => {
|
|
390
|
+
const chunk = new AIMessageChunk({
|
|
391
|
+
content: 'Hello',
|
|
392
|
+
id: 'msg-1',
|
|
393
|
+
});
|
|
394
|
+
const state = {
|
|
395
|
+
started: false,
|
|
396
|
+
messageId: 'default',
|
|
397
|
+
reasoningStarted: false,
|
|
398
|
+
textStarted: false,
|
|
399
|
+
};
|
|
400
|
+
const chunks: unknown[] = [];
|
|
401
|
+
const controller = createMockController(chunks);
|
|
402
|
+
|
|
403
|
+
processModelChunk(chunk, state, controller);
|
|
404
|
+
|
|
405
|
+
expect(state.started).toBe(true);
|
|
406
|
+
expect(state.textStarted).toBe(true);
|
|
407
|
+
expect(state.messageId).toBe('msg-1');
|
|
408
|
+
expect(chunks).toEqual([
|
|
409
|
+
{ type: 'text-start', id: 'msg-1' },
|
|
410
|
+
{ type: 'text-delta', delta: 'Hello', id: 'msg-1' },
|
|
411
|
+
]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should emit only text-delta for subsequent chunks', () => {
|
|
415
|
+
const chunk = new AIMessageChunk({
|
|
416
|
+
content: ' World',
|
|
417
|
+
id: 'msg-1',
|
|
418
|
+
});
|
|
419
|
+
const state = {
|
|
420
|
+
started: true,
|
|
421
|
+
messageId: 'msg-1',
|
|
422
|
+
reasoningStarted: false,
|
|
423
|
+
textStarted: true,
|
|
424
|
+
};
|
|
425
|
+
const chunks: unknown[] = [];
|
|
426
|
+
const controller = createMockController(chunks);
|
|
427
|
+
|
|
428
|
+
processModelChunk(chunk, state, controller);
|
|
429
|
+
|
|
430
|
+
expect(chunks).toEqual([
|
|
431
|
+
{ type: 'text-delta', delta: ' World', id: 'msg-1' },
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should handle array content with text parts', () => {
|
|
436
|
+
const chunk = new AIMessageChunk({
|
|
437
|
+
content: [{ type: 'text', text: 'Array content' }],
|
|
438
|
+
id: 'msg-1',
|
|
439
|
+
});
|
|
440
|
+
const state = {
|
|
441
|
+
started: false,
|
|
442
|
+
messageId: 'default',
|
|
443
|
+
reasoningStarted: false,
|
|
444
|
+
textStarted: false,
|
|
445
|
+
};
|
|
446
|
+
const chunks: unknown[] = [];
|
|
447
|
+
const controller = createMockController(chunks);
|
|
448
|
+
|
|
449
|
+
processModelChunk(chunk, state, controller);
|
|
450
|
+
|
|
451
|
+
expect(chunks).toContainEqual({
|
|
452
|
+
type: 'text-delta',
|
|
453
|
+
delta: 'Array content',
|
|
454
|
+
id: 'msg-1',
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should not emit for empty content', () => {
|
|
459
|
+
const chunk = new AIMessageChunk({
|
|
460
|
+
content: '',
|
|
461
|
+
id: 'msg-1',
|
|
462
|
+
});
|
|
463
|
+
const state = {
|
|
464
|
+
started: false,
|
|
465
|
+
messageId: 'default',
|
|
466
|
+
reasoningStarted: false,
|
|
467
|
+
textStarted: false,
|
|
468
|
+
};
|
|
469
|
+
const chunks: unknown[] = [];
|
|
470
|
+
const controller = createMockController(chunks);
|
|
471
|
+
|
|
472
|
+
processModelChunk(chunk, state, controller);
|
|
473
|
+
|
|
474
|
+
expect(chunks).toHaveLength(0);
|
|
475
|
+
expect(state.started).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should handle reasoning content from contentBlocks', () => {
|
|
479
|
+
const chunk = new AIMessageChunk({
|
|
480
|
+
content: '',
|
|
481
|
+
id: 'msg-1',
|
|
482
|
+
});
|
|
483
|
+
Object.defineProperty(chunk, 'contentBlocks', {
|
|
484
|
+
get: () => [{ type: 'reasoning', reasoning: 'Let me think...' }],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const state = {
|
|
488
|
+
started: false,
|
|
489
|
+
messageId: 'default',
|
|
490
|
+
reasoningStarted: false,
|
|
491
|
+
textStarted: false,
|
|
492
|
+
};
|
|
493
|
+
const chunks: unknown[] = [];
|
|
494
|
+
const controller = createMockController(chunks);
|
|
495
|
+
|
|
496
|
+
processModelChunk(chunk, state, controller);
|
|
497
|
+
|
|
498
|
+
expect(state.reasoningStarted).toBe(true);
|
|
499
|
+
expect(state.started).toBe(true);
|
|
500
|
+
expect(chunks).toEqual([
|
|
501
|
+
{ type: 'reasoning-start', id: 'msg-1' },
|
|
502
|
+
{ type: 'reasoning-delta', delta: 'Let me think...', id: 'msg-1' },
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should handle thinking content from contentBlocks (Anthropic-style)', () => {
|
|
507
|
+
const chunk = new AIMessageChunk({
|
|
508
|
+
content: '',
|
|
509
|
+
id: 'msg-1',
|
|
510
|
+
});
|
|
511
|
+
Object.defineProperty(chunk, 'contentBlocks', {
|
|
512
|
+
get: () => [{ type: 'thinking', thinking: 'Analyzing...' }],
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const state = {
|
|
516
|
+
started: false,
|
|
517
|
+
messageId: 'default',
|
|
518
|
+
reasoningStarted: false,
|
|
519
|
+
textStarted: false,
|
|
520
|
+
};
|
|
521
|
+
const chunks: unknown[] = [];
|
|
522
|
+
const controller = createMockController(chunks);
|
|
523
|
+
|
|
524
|
+
processModelChunk(chunk, state, controller);
|
|
525
|
+
|
|
526
|
+
expect(chunks).toEqual([
|
|
527
|
+
{ type: 'reasoning-start', id: 'msg-1' },
|
|
528
|
+
{ type: 'reasoning-delta', delta: 'Analyzing...', id: 'msg-1' },
|
|
529
|
+
]);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should handle GPT-5 reasoning from response_metadata.output', () => {
|
|
533
|
+
const chunk = new AIMessageChunk({
|
|
534
|
+
content: '',
|
|
535
|
+
id: 'msg-1',
|
|
536
|
+
});
|
|
537
|
+
Object.defineProperty(chunk, 'response_metadata', {
|
|
538
|
+
get: () => ({
|
|
539
|
+
output: [
|
|
540
|
+
{
|
|
541
|
+
id: 'rs_123',
|
|
542
|
+
type: 'reasoning',
|
|
543
|
+
summary: [
|
|
544
|
+
{
|
|
545
|
+
type: 'summary_text',
|
|
546
|
+
text: 'I need to analyze this question carefully...',
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const state = {
|
|
555
|
+
started: false,
|
|
556
|
+
messageId: 'default',
|
|
557
|
+
reasoningStarted: false,
|
|
558
|
+
textStarted: false,
|
|
559
|
+
};
|
|
560
|
+
const chunks: unknown[] = [];
|
|
561
|
+
const controller = createMockController(chunks);
|
|
562
|
+
|
|
563
|
+
processModelChunk(chunk, state, controller);
|
|
564
|
+
|
|
565
|
+
expect(state.reasoningStarted).toBe(true);
|
|
566
|
+
expect(chunks).toEqual([
|
|
567
|
+
{ type: 'reasoning-start', id: 'msg-1' },
|
|
568
|
+
{
|
|
569
|
+
type: 'reasoning-delta',
|
|
570
|
+
delta: 'I need to analyze this question carefully...',
|
|
571
|
+
id: 'msg-1',
|
|
572
|
+
},
|
|
573
|
+
]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('should handle GPT-5 reasoning from additional_kwargs.reasoning.summary', () => {
|
|
577
|
+
const chunk = new AIMessageChunk({
|
|
578
|
+
content: '',
|
|
579
|
+
id: 'msg-1',
|
|
580
|
+
additional_kwargs: {
|
|
581
|
+
reasoning: {
|
|
582
|
+
id: 'rs_456',
|
|
583
|
+
type: 'reasoning',
|
|
584
|
+
summary: [
|
|
585
|
+
{
|
|
586
|
+
type: 'summary_text',
|
|
587
|
+
text: 'Breaking down the problem into parts...',
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const state = {
|
|
595
|
+
started: false,
|
|
596
|
+
messageId: 'default',
|
|
597
|
+
reasoningStarted: false,
|
|
598
|
+
textStarted: false,
|
|
599
|
+
};
|
|
600
|
+
const chunks: unknown[] = [];
|
|
601
|
+
const controller = createMockController(chunks);
|
|
602
|
+
|
|
603
|
+
processModelChunk(chunk, state, controller);
|
|
604
|
+
|
|
605
|
+
expect(state.reasoningStarted).toBe(true);
|
|
606
|
+
expect(chunks).toEqual([
|
|
607
|
+
{ type: 'reasoning-start', id: 'msg-1' },
|
|
608
|
+
{
|
|
609
|
+
type: 'reasoning-delta',
|
|
610
|
+
delta: 'Breaking down the problem into parts...',
|
|
611
|
+
id: 'msg-1',
|
|
612
|
+
},
|
|
613
|
+
]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('should close reasoning when text starts', () => {
|
|
617
|
+
// First send reasoning
|
|
618
|
+
const reasoningChunk = new AIMessageChunk({
|
|
619
|
+
content: '',
|
|
620
|
+
id: 'msg-1',
|
|
621
|
+
});
|
|
622
|
+
Object.defineProperty(reasoningChunk, 'contentBlocks', {
|
|
623
|
+
get: () => [{ type: 'reasoning', reasoning: 'Thinking...' }],
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const state = {
|
|
627
|
+
started: false,
|
|
628
|
+
messageId: 'default',
|
|
629
|
+
reasoningStarted: false,
|
|
630
|
+
textStarted: false,
|
|
631
|
+
};
|
|
632
|
+
const chunks: unknown[] = [];
|
|
633
|
+
const controller = createMockController(chunks);
|
|
634
|
+
|
|
635
|
+
processModelChunk(reasoningChunk, state, controller);
|
|
636
|
+
|
|
637
|
+
// Now send text
|
|
638
|
+
const textChunk = new AIMessageChunk({
|
|
639
|
+
content: 'Here is my answer',
|
|
640
|
+
id: 'msg-1',
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
processModelChunk(textChunk, state, controller);
|
|
644
|
+
|
|
645
|
+
expect(chunks).toEqual([
|
|
646
|
+
{ type: 'reasoning-start', id: 'msg-1' },
|
|
647
|
+
{ type: 'reasoning-delta', delta: 'Thinking...', id: 'msg-1' },
|
|
648
|
+
{ type: 'reasoning-end', id: 'msg-1' },
|
|
649
|
+
{ type: 'text-start', id: 'msg-1' },
|
|
650
|
+
{ type: 'text-delta', delta: 'Here is my answer', id: 'msg-1' },
|
|
651
|
+
]);
|
|
652
|
+
expect(state.reasoningStarted).toBe(false);
|
|
653
|
+
expect(state.textStarted).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should maintain consistent IDs when chunk.id changes during reasoning-to-text transition', () => {
|
|
657
|
+
// This test reproduces the bug where the message ID changes between chunks,
|
|
658
|
+
// which would cause the client to fail to look up activeReasoningParts or activeTextParts
|
|
659
|
+
// because the ID used for *-start doesn't match the ID used for *-delta and *-end.
|
|
660
|
+
//
|
|
661
|
+
const state = {
|
|
662
|
+
started: false,
|
|
663
|
+
messageId: 'default',
|
|
664
|
+
reasoningStarted: false,
|
|
665
|
+
textStarted: false,
|
|
666
|
+
reasoningMessageId: null as string | null,
|
|
667
|
+
textMessageId: null as string | null,
|
|
668
|
+
};
|
|
669
|
+
const chunks: unknown[] = [];
|
|
670
|
+
const controller = createMockController(chunks);
|
|
671
|
+
|
|
672
|
+
// First reasoning chunk arrives with id "run-abc123"
|
|
673
|
+
const reasoningChunk1 = new AIMessageChunk({
|
|
674
|
+
content: '',
|
|
675
|
+
id: 'run-abc123',
|
|
676
|
+
});
|
|
677
|
+
Object.defineProperty(reasoningChunk1, 'contentBlocks', {
|
|
678
|
+
get: () => [{ type: 'reasoning', reasoning: 'Let me think...' }],
|
|
679
|
+
});
|
|
680
|
+
processModelChunk(reasoningChunk1, state, controller);
|
|
681
|
+
|
|
682
|
+
const reasoningChunk2 = new AIMessageChunk({
|
|
683
|
+
content: '',
|
|
684
|
+
id: 'msg-xyz789',
|
|
685
|
+
});
|
|
686
|
+
Object.defineProperty(reasoningChunk2, 'contentBlocks', {
|
|
687
|
+
get: () => [{ type: 'reasoning', reasoning: ' about this.' }],
|
|
688
|
+
});
|
|
689
|
+
processModelChunk(reasoningChunk2, state, controller);
|
|
690
|
+
|
|
691
|
+
// Text chunk arrives with the new id "msg-xyz789"
|
|
692
|
+
const textChunk = new AIMessageChunk({
|
|
693
|
+
content: 'Here is my answer.',
|
|
694
|
+
id: 'msg-xyz789',
|
|
695
|
+
});
|
|
696
|
+
processModelChunk(textChunk, state, controller);
|
|
697
|
+
|
|
698
|
+
// Verify all reasoning chunks use the same id that was used for reasoning-start
|
|
699
|
+
// and all text chunks use the same id that was used for text-start
|
|
700
|
+
expect(chunks).toEqual([
|
|
701
|
+
{ type: 'reasoning-start', id: 'run-abc123' },
|
|
702
|
+
{ type: 'reasoning-delta', delta: 'Let me think...', id: 'run-abc123' },
|
|
703
|
+
{ type: 'reasoning-delta', delta: ' about this.', id: 'run-abc123' },
|
|
704
|
+
{ type: 'reasoning-end', id: 'run-abc123' },
|
|
705
|
+
{ type: 'text-start', id: 'msg-xyz789' },
|
|
706
|
+
{ type: 'text-delta', delta: 'Here is my answer.', id: 'msg-xyz789' },
|
|
707
|
+
]);
|
|
708
|
+
|
|
709
|
+
expect(state.reasoningMessageId).toBe('run-abc123');
|
|
710
|
+
expect(state.textMessageId).toBe('msg-xyz789');
|
|
711
|
+
expect(state.messageId).toBe('msg-xyz789');
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('should maintain consistent text IDs when chunk.id changes during text streaming', () => {
|
|
715
|
+
// Similar bug can occur with text-only streaming if the ID changes between chunks
|
|
716
|
+
|
|
717
|
+
const state = {
|
|
718
|
+
started: false,
|
|
719
|
+
messageId: 'default',
|
|
720
|
+
reasoningStarted: false,
|
|
721
|
+
textStarted: false,
|
|
722
|
+
reasoningMessageId: null as string | null,
|
|
723
|
+
textMessageId: null as string | null,
|
|
724
|
+
};
|
|
725
|
+
const chunks: unknown[] = [];
|
|
726
|
+
const controller = createMockController(chunks);
|
|
727
|
+
|
|
728
|
+
// First text chunk arrives with id "run-abc123"
|
|
729
|
+
const textChunk1 = new AIMessageChunk({
|
|
730
|
+
content: 'Hello',
|
|
731
|
+
id: 'run-abc123',
|
|
732
|
+
});
|
|
733
|
+
processModelChunk(textChunk1, state, controller);
|
|
734
|
+
|
|
735
|
+
const textChunk2 = new AIMessageChunk({
|
|
736
|
+
content: ' world!',
|
|
737
|
+
id: 'msg-xyz789',
|
|
738
|
+
});
|
|
739
|
+
processModelChunk(textChunk2, state, controller);
|
|
740
|
+
|
|
741
|
+
// Verify all text chunks use the same id that was used for text-start
|
|
742
|
+
expect(chunks).toEqual([
|
|
743
|
+
{ type: 'text-start', id: 'run-abc123' },
|
|
744
|
+
{ type: 'text-delta', delta: 'Hello', id: 'run-abc123' },
|
|
745
|
+
{ type: 'text-delta', delta: ' world!', id: 'run-abc123' },
|
|
746
|
+
]);
|
|
747
|
+
|
|
748
|
+
expect(state.textMessageId).toBe('run-abc123');
|
|
749
|
+
expect(state.messageId).toBe('msg-xyz789');
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
describe('isPlainMessageObject', () => {
|
|
754
|
+
it('should return true for plain objects', () => {
|
|
755
|
+
expect(isPlainMessageObject({ type: 'ai', content: 'Hello' })).toBe(true);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('should return false for LangChain class instances', () => {
|
|
759
|
+
const aiChunk = new AIMessageChunk({ content: 'Hello' });
|
|
760
|
+
expect(isPlainMessageObject(aiChunk)).toBe(false);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('should return false for null', () => {
|
|
764
|
+
expect(isPlainMessageObject(null)).toBe(false);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('should return false for non-objects', () => {
|
|
768
|
+
expect(isPlainMessageObject('string')).toBe(false);
|
|
769
|
+
expect(isPlainMessageObject(123)).toBe(false);
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
describe('isAIMessageChunk', () => {
|
|
774
|
+
it('should return true for AIMessageChunk instances', () => {
|
|
775
|
+
const chunk = new AIMessageChunk({ content: 'Hello' });
|
|
776
|
+
expect(isAIMessageChunk(chunk)).toBe(true);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('should return true for plain objects with type: ai', () => {
|
|
780
|
+
const plainObj = { type: 'ai', content: 'Hello', id: 'msg-1' };
|
|
781
|
+
expect(isAIMessageChunk(plainObj)).toBe(true);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('should return false for AIMessage instances (not chunks)', () => {
|
|
785
|
+
const msg = new AIMessage({ content: 'Hello' });
|
|
786
|
+
// AIMessage is not AIMessageChunk, but it extends BaseMessage
|
|
787
|
+
// The function should return false for AIMessage if it's checking specifically for chunks
|
|
788
|
+
expect(isAIMessageChunk(msg)).toBe(false);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('should return false for plain objects with other types', () => {
|
|
792
|
+
expect(isAIMessageChunk({ type: 'tool', content: 'Hello' })).toBe(false);
|
|
793
|
+
expect(isAIMessageChunk({ type: 'human', content: 'Hello' })).toBe(false);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('should return false for null/undefined', () => {
|
|
797
|
+
expect(isAIMessageChunk(null)).toBe(false);
|
|
798
|
+
expect(isAIMessageChunk(undefined)).toBe(false);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
describe('isToolMessageType', () => {
|
|
803
|
+
it('should return true for ToolMessage instances', () => {
|
|
804
|
+
const toolMsg = new ToolMessage({
|
|
805
|
+
tool_call_id: 'call-1',
|
|
806
|
+
content: 'Result',
|
|
807
|
+
});
|
|
808
|
+
expect(isToolMessageType(toolMsg)).toBe(true);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('should return true for plain objects with type: tool', () => {
|
|
812
|
+
const plainObj = {
|
|
813
|
+
type: 'tool',
|
|
814
|
+
content: 'Result',
|
|
815
|
+
tool_call_id: 'call-1',
|
|
816
|
+
};
|
|
817
|
+
expect(isToolMessageType(plainObj)).toBe(true);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should return false for other types', () => {
|
|
821
|
+
expect(isToolMessageType({ type: 'ai', content: 'Hello' })).toBe(false);
|
|
822
|
+
expect(isToolMessageType({ type: 'human', content: 'Hello' })).toBe(false);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should return false for null/undefined', () => {
|
|
826
|
+
expect(isToolMessageType(null)).toBe(false);
|
|
827
|
+
expect(isToolMessageType(undefined)).toBe(false);
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
describe('getMessageText', () => {
|
|
832
|
+
it('should extract text from AIMessageChunk', () => {
|
|
833
|
+
const chunk = new AIMessageChunk({ content: 'Hello World' });
|
|
834
|
+
expect(getMessageText(chunk)).toBe('Hello World');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('should extract text from plain objects with content', () => {
|
|
838
|
+
const plainObj = { content: 'Plain text' };
|
|
839
|
+
expect(getMessageText(plainObj)).toBe('Plain text');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('should extract text from content array with text blocks', () => {
|
|
843
|
+
const plainObj = { content: [{ type: 'text', text: 'Array' }] };
|
|
844
|
+
expect(getMessageText(plainObj)).toBe('Array');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('should return empty string for non-text content blocks', () => {
|
|
848
|
+
const plainObj = {
|
|
849
|
+
content: [{ type: 'image', url: 'http://example.com' }],
|
|
850
|
+
};
|
|
851
|
+
expect(getMessageText(plainObj)).toBe('');
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('should return empty string for null', () => {
|
|
855
|
+
expect(getMessageText(null)).toBe('');
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('should return empty string for objects without content', () => {
|
|
859
|
+
expect(getMessageText({ type: 'ai' })).toBe('');
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe('isImageGenerationOutput', () => {
|
|
864
|
+
it('should return true for valid image generation outputs', () => {
|
|
865
|
+
const output = {
|
|
866
|
+
id: 'img-1',
|
|
867
|
+
type: 'image_generation_call',
|
|
868
|
+
status: 'completed',
|
|
869
|
+
result: 'base64data',
|
|
870
|
+
};
|
|
871
|
+
expect(isImageGenerationOutput(output)).toBe(true);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('should return false for other types', () => {
|
|
875
|
+
expect(isImageGenerationOutput({ type: 'tool_call' })).toBe(false);
|
|
876
|
+
expect(isImageGenerationOutput({ type: 'text' })).toBe(false);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('should return false for null/undefined', () => {
|
|
880
|
+
expect(isImageGenerationOutput(null)).toBe(false);
|
|
881
|
+
expect(isImageGenerationOutput(undefined)).toBe(false);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should return false for non-objects', () => {
|
|
885
|
+
expect(isImageGenerationOutput('string')).toBe(false);
|
|
886
|
+
expect(isImageGenerationOutput(123)).toBe(false);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
describe('extractImageOutputs', () => {
|
|
891
|
+
it('should extract image generation outputs from additional_kwargs', () => {
|
|
892
|
+
const additionalKwargs = {
|
|
893
|
+
tool_outputs: [
|
|
894
|
+
{
|
|
895
|
+
id: 'img-1',
|
|
896
|
+
type: 'image_generation_call',
|
|
897
|
+
status: 'completed',
|
|
898
|
+
result: 'base64data',
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
id: 'tool-1',
|
|
902
|
+
type: 'tool_call',
|
|
903
|
+
status: 'completed',
|
|
904
|
+
},
|
|
905
|
+
],
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const result = extractImageOutputs(additionalKwargs);
|
|
909
|
+
|
|
910
|
+
expect(result).toHaveLength(1);
|
|
911
|
+
expect(result[0].id).toBe('img-1');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('should return empty array for undefined additional_kwargs', () => {
|
|
915
|
+
expect(extractImageOutputs(undefined)).toEqual([]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('should return empty array when tool_outputs is not an array', () => {
|
|
919
|
+
expect(extractImageOutputs({ tool_outputs: 'not-array' })).toEqual([]);
|
|
920
|
+
expect(extractImageOutputs({})).toEqual([]);
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
describe('processLangGraphEvent', () => {
|
|
925
|
+
const createMockState = () => ({
|
|
926
|
+
messageSeen: {} as Record<
|
|
927
|
+
string,
|
|
928
|
+
{ text?: boolean; reasoning?: boolean; tool?: Record<string, boolean> }
|
|
929
|
+
>,
|
|
930
|
+
messageConcat: {} as Record<string, AIMessageChunk>,
|
|
931
|
+
emittedToolCalls: new Set<string>(),
|
|
932
|
+
emittedImages: new Set<string>(),
|
|
933
|
+
emittedReasoningIds: new Set<string>(),
|
|
934
|
+
messageReasoningIds: {} as Record<string, string>,
|
|
935
|
+
toolCallInfoByIndex: {} as Record<
|
|
936
|
+
string,
|
|
937
|
+
Record<number, { id: string; name: string }>
|
|
938
|
+
>,
|
|
939
|
+
currentStep: null as number | null,
|
|
940
|
+
emittedToolCallsByKey: new Map<string, string>(),
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('should handle custom events without type field (fallback to data-custom)', () => {
|
|
944
|
+
const state = createMockState();
|
|
945
|
+
const chunks: unknown[] = [];
|
|
946
|
+
const controller = createMockController(chunks);
|
|
947
|
+
|
|
948
|
+
processLangGraphEvent(['custom', { data: 'value' }], state, controller);
|
|
949
|
+
|
|
950
|
+
expect(chunks).toEqual([
|
|
951
|
+
{
|
|
952
|
+
type: 'data-custom',
|
|
953
|
+
id: undefined,
|
|
954
|
+
transient: true,
|
|
955
|
+
data: { data: 'value' },
|
|
956
|
+
},
|
|
957
|
+
]);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('should handle custom events with type field (data-{type})', () => {
|
|
961
|
+
const state = createMockState();
|
|
962
|
+
const chunks: unknown[] = [];
|
|
963
|
+
const controller = createMockController(chunks);
|
|
964
|
+
|
|
965
|
+
processLangGraphEvent(
|
|
966
|
+
['custom', { type: 'progress', value: 50, message: 'Processing...' }],
|
|
967
|
+
state,
|
|
968
|
+
controller,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
expect(chunks).toEqual([
|
|
972
|
+
{
|
|
973
|
+
type: 'data-progress',
|
|
974
|
+
id: undefined,
|
|
975
|
+
transient: true,
|
|
976
|
+
data: { type: 'progress', value: 50, message: 'Processing...' },
|
|
977
|
+
},
|
|
978
|
+
]);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('should handle custom events with id field (persistent, not transient)', () => {
|
|
982
|
+
const state = createMockState();
|
|
983
|
+
const chunks: unknown[] = [];
|
|
984
|
+
const controller = createMockController(chunks);
|
|
985
|
+
|
|
986
|
+
processLangGraphEvent(
|
|
987
|
+
[
|
|
988
|
+
'custom',
|
|
989
|
+
{ type: 'progress', id: 'progress-1', value: 50, message: 'Half done' },
|
|
990
|
+
],
|
|
991
|
+
state,
|
|
992
|
+
controller,
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
expect(chunks).toEqual([
|
|
996
|
+
{
|
|
997
|
+
type: 'data-progress',
|
|
998
|
+
id: 'progress-1',
|
|
999
|
+
transient: false, // Has id, so NOT transient
|
|
1000
|
+
data: {
|
|
1001
|
+
type: 'progress',
|
|
1002
|
+
id: 'progress-1',
|
|
1003
|
+
value: 50,
|
|
1004
|
+
message: 'Half done',
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('should handle custom events with different type names', () => {
|
|
1011
|
+
const state = createMockState();
|
|
1012
|
+
const chunks: unknown[] = [];
|
|
1013
|
+
const controller = createMockController(chunks);
|
|
1014
|
+
|
|
1015
|
+
// Test status event
|
|
1016
|
+
processLangGraphEvent(
|
|
1017
|
+
['custom', { type: 'status', step: 'fetching', complete: false }],
|
|
1018
|
+
state,
|
|
1019
|
+
controller,
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
// Test analytics event
|
|
1023
|
+
processLangGraphEvent(
|
|
1024
|
+
['custom', { type: 'analytics', event: 'tool_called', tool: 'weather' }],
|
|
1025
|
+
state,
|
|
1026
|
+
controller,
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
expect(chunks).toEqual([
|
|
1030
|
+
{
|
|
1031
|
+
type: 'data-status',
|
|
1032
|
+
id: undefined,
|
|
1033
|
+
transient: true,
|
|
1034
|
+
data: { type: 'status', step: 'fetching', complete: false },
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
type: 'data-analytics',
|
|
1038
|
+
id: undefined,
|
|
1039
|
+
transient: true,
|
|
1040
|
+
data: { type: 'analytics', event: 'tool_called', tool: 'weather' },
|
|
1041
|
+
},
|
|
1042
|
+
]);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('should handle three-element arrays with namespace', () => {
|
|
1046
|
+
const state = createMockState();
|
|
1047
|
+
const chunks: unknown[] = [];
|
|
1048
|
+
const controller = createMockController(chunks);
|
|
1049
|
+
|
|
1050
|
+
processLangGraphEvent(
|
|
1051
|
+
['namespace', 'custom', { data: 'value' }],
|
|
1052
|
+
state,
|
|
1053
|
+
controller,
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
expect(chunks).toEqual([
|
|
1057
|
+
{
|
|
1058
|
+
type: 'data-custom',
|
|
1059
|
+
id: undefined,
|
|
1060
|
+
transient: true,
|
|
1061
|
+
data: { data: 'value' },
|
|
1062
|
+
},
|
|
1063
|
+
]);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it('should handle three-element arrays with namespace and type field', () => {
|
|
1067
|
+
const state = createMockState();
|
|
1068
|
+
const chunks: unknown[] = [];
|
|
1069
|
+
const controller = createMockController(chunks);
|
|
1070
|
+
|
|
1071
|
+
processLangGraphEvent(
|
|
1072
|
+
['namespace', 'custom', { type: 'progress', value: 75 }],
|
|
1073
|
+
state,
|
|
1074
|
+
controller,
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
expect(chunks).toEqual([
|
|
1078
|
+
{
|
|
1079
|
+
type: 'data-progress',
|
|
1080
|
+
id: undefined,
|
|
1081
|
+
transient: true,
|
|
1082
|
+
data: { type: 'progress', value: 75 },
|
|
1083
|
+
},
|
|
1084
|
+
]);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('should handle custom events with string data (fallback to data-custom)', () => {
|
|
1088
|
+
const state = createMockState();
|
|
1089
|
+
const chunks: unknown[] = [];
|
|
1090
|
+
const controller = createMockController(chunks);
|
|
1091
|
+
|
|
1092
|
+
processLangGraphEvent(['custom', 'simple string data'], state, controller);
|
|
1093
|
+
|
|
1094
|
+
expect(chunks).toEqual([
|
|
1095
|
+
{
|
|
1096
|
+
type: 'data-custom',
|
|
1097
|
+
id: undefined,
|
|
1098
|
+
transient: true,
|
|
1099
|
+
data: 'simple string data',
|
|
1100
|
+
},
|
|
1101
|
+
]);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('should handle custom events with array data (fallback to data-custom)', () => {
|
|
1105
|
+
const state = createMockState();
|
|
1106
|
+
const chunks: unknown[] = [];
|
|
1107
|
+
const controller = createMockController(chunks);
|
|
1108
|
+
|
|
1109
|
+
processLangGraphEvent(
|
|
1110
|
+
['custom', [1, 2, 3, { type: 'ignored' }]],
|
|
1111
|
+
state,
|
|
1112
|
+
controller,
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
expect(chunks).toEqual([
|
|
1116
|
+
{
|
|
1117
|
+
type: 'data-custom',
|
|
1118
|
+
id: undefined,
|
|
1119
|
+
transient: true,
|
|
1120
|
+
data: [1, 2, 3, { type: 'ignored' }],
|
|
1121
|
+
},
|
|
1122
|
+
]);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('should handle AI message chunks with text content', () => {
|
|
1126
|
+
const state = createMockState();
|
|
1127
|
+
const chunks: unknown[] = [];
|
|
1128
|
+
const controller = createMockController(chunks);
|
|
1129
|
+
|
|
1130
|
+
const aiChunk = new AIMessageChunk({ content: 'Hello', id: 'msg-1' });
|
|
1131
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1132
|
+
|
|
1133
|
+
expect(chunks).toContainEqual({ type: 'text-start', id: 'msg-1' });
|
|
1134
|
+
expect(chunks).toContainEqual({
|
|
1135
|
+
type: 'text-delta',
|
|
1136
|
+
delta: 'Hello',
|
|
1137
|
+
id: 'msg-1',
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('should skip messages without id', () => {
|
|
1142
|
+
const state = createMockState();
|
|
1143
|
+
const chunks: unknown[] = [];
|
|
1144
|
+
const controller = createMockController(chunks);
|
|
1145
|
+
|
|
1146
|
+
const aiChunk = new AIMessageChunk({ content: 'Hello' });
|
|
1147
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1148
|
+
|
|
1149
|
+
expect(chunks).toHaveLength(0);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it('should handle tool message output', () => {
|
|
1153
|
+
const state = createMockState();
|
|
1154
|
+
const chunks: unknown[] = [];
|
|
1155
|
+
const controller = createMockController(chunks);
|
|
1156
|
+
|
|
1157
|
+
const toolMsg = new ToolMessage({
|
|
1158
|
+
tool_call_id: 'call-1',
|
|
1159
|
+
content: 'Tool result',
|
|
1160
|
+
});
|
|
1161
|
+
toolMsg.id = 'msg-1';
|
|
1162
|
+
processLangGraphEvent(['messages', [toolMsg]], state, controller);
|
|
1163
|
+
|
|
1164
|
+
expect(chunks).toContainEqual({
|
|
1165
|
+
type: 'tool-output-available',
|
|
1166
|
+
toolCallId: 'call-1',
|
|
1167
|
+
output: 'Tool result',
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it('should handle plain AI message objects from RemoteGraph', () => {
|
|
1172
|
+
const state = createMockState();
|
|
1173
|
+
const chunks: unknown[] = [];
|
|
1174
|
+
const controller = createMockController(chunks);
|
|
1175
|
+
|
|
1176
|
+
const plainMsg = { type: 'ai', content: 'Hello', id: 'msg-1' };
|
|
1177
|
+
processLangGraphEvent(['messages', [plainMsg]], state, controller);
|
|
1178
|
+
|
|
1179
|
+
expect(chunks).toContainEqual({ type: 'text-start', id: 'msg-1' });
|
|
1180
|
+
expect(chunks).toContainEqual({
|
|
1181
|
+
type: 'text-delta',
|
|
1182
|
+
delta: 'Hello',
|
|
1183
|
+
id: 'msg-1',
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('should handle plain tool message objects from RemoteGraph', () => {
|
|
1188
|
+
const state = createMockState();
|
|
1189
|
+
const chunks: unknown[] = [];
|
|
1190
|
+
const controller = createMockController(chunks);
|
|
1191
|
+
|
|
1192
|
+
const plainToolMsg = {
|
|
1193
|
+
type: 'tool',
|
|
1194
|
+
content: 'Result',
|
|
1195
|
+
id: 'msg-1',
|
|
1196
|
+
tool_call_id: 'call-1',
|
|
1197
|
+
};
|
|
1198
|
+
processLangGraphEvent(['messages', [plainToolMsg]], state, controller);
|
|
1199
|
+
|
|
1200
|
+
expect(chunks).toContainEqual({
|
|
1201
|
+
type: 'tool-output-available',
|
|
1202
|
+
toolCallId: 'call-1',
|
|
1203
|
+
output: 'Result',
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it('should handle values event and finalize pending messages', () => {
|
|
1208
|
+
const state = createMockState();
|
|
1209
|
+
state.messageSeen['msg-1'] = { text: true };
|
|
1210
|
+
const chunks: unknown[] = [];
|
|
1211
|
+
const controller = createMockController(chunks);
|
|
1212
|
+
|
|
1213
|
+
processLangGraphEvent(['values', {}], state, controller);
|
|
1214
|
+
|
|
1215
|
+
expect(chunks).toContainEqual({ type: 'text-end', id: 'msg-1' });
|
|
1216
|
+
expect(state.messageSeen['msg-1']).toBeUndefined();
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it('should handle tool calls in values event', () => {
|
|
1220
|
+
const state = createMockState();
|
|
1221
|
+
const chunks: unknown[] = [];
|
|
1222
|
+
const controller = createMockController(chunks);
|
|
1223
|
+
|
|
1224
|
+
const valuesData = {
|
|
1225
|
+
messages: [
|
|
1226
|
+
{
|
|
1227
|
+
id: 'msg-1',
|
|
1228
|
+
type: 'ai',
|
|
1229
|
+
tool_calls: [
|
|
1230
|
+
{ id: 'call-1', name: 'get_weather', args: { city: 'NYC' } },
|
|
1231
|
+
],
|
|
1232
|
+
},
|
|
1233
|
+
],
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
processLangGraphEvent(['values', valuesData], state, controller);
|
|
1237
|
+
|
|
1238
|
+
// Should emit tool-input-start before tool-input-available for non-streamed tool calls
|
|
1239
|
+
expect(chunks).toContainEqual({
|
|
1240
|
+
type: 'tool-input-start',
|
|
1241
|
+
toolCallId: 'call-1',
|
|
1242
|
+
toolName: 'get_weather',
|
|
1243
|
+
dynamic: true,
|
|
1244
|
+
});
|
|
1245
|
+
expect(chunks).toContainEqual({
|
|
1246
|
+
type: 'tool-input-available',
|
|
1247
|
+
toolCallId: 'call-1',
|
|
1248
|
+
toolName: 'get_weather',
|
|
1249
|
+
input: { city: 'NYC' },
|
|
1250
|
+
dynamic: true,
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('should not duplicate already emitted tool calls', () => {
|
|
1255
|
+
const state = createMockState();
|
|
1256
|
+
state.emittedToolCalls.add('call-1');
|
|
1257
|
+
const chunks: unknown[] = [];
|
|
1258
|
+
const controller = createMockController(chunks);
|
|
1259
|
+
|
|
1260
|
+
const valuesData = {
|
|
1261
|
+
messages: [
|
|
1262
|
+
{
|
|
1263
|
+
id: 'msg-1',
|
|
1264
|
+
type: 'ai',
|
|
1265
|
+
tool_calls: [
|
|
1266
|
+
{ id: 'call-1', name: 'get_weather', args: { city: 'NYC' } },
|
|
1267
|
+
],
|
|
1268
|
+
},
|
|
1269
|
+
],
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
processLangGraphEvent(['values', valuesData], state, controller);
|
|
1273
|
+
|
|
1274
|
+
const toolInputEvents = chunks.filter(
|
|
1275
|
+
(c: unknown) =>
|
|
1276
|
+
(c as { type: string }).type === 'tool-input-available' ||
|
|
1277
|
+
(c as { type: string }).type === 'tool-input-start',
|
|
1278
|
+
);
|
|
1279
|
+
expect(toolInputEvents).toHaveLength(0);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it('should emit GPT-5 reasoning from values event when not streamed', () => {
|
|
1283
|
+
const state = createMockState();
|
|
1284
|
+
const chunks: unknown[] = [];
|
|
1285
|
+
const controller = createMockController(chunks);
|
|
1286
|
+
|
|
1287
|
+
const valuesData = {
|
|
1288
|
+
messages: [
|
|
1289
|
+
{
|
|
1290
|
+
id: 'msg-1',
|
|
1291
|
+
type: 'ai',
|
|
1292
|
+
content: '',
|
|
1293
|
+
response_metadata: {
|
|
1294
|
+
output: [
|
|
1295
|
+
{
|
|
1296
|
+
id: 'rs_123',
|
|
1297
|
+
type: 'reasoning',
|
|
1298
|
+
summary: [
|
|
1299
|
+
{
|
|
1300
|
+
type: 'summary_text',
|
|
1301
|
+
text: 'Analyzing the user request step by step...',
|
|
1302
|
+
},
|
|
1303
|
+
],
|
|
1304
|
+
},
|
|
1305
|
+
],
|
|
1306
|
+
},
|
|
1307
|
+
},
|
|
1308
|
+
],
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
processLangGraphEvent(['values', valuesData], state, controller);
|
|
1312
|
+
|
|
1313
|
+
expect(chunks).toContainEqual({ type: 'reasoning-start', id: 'msg-1' });
|
|
1314
|
+
expect(chunks).toContainEqual({
|
|
1315
|
+
type: 'reasoning-delta',
|
|
1316
|
+
delta: 'Analyzing the user request step by step...',
|
|
1317
|
+
id: 'msg-1',
|
|
1318
|
+
});
|
|
1319
|
+
expect(chunks).toContainEqual({ type: 'reasoning-end', id: 'msg-1' });
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('should not duplicate reasoning already emitted during streaming', () => {
|
|
1323
|
+
const state = createMockState();
|
|
1324
|
+
// Mark reasoning ID as already emitted (simulates streaming having already emitted this reasoning)
|
|
1325
|
+
state.emittedReasoningIds.add('rs_123');
|
|
1326
|
+
state.messageSeen['msg-1'] = { reasoning: true };
|
|
1327
|
+
const chunks: unknown[] = [];
|
|
1328
|
+
const controller = createMockController(chunks);
|
|
1329
|
+
|
|
1330
|
+
const valuesData = {
|
|
1331
|
+
messages: [
|
|
1332
|
+
{
|
|
1333
|
+
id: 'msg-1',
|
|
1334
|
+
type: 'ai',
|
|
1335
|
+
content: '',
|
|
1336
|
+
response_metadata: {
|
|
1337
|
+
output: [
|
|
1338
|
+
{
|
|
1339
|
+
id: 'rs_123',
|
|
1340
|
+
type: 'reasoning',
|
|
1341
|
+
summary: [
|
|
1342
|
+
{
|
|
1343
|
+
type: 'summary_text',
|
|
1344
|
+
text: 'This should not be emitted again...',
|
|
1345
|
+
},
|
|
1346
|
+
],
|
|
1347
|
+
},
|
|
1348
|
+
],
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
],
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
processLangGraphEvent(['values', valuesData], state, controller);
|
|
1355
|
+
|
|
1356
|
+
// Should only emit reasoning-end (for finalization), not start/delta
|
|
1357
|
+
// because we already emitted reasoning with ID 'rs_123' during streaming
|
|
1358
|
+
const reasoningStartEvents = chunks.filter(
|
|
1359
|
+
(c: unknown) => (c as { type: string }).type === 'reasoning-start',
|
|
1360
|
+
);
|
|
1361
|
+
const reasoningDeltaEvents = chunks.filter(
|
|
1362
|
+
(c: unknown) => (c as { type: string }).type === 'reasoning-delta',
|
|
1363
|
+
);
|
|
1364
|
+
expect(reasoningStartEvents).toHaveLength(0);
|
|
1365
|
+
expect(reasoningDeltaEvents).toHaveLength(0);
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('should handle tool calls in additional_kwargs format', () => {
|
|
1369
|
+
const state = createMockState();
|
|
1370
|
+
const chunks: unknown[] = [];
|
|
1371
|
+
const controller = createMockController(chunks);
|
|
1372
|
+
|
|
1373
|
+
const valuesData = {
|
|
1374
|
+
messages: [
|
|
1375
|
+
{
|
|
1376
|
+
id: 'msg-1',
|
|
1377
|
+
type: 'ai',
|
|
1378
|
+
additional_kwargs: {
|
|
1379
|
+
tool_calls: [
|
|
1380
|
+
{
|
|
1381
|
+
id: 'call-1',
|
|
1382
|
+
function: {
|
|
1383
|
+
name: 'get_weather',
|
|
1384
|
+
arguments: '{"city":"NYC"}',
|
|
1385
|
+
},
|
|
1386
|
+
},
|
|
1387
|
+
],
|
|
1388
|
+
},
|
|
1389
|
+
},
|
|
1390
|
+
],
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
processLangGraphEvent(['values', valuesData], state, controller);
|
|
1394
|
+
|
|
1395
|
+
// Should emit tool-input-start before tool-input-available for non-streamed tool calls
|
|
1396
|
+
expect(chunks).toContainEqual({
|
|
1397
|
+
type: 'tool-input-start',
|
|
1398
|
+
toolCallId: 'call-1',
|
|
1399
|
+
toolName: 'get_weather',
|
|
1400
|
+
dynamic: true,
|
|
1401
|
+
});
|
|
1402
|
+
expect(chunks).toContainEqual({
|
|
1403
|
+
type: 'tool-input-available',
|
|
1404
|
+
toolCallId: 'call-1',
|
|
1405
|
+
toolName: 'get_weather',
|
|
1406
|
+
input: { city: 'NYC' },
|
|
1407
|
+
dynamic: true,
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('should handle image generation outputs', () => {
|
|
1412
|
+
const state = createMockState();
|
|
1413
|
+
const chunks: unknown[] = [];
|
|
1414
|
+
const controller = createMockController(chunks);
|
|
1415
|
+
|
|
1416
|
+
const aiChunk = new AIMessageChunk({ content: '', id: 'msg-1' });
|
|
1417
|
+
(
|
|
1418
|
+
aiChunk as unknown as { additional_kwargs: Record<string, unknown> }
|
|
1419
|
+
).additional_kwargs = {
|
|
1420
|
+
tool_outputs: [
|
|
1421
|
+
{
|
|
1422
|
+
id: 'img-1',
|
|
1423
|
+
type: 'image_generation_call',
|
|
1424
|
+
status: 'completed',
|
|
1425
|
+
result: 'base64imagedata',
|
|
1426
|
+
output_format: 'png',
|
|
1427
|
+
},
|
|
1428
|
+
],
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1432
|
+
|
|
1433
|
+
expect(chunks).toContainEqual({
|
|
1434
|
+
type: 'file',
|
|
1435
|
+
mediaType: 'image/png',
|
|
1436
|
+
url: 'data:image/png;base64,base64imagedata',
|
|
1437
|
+
});
|
|
1438
|
+
expect(state.emittedImages.has('img-1')).toBe(true);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
it('should not emit duplicate images', () => {
|
|
1442
|
+
const state = createMockState();
|
|
1443
|
+
state.emittedImages.add('img-1');
|
|
1444
|
+
const chunks: unknown[] = [];
|
|
1445
|
+
const controller = createMockController(chunks);
|
|
1446
|
+
|
|
1447
|
+
const aiChunk = new AIMessageChunk({ content: '', id: 'msg-1' });
|
|
1448
|
+
(
|
|
1449
|
+
aiChunk as unknown as { additional_kwargs: Record<string, unknown> }
|
|
1450
|
+
).additional_kwargs = {
|
|
1451
|
+
tool_outputs: [
|
|
1452
|
+
{
|
|
1453
|
+
id: 'img-1',
|
|
1454
|
+
type: 'image_generation_call',
|
|
1455
|
+
status: 'completed',
|
|
1456
|
+
result: 'base64imagedata',
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1462
|
+
|
|
1463
|
+
const fileEvents = chunks.filter(
|
|
1464
|
+
(c: unknown) => (c as { type: string }).type === 'file',
|
|
1465
|
+
);
|
|
1466
|
+
expect(fileEvents).toHaveLength(0);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it('should handle tool call chunks with streaming', () => {
|
|
1470
|
+
const state = createMockState();
|
|
1471
|
+
const chunks: unknown[] = [];
|
|
1472
|
+
const controller = createMockController(chunks);
|
|
1473
|
+
|
|
1474
|
+
const aiChunk = new AIMessageChunk({
|
|
1475
|
+
content: '',
|
|
1476
|
+
id: 'msg-1',
|
|
1477
|
+
tool_call_chunks: [
|
|
1478
|
+
{
|
|
1479
|
+
id: 'call-1',
|
|
1480
|
+
name: 'get_weather',
|
|
1481
|
+
args: '{"city":',
|
|
1482
|
+
index: 0,
|
|
1483
|
+
},
|
|
1484
|
+
],
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1488
|
+
|
|
1489
|
+
expect(chunks).toContainEqual({
|
|
1490
|
+
type: 'tool-input-start',
|
|
1491
|
+
toolCallId: 'call-1',
|
|
1492
|
+
toolName: 'get_weather',
|
|
1493
|
+
dynamic: true,
|
|
1494
|
+
});
|
|
1495
|
+
expect(chunks).toContainEqual({
|
|
1496
|
+
type: 'tool-input-delta',
|
|
1497
|
+
toolCallId: 'call-1',
|
|
1498
|
+
inputTextDelta: '{"city":',
|
|
1499
|
+
});
|
|
1500
|
+
expect(state.emittedToolCalls.has('call-1')).toBe(true);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('should skip tool call chunks without id', () => {
|
|
1504
|
+
const state = createMockState();
|
|
1505
|
+
const chunks: unknown[] = [];
|
|
1506
|
+
const controller = createMockController(chunks);
|
|
1507
|
+
|
|
1508
|
+
const aiChunk = new AIMessageChunk({
|
|
1509
|
+
content: '',
|
|
1510
|
+
id: 'msg-1',
|
|
1511
|
+
tool_call_chunks: [
|
|
1512
|
+
{
|
|
1513
|
+
// No id
|
|
1514
|
+
name: 'get_weather',
|
|
1515
|
+
args: '{"city":',
|
|
1516
|
+
index: 0,
|
|
1517
|
+
},
|
|
1518
|
+
],
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
processLangGraphEvent(['messages', [aiChunk]], state, controller);
|
|
1522
|
+
|
|
1523
|
+
const toolEvents = chunks.filter(
|
|
1524
|
+
(c: unknown) =>
|
|
1525
|
+
(c as { type: string }).type === 'tool-input-start' ||
|
|
1526
|
+
(c as { type: string }).type === 'tool-input-delta',
|
|
1527
|
+
);
|
|
1528
|
+
expect(toolEvents).toHaveLength(0);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it('should emit start-step on first message with langgraph_step', () => {
|
|
1532
|
+
const state = createMockState();
|
|
1533
|
+
const chunks: unknown[] = [];
|
|
1534
|
+
const controller = createMockController(chunks);
|
|
1535
|
+
|
|
1536
|
+
const aiChunk = new AIMessageChunk({ content: 'Hello', id: 'msg-1' });
|
|
1537
|
+
const metadata = { langgraph_step: 1, langgraph_node: 'model' };
|
|
1538
|
+
|
|
1539
|
+
processLangGraphEvent(['messages', [aiChunk, metadata]], state, controller);
|
|
1540
|
+
|
|
1541
|
+
expect(chunks).toContainEqual({ type: 'start-step' });
|
|
1542
|
+
expect(state.currentStep).toBe(1);
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('should emit finish-step and start-step on step change', () => {
|
|
1546
|
+
const state = createMockState();
|
|
1547
|
+
state.currentStep = 1;
|
|
1548
|
+
const chunks: unknown[] = [];
|
|
1549
|
+
const controller = createMockController(chunks);
|
|
1550
|
+
|
|
1551
|
+
const aiChunk = new AIMessageChunk({ content: 'Hello', id: 'msg-1' });
|
|
1552
|
+
const metadata = { langgraph_step: 2, langgraph_node: 'tools' };
|
|
1553
|
+
|
|
1554
|
+
processLangGraphEvent(['messages', [aiChunk, metadata]], state, controller);
|
|
1555
|
+
|
|
1556
|
+
expect(chunks[0]).toEqual({ type: 'finish-step' });
|
|
1557
|
+
expect(chunks[1]).toEqual({ type: 'start-step' });
|
|
1558
|
+
expect(state.currentStep).toBe(2);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it('should not emit step events when step unchanged', () => {
|
|
1562
|
+
const state = createMockState();
|
|
1563
|
+
state.currentStep = 1;
|
|
1564
|
+
const chunks: unknown[] = [];
|
|
1565
|
+
const controller = createMockController(chunks);
|
|
1566
|
+
|
|
1567
|
+
const aiChunk = new AIMessageChunk({
|
|
1568
|
+
content: 'More content',
|
|
1569
|
+
id: 'msg-1',
|
|
1570
|
+
});
|
|
1571
|
+
const metadata = { langgraph_step: 1, langgraph_node: 'model' };
|
|
1572
|
+
|
|
1573
|
+
processLangGraphEvent(['messages', [aiChunk, metadata]], state, controller);
|
|
1574
|
+
|
|
1575
|
+
const stepEvents = chunks.filter(
|
|
1576
|
+
(c: unknown) =>
|
|
1577
|
+
(c as { type: string }).type === 'start-step' ||
|
|
1578
|
+
(c as { type: string }).type === 'finish-step',
|
|
1579
|
+
);
|
|
1580
|
+
expect(stepEvents).toHaveLength(0);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
it('should emit tool-output-error for ToolMessage with status error', () => {
|
|
1584
|
+
const state = createMockState();
|
|
1585
|
+
const chunks: unknown[] = [];
|
|
1586
|
+
const controller = createMockController(chunks);
|
|
1587
|
+
|
|
1588
|
+
const toolMsg = new ToolMessage({
|
|
1589
|
+
tool_call_id: 'call-1',
|
|
1590
|
+
content: 'Connection timeout',
|
|
1591
|
+
});
|
|
1592
|
+
toolMsg.id = 'msg-1';
|
|
1593
|
+
// Simulate error status (not directly settable via constructor)
|
|
1594
|
+
(toolMsg as unknown as { status: string }).status = 'error';
|
|
1595
|
+
|
|
1596
|
+
processLangGraphEvent(['messages', [toolMsg]], state, controller);
|
|
1597
|
+
|
|
1598
|
+
expect(chunks).toContainEqual({
|
|
1599
|
+
type: 'tool-output-error',
|
|
1600
|
+
toolCallId: 'call-1',
|
|
1601
|
+
errorText: 'Connection timeout',
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
it('should emit tool-output-available for ToolMessage with status success', () => {
|
|
1606
|
+
const state = createMockState();
|
|
1607
|
+
const chunks: unknown[] = [];
|
|
1608
|
+
const controller = createMockController(chunks);
|
|
1609
|
+
|
|
1610
|
+
const toolMsg = new ToolMessage({
|
|
1611
|
+
tool_call_id: 'call-1',
|
|
1612
|
+
content: 'Result data',
|
|
1613
|
+
});
|
|
1614
|
+
toolMsg.id = 'msg-1';
|
|
1615
|
+
(toolMsg as unknown as { status: string }).status = 'success';
|
|
1616
|
+
|
|
1617
|
+
processLangGraphEvent(['messages', [toolMsg]], state, controller);
|
|
1618
|
+
|
|
1619
|
+
expect(chunks).toContainEqual({
|
|
1620
|
+
type: 'tool-output-available',
|
|
1621
|
+
toolCallId: 'call-1',
|
|
1622
|
+
output: 'Result data',
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it('should handle plain tool message objects with error status', () => {
|
|
1627
|
+
const state = createMockState();
|
|
1628
|
+
const chunks: unknown[] = [];
|
|
1629
|
+
const controller = createMockController(chunks);
|
|
1630
|
+
|
|
1631
|
+
const plainToolMsg = {
|
|
1632
|
+
type: 'tool',
|
|
1633
|
+
content: 'API rate limit exceeded',
|
|
1634
|
+
id: 'msg-1',
|
|
1635
|
+
tool_call_id: 'call-1',
|
|
1636
|
+
status: 'error',
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
processLangGraphEvent(['messages', [plainToolMsg]], state, controller);
|
|
1640
|
+
|
|
1641
|
+
expect(chunks).toContainEqual({
|
|
1642
|
+
type: 'tool-output-error',
|
|
1643
|
+
toolCallId: 'call-1',
|
|
1644
|
+
errorText: 'API rate limit exceeded',
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
it('should handle HITL interrupt in values event', () => {
|
|
1649
|
+
const state = createMockState();
|
|
1650
|
+
const chunks: unknown[] = [];
|
|
1651
|
+
const controller = createMockController(chunks);
|
|
1652
|
+
|
|
1653
|
+
const valuesWithInterrupt = {
|
|
1654
|
+
messages: [],
|
|
1655
|
+
__interrupt__: [
|
|
1656
|
+
{
|
|
1657
|
+
value: {
|
|
1658
|
+
action_requests: [
|
|
1659
|
+
{
|
|
1660
|
+
name: 'send_email',
|
|
1661
|
+
arguments: {
|
|
1662
|
+
to: 'test@example.com',
|
|
1663
|
+
subject: 'Hello',
|
|
1664
|
+
body: 'Test message',
|
|
1665
|
+
},
|
|
1666
|
+
id: 'call-hitl-1',
|
|
1667
|
+
},
|
|
1668
|
+
],
|
|
1669
|
+
review_configs: [
|
|
1670
|
+
{
|
|
1671
|
+
action_name: 'send_email',
|
|
1672
|
+
allowed_decisions: ['approve', 'edit', 'reject'],
|
|
1673
|
+
},
|
|
1674
|
+
],
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
],
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
processLangGraphEvent(['values', valuesWithInterrupt], state, controller);
|
|
1681
|
+
|
|
1682
|
+
// Should emit tool-input-start before tool-input-available for HITL tools
|
|
1683
|
+
expect(chunks).toContainEqual({
|
|
1684
|
+
type: 'tool-input-start',
|
|
1685
|
+
toolCallId: 'call-hitl-1',
|
|
1686
|
+
toolName: 'send_email',
|
|
1687
|
+
dynamic: true,
|
|
1688
|
+
});
|
|
1689
|
+
expect(chunks).toContainEqual({
|
|
1690
|
+
type: 'tool-input-available',
|
|
1691
|
+
toolCallId: 'call-hitl-1',
|
|
1692
|
+
toolName: 'send_email',
|
|
1693
|
+
input: {
|
|
1694
|
+
to: 'test@example.com',
|
|
1695
|
+
subject: 'Hello',
|
|
1696
|
+
body: 'Test message',
|
|
1697
|
+
},
|
|
1698
|
+
dynamic: true,
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
expect(chunks).toContainEqual({
|
|
1702
|
+
type: 'tool-approval-request',
|
|
1703
|
+
approvalId: 'call-hitl-1',
|
|
1704
|
+
toolCallId: 'call-hitl-1',
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
it('should handle multiple HITL interrupts in values event', () => {
|
|
1709
|
+
const state = createMockState();
|
|
1710
|
+
const chunks: unknown[] = [];
|
|
1711
|
+
const controller = createMockController(chunks);
|
|
1712
|
+
|
|
1713
|
+
const valuesWithMultipleInterrupts = {
|
|
1714
|
+
messages: [],
|
|
1715
|
+
__interrupt__: [
|
|
1716
|
+
{
|
|
1717
|
+
value: {
|
|
1718
|
+
action_requests: [
|
|
1719
|
+
{
|
|
1720
|
+
name: 'send_email',
|
|
1721
|
+
arguments: { to: 'user@example.com' },
|
|
1722
|
+
id: 'call-email-1',
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
name: 'delete_file',
|
|
1726
|
+
arguments: { filename: 'temp.txt' },
|
|
1727
|
+
id: 'call-delete-1',
|
|
1728
|
+
},
|
|
1729
|
+
],
|
|
1730
|
+
review_configs: [
|
|
1731
|
+
{
|
|
1732
|
+
action_name: 'send_email',
|
|
1733
|
+
allowed_decisions: ['approve', 'reject'],
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
action_name: 'delete_file',
|
|
1737
|
+
allowed_decisions: ['approve', 'reject'],
|
|
1738
|
+
},
|
|
1739
|
+
],
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
],
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
processLangGraphEvent(
|
|
1746
|
+
['values', valuesWithMultipleInterrupts],
|
|
1747
|
+
state,
|
|
1748
|
+
controller,
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
// Check both tool starts
|
|
1752
|
+
expect(chunks).toContainEqual({
|
|
1753
|
+
type: 'tool-input-start',
|
|
1754
|
+
toolCallId: 'call-email-1',
|
|
1755
|
+
toolName: 'send_email',
|
|
1756
|
+
dynamic: true,
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
expect(chunks).toContainEqual({
|
|
1760
|
+
type: 'tool-input-start',
|
|
1761
|
+
toolCallId: 'call-delete-1',
|
|
1762
|
+
toolName: 'delete_file',
|
|
1763
|
+
dynamic: true,
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
// Check both tool inputs
|
|
1767
|
+
expect(chunks).toContainEqual({
|
|
1768
|
+
type: 'tool-input-available',
|
|
1769
|
+
toolCallId: 'call-email-1',
|
|
1770
|
+
toolName: 'send_email',
|
|
1771
|
+
input: { to: 'user@example.com' },
|
|
1772
|
+
dynamic: true,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
expect(chunks).toContainEqual({
|
|
1776
|
+
type: 'tool-input-available',
|
|
1777
|
+
toolCallId: 'call-delete-1',
|
|
1778
|
+
toolName: 'delete_file',
|
|
1779
|
+
input: { filename: 'temp.txt' },
|
|
1780
|
+
dynamic: true,
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
// Check both approval requests
|
|
1784
|
+
expect(chunks).toContainEqual({
|
|
1785
|
+
type: 'tool-approval-request',
|
|
1786
|
+
approvalId: 'call-email-1',
|
|
1787
|
+
toolCallId: 'call-email-1',
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
expect(chunks).toContainEqual({
|
|
1791
|
+
type: 'tool-approval-request',
|
|
1792
|
+
approvalId: 'call-delete-1',
|
|
1793
|
+
toolCallId: 'call-delete-1',
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
it('should generate fallback ID for HITL interrupt without id', () => {
|
|
1798
|
+
const state = createMockState();
|
|
1799
|
+
const chunks: unknown[] = [];
|
|
1800
|
+
const controller = createMockController(chunks);
|
|
1801
|
+
|
|
1802
|
+
const valuesWithInterruptNoId = {
|
|
1803
|
+
messages: [],
|
|
1804
|
+
__interrupt__: [
|
|
1805
|
+
{
|
|
1806
|
+
value: {
|
|
1807
|
+
action_requests: [
|
|
1808
|
+
{
|
|
1809
|
+
name: 'send_email',
|
|
1810
|
+
arguments: { to: 'test@example.com' },
|
|
1811
|
+
// no id provided
|
|
1812
|
+
},
|
|
1813
|
+
],
|
|
1814
|
+
review_configs: [],
|
|
1815
|
+
},
|
|
1816
|
+
},
|
|
1817
|
+
],
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
processLangGraphEvent(
|
|
1821
|
+
['values', valuesWithInterruptNoId],
|
|
1822
|
+
state,
|
|
1823
|
+
controller,
|
|
1824
|
+
);
|
|
1825
|
+
|
|
1826
|
+
// Should have generated a fallback ID and emit tool-input-start first
|
|
1827
|
+
const toolStartChunk = chunks.find(
|
|
1828
|
+
(
|
|
1829
|
+
c,
|
|
1830
|
+
): c is {
|
|
1831
|
+
type: 'tool-input-start';
|
|
1832
|
+
toolCallId: string;
|
|
1833
|
+
toolName: string;
|
|
1834
|
+
} => (c as { type: string }).type === 'tool-input-start',
|
|
1835
|
+
);
|
|
1836
|
+
expect(toolStartChunk).toBeDefined();
|
|
1837
|
+
expect(toolStartChunk?.toolCallId).toMatch(/^hitl-send_email-/);
|
|
1838
|
+
|
|
1839
|
+
const toolInputChunk = chunks.find(
|
|
1840
|
+
(
|
|
1841
|
+
c,
|
|
1842
|
+
): c is {
|
|
1843
|
+
type: 'tool-input-available';
|
|
1844
|
+
toolCallId: string;
|
|
1845
|
+
toolName: string;
|
|
1846
|
+
input: unknown;
|
|
1847
|
+
} => (c as { type: string }).type === 'tool-input-available',
|
|
1848
|
+
);
|
|
1849
|
+
expect(toolInputChunk).toBeDefined();
|
|
1850
|
+
expect(toolInputChunk?.toolCallId).toMatch(/^hitl-send_email-/);
|
|
1851
|
+
expect(toolInputChunk?.toolCallId).toBe(toolStartChunk?.toolCallId);
|
|
1852
|
+
|
|
1853
|
+
const approvalChunk = chunks.find(
|
|
1854
|
+
(
|
|
1855
|
+
c,
|
|
1856
|
+
): c is {
|
|
1857
|
+
type: 'tool-approval-request';
|
|
1858
|
+
approvalId: string;
|
|
1859
|
+
toolCallId: string;
|
|
1860
|
+
} => (c as { type: string }).type === 'tool-approval-request',
|
|
1861
|
+
);
|
|
1862
|
+
expect(approvalChunk).toBeDefined();
|
|
1863
|
+
expect(approvalChunk?.toolCallId).toBe(toolInputChunk?.toolCallId);
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
it('should handle JS SDK camelCase interrupt format (actionRequests, args)', () => {
|
|
1867
|
+
const state = createMockState();
|
|
1868
|
+
const chunks: unknown[] = [];
|
|
1869
|
+
const controller = createMockController(chunks);
|
|
1870
|
+
|
|
1871
|
+
// JS SDK uses camelCase: actionRequests with args instead of arguments
|
|
1872
|
+
const valuesWithInterrupt = {
|
|
1873
|
+
messages: [],
|
|
1874
|
+
__interrupt__: [
|
|
1875
|
+
{
|
|
1876
|
+
id: 'interrupt-123',
|
|
1877
|
+
value: {
|
|
1878
|
+
actionRequests: [
|
|
1879
|
+
{
|
|
1880
|
+
name: 'send_email',
|
|
1881
|
+
args: {
|
|
1882
|
+
to: 'test@example.com',
|
|
1883
|
+
subject: 'Hello',
|
|
1884
|
+
},
|
|
1885
|
+
},
|
|
1886
|
+
],
|
|
1887
|
+
reviewConfigs: [
|
|
1888
|
+
{
|
|
1889
|
+
actionName: 'send_email',
|
|
1890
|
+
allowedDecisions: ['approve', 'edit', 'reject'],
|
|
1891
|
+
},
|
|
1892
|
+
],
|
|
1893
|
+
},
|
|
1894
|
+
},
|
|
1895
|
+
],
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
processLangGraphEvent(['values', valuesWithInterrupt], state, controller);
|
|
1899
|
+
|
|
1900
|
+
// Should emit tool-input-start before tool-input-available
|
|
1901
|
+
expect(chunks).toContainEqual({
|
|
1902
|
+
type: 'tool-input-start',
|
|
1903
|
+
toolCallId: expect.stringMatching(/^hitl-send_email-/),
|
|
1904
|
+
toolName: 'send_email',
|
|
1905
|
+
dynamic: true,
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
expect(chunks).toContainEqual({
|
|
1909
|
+
type: 'tool-input-available',
|
|
1910
|
+
toolCallId: expect.stringMatching(/^hitl-send_email-/),
|
|
1911
|
+
toolName: 'send_email',
|
|
1912
|
+
input: { to: 'test@example.com', subject: 'Hello' },
|
|
1913
|
+
dynamic: true,
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
const approvalChunk = chunks.find(
|
|
1917
|
+
(
|
|
1918
|
+
c,
|
|
1919
|
+
): c is {
|
|
1920
|
+
type: 'tool-approval-request';
|
|
1921
|
+
approvalId: string;
|
|
1922
|
+
toolCallId: string;
|
|
1923
|
+
} => (c as { type: string }).type === 'tool-approval-request',
|
|
1924
|
+
);
|
|
1925
|
+
expect(approvalChunk).toBeDefined();
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
it('should reuse existing tool call ID when interrupt matches previously emitted tool call', () => {
|
|
1929
|
+
const state = createMockState();
|
|
1930
|
+
const chunks: unknown[] = [];
|
|
1931
|
+
const controller = createMockController(chunks);
|
|
1932
|
+
|
|
1933
|
+
// Simulate tool-input-available already emitted with specific ID
|
|
1934
|
+
const toolCallId = 'call_xAIvZJjC2JwEtTrHoiRaVBXs';
|
|
1935
|
+
const toolName = 'send_email';
|
|
1936
|
+
const input = { to: 'john@example.com', subject: 'Hello', body: 'Hello' };
|
|
1937
|
+
|
|
1938
|
+
// Pre-populate the state as if tool was already emitted
|
|
1939
|
+
state.emittedToolCalls.add(toolCallId);
|
|
1940
|
+
state.emittedToolCallsByKey.set(
|
|
1941
|
+
`${toolName}:${JSON.stringify(input)}`,
|
|
1942
|
+
toolCallId,
|
|
1943
|
+
);
|
|
1944
|
+
|
|
1945
|
+
// Now process interrupt with same tool name and args
|
|
1946
|
+
const valuesWithInterrupt = {
|
|
1947
|
+
__interrupt__: [
|
|
1948
|
+
{
|
|
1949
|
+
id: 'interrupt-456',
|
|
1950
|
+
value: {
|
|
1951
|
+
actionRequests: [
|
|
1952
|
+
{
|
|
1953
|
+
name: toolName,
|
|
1954
|
+
args: input,
|
|
1955
|
+
// Note: no id field in action request
|
|
1956
|
+
},
|
|
1957
|
+
],
|
|
1958
|
+
reviewConfigs: [],
|
|
1959
|
+
},
|
|
1960
|
+
},
|
|
1961
|
+
],
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
processLangGraphEvent(['values', valuesWithInterrupt], state, controller);
|
|
1965
|
+
|
|
1966
|
+
// Should NOT emit tool-input-start or tool-input-available (already emitted)
|
|
1967
|
+
expect(
|
|
1968
|
+
chunks.filter(
|
|
1969
|
+
c =>
|
|
1970
|
+
(c as { type: string }).type === 'tool-input-start' ||
|
|
1971
|
+
(c as { type: string }).type === 'tool-input-available',
|
|
1972
|
+
),
|
|
1973
|
+
).toHaveLength(0);
|
|
1974
|
+
|
|
1975
|
+
// Should emit tool-approval-request with the ORIGINAL tool call ID
|
|
1976
|
+
expect(chunks).toContainEqual({
|
|
1977
|
+
type: 'tool-approval-request',
|
|
1978
|
+
approvalId: toolCallId,
|
|
1979
|
+
toolCallId: toolCallId,
|
|
1980
|
+
});
|
|
1981
|
+
});
|
|
1982
|
+
});
|