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