@kernl-sdk/ai 0.1.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-check-types.log +4 -0
  3. package/CHANGELOG.md +72 -0
  4. package/LICENSE +1 -1
  5. package/dist/__tests__/integration.test.js +277 -26
  6. package/dist/__tests__/language-model.test.js +2 -1
  7. package/dist/convert/__tests__/message.test.js +27 -2
  8. package/dist/convert/__tests__/stream.test.js +31 -7
  9. package/dist/convert/__tests__/ui-message.test.d.ts +2 -0
  10. package/dist/convert/__tests__/ui-message.test.d.ts.map +1 -0
  11. package/dist/convert/__tests__/ui-message.test.js +1836 -0
  12. package/dist/convert/__tests__/ui-stream.test.d.ts +2 -0
  13. package/dist/convert/__tests__/ui-stream.test.d.ts.map +1 -0
  14. package/dist/convert/__tests__/ui-stream.test.js +452 -0
  15. package/dist/convert/message.d.ts +2 -1
  16. package/dist/convert/message.d.ts.map +1 -1
  17. package/dist/convert/message.js +15 -9
  18. package/dist/convert/response.d.ts +2 -1
  19. package/dist/convert/response.d.ts.map +1 -1
  20. package/dist/convert/response.js +66 -46
  21. package/dist/convert/settings.d.ts +2 -1
  22. package/dist/convert/settings.d.ts.map +1 -1
  23. package/dist/convert/stream.d.ts +2 -1
  24. package/dist/convert/stream.d.ts.map +1 -1
  25. package/dist/convert/stream.js +12 -17
  26. package/dist/convert/tools.d.ts +2 -1
  27. package/dist/convert/tools.d.ts.map +1 -1
  28. package/dist/convert/ui-message.d.ts +40 -0
  29. package/dist/convert/ui-message.d.ts.map +1 -0
  30. package/dist/convert/ui-message.js +324 -0
  31. package/dist/convert/ui-stream.d.ts +29 -0
  32. package/dist/convert/ui-stream.d.ts.map +1 -0
  33. package/dist/convert/ui-stream.js +139 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/language-model.d.ts.map +1 -1
  38. package/dist/language-model.js +72 -83
  39. package/package.json +11 -7
  40. package/src/__tests__/integration.test.ts +789 -507
  41. package/src/__tests__/language-model.test.ts +2 -1
  42. package/src/convert/__tests__/message.test.ts +29 -2
  43. package/src/convert/__tests__/stream.test.ts +34 -7
  44. package/src/convert/__tests__/ui-message.test.ts +2008 -0
  45. package/src/convert/__tests__/ui-stream.test.ts +547 -0
  46. package/src/convert/message.ts +17 -12
  47. package/src/convert/response.ts +82 -52
  48. package/src/convert/settings.ts +2 -1
  49. package/src/convert/stream.ts +22 -20
  50. package/src/convert/tools.ts +1 -1
  51. package/src/convert/ui-message.ts +409 -0
  52. package/src/convert/ui-stream.ts +167 -0
  53. package/src/index.ts +2 -0
  54. package/src/language-model.ts +78 -87
  55. package/tsconfig.json +1 -1
  56. package/vitest.config.ts +1 -0
  57. package/src/error.ts +0 -16
  58. package/src/types.ts +0 -0
@@ -0,0 +1,1836 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { IN_PROGRESS, COMPLETED, FAILED } from "@kernl-sdk/protocol";
3
+ import { UIMessageCodec, historyToUIMessages } from "../ui-message";
4
+ describe("UIMessageCodec", () => {
5
+ // ----------------------------
6
+ // Text parts
7
+ // ----------------------------
8
+ describe("decode - text parts", () => {
9
+ it("should convert a user message with a text part", async () => {
10
+ const result = await UIMessageCodec.decode({
11
+ id: "1",
12
+ role: "user",
13
+ parts: [{ type: "text", text: "Hello, world!" }],
14
+ });
15
+ expect(result).toHaveLength(1);
16
+ expect(result[0]).toMatchObject({
17
+ kind: "message",
18
+ id: "1",
19
+ role: "user",
20
+ content: [
21
+ {
22
+ kind: "text",
23
+ text: "Hello, world!",
24
+ },
25
+ ],
26
+ });
27
+ });
28
+ it("should convert an assistant message with a text part", async () => {
29
+ const result = await UIMessageCodec.decode({
30
+ id: "2",
31
+ role: "assistant",
32
+ parts: [{ type: "text", text: "Hi there!" }],
33
+ });
34
+ expect(result).toHaveLength(1);
35
+ expect(result[0]).toMatchObject({
36
+ kind: "message",
37
+ id: "2",
38
+ role: "assistant",
39
+ content: [
40
+ {
41
+ kind: "text",
42
+ text: "Hi there!",
43
+ },
44
+ ],
45
+ });
46
+ });
47
+ it("should convert a system message with a text part", async () => {
48
+ const result = await UIMessageCodec.decode({
49
+ id: "3",
50
+ role: "system",
51
+ parts: [{ type: "text", text: "System prompt" }],
52
+ });
53
+ expect(result).toHaveLength(1);
54
+ expect(result[0]).toMatchObject({
55
+ kind: "message",
56
+ id: "3",
57
+ role: "system",
58
+ content: [
59
+ {
60
+ kind: "text",
61
+ text: "System prompt",
62
+ },
63
+ ],
64
+ });
65
+ });
66
+ it("should preserve providerMetadata", async () => {
67
+ const result = await UIMessageCodec.decode({
68
+ id: "4",
69
+ role: "user",
70
+ parts: [
71
+ {
72
+ type: "text",
73
+ text: "Test",
74
+ providerMetadata: { anthropic: { custom: "data" } },
75
+ },
76
+ ],
77
+ });
78
+ expect(result).toHaveLength(1);
79
+ const message = result[0];
80
+ if (message.kind === "message") {
81
+ expect(message.content[0]).toMatchObject({
82
+ kind: "text",
83
+ text: "Test",
84
+ providerMetadata: { anthropic: { custom: "data" } },
85
+ });
86
+ }
87
+ });
88
+ });
89
+ // ----------------------------
90
+ // File parts - Data URLs
91
+ // ----------------------------
92
+ describe("decode - file parts with Data URLs", () => {
93
+ it("should convert a file part with base64 Data URL", async () => {
94
+ const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
95
+ const dataUrl = `data:image/png;base64,${base64Data}`;
96
+ const result = await UIMessageCodec.decode({
97
+ id: "1",
98
+ role: "user",
99
+ parts: [
100
+ {
101
+ type: "file",
102
+ mediaType: "image/png",
103
+ filename: "test.png",
104
+ url: dataUrl,
105
+ },
106
+ ],
107
+ });
108
+ expect(result).toHaveLength(1);
109
+ const message = result[0];
110
+ if (message.kind === "message") {
111
+ expect(message.content[0]).toMatchObject({
112
+ kind: "file",
113
+ mimeType: "image/png",
114
+ filename: "test.png",
115
+ data: base64Data,
116
+ });
117
+ expect(message.content[0]).not.toHaveProperty("uri");
118
+ }
119
+ });
120
+ it("should handle various image MIME types", async () => {
121
+ const mimeTypes = [
122
+ "image/png",
123
+ "image/jpeg",
124
+ "image/gif",
125
+ "image/webp",
126
+ "image/svg+xml",
127
+ ];
128
+ for (const mimeType of mimeTypes) {
129
+ const result = await UIMessageCodec.decode({
130
+ id: "1",
131
+ role: "user",
132
+ parts: [
133
+ {
134
+ type: "file",
135
+ mediaType: mimeType,
136
+ url: `data:${mimeType};base64,abc123`,
137
+ },
138
+ ],
139
+ });
140
+ expect(result).toHaveLength(1);
141
+ const message = result[0];
142
+ if (message.kind === "message") {
143
+ expect(message.content[0]).toMatchObject({
144
+ kind: "file",
145
+ mimeType,
146
+ data: "abc123",
147
+ });
148
+ }
149
+ }
150
+ });
151
+ it("should handle PDF and document types", async () => {
152
+ const result = await UIMessageCodec.decode({
153
+ id: "1",
154
+ role: "user",
155
+ parts: [
156
+ {
157
+ type: "file",
158
+ mediaType: "application/pdf",
159
+ filename: "document.pdf",
160
+ url: "data:application/pdf;base64,JVBERi0xLjQ=",
161
+ },
162
+ ],
163
+ });
164
+ expect(result).toHaveLength(1);
165
+ const message = result[0];
166
+ if (message.kind === "message") {
167
+ expect(message.content[0]).toMatchObject({
168
+ kind: "file",
169
+ mimeType: "application/pdf",
170
+ filename: "document.pdf",
171
+ data: "JVBERi0xLjQ=",
172
+ });
173
+ }
174
+ });
175
+ });
176
+ // ----------------------------
177
+ // File parts - Regular URLs
178
+ // ----------------------------
179
+ describe("decode - file parts with URLs", () => {
180
+ it("should convert a file part with http URL to uri field", async () => {
181
+ const result = await UIMessageCodec.decode({
182
+ id: "1",
183
+ role: "user",
184
+ parts: [
185
+ {
186
+ type: "file",
187
+ mediaType: "image/png",
188
+ filename: "photo.png",
189
+ url: "http://example.com/photo.png",
190
+ },
191
+ ],
192
+ });
193
+ expect(result).toHaveLength(1);
194
+ const message = result[0];
195
+ if (message.kind === "message") {
196
+ expect(message.content[0]).toMatchObject({
197
+ kind: "file",
198
+ mimeType: "image/png",
199
+ filename: "photo.png",
200
+ uri: "http://example.com/photo.png",
201
+ });
202
+ expect(message.content[0]).not.toHaveProperty("data");
203
+ }
204
+ });
205
+ it("should convert a file part with https URL to uri field", async () => {
206
+ const result = await UIMessageCodec.decode({
207
+ id: "1",
208
+ role: "user",
209
+ parts: [
210
+ {
211
+ type: "file",
212
+ mediaType: "application/pdf",
213
+ url: "https://example.com/document.pdf",
214
+ },
215
+ ],
216
+ });
217
+ expect(result).toHaveLength(1);
218
+ const message = result[0];
219
+ if (message.kind === "message") {
220
+ expect(message.content[0]).toMatchObject({
221
+ kind: "file",
222
+ mimeType: "application/pdf",
223
+ uri: "https://example.com/document.pdf",
224
+ });
225
+ }
226
+ });
227
+ it("should handle file URLs without filename", async () => {
228
+ const result = await UIMessageCodec.decode({
229
+ id: "1",
230
+ role: "user",
231
+ parts: [
232
+ {
233
+ type: "file",
234
+ mediaType: "text/plain",
235
+ url: "https://example.com/file",
236
+ },
237
+ ],
238
+ });
239
+ expect(result).toHaveLength(1);
240
+ const message = result[0];
241
+ if (message.kind === "message") {
242
+ expect(message.content[0]).toMatchObject({
243
+ kind: "file",
244
+ mimeType: "text/plain",
245
+ uri: "https://example.com/file",
246
+ });
247
+ const filePart = message.content[0];
248
+ if (filePart.kind === "file") {
249
+ expect(filePart.filename).toBeUndefined();
250
+ }
251
+ }
252
+ });
253
+ it("should convert non-base64 Data URL to uri field", async () => {
254
+ const result = await UIMessageCodec.decode({
255
+ id: "1",
256
+ role: "user",
257
+ parts: [
258
+ {
259
+ type: "file",
260
+ mediaType: "text/plain",
261
+ url: "data:text/plain,Hello%20World",
262
+ },
263
+ ],
264
+ });
265
+ expect(result).toHaveLength(1);
266
+ const message = result[0];
267
+ if (message.kind === "message") {
268
+ expect(message.content[0]).toMatchObject({
269
+ kind: "file",
270
+ mimeType: "text/plain",
271
+ uri: "data:text/plain,Hello%20World",
272
+ });
273
+ }
274
+ });
275
+ });
276
+ // ----------------------------
277
+ // Multiple parts
278
+ // ----------------------------
279
+ describe("decode - multiple parts", () => {
280
+ it("should convert a message with multiple text parts", async () => {
281
+ const result = await UIMessageCodec.decode({
282
+ id: "1",
283
+ role: "user",
284
+ parts: [
285
+ { type: "text", text: "First part" },
286
+ { type: "text", text: "Second part" },
287
+ ],
288
+ });
289
+ expect(result).toHaveLength(1);
290
+ const message = result[0];
291
+ if (message.kind === "message") {
292
+ expect(message.content).toHaveLength(2);
293
+ expect(message.content[0]).toMatchObject({
294
+ kind: "text",
295
+ text: "First part",
296
+ });
297
+ expect(message.content[1]).toMatchObject({
298
+ kind: "text",
299
+ text: "Second part",
300
+ });
301
+ }
302
+ });
303
+ it("should convert a message with mixed text and file parts", async () => {
304
+ const result = await UIMessageCodec.decode({
305
+ id: "1",
306
+ role: "user",
307
+ parts: [
308
+ { type: "text", text: "Check this image:" },
309
+ {
310
+ type: "file",
311
+ mediaType: "image/png",
312
+ url: "data:image/png;base64,abc123",
313
+ },
314
+ { type: "text", text: "What do you see?" },
315
+ ],
316
+ });
317
+ expect(result).toHaveLength(1);
318
+ const message = result[0];
319
+ if (message.kind === "message") {
320
+ expect(message.content).toHaveLength(3);
321
+ expect(message.content[0]).toMatchObject({
322
+ kind: "text",
323
+ text: "Check this image:",
324
+ });
325
+ expect(message.content[1]).toMatchObject({
326
+ kind: "file",
327
+ mimeType: "image/png",
328
+ data: "abc123",
329
+ });
330
+ expect(message.content[2]).toMatchObject({
331
+ kind: "text",
332
+ text: "What do you see?",
333
+ });
334
+ }
335
+ });
336
+ it("should convert a message with multiple files", async () => {
337
+ const result = await UIMessageCodec.decode({
338
+ id: "1",
339
+ role: "user",
340
+ parts: [
341
+ {
342
+ type: "file",
343
+ mediaType: "image/png",
344
+ url: "data:image/png;base64,image1",
345
+ },
346
+ {
347
+ type: "file",
348
+ mediaType: "image/jpeg",
349
+ url: "https://example.com/image2.jpg",
350
+ },
351
+ ],
352
+ });
353
+ expect(result).toHaveLength(1);
354
+ const message = result[0];
355
+ if (message.kind === "message") {
356
+ expect(message.content).toHaveLength(2);
357
+ expect(message.content[0]).toMatchObject({
358
+ kind: "file",
359
+ data: "image1",
360
+ });
361
+ expect(message.content[1]).toMatchObject({
362
+ kind: "file",
363
+ uri: "https://example.com/image2.jpg",
364
+ });
365
+ }
366
+ });
367
+ });
368
+ // ----------------------------
369
+ // Tool parts
370
+ // ----------------------------
371
+ describe("decode - tool parts", () => {
372
+ describe("static tool invocations (tool-*)", () => {
373
+ it("should convert input-available state to ToolCall with IN_PROGRESS", async () => {
374
+ const result = await UIMessageCodec.decode({
375
+ id: "1",
376
+ role: "assistant",
377
+ parts: [
378
+ {
379
+ type: "tool-calculator",
380
+ state: "input-available",
381
+ toolCallId: "call-1",
382
+ input: { operation: "add", numbers: [1, 2] },
383
+ },
384
+ ],
385
+ });
386
+ expect(result).toHaveLength(1);
387
+ expect(result[0]).toMatchObject({
388
+ kind: "tool-call",
389
+ callId: "call-1",
390
+ toolId: "calculator",
391
+ state: IN_PROGRESS,
392
+ arguments: JSON.stringify({ operation: "add", numbers: [1, 2] }),
393
+ });
394
+ });
395
+ it("should convert output-available state to ToolResult with COMPLETED", async () => {
396
+ const result = await UIMessageCodec.decode({
397
+ id: "1",
398
+ role: "assistant",
399
+ parts: [
400
+ {
401
+ type: "tool-calculator",
402
+ state: "output-available",
403
+ toolCallId: "call-1",
404
+ input: { operation: "add", numbers: [1, 2] },
405
+ output: { result: 3 },
406
+ },
407
+ ],
408
+ });
409
+ expect(result).toHaveLength(1);
410
+ expect(result[0]).toMatchObject({
411
+ kind: "tool-result",
412
+ callId: "call-1",
413
+ toolId: "calculator",
414
+ state: COMPLETED,
415
+ result: { result: 3 },
416
+ error: null,
417
+ });
418
+ });
419
+ it("should convert output-error state to ToolResult with FAILED", async () => {
420
+ const result = await UIMessageCodec.decode({
421
+ id: "1",
422
+ role: "assistant",
423
+ parts: [
424
+ {
425
+ type: "tool-calculator",
426
+ state: "output-error",
427
+ toolCallId: "call-1",
428
+ input: { operation: "divide", numbers: [1, 0] },
429
+ errorText: "Division by zero",
430
+ },
431
+ ],
432
+ });
433
+ expect(result).toHaveLength(1);
434
+ expect(result[0]).toMatchObject({
435
+ kind: "tool-result",
436
+ callId: "call-1",
437
+ toolId: "calculator",
438
+ state: FAILED,
439
+ result: null,
440
+ error: "Division by zero",
441
+ });
442
+ });
443
+ it("should skip input-streaming state", async () => {
444
+ const result = await UIMessageCodec.decode({
445
+ id: "1",
446
+ role: "assistant",
447
+ parts: [
448
+ {
449
+ type: "tool-calculator",
450
+ state: "input-streaming",
451
+ toolCallId: "call-1",
452
+ input: { operation: "add" },
453
+ },
454
+ { type: "text", text: "After streaming" },
455
+ ],
456
+ });
457
+ expect(result).toHaveLength(1);
458
+ expect(result[0]).toMatchObject({
459
+ kind: "message",
460
+ content: [{ kind: "text", text: "After streaming" }],
461
+ });
462
+ });
463
+ it("should preserve callProviderMetadata on tool calls", async () => {
464
+ const result = await UIMessageCodec.decode({
465
+ id: "1",
466
+ role: "assistant",
467
+ parts: [
468
+ {
469
+ type: "tool-weather",
470
+ state: "input-available",
471
+ toolCallId: "call-1",
472
+ input: { city: "Tokyo" },
473
+ callProviderMetadata: {
474
+ anthropic: { cacheControl: { type: "ephemeral" } },
475
+ },
476
+ },
477
+ ],
478
+ });
479
+ expect(result).toHaveLength(1);
480
+ expect(result[0]).toMatchObject({
481
+ kind: "tool-call",
482
+ callId: "call-1",
483
+ toolId: "weather",
484
+ providerMetadata: {
485
+ anthropic: { cacheControl: { type: "ephemeral" } },
486
+ },
487
+ });
488
+ });
489
+ it("should preserve callProviderMetadata on tool results", async () => {
490
+ const result = await UIMessageCodec.decode({
491
+ id: "1",
492
+ role: "assistant",
493
+ parts: [
494
+ {
495
+ type: "tool-weather",
496
+ state: "output-available",
497
+ toolCallId: "call-1",
498
+ input: { city: "Tokyo" },
499
+ output: { temperature: 20 },
500
+ callProviderMetadata: {
501
+ anthropic: { signature: "12345" },
502
+ },
503
+ },
504
+ ],
505
+ });
506
+ expect(result).toHaveLength(1);
507
+ expect(result[0]).toMatchObject({
508
+ kind: "tool-result",
509
+ callId: "call-1",
510
+ toolId: "weather",
511
+ providerMetadata: {
512
+ anthropic: { signature: "12345" },
513
+ },
514
+ });
515
+ });
516
+ });
517
+ describe("dynamic tool invocations", () => {
518
+ it("should convert input-available state to ToolCall", async () => {
519
+ const result = await UIMessageCodec.decode({
520
+ id: "1",
521
+ role: "assistant",
522
+ parts: [
523
+ {
524
+ type: "dynamic-tool",
525
+ toolName: "screenshot",
526
+ state: "input-available",
527
+ toolCallId: "call-1",
528
+ input: { region: "full" },
529
+ },
530
+ ],
531
+ });
532
+ expect(result).toHaveLength(1);
533
+ expect(result[0]).toMatchObject({
534
+ kind: "tool-call",
535
+ callId: "call-1",
536
+ toolId: "screenshot",
537
+ state: IN_PROGRESS,
538
+ arguments: JSON.stringify({ region: "full" }),
539
+ });
540
+ });
541
+ it("should convert output-available state to ToolResult", async () => {
542
+ const result = await UIMessageCodec.decode({
543
+ id: "1",
544
+ role: "assistant",
545
+ parts: [
546
+ {
547
+ type: "dynamic-tool",
548
+ toolName: "screenshot",
549
+ state: "output-available",
550
+ toolCallId: "call-1",
551
+ input: { region: "full" },
552
+ output: "base64imagedata",
553
+ },
554
+ ],
555
+ });
556
+ expect(result).toHaveLength(1);
557
+ expect(result[0]).toMatchObject({
558
+ kind: "tool-result",
559
+ callId: "call-1",
560
+ toolId: "screenshot",
561
+ state: COMPLETED,
562
+ result: "base64imagedata",
563
+ error: null,
564
+ });
565
+ });
566
+ it("should convert output-error state to ToolResult", async () => {
567
+ const result = await UIMessageCodec.decode({
568
+ id: "1",
569
+ role: "assistant",
570
+ parts: [
571
+ {
572
+ type: "dynamic-tool",
573
+ toolName: "screenshot",
574
+ state: "output-error",
575
+ toolCallId: "call-1",
576
+ input: { region: "full" },
577
+ errorText: "Screen capture failed",
578
+ },
579
+ ],
580
+ });
581
+ expect(result).toHaveLength(1);
582
+ expect(result[0]).toMatchObject({
583
+ kind: "tool-result",
584
+ callId: "call-1",
585
+ toolId: "screenshot",
586
+ state: FAILED,
587
+ result: null,
588
+ error: "Screen capture failed",
589
+ });
590
+ });
591
+ });
592
+ describe("multiple tool invocations", () => {
593
+ it("should handle message with multiple tool calls", async () => {
594
+ const result = await UIMessageCodec.decode({
595
+ id: "1",
596
+ role: "assistant",
597
+ parts: [
598
+ { type: "text", text: "Let me help with that" },
599
+ {
600
+ type: "tool-calculator",
601
+ state: "input-available",
602
+ toolCallId: "call-1",
603
+ input: { operation: "add", numbers: [1, 2] },
604
+ },
605
+ {
606
+ type: "tool-weather",
607
+ state: "input-available",
608
+ toolCallId: "call-2",
609
+ input: { city: "Tokyo" },
610
+ },
611
+ ],
612
+ });
613
+ expect(result).toHaveLength(3);
614
+ expect(result[0]).toMatchObject({
615
+ kind: "message",
616
+ content: [{ kind: "text", text: "Let me help with that" }],
617
+ });
618
+ expect(result[1]).toMatchObject({
619
+ kind: "tool-call",
620
+ callId: "call-1",
621
+ toolId: "calculator",
622
+ });
623
+ expect(result[2]).toMatchObject({
624
+ kind: "tool-call",
625
+ callId: "call-2",
626
+ toolId: "weather",
627
+ });
628
+ });
629
+ it("should handle message with mixed tool calls and results", async () => {
630
+ const result = await UIMessageCodec.decode({
631
+ id: "1",
632
+ role: "assistant",
633
+ parts: [
634
+ {
635
+ type: "tool-calculator",
636
+ state: "output-available",
637
+ toolCallId: "call-1",
638
+ input: { operation: "add", numbers: [1, 2] },
639
+ output: { result: 3 },
640
+ },
641
+ { type: "text", text: "The result is 3" },
642
+ {
643
+ type: "tool-weather",
644
+ state: "input-available",
645
+ toolCallId: "call-2",
646
+ input: { city: "Tokyo" },
647
+ },
648
+ ],
649
+ });
650
+ // Message with text parts comes first (via unshift), then tools in order
651
+ expect(result).toHaveLength(3);
652
+ expect(result[0]).toMatchObject({
653
+ kind: "message",
654
+ content: [{ kind: "text", text: "The result is 3" }],
655
+ });
656
+ expect(result[1]).toMatchObject({
657
+ kind: "tool-result",
658
+ callId: "call-1",
659
+ });
660
+ expect(result[2]).toMatchObject({
661
+ kind: "tool-call",
662
+ callId: "call-2",
663
+ });
664
+ });
665
+ it("should handle message with both static and dynamic tools", async () => {
666
+ const result = await UIMessageCodec.decode({
667
+ id: "1",
668
+ role: "assistant",
669
+ parts: [
670
+ {
671
+ type: "tool-calculator",
672
+ state: "input-available",
673
+ toolCallId: "call-1",
674
+ input: { operation: "add", numbers: [1, 2] },
675
+ },
676
+ {
677
+ type: "dynamic-tool",
678
+ toolName: "screenshot",
679
+ state: "input-available",
680
+ toolCallId: "call-2",
681
+ input: { region: "full" },
682
+ },
683
+ ],
684
+ });
685
+ expect(result).toHaveLength(2);
686
+ expect(result[0]).toMatchObject({
687
+ kind: "tool-call",
688
+ toolId: "calculator",
689
+ });
690
+ expect(result[1]).toMatchObject({
691
+ kind: "tool-call",
692
+ toolId: "screenshot",
693
+ });
694
+ });
695
+ });
696
+ describe("tool output types", () => {
697
+ it("should handle string output", async () => {
698
+ const result = await UIMessageCodec.decode({
699
+ id: "1",
700
+ role: "assistant",
701
+ parts: [
702
+ {
703
+ type: "tool-calculator",
704
+ state: "output-available",
705
+ toolCallId: "call-1",
706
+ input: {},
707
+ output: "result string",
708
+ },
709
+ ],
710
+ });
711
+ expect(result[0]).toMatchObject({
712
+ kind: "tool-result",
713
+ result: "result string",
714
+ });
715
+ });
716
+ it("should handle number output", async () => {
717
+ const result = await UIMessageCodec.decode({
718
+ id: "1",
719
+ role: "assistant",
720
+ parts: [
721
+ {
722
+ type: "tool-calculator",
723
+ state: "output-available",
724
+ toolCallId: "call-1",
725
+ input: {},
726
+ output: 42,
727
+ },
728
+ ],
729
+ });
730
+ expect(result[0]).toMatchObject({
731
+ kind: "tool-result",
732
+ result: 42,
733
+ });
734
+ });
735
+ it("should handle object output", async () => {
736
+ const result = await UIMessageCodec.decode({
737
+ id: "1",
738
+ role: "assistant",
739
+ parts: [
740
+ {
741
+ type: "tool-weather",
742
+ state: "output-available",
743
+ toolCallId: "call-1",
744
+ input: {},
745
+ output: {
746
+ temperature: 20,
747
+ condition: "sunny",
748
+ humidity: 60,
749
+ },
750
+ },
751
+ ],
752
+ });
753
+ expect(result[0]).toMatchObject({
754
+ kind: "tool-result",
755
+ result: {
756
+ temperature: 20,
757
+ condition: "sunny",
758
+ humidity: 60,
759
+ },
760
+ });
761
+ });
762
+ it("should handle array output", async () => {
763
+ const result = await UIMessageCodec.decode({
764
+ id: "1",
765
+ role: "assistant",
766
+ parts: [
767
+ {
768
+ type: "tool-search",
769
+ state: "output-available",
770
+ toolCallId: "call-1",
771
+ input: {},
772
+ output: ["result1", "result2", "result3"],
773
+ },
774
+ ],
775
+ });
776
+ expect(result[0]).toMatchObject({
777
+ kind: "tool-result",
778
+ result: ["result1", "result2", "result3"],
779
+ });
780
+ });
781
+ it("should handle null output", async () => {
782
+ const result = await UIMessageCodec.decode({
783
+ id: "1",
784
+ role: "assistant",
785
+ parts: [
786
+ {
787
+ type: "tool-test",
788
+ state: "output-available",
789
+ toolCallId: "call-1",
790
+ input: {},
791
+ output: null,
792
+ },
793
+ ],
794
+ });
795
+ expect(result[0]).toMatchObject({
796
+ kind: "tool-result",
797
+ result: null,
798
+ });
799
+ });
800
+ it("should handle undefined output as empty object", async () => {
801
+ const result = await UIMessageCodec.decode({
802
+ id: "1",
803
+ role: "assistant",
804
+ parts: [
805
+ {
806
+ type: "tool-test",
807
+ state: "input-available",
808
+ toolCallId: "call-1",
809
+ input: undefined,
810
+ },
811
+ ],
812
+ });
813
+ expect(result[0]).toMatchObject({
814
+ kind: "tool-call",
815
+ arguments: "{}",
816
+ });
817
+ });
818
+ });
819
+ });
820
+ // ----------------------------
821
+ // Reasoning parts
822
+ // ----------------------------
823
+ describe("decode - reasoning parts", () => {
824
+ it("should convert reasoning parts to separate reasoning items", async () => {
825
+ const result = await UIMessageCodec.decode({
826
+ id: "1",
827
+ role: "assistant",
828
+ parts: [
829
+ { type: "text", text: "Before reasoning" },
830
+ { type: "reasoning", text: "Let me think..." },
831
+ { type: "text", text: "After reasoning" },
832
+ ],
833
+ });
834
+ expect(result).toHaveLength(2);
835
+ expect(result[0]).toMatchObject({
836
+ kind: "message",
837
+ content: [
838
+ { kind: "text", text: "Before reasoning" },
839
+ { kind: "text", text: "After reasoning" },
840
+ ],
841
+ });
842
+ expect(result[1]).toMatchObject({
843
+ kind: "reasoning",
844
+ text: "Let me think...",
845
+ });
846
+ });
847
+ it("should preserve providerMetadata on reasoning parts", async () => {
848
+ const result = await UIMessageCodec.decode({
849
+ id: "1",
850
+ role: "assistant",
851
+ parts: [
852
+ {
853
+ type: "reasoning",
854
+ text: "Thinking...",
855
+ providerMetadata: {
856
+ anthropic: { signature: "12345" },
857
+ },
858
+ },
859
+ ],
860
+ });
861
+ expect(result).toHaveLength(1);
862
+ expect(result[0]).toMatchObject({
863
+ kind: "reasoning",
864
+ text: "Thinking...",
865
+ providerMetadata: {
866
+ anthropic: { signature: "12345" },
867
+ },
868
+ });
869
+ });
870
+ it("should handle multiple reasoning parts", async () => {
871
+ const result = await UIMessageCodec.decode({
872
+ id: "1",
873
+ role: "assistant",
874
+ parts: [
875
+ { type: "reasoning", text: "First thought" },
876
+ { type: "reasoning", text: "Second thought" },
877
+ { type: "text", text: "Final answer" },
878
+ ],
879
+ });
880
+ // Message comes first (via unshift), then reasoning parts in order
881
+ expect(result).toHaveLength(3);
882
+ expect(result[0]).toMatchObject({
883
+ kind: "message",
884
+ content: [{ kind: "text", text: "Final answer" }],
885
+ });
886
+ expect(result[1]).toMatchObject({
887
+ kind: "reasoning",
888
+ text: "First thought",
889
+ });
890
+ expect(result[2]).toMatchObject({
891
+ kind: "reasoning",
892
+ text: "Second thought",
893
+ });
894
+ });
895
+ });
896
+ // ----------------------------
897
+ // Parts that should be skipped
898
+ // ----------------------------
899
+ describe("decode - parts to skip", () => {
900
+ it("should skip step-start parts", async () => {
901
+ const result = await UIMessageCodec.decode({
902
+ id: "1",
903
+ role: "assistant",
904
+ parts: [{ type: "step-start" }, { type: "text", text: "Step content" }],
905
+ });
906
+ expect(result).toHaveLength(1);
907
+ expect(result[0]).toMatchObject({
908
+ kind: "message",
909
+ content: [{ kind: "text", text: "Step content" }],
910
+ });
911
+ });
912
+ it("should skip source-url parts", async () => {
913
+ const result = await UIMessageCodec.decode({
914
+ id: "1",
915
+ role: "assistant",
916
+ parts: [
917
+ {
918
+ type: "source-url",
919
+ sourceId: "1",
920
+ url: "https://example.com",
921
+ title: "Example",
922
+ },
923
+ { type: "text", text: "Main content" },
924
+ ],
925
+ });
926
+ expect(result).toHaveLength(1);
927
+ expect(result[0]).toMatchObject({
928
+ kind: "message",
929
+ content: [{ kind: "text", text: "Main content" }],
930
+ });
931
+ });
932
+ it("should skip source-document parts", async () => {
933
+ const result = await UIMessageCodec.decode({
934
+ id: "1",
935
+ role: "assistant",
936
+ parts: [
937
+ {
938
+ type: "source-document",
939
+ sourceId: "1",
940
+ mediaType: "text/plain",
941
+ title: "Doc",
942
+ filename: "doc.txt",
943
+ },
944
+ { type: "text", text: "Main content" },
945
+ ],
946
+ });
947
+ expect(result).toHaveLength(1);
948
+ expect(result[0]).toMatchObject({
949
+ kind: "message",
950
+ content: [{ kind: "text", text: "Main content" }],
951
+ });
952
+ });
953
+ });
954
+ // ----------------------------
955
+ // Metadata handling
956
+ // ----------------------------
957
+ describe("decode - metadata", () => {
958
+ it("should preserve message metadata", async () => {
959
+ const result = await UIMessageCodec.decode({
960
+ id: "1",
961
+ role: "user",
962
+ metadata: { userId: "123", timestamp: 1234567890 },
963
+ parts: [{ type: "text", text: "Hello" }],
964
+ });
965
+ expect(result).toHaveLength(1);
966
+ const message = result[0];
967
+ if (message.kind === "message") {
968
+ expect(message.metadata).toEqual({
969
+ userId: "123",
970
+ timestamp: 1234567890,
971
+ });
972
+ }
973
+ });
974
+ it("should handle messages without metadata", async () => {
975
+ const result = await UIMessageCodec.decode({
976
+ id: "1",
977
+ role: "user",
978
+ parts: [{ type: "text", text: "Hello" }],
979
+ });
980
+ expect(result).toHaveLength(1);
981
+ const message = result[0];
982
+ if (message.kind === "message") {
983
+ expect(message.metadata).toBeUndefined();
984
+ }
985
+ });
986
+ });
987
+ // ----------------------------
988
+ // Edge cases
989
+ // ----------------------------
990
+ describe("decode - edge cases", () => {
991
+ it("should reject empty parts array via AI SDK validation", async () => {
992
+ await expect(UIMessageCodec.decode({
993
+ id: "1",
994
+ role: "user",
995
+ parts: [],
996
+ })).rejects.toThrow("Message must contain at least one part");
997
+ });
998
+ it("should handle parts that are all skipped", async () => {
999
+ const result = await UIMessageCodec.decode({
1000
+ id: "1",
1001
+ role: "assistant",
1002
+ parts: [
1003
+ { type: "reasoning", text: "Thinking..." },
1004
+ { type: "step-start" },
1005
+ ],
1006
+ });
1007
+ expect(result).toHaveLength(1);
1008
+ expect(result[0]).toMatchObject({
1009
+ kind: "reasoning",
1010
+ text: "Thinking...",
1011
+ });
1012
+ });
1013
+ it("should reject unknown part type via AI SDK validation", async () => {
1014
+ await expect(UIMessageCodec.decode({
1015
+ id: "1",
1016
+ role: "user",
1017
+ parts: [{ type: "unknown-type" }],
1018
+ })).rejects.toThrow("Type validation failed");
1019
+ });
1020
+ });
1021
+ // ----------------------------
1022
+ // Validation tests
1023
+ // ----------------------------
1024
+ describe("decode - validation via AI SDK", () => {
1025
+ it("should validate and reject invalid role", async () => {
1026
+ await expect(UIMessageCodec.decode({
1027
+ id: "1",
1028
+ role: "invalid",
1029
+ parts: [{ type: "text", text: "Hello" }],
1030
+ })).rejects.toThrow();
1031
+ });
1032
+ it("should validate and accept all valid roles", async () => {
1033
+ const roles = [
1034
+ "system",
1035
+ "user",
1036
+ "assistant",
1037
+ ];
1038
+ for (const role of roles) {
1039
+ const result = await UIMessageCodec.decode({
1040
+ id: "1",
1041
+ role,
1042
+ parts: [{ type: "text", text: "Hello" }],
1043
+ });
1044
+ expect(result).toHaveLength(1);
1045
+ const message = result[0];
1046
+ if (message.kind === "message") {
1047
+ expect(message.role).toBe(role);
1048
+ }
1049
+ }
1050
+ });
1051
+ });
1052
+ });
1053
+ describe("historyToUIMessages", () => {
1054
+ describe("basic message conversion", () => {
1055
+ it("should convert a simple user message", () => {
1056
+ const items = [
1057
+ {
1058
+ kind: "message",
1059
+ id: "msg-1",
1060
+ role: "user",
1061
+ content: [{ kind: "text", text: "Hello, AI!" }],
1062
+ },
1063
+ ];
1064
+ const result = historyToUIMessages(items);
1065
+ expect(result).toEqual([
1066
+ {
1067
+ id: "msg-1",
1068
+ role: "user",
1069
+ parts: [{ type: "text", text: "Hello, AI!" }],
1070
+ },
1071
+ ]);
1072
+ });
1073
+ it("should convert a simple assistant message", () => {
1074
+ const items = [
1075
+ {
1076
+ kind: "message",
1077
+ id: "msg-2",
1078
+ role: "assistant",
1079
+ content: [{ kind: "text", text: "Hello, human!" }],
1080
+ },
1081
+ ];
1082
+ const result = historyToUIMessages(items);
1083
+ expect(result).toEqual([
1084
+ {
1085
+ id: "msg-2",
1086
+ role: "assistant",
1087
+ parts: [{ type: "text", text: "Hello, human!" }],
1088
+ },
1089
+ ]);
1090
+ });
1091
+ it("should convert a system message", () => {
1092
+ const items = [
1093
+ {
1094
+ kind: "message",
1095
+ id: "msg-3",
1096
+ role: "system",
1097
+ content: [{ kind: "text", text: "System instructions" }],
1098
+ },
1099
+ ];
1100
+ const result = historyToUIMessages(items);
1101
+ expect(result).toEqual([
1102
+ {
1103
+ id: "msg-3",
1104
+ role: "system",
1105
+ parts: [{ type: "text", text: "System instructions" }],
1106
+ },
1107
+ ]);
1108
+ });
1109
+ it("should handle multiple messages", () => {
1110
+ const items = [
1111
+ {
1112
+ kind: "message",
1113
+ id: "msg-1",
1114
+ role: "user",
1115
+ content: [{ kind: "text", text: "What's the weather?" }],
1116
+ },
1117
+ {
1118
+ kind: "message",
1119
+ id: "msg-2",
1120
+ role: "assistant",
1121
+ content: [{ kind: "text", text: "I'll check that for you." }],
1122
+ },
1123
+ {
1124
+ kind: "message",
1125
+ id: "msg-3",
1126
+ role: "user",
1127
+ content: [{ kind: "text", text: "Thanks!" }],
1128
+ },
1129
+ ];
1130
+ const result = historyToUIMessages(items);
1131
+ expect(result).toEqual([
1132
+ {
1133
+ id: "msg-1",
1134
+ role: "user",
1135
+ parts: [{ type: "text", text: "What's the weather?" }],
1136
+ },
1137
+ {
1138
+ id: "msg-2",
1139
+ role: "assistant",
1140
+ parts: [{ type: "text", text: "I'll check that for you." }],
1141
+ },
1142
+ {
1143
+ id: "msg-3",
1144
+ role: "user",
1145
+ parts: [{ type: "text", text: "Thanks!" }],
1146
+ },
1147
+ ]);
1148
+ });
1149
+ });
1150
+ describe("file parts", () => {
1151
+ it("should convert file with base64 data URL", () => {
1152
+ const items = [
1153
+ {
1154
+ kind: "message",
1155
+ id: "msg-1",
1156
+ role: "user",
1157
+ content: [
1158
+ {
1159
+ kind: "file",
1160
+ mimeType: "image/jpeg",
1161
+ filename: "test.jpg",
1162
+ data: "dGVzdA==",
1163
+ },
1164
+ ],
1165
+ },
1166
+ ];
1167
+ const result = historyToUIMessages(items);
1168
+ expect(result).toEqual([
1169
+ {
1170
+ id: "msg-1",
1171
+ role: "user",
1172
+ parts: [
1173
+ {
1174
+ type: "file",
1175
+ url: "data:image/jpeg;base64,dGVzdA==",
1176
+ mediaType: "image/jpeg",
1177
+ filename: "test.jpg",
1178
+ },
1179
+ ],
1180
+ },
1181
+ ]);
1182
+ });
1183
+ it("should convert file with URI", () => {
1184
+ const items = [
1185
+ {
1186
+ kind: "message",
1187
+ id: "msg-1",
1188
+ role: "user",
1189
+ content: [
1190
+ {
1191
+ kind: "file",
1192
+ mimeType: "image/png",
1193
+ filename: "photo.png",
1194
+ uri: "https://example.com/photo.png",
1195
+ },
1196
+ ],
1197
+ },
1198
+ ];
1199
+ const result = historyToUIMessages(items);
1200
+ expect(result).toEqual([
1201
+ {
1202
+ id: "msg-1",
1203
+ role: "user",
1204
+ parts: [
1205
+ {
1206
+ type: "file",
1207
+ url: "https://example.com/photo.png",
1208
+ mediaType: "image/png",
1209
+ filename: "photo.png",
1210
+ },
1211
+ ],
1212
+ },
1213
+ ]);
1214
+ });
1215
+ it("should handle mixed text and file content", () => {
1216
+ const items = [
1217
+ {
1218
+ kind: "message",
1219
+ id: "msg-1",
1220
+ role: "user",
1221
+ content: [
1222
+ {
1223
+ kind: "file",
1224
+ mimeType: "image/jpeg",
1225
+ filename: "image.jpg",
1226
+ uri: "https://example.com/image.jpg",
1227
+ },
1228
+ { kind: "text", text: "Check this image" },
1229
+ ],
1230
+ },
1231
+ ];
1232
+ const result = historyToUIMessages(items);
1233
+ expect(result).toEqual([
1234
+ {
1235
+ id: "msg-1",
1236
+ role: "user",
1237
+ parts: [
1238
+ {
1239
+ type: "file",
1240
+ url: "https://example.com/image.jpg",
1241
+ mediaType: "image/jpeg",
1242
+ filename: "image.jpg",
1243
+ },
1244
+ { type: "text", text: "Check this image" },
1245
+ ],
1246
+ },
1247
+ ]);
1248
+ });
1249
+ });
1250
+ describe("data parts", () => {
1251
+ it("should convert data parts", () => {
1252
+ const items = [
1253
+ {
1254
+ kind: "message",
1255
+ id: "msg-1",
1256
+ role: "assistant",
1257
+ content: [
1258
+ {
1259
+ kind: "data",
1260
+ data: { temperature: 72, humidity: 60 },
1261
+ },
1262
+ ],
1263
+ },
1264
+ ];
1265
+ const result = historyToUIMessages(items);
1266
+ expect(result).toEqual([
1267
+ {
1268
+ id: "msg-1",
1269
+ role: "assistant",
1270
+ parts: [
1271
+ { type: "data-temperature", data: 72 },
1272
+ { type: "data-humidity", data: 60 },
1273
+ ],
1274
+ },
1275
+ ]);
1276
+ });
1277
+ it("should handle mixed data and text parts", () => {
1278
+ const items = [
1279
+ {
1280
+ kind: "message",
1281
+ id: "msg-1",
1282
+ role: "assistant",
1283
+ content: [
1284
+ { kind: "text", text: "Here's the data:" },
1285
+ {
1286
+ kind: "data",
1287
+ data: { count: 5 },
1288
+ },
1289
+ ],
1290
+ },
1291
+ ];
1292
+ const result = historyToUIMessages(items);
1293
+ expect(result).toEqual([
1294
+ {
1295
+ id: "msg-1",
1296
+ role: "assistant",
1297
+ parts: [
1298
+ { type: "text", text: "Here's the data:" },
1299
+ { type: "data-count", data: 5 },
1300
+ ],
1301
+ },
1302
+ ]);
1303
+ });
1304
+ });
1305
+ describe("tool calls and results", () => {
1306
+ it("should convert tool call with successful result", () => {
1307
+ const items = [
1308
+ {
1309
+ kind: "message",
1310
+ id: "msg-1",
1311
+ role: "assistant",
1312
+ content: [{ kind: "text", text: "Let me calculate that." }],
1313
+ },
1314
+ {
1315
+ kind: "tool-call",
1316
+ callId: "call-1",
1317
+ toolId: "calculator",
1318
+ state: IN_PROGRESS,
1319
+ arguments: JSON.stringify({ operation: "add", numbers: [1, 2] }),
1320
+ },
1321
+ {
1322
+ kind: "tool-result",
1323
+ callId: "call-1",
1324
+ toolId: "calculator",
1325
+ state: COMPLETED,
1326
+ result: 3,
1327
+ error: null,
1328
+ },
1329
+ ];
1330
+ const result = historyToUIMessages(items);
1331
+ expect(result).toEqual([
1332
+ {
1333
+ id: "msg-1",
1334
+ role: "assistant",
1335
+ parts: [
1336
+ { type: "text", text: "Let me calculate that." },
1337
+ {
1338
+ type: "tool-calculator",
1339
+ toolCallId: "call-1",
1340
+ toolName: "calculator",
1341
+ input: { operation: "add", numbers: [1, 2] },
1342
+ state: "output-available",
1343
+ output: 3,
1344
+ },
1345
+ ],
1346
+ },
1347
+ ]);
1348
+ });
1349
+ it("should convert tool call with error result", () => {
1350
+ const items = [
1351
+ {
1352
+ kind: "message",
1353
+ id: "msg-1",
1354
+ role: "assistant",
1355
+ content: [{ kind: "text", text: "Let me try that." }],
1356
+ },
1357
+ {
1358
+ kind: "tool-call",
1359
+ callId: "call-1",
1360
+ toolId: "calculator",
1361
+ state: IN_PROGRESS,
1362
+ arguments: JSON.stringify({ operation: "divide", numbers: [1, 0] }),
1363
+ },
1364
+ {
1365
+ kind: "tool-result",
1366
+ callId: "call-1",
1367
+ toolId: "calculator",
1368
+ state: FAILED,
1369
+ result: null,
1370
+ error: "Division by zero",
1371
+ },
1372
+ ];
1373
+ const result = historyToUIMessages(items);
1374
+ expect(result).toEqual([
1375
+ {
1376
+ id: "msg-1",
1377
+ role: "assistant",
1378
+ parts: [
1379
+ { type: "text", text: "Let me try that." },
1380
+ {
1381
+ type: "tool-calculator",
1382
+ toolCallId: "call-1",
1383
+ toolName: "calculator",
1384
+ input: { operation: "divide", numbers: [1, 0] },
1385
+ state: "output-error",
1386
+ errorText: "Division by zero",
1387
+ },
1388
+ ],
1389
+ },
1390
+ ]);
1391
+ });
1392
+ it("should convert tool call without result (pending)", () => {
1393
+ const items = [
1394
+ {
1395
+ kind: "message",
1396
+ id: "msg-1",
1397
+ role: "assistant",
1398
+ content: [{ kind: "text", text: "Processing..." }],
1399
+ },
1400
+ {
1401
+ kind: "tool-call",
1402
+ callId: "call-1",
1403
+ toolId: "search",
1404
+ state: IN_PROGRESS,
1405
+ arguments: JSON.stringify({ query: "weather" }),
1406
+ },
1407
+ ];
1408
+ const result = historyToUIMessages(items);
1409
+ expect(result).toEqual([
1410
+ {
1411
+ id: "msg-1",
1412
+ role: "assistant",
1413
+ parts: [
1414
+ { type: "text", text: "Processing..." },
1415
+ {
1416
+ type: "tool-search",
1417
+ toolCallId: "call-1",
1418
+ toolName: "search",
1419
+ input: { query: "weather" },
1420
+ state: "input-available",
1421
+ },
1422
+ ],
1423
+ },
1424
+ ]);
1425
+ });
1426
+ it("should handle multiple tool calls with results", () => {
1427
+ const items = [
1428
+ {
1429
+ kind: "message",
1430
+ id: "msg-1",
1431
+ role: "assistant",
1432
+ content: [{ kind: "text", text: "I'll use multiple tools." }],
1433
+ },
1434
+ {
1435
+ kind: "tool-call",
1436
+ callId: "call-1",
1437
+ toolId: "tool1",
1438
+ state: IN_PROGRESS,
1439
+ arguments: JSON.stringify({ value: "value-1" }),
1440
+ },
1441
+ {
1442
+ kind: "tool-result",
1443
+ callId: "call-1",
1444
+ toolId: "tool1",
1445
+ state: COMPLETED,
1446
+ result: "result-1",
1447
+ error: null,
1448
+ },
1449
+ {
1450
+ kind: "tool-call",
1451
+ callId: "call-2",
1452
+ toolId: "tool2",
1453
+ state: IN_PROGRESS,
1454
+ arguments: JSON.stringify({ value: "value-2" }),
1455
+ },
1456
+ {
1457
+ kind: "tool-result",
1458
+ callId: "call-2",
1459
+ toolId: "tool2",
1460
+ state: COMPLETED,
1461
+ result: "result-2",
1462
+ error: null,
1463
+ },
1464
+ {
1465
+ kind: "tool-call",
1466
+ callId: "call-3",
1467
+ toolId: "tool3",
1468
+ state: IN_PROGRESS,
1469
+ arguments: JSON.stringify({ value: "value-3" }),
1470
+ },
1471
+ {
1472
+ kind: "tool-result",
1473
+ callId: "call-3",
1474
+ toolId: "tool3",
1475
+ state: COMPLETED,
1476
+ result: "result-3",
1477
+ error: null,
1478
+ },
1479
+ ];
1480
+ const result = historyToUIMessages(items);
1481
+ expect(result).toEqual([
1482
+ {
1483
+ id: "msg-1",
1484
+ role: "assistant",
1485
+ parts: [
1486
+ { type: "text", text: "I'll use multiple tools." },
1487
+ {
1488
+ type: "tool-tool1",
1489
+ toolCallId: "call-1",
1490
+ toolName: "tool1",
1491
+ input: { value: "value-1" },
1492
+ state: "output-available",
1493
+ output: "result-1",
1494
+ },
1495
+ {
1496
+ type: "tool-tool2",
1497
+ toolCallId: "call-2",
1498
+ toolName: "tool2",
1499
+ input: { value: "value-2" },
1500
+ state: "output-available",
1501
+ output: "result-2",
1502
+ },
1503
+ {
1504
+ type: "tool-tool3",
1505
+ toolCallId: "call-3",
1506
+ toolName: "tool3",
1507
+ input: { value: "value-3" },
1508
+ state: "output-available",
1509
+ output: "result-3",
1510
+ },
1511
+ ],
1512
+ },
1513
+ ]);
1514
+ });
1515
+ it("should skip orphaned tool results (result without call)", () => {
1516
+ const items = [
1517
+ {
1518
+ kind: "message",
1519
+ id: "msg-1",
1520
+ role: "assistant",
1521
+ content: [{ kind: "text", text: "Response" }],
1522
+ },
1523
+ {
1524
+ kind: "tool-result",
1525
+ callId: "orphan-1",
1526
+ toolId: "calculator",
1527
+ state: COMPLETED,
1528
+ result: 42,
1529
+ error: null,
1530
+ },
1531
+ ];
1532
+ const result = historyToUIMessages(items);
1533
+ expect(result).toEqual([
1534
+ {
1535
+ id: "msg-1",
1536
+ role: "assistant",
1537
+ parts: [{ type: "text", text: "Response" }],
1538
+ },
1539
+ ]);
1540
+ });
1541
+ it("should handle tool calls followed by next message", () => {
1542
+ const items = [
1543
+ {
1544
+ kind: "message",
1545
+ id: "msg-1",
1546
+ role: "assistant",
1547
+ content: [{ kind: "text", text: "Using tool" }],
1548
+ },
1549
+ {
1550
+ kind: "tool-call",
1551
+ callId: "call-1",
1552
+ toolId: "search",
1553
+ state: IN_PROGRESS,
1554
+ arguments: JSON.stringify({ query: "test" }),
1555
+ },
1556
+ {
1557
+ kind: "tool-result",
1558
+ callId: "call-1",
1559
+ toolId: "search",
1560
+ state: COMPLETED,
1561
+ result: "found it",
1562
+ error: null,
1563
+ },
1564
+ {
1565
+ kind: "message",
1566
+ id: "msg-2",
1567
+ role: "user",
1568
+ content: [{ kind: "text", text: "Thanks!" }],
1569
+ },
1570
+ ];
1571
+ const result = historyToUIMessages(items);
1572
+ expect(result).toEqual([
1573
+ {
1574
+ id: "msg-1",
1575
+ role: "assistant",
1576
+ parts: [
1577
+ { type: "text", text: "Using tool" },
1578
+ {
1579
+ type: "tool-search",
1580
+ toolCallId: "call-1",
1581
+ toolName: "search",
1582
+ input: { query: "test" },
1583
+ state: "output-available",
1584
+ output: "found it",
1585
+ },
1586
+ ],
1587
+ },
1588
+ {
1589
+ id: "msg-2",
1590
+ role: "user",
1591
+ parts: [{ type: "text", text: "Thanks!" }],
1592
+ },
1593
+ ]);
1594
+ });
1595
+ });
1596
+ describe("reasoning parts", () => {
1597
+ it("should attach reasoning to last assistant message", () => {
1598
+ const items = [
1599
+ {
1600
+ kind: "message",
1601
+ id: "msg-1",
1602
+ role: "assistant",
1603
+ content: [{ kind: "text", text: "Let me think..." }],
1604
+ },
1605
+ {
1606
+ kind: "reasoning",
1607
+ text: "Analyzing the problem step by step",
1608
+ },
1609
+ ];
1610
+ const result = historyToUIMessages(items);
1611
+ expect(result).toEqual([
1612
+ {
1613
+ id: "msg-1",
1614
+ role: "assistant",
1615
+ parts: [
1616
+ { type: "text", text: "Let me think..." },
1617
+ {
1618
+ type: "reasoning",
1619
+ text: "Analyzing the problem step by step",
1620
+ },
1621
+ ],
1622
+ },
1623
+ ]);
1624
+ });
1625
+ it("should create new message for standalone reasoning", () => {
1626
+ const items = [
1627
+ {
1628
+ kind: "message",
1629
+ id: "msg-1",
1630
+ role: "user",
1631
+ content: [{ kind: "text", text: "Solve this problem" }],
1632
+ },
1633
+ {
1634
+ kind: "reasoning",
1635
+ id: "reasoning-1",
1636
+ text: "First, I need to understand the problem",
1637
+ },
1638
+ ];
1639
+ const result = historyToUIMessages(items);
1640
+ expect(result).toEqual([
1641
+ {
1642
+ id: "msg-1",
1643
+ role: "user",
1644
+ parts: [{ type: "text", text: "Solve this problem" }],
1645
+ },
1646
+ {
1647
+ id: "reasoning-1",
1648
+ role: "assistant",
1649
+ parts: [
1650
+ {
1651
+ type: "reasoning",
1652
+ text: "First, I need to understand the problem",
1653
+ },
1654
+ ],
1655
+ },
1656
+ ]);
1657
+ });
1658
+ it("should generate id for reasoning without id", () => {
1659
+ const items = [
1660
+ {
1661
+ kind: "message",
1662
+ id: "msg-1",
1663
+ role: "user",
1664
+ content: [{ kind: "text", text: "Question" }],
1665
+ },
1666
+ {
1667
+ kind: "reasoning",
1668
+ text: "Thinking...",
1669
+ },
1670
+ ];
1671
+ const result = historyToUIMessages(items);
1672
+ expect(result).toEqual([
1673
+ {
1674
+ id: "msg-1",
1675
+ role: "user",
1676
+ parts: [{ type: "text", text: "Question" }],
1677
+ },
1678
+ {
1679
+ id: "reasoning-1",
1680
+ role: "assistant",
1681
+ parts: [{ type: "reasoning", text: "Thinking..." }],
1682
+ },
1683
+ ]);
1684
+ });
1685
+ it("should attach multiple reasoning parts to assistant message", () => {
1686
+ const items = [
1687
+ {
1688
+ kind: "message",
1689
+ id: "msg-1",
1690
+ role: "assistant",
1691
+ content: [{ kind: "text", text: "Response" }],
1692
+ },
1693
+ {
1694
+ kind: "reasoning",
1695
+ text: "Step 1: analyze",
1696
+ },
1697
+ {
1698
+ kind: "reasoning",
1699
+ text: "Step 2: synthesize",
1700
+ },
1701
+ ];
1702
+ const result = historyToUIMessages(items);
1703
+ expect(result).toEqual([
1704
+ {
1705
+ id: "msg-1",
1706
+ role: "assistant",
1707
+ parts: [
1708
+ { type: "text", text: "Response" },
1709
+ { type: "reasoning", text: "Step 1: analyze" },
1710
+ { type: "reasoning", text: "Step 2: synthesize" },
1711
+ ],
1712
+ },
1713
+ ]);
1714
+ });
1715
+ });
1716
+ describe("edge cases", () => {
1717
+ it("should handle empty history", () => {
1718
+ const items = [];
1719
+ const result = historyToUIMessages(items);
1720
+ expect(result).toEqual([]);
1721
+ });
1722
+ it("should handle message with empty content", () => {
1723
+ const items = [
1724
+ {
1725
+ kind: "message",
1726
+ id: "msg-1",
1727
+ role: "user",
1728
+ content: [],
1729
+ },
1730
+ ];
1731
+ const result = historyToUIMessages(items);
1732
+ expect(result).toEqual([
1733
+ {
1734
+ id: "msg-1",
1735
+ role: "user",
1736
+ parts: [],
1737
+ },
1738
+ ]);
1739
+ });
1740
+ it("should handle complex conversation with mixed content", () => {
1741
+ const items = [
1742
+ {
1743
+ kind: "message",
1744
+ id: "msg-1",
1745
+ role: "user",
1746
+ content: [
1747
+ { kind: "text", text: "Check this image and calculate" },
1748
+ {
1749
+ kind: "file",
1750
+ mimeType: "image/png",
1751
+ filename: "chart.png",
1752
+ uri: "https://example.com/chart.png",
1753
+ },
1754
+ ],
1755
+ },
1756
+ {
1757
+ kind: "message",
1758
+ id: "msg-2",
1759
+ role: "assistant",
1760
+ content: [{ kind: "text", text: "I'll analyze the image" }],
1761
+ },
1762
+ {
1763
+ kind: "reasoning",
1764
+ text: "The image shows numerical data",
1765
+ },
1766
+ {
1767
+ kind: "tool-call",
1768
+ callId: "call-1",
1769
+ toolId: "calculator",
1770
+ state: IN_PROGRESS,
1771
+ arguments: JSON.stringify({ operation: "sum", values: [10, 20, 30] }),
1772
+ },
1773
+ {
1774
+ kind: "tool-result",
1775
+ callId: "call-1",
1776
+ toolId: "calculator",
1777
+ state: COMPLETED,
1778
+ result: 60,
1779
+ error: null,
1780
+ },
1781
+ {
1782
+ kind: "message",
1783
+ id: "msg-3",
1784
+ role: "assistant",
1785
+ content: [
1786
+ { kind: "text", text: "The sum is" },
1787
+ { kind: "data", data: { total: 60 } },
1788
+ ],
1789
+ },
1790
+ ];
1791
+ const result = historyToUIMessages(items);
1792
+ expect(result).toEqual([
1793
+ {
1794
+ id: "msg-1",
1795
+ role: "user",
1796
+ parts: [
1797
+ { type: "text", text: "Check this image and calculate" },
1798
+ {
1799
+ type: "file",
1800
+ url: "https://example.com/chart.png",
1801
+ mediaType: "image/png",
1802
+ filename: "chart.png",
1803
+ },
1804
+ ],
1805
+ },
1806
+ {
1807
+ id: "msg-2",
1808
+ role: "assistant",
1809
+ parts: [
1810
+ { type: "text", text: "I'll analyze the image" },
1811
+ {
1812
+ type: "reasoning",
1813
+ text: "The image shows numerical data",
1814
+ },
1815
+ {
1816
+ type: "tool-calculator",
1817
+ toolCallId: "call-1",
1818
+ toolName: "calculator",
1819
+ input: { operation: "sum", values: [10, 20, 30] },
1820
+ state: "output-available",
1821
+ output: 60,
1822
+ },
1823
+ ],
1824
+ },
1825
+ {
1826
+ id: "msg-3",
1827
+ role: "assistant",
1828
+ parts: [
1829
+ { type: "text", text: "The sum is" },
1830
+ { type: "data-total", data: 60 },
1831
+ ],
1832
+ },
1833
+ ]);
1834
+ });
1835
+ });
1836
+ });