@ai-sdk/langchain 0.0.0-02dba89b-20251009204516 → 0.0.0-17394c74-20260122151521

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/src/utils.ts ADDED
@@ -0,0 +1,1587 @@
1
+ import {
2
+ AIMessage,
3
+ HumanMessage,
4
+ ToolMessage,
5
+ BaseMessage,
6
+ AIMessageChunk,
7
+ BaseMessageChunk,
8
+ ToolCallChunk,
9
+ type ContentBlock,
10
+ type ToolCall,
11
+ } from '@langchain/core/messages';
12
+ import {
13
+ type UIMessageChunk,
14
+ type ToolResultPart,
15
+ type AssistantContent,
16
+ type UserContent,
17
+ } from 'ai';
18
+
19
+ import {
20
+ type LangGraphEventState,
21
+ type ReasoningContentBlock,
22
+ type ThinkingContentBlock,
23
+ type GPT5ReasoningOutput,
24
+ type ImageGenerationOutput,
25
+ } from './types';
26
+
27
+ /**
28
+ * Converts a ToolResultPart to a LangChain ToolMessage
29
+ * @param block - The ToolResultPart to convert.
30
+ * @returns The converted ToolMessage.
31
+ */
32
+ export function convertToolResultPart(block: ToolResultPart): ToolMessage {
33
+ const content = (() => {
34
+ if (block.output.type === 'text' || block.output.type === 'error-text') {
35
+ return block.output.value;
36
+ }
37
+
38
+ if (block.output.type === 'json' || block.output.type === 'error-json') {
39
+ return JSON.stringify(block.output.value);
40
+ }
41
+
42
+ if (block.output.type === 'content') {
43
+ return block.output.value
44
+ .map(outputBlock => {
45
+ if (outputBlock.type === 'text') {
46
+ return outputBlock.text;
47
+ }
48
+ return '';
49
+ })
50
+ .join('');
51
+ }
52
+
53
+ return '';
54
+ })();
55
+
56
+ return new ToolMessage({
57
+ tool_call_id: block.toolCallId,
58
+ content,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Converts AssistantContent to LangChain AIMessage
64
+ * @param content - The AssistantContent to convert.
65
+ * @returns The converted AIMessage.
66
+ */
67
+ export function convertAssistantContent(content: AssistantContent): AIMessage {
68
+ if (typeof content === 'string') {
69
+ return new AIMessage({ content });
70
+ }
71
+
72
+ const textParts: string[] = [];
73
+ const toolCalls: Array<{
74
+ id: string;
75
+ name: string;
76
+ args: Record<string, unknown>;
77
+ }> = [];
78
+
79
+ for (const part of content) {
80
+ if (part.type === 'text') {
81
+ textParts.push(part.text);
82
+ } else if (part.type === 'tool-call') {
83
+ toolCalls.push({
84
+ id: part.toolCallId,
85
+ name: part.toolName,
86
+ args: part.input as Record<string, unknown>,
87
+ });
88
+ }
89
+ }
90
+
91
+ return new AIMessage({
92
+ content: textParts.join(''),
93
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Helper to generate a default filename from mediaType
99
+ */
100
+ function getDefaultFilename(
101
+ mediaType: string,
102
+ prefix: string = 'file',
103
+ ): string {
104
+ const ext = mediaType.split('/')[1] || 'bin';
105
+ return `${prefix}.${ext}`;
106
+ }
107
+
108
+ /**
109
+ * OpenAI-native content block type for images.
110
+ * This format is passed through directly by ChatOpenAI to OpenAI's API.
111
+ */
112
+ type OpenAIImageBlock = {
113
+ type: 'image_url';
114
+ image_url: {
115
+ url: string;
116
+ detail?: 'auto' | 'low' | 'high';
117
+ };
118
+ };
119
+
120
+ /**
121
+ * Content block type for HumanMessage that supports both text and OpenAI images.
122
+ */
123
+ type HumanMessageContentBlock =
124
+ | { type: 'text'; text: string }
125
+ | OpenAIImageBlock
126
+ | ContentBlock;
127
+
128
+ /**
129
+ * Converts UserContent to LangChain HumanMessage
130
+ * @param content - The UserContent to convert.
131
+ * @returns The converted HumanMessage.
132
+ */
133
+ export function convertUserContent(content: UserContent): HumanMessage {
134
+ if (typeof content === 'string') {
135
+ return new HumanMessage({ content });
136
+ }
137
+
138
+ const contentBlocks: HumanMessageContentBlock[] = [];
139
+
140
+ for (const part of content) {
141
+ if (part.type === 'text') {
142
+ contentBlocks.push({ type: 'text', text: part.text });
143
+ } else if (part.type === 'image') {
144
+ const imagePart = part as {
145
+ type: 'image';
146
+ image: string | Uint8Array | URL | ArrayBuffer;
147
+ mediaType?: string;
148
+ };
149
+
150
+ /**
151
+ * Use OpenAI's native image_url format which is passed through directly
152
+ * handle URL objects
153
+ */
154
+ if (imagePart.image instanceof URL) {
155
+ contentBlocks.push({
156
+ type: 'image_url',
157
+ image_url: { url: imagePart.image.toString() },
158
+ });
159
+ } else if (typeof imagePart.image === 'string') {
160
+ /**
161
+ * Handle string (could be URL or base64)
162
+ */
163
+ /**
164
+ * Check if it's a URL (including data: URLs)
165
+ */
166
+ if (
167
+ imagePart.image.startsWith('http://') ||
168
+ imagePart.image.startsWith('https://') ||
169
+ imagePart.image.startsWith('data:')
170
+ ) {
171
+ /**
172
+ * OpenAI accepts both http URLs and data URLs directly
173
+ */
174
+ contentBlocks.push({
175
+ type: 'image_url',
176
+ image_url: { url: imagePart.image },
177
+ });
178
+ } else {
179
+ /**
180
+ * Assume base64 encoded data - wrap in data URL
181
+ */
182
+ const mimeType = imagePart.mediaType || 'image/png';
183
+ contentBlocks.push({
184
+ type: 'image_url',
185
+ image_url: { url: `data:${mimeType};base64,${imagePart.image}` },
186
+ });
187
+ }
188
+ } else if (
189
+ /**
190
+ * Handle Uint8Array or ArrayBuffer (binary data)
191
+ */
192
+ imagePart.image instanceof Uint8Array ||
193
+ imagePart.image instanceof ArrayBuffer
194
+ ) {
195
+ const bytes =
196
+ imagePart.image instanceof ArrayBuffer
197
+ ? new Uint8Array(imagePart.image)
198
+ : imagePart.image;
199
+ /**
200
+ * Convert to base64 data URL
201
+ */
202
+ const base64 = btoa(String.fromCharCode(...bytes));
203
+ const mimeType = imagePart.mediaType || 'image/png';
204
+ contentBlocks.push({
205
+ type: 'image_url',
206
+ image_url: { url: `data:${mimeType};base64,${base64}` },
207
+ });
208
+ }
209
+ } else if (part.type === 'file') {
210
+ const filePart = part as {
211
+ type: 'file';
212
+ data: string | Uint8Array | URL | ArrayBuffer;
213
+ mediaType: string;
214
+ filename?: string;
215
+ };
216
+
217
+ /**
218
+ * Check if this is an image file - if so, use OpenAI's image_url format
219
+ */
220
+ const isImage = filePart.mediaType?.startsWith('image/');
221
+
222
+ if (isImage) {
223
+ /**
224
+ * Handle image files using OpenAI's native image_url format
225
+ */
226
+ if (filePart.data instanceof URL) {
227
+ contentBlocks.push({
228
+ type: 'image_url',
229
+ image_url: { url: filePart.data.toString() },
230
+ });
231
+ } else if (typeof filePart.data === 'string') {
232
+ /**
233
+ * URLs (including data URLs) can be passed directly
234
+ */
235
+ if (
236
+ filePart.data.startsWith('http://') ||
237
+ filePart.data.startsWith('https://') ||
238
+ filePart.data.startsWith('data:')
239
+ ) {
240
+ contentBlocks.push({
241
+ type: 'image_url',
242
+ image_url: { url: filePart.data },
243
+ });
244
+ } else {
245
+ /**
246
+ * Assume base64 - wrap in data URL
247
+ */
248
+ contentBlocks.push({
249
+ type: 'image_url',
250
+ image_url: {
251
+ url: `data:${filePart.mediaType};base64,${filePart.data}`,
252
+ },
253
+ });
254
+ }
255
+ } else if (
256
+ filePart.data instanceof Uint8Array ||
257
+ filePart.data instanceof ArrayBuffer
258
+ ) {
259
+ const bytes =
260
+ filePart.data instanceof ArrayBuffer
261
+ ? new Uint8Array(filePart.data)
262
+ : filePart.data;
263
+ const base64 = btoa(String.fromCharCode(...bytes));
264
+ contentBlocks.push({
265
+ type: 'image_url',
266
+ image_url: { url: `data:${filePart.mediaType};base64,${base64}` },
267
+ });
268
+ }
269
+ } else {
270
+ // Handle non-image files using LangChain's ContentBlock format
271
+ const filename =
272
+ filePart.filename || getDefaultFilename(filePart.mediaType, 'file');
273
+
274
+ if (filePart.data instanceof URL) {
275
+ contentBlocks.push({
276
+ type: 'file',
277
+ url: filePart.data.toString(),
278
+ mimeType: filePart.mediaType,
279
+ filename,
280
+ });
281
+ } else if (typeof filePart.data === 'string') {
282
+ if (
283
+ filePart.data.startsWith('http://') ||
284
+ filePart.data.startsWith('https://')
285
+ ) {
286
+ contentBlocks.push({
287
+ type: 'file',
288
+ url: filePart.data,
289
+ mimeType: filePart.mediaType,
290
+ filename,
291
+ });
292
+ } else if (filePart.data.startsWith('data:')) {
293
+ const matches = filePart.data.match(/^data:([^;]+);base64,(.+)$/);
294
+ if (matches) {
295
+ contentBlocks.push({
296
+ type: 'file',
297
+ data: matches[2],
298
+ mimeType: matches[1],
299
+ filename,
300
+ });
301
+ } else {
302
+ contentBlocks.push({
303
+ type: 'file',
304
+ url: filePart.data,
305
+ mimeType: filePart.mediaType,
306
+ filename,
307
+ });
308
+ }
309
+ } else {
310
+ contentBlocks.push({
311
+ type: 'file',
312
+ data: filePart.data,
313
+ mimeType: filePart.mediaType,
314
+ filename,
315
+ });
316
+ }
317
+ } else if (
318
+ filePart.data instanceof Uint8Array ||
319
+ filePart.data instanceof ArrayBuffer
320
+ ) {
321
+ const bytes =
322
+ filePart.data instanceof ArrayBuffer
323
+ ? new Uint8Array(filePart.data)
324
+ : filePart.data;
325
+ const base64 = btoa(String.fromCharCode(...bytes));
326
+ contentBlocks.push({
327
+ type: 'file',
328
+ data: base64,
329
+ mimeType: filePart.mediaType,
330
+ filename,
331
+ });
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * If we only have text parts, join them as a simple string for efficiency
339
+ */
340
+ if (contentBlocks.every(block => block.type === 'text')) {
341
+ return new HumanMessage({
342
+ content: contentBlocks
343
+ .map(block => (block as unknown as { text: string }).text)
344
+ .join(''),
345
+ });
346
+ }
347
+
348
+ return new HumanMessage({ content: contentBlocks });
349
+ }
350
+
351
+ /**
352
+ * Helper to check if a content item is a ToolResultPart
353
+ * @param item - The item to check.
354
+ * @returns True if the item is a ToolResultPart, false otherwise.
355
+ */
356
+ export function isToolResultPart(item: unknown): item is ToolResultPart {
357
+ return (
358
+ item != null &&
359
+ typeof item === 'object' &&
360
+ 'type' in item &&
361
+ (item as { type: string }).type === 'tool-result'
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Processes a model stream chunk and emits UI message chunks.
367
+ * @param chunk - The AIMessageChunk to process.
368
+ * @param state - The state of the model stream.
369
+ * @param controller - The controller to use to emit the UI message chunks.
370
+ */
371
+ export function processModelChunk(
372
+ chunk: AIMessageChunk,
373
+ state: {
374
+ started: boolean;
375
+ messageId: string;
376
+ reasoningStarted?: boolean;
377
+ textStarted?: boolean;
378
+ /** Track the ID used for reasoning-start to ensure reasoning-end uses the same ID */
379
+ reasoningMessageId?: string | null;
380
+ /** Track the ID used for text-start to ensure text-end uses the same ID */
381
+ textMessageId?: string | null;
382
+ emittedImages?: Set<string>;
383
+ },
384
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
385
+ ): void {
386
+ /**
387
+ * Initialize emittedImages set if not present
388
+ */
389
+ if (!state.emittedImages) {
390
+ state.emittedImages = new Set<string>();
391
+ }
392
+
393
+ /**
394
+ * Get the message ID from the chunk if available
395
+ */
396
+ if (chunk.id) {
397
+ state.messageId = chunk.id;
398
+ }
399
+
400
+ /**
401
+ * Handle image generation outputs from additional_kwargs.tool_outputs
402
+ */
403
+ const chunkObj = chunk as unknown as Record<string, unknown>;
404
+ const additionalKwargs = chunkObj.additional_kwargs as
405
+ | Record<string, unknown>
406
+ | undefined;
407
+ const imageOutputs = extractImageOutputs(additionalKwargs);
408
+
409
+ for (const imageOutput of imageOutputs) {
410
+ /**
411
+ * Only emit if we have image data and haven't emitted this image yet
412
+ */
413
+ if (imageOutput.result && !state.emittedImages.has(imageOutput.id)) {
414
+ state.emittedImages.add(imageOutput.id);
415
+
416
+ /**
417
+ * Emit as a file part using proper AI SDK multimodal format
418
+ */
419
+ const mediaType = `image/${imageOutput.output_format || 'png'}`;
420
+ controller.enqueue({
421
+ type: 'file',
422
+ mediaType,
423
+ url: `data:${mediaType};base64,${imageOutput.result}`,
424
+ });
425
+ state.started = true;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Handle reasoning content from contentBlocks or response_metadata.output
431
+ * For direct model streams, we check both sources since there's no values event
432
+ * that would cause duplication (unlike LangGraph streams)
433
+ */
434
+ const reasoning =
435
+ extractReasoningFromContentBlocks(chunk) ||
436
+ extractReasoningFromValuesMessage(chunk);
437
+ if (reasoning) {
438
+ if (!state.reasoningStarted) {
439
+ // Track the ID used for reasoning-start to ensure subsequent chunks use the same ID
440
+ state.reasoningMessageId = state.messageId;
441
+ controller.enqueue({ type: 'reasoning-start', id: state.messageId });
442
+ state.reasoningStarted = true;
443
+ state.started = true;
444
+ }
445
+ controller.enqueue({
446
+ type: 'reasoning-delta',
447
+ delta: reasoning,
448
+ id: state.reasoningMessageId ?? state.messageId,
449
+ });
450
+ }
451
+
452
+ /**
453
+ * Extract text content from AIMessageChunk
454
+ */
455
+ const text =
456
+ typeof chunk.content === 'string'
457
+ ? chunk.content
458
+ : Array.isArray(chunk.content)
459
+ ? chunk.content
460
+ .filter(
461
+ (c): c is { type: 'text'; text: string } =>
462
+ typeof c === 'object' &&
463
+ c !== null &&
464
+ 'type' in c &&
465
+ c.type === 'text',
466
+ )
467
+ .map(c => c.text)
468
+ .join('')
469
+ : '';
470
+
471
+ if (text) {
472
+ /**
473
+ * If reasoning was streamed before text, close reasoning first
474
+ */
475
+ if (state.reasoningStarted && !state.textStarted) {
476
+ controller.enqueue({
477
+ type: 'reasoning-end',
478
+ id: state.reasoningMessageId ?? state.messageId,
479
+ });
480
+ state.reasoningStarted = false;
481
+ }
482
+
483
+ if (!state.textStarted) {
484
+ // Track the ID used for text-start to ensure subsequent chunks use the same ID
485
+ state.textMessageId = state.messageId;
486
+ controller.enqueue({ type: 'text-start', id: state.messageId });
487
+ state.textStarted = true;
488
+ state.started = true;
489
+ }
490
+ controller.enqueue({
491
+ type: 'text-delta',
492
+ delta: text,
493
+ id: state.textMessageId ?? state.messageId,
494
+ });
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Checks if a message is a plain object (not a LangChain class instance).
500
+ * LangChain class instances have a _getType method.
501
+ *
502
+ * @param msg - The message to check.
503
+ * @returns True if the message is a plain object, false otherwise.
504
+ */
505
+ export function isPlainMessageObject(msg: unknown): boolean {
506
+ if (msg == null || typeof msg !== 'object') return false;
507
+ /**
508
+ * LangChain class instances have _getType method
509
+ */
510
+ return typeof (msg as { _getType?: unknown })._getType !== 'function';
511
+ }
512
+
513
+ /**
514
+ * Extracts the actual message ID from a message.
515
+ * Handles both class instances (msg.id) and serialized LangChain messages (msg.kwargs.id).
516
+ *
517
+ * @param msg - The message to extract the ID from.
518
+ * @returns The message ID string, or undefined if not found.
519
+ */
520
+ export function getMessageId(msg: unknown): string | undefined {
521
+ if (msg == null || typeof msg !== 'object') return undefined;
522
+
523
+ const msgObj = msg as Record<string, unknown>;
524
+
525
+ /**
526
+ * For class instances, id is directly on the object
527
+ */
528
+ if (typeof msgObj.id === 'string') {
529
+ return msgObj.id;
530
+ }
531
+
532
+ /**
533
+ * For serialized LangChain messages, id is in kwargs
534
+ */
535
+ if (
536
+ msgObj.type === 'constructor' &&
537
+ msgObj.kwargs &&
538
+ typeof msgObj.kwargs === 'object'
539
+ ) {
540
+ const kwargs = msgObj.kwargs as Record<string, unknown>;
541
+ if (typeof kwargs.id === 'string') {
542
+ return kwargs.id;
543
+ }
544
+ }
545
+
546
+ return undefined;
547
+ }
548
+
549
+ /**
550
+ * Checks if a message is an AI message chunk (works for both class instances and plain objects).
551
+ * For class instances, only AIMessageChunk is matched (not AIMessage).
552
+ * For plain objects from RemoteGraph API, matches type === 'ai'.
553
+ * For serialized LangChain messages, matches type === 'constructor' with AIMessageChunk in id path.
554
+ *
555
+ * @param msg - The message to check.
556
+ * @returns True if the message is an AI message chunk, false otherwise.
557
+ */
558
+ export function isAIMessageChunk(
559
+ msg: unknown,
560
+ ): msg is AIMessageChunk & { type?: string; content?: string } {
561
+ /**
562
+ * Actual AIMessageChunk class instance
563
+ */
564
+ if (AIMessageChunk.isInstance(msg)) return true;
565
+ /**
566
+ * Plain object from RemoteGraph API (not a LangChain class instance)
567
+ */
568
+ if (isPlainMessageObject(msg)) {
569
+ const obj = msg as Record<string, unknown>;
570
+ /**
571
+ * Direct type === 'ai' (RemoteGraph format)
572
+ */
573
+ if ('type' in obj && obj.type === 'ai') return true;
574
+ /**
575
+ * Serialized LangChain message format: { lc: 1, type: "constructor", id: ["...", "AIMessageChunk"], kwargs: {...} }
576
+ */
577
+ if (
578
+ obj.type === 'constructor' &&
579
+ Array.isArray(obj.id) &&
580
+ (obj.id.includes('AIMessageChunk') || obj.id.includes('AIMessage'))
581
+ ) {
582
+ return true;
583
+ }
584
+ }
585
+ return false;
586
+ }
587
+
588
+ /**
589
+ * Checks if a message is a Tool message (works for both class instances and plain objects).
590
+ *
591
+ * @param msg - The message to check.
592
+ * @returns True if the message is a Tool message, false otherwise.
593
+ */
594
+ export function isToolMessageType(
595
+ msg: unknown,
596
+ ): msg is ToolMessage & { type?: string; tool_call_id?: string } {
597
+ if (ToolMessage.isInstance(msg)) return true;
598
+ /**
599
+ * Plain object from RemoteGraph API (not a LangChain class instance)
600
+ */
601
+ if (isPlainMessageObject(msg)) {
602
+ const obj = msg as Record<string, unknown>;
603
+ /**
604
+ * Direct type === 'tool' (RemoteGraph format)
605
+ */
606
+ if ('type' in obj && obj.type === 'tool') return true;
607
+ /**
608
+ * Serialized LangChain message format
609
+ */
610
+ if (
611
+ obj.type === 'constructor' &&
612
+ Array.isArray(obj.id) &&
613
+ obj.id.includes('ToolMessage')
614
+ ) {
615
+ return true;
616
+ }
617
+ }
618
+ return false;
619
+ }
620
+
621
+ /**
622
+ * Gets text content from a message (works for both class instances and plain objects).
623
+ *
624
+ * @param msg - The message to get the text from.
625
+ * @returns The text content of the message.
626
+ */
627
+ export function getMessageText(msg: unknown): string {
628
+ if (AIMessageChunk.isInstance(msg)) {
629
+ return msg.text ?? '';
630
+ }
631
+
632
+ if (msg == null || typeof msg !== 'object') return '';
633
+
634
+ const msgObj = msg as Record<string, unknown>;
635
+
636
+ // For serialized LangChain messages, content is in kwargs
637
+ const dataSource =
638
+ msgObj.type === 'constructor' &&
639
+ msgObj.kwargs &&
640
+ typeof msgObj.kwargs === 'object'
641
+ ? (msgObj.kwargs as Record<string, unknown>)
642
+ : msgObj;
643
+
644
+ if ('content' in dataSource) {
645
+ const content = dataSource.content;
646
+ /**
647
+ * Handle string content
648
+ */
649
+ if (typeof content === 'string') {
650
+ return content;
651
+ }
652
+ /**
653
+ * Handle array of content blocks (e.g., [{ type: 'text', text: 'The', index: 0 }])
654
+ */
655
+ if (Array.isArray(content)) {
656
+ return content
657
+ .filter(
658
+ (block): block is { type: 'text'; text: string } =>
659
+ block != null &&
660
+ typeof block === 'object' &&
661
+ block.type === 'text' &&
662
+ typeof block.text === 'string',
663
+ )
664
+ .map(block => block.text)
665
+ .join('');
666
+ }
667
+ return '';
668
+ }
669
+ return '';
670
+ }
671
+
672
+ /**
673
+ * Checks if an object is a reasoning content block
674
+ *
675
+ * @param obj - The object to check.
676
+ * @returns True if the object is a reasoning content block, false otherwise.
677
+ */
678
+ export function isReasoningContentBlock(
679
+ obj: unknown,
680
+ ): obj is ReasoningContentBlock {
681
+ return (
682
+ obj != null &&
683
+ typeof obj === 'object' &&
684
+ 'type' in obj &&
685
+ (obj as { type: string }).type === 'reasoning' &&
686
+ 'reasoning' in obj &&
687
+ typeof (obj as { reasoning: unknown }).reasoning === 'string'
688
+ );
689
+ }
690
+
691
+ /**
692
+ * Checks if an object is a thinking content block (Anthropic-style)
693
+ *
694
+ * @param obj - The object to check.
695
+ * @returns True if the object is a thinking content block, false otherwise.
696
+ */
697
+ export function isThinkingContentBlock(
698
+ obj: unknown,
699
+ ): obj is ThinkingContentBlock {
700
+ return (
701
+ obj != null &&
702
+ typeof obj === 'object' &&
703
+ 'type' in obj &&
704
+ (obj as { type: string }).type === 'thinking' &&
705
+ 'thinking' in obj &&
706
+ typeof (obj as { thinking: unknown }).thinking === 'string'
707
+ );
708
+ }
709
+
710
+ /**
711
+ * Checks if an object is a GPT-5 reasoning output block
712
+ */
713
+ function isGPT5ReasoningOutput(obj: unknown): obj is GPT5ReasoningOutput {
714
+ return (
715
+ obj != null &&
716
+ typeof obj === 'object' &&
717
+ 'type' in obj &&
718
+ (obj as { type: string }).type === 'reasoning' &&
719
+ 'summary' in obj &&
720
+ Array.isArray((obj as { summary: unknown }).summary)
721
+ );
722
+ }
723
+
724
+ /**
725
+ * Extracts the reasoning block ID from a message (GPT-5 format).
726
+ * This ID is consistent across streaming and values events.
727
+ * Handles both class instances and serialized LangChain message objects.
728
+ *
729
+ * @param msg - The message to extract the reasoning ID from.
730
+ * @returns The reasoning block ID if found, undefined otherwise.
731
+ */
732
+ export function extractReasoningId(msg: unknown): string | undefined {
733
+ if (msg == null || typeof msg !== 'object') return undefined;
734
+
735
+ // For serialized LangChain messages, the data is in kwargs
736
+ const msgObj = msg as Record<string, unknown>;
737
+ const kwargs =
738
+ msgObj.kwargs && typeof msgObj.kwargs === 'object'
739
+ ? (msgObj.kwargs as Record<string, unknown>)
740
+ : msgObj;
741
+
742
+ // Check additional_kwargs.reasoning.id (GPT-5 streaming format)
743
+ const additionalKwargs = (
744
+ kwargs as { additional_kwargs?: { reasoning?: { id?: string } } }
745
+ ).additional_kwargs;
746
+ if (additionalKwargs?.reasoning?.id) {
747
+ return additionalKwargs.reasoning.id;
748
+ }
749
+
750
+ // Check response_metadata.output for reasoning block ID (GPT-5 final format)
751
+ const responseMetadata = (
752
+ kwargs as { response_metadata?: { output?: unknown[] } }
753
+ ).response_metadata;
754
+ if (responseMetadata && Array.isArray(responseMetadata.output)) {
755
+ for (const item of responseMetadata.output) {
756
+ if (isGPT5ReasoningOutput(item)) {
757
+ return item.id;
758
+ }
759
+ }
760
+ }
761
+
762
+ return undefined;
763
+ }
764
+
765
+ /**
766
+ * Extracts reasoning content from contentBlocks or additional_kwargs.reasoning.summary
767
+ *
768
+ * IMPORTANT: This function is designed for STREAMING chunks where content is delta-based.
769
+ * It does NOT extract from response_metadata.output because that contains accumulated
770
+ * content (not deltas) and would cause duplication during streaming.
771
+ *
772
+ * For non-streaming/values events, use extractReasoningFromValuesMessage instead.
773
+ *
774
+ * Handles both class instances and serialized LangChain message objects.
775
+ *
776
+ * @param msg - The message to extract reasoning from.
777
+ * @returns The reasoning text if found, undefined otherwise.
778
+ */
779
+ export function extractReasoningFromContentBlocks(
780
+ msg: unknown,
781
+ ): string | undefined {
782
+ if (msg == null || typeof msg !== 'object') return undefined;
783
+
784
+ // For serialized LangChain messages, the data is in kwargs
785
+ const msgObj = msg as Record<string, unknown>;
786
+ const kwargs =
787
+ msgObj.kwargs && typeof msgObj.kwargs === 'object'
788
+ ? (msgObj.kwargs as Record<string, unknown>)
789
+ : msgObj;
790
+
791
+ // Check contentBlocks (Anthropic-style) - highest priority
792
+ const contentBlocks = (kwargs as { contentBlocks?: unknown[] }).contentBlocks;
793
+ if (Array.isArray(contentBlocks)) {
794
+ const reasoningParts: string[] = [];
795
+ for (const block of contentBlocks) {
796
+ if (isReasoningContentBlock(block)) {
797
+ reasoningParts.push(block.reasoning);
798
+ } else if (isThinkingContentBlock(block)) {
799
+ reasoningParts.push(block.thinking);
800
+ }
801
+ }
802
+ if (reasoningParts.length > 0) {
803
+ return reasoningParts.join('');
804
+ }
805
+ }
806
+
807
+ // Check additional_kwargs.reasoning.summary (GPT-5 streaming format)
808
+ // This contains DELTA content during streaming, not accumulated content
809
+ // Format can be either { type: "summary_text", text: "..." } or just { text: "..." }
810
+ const additionalKwargs = (
811
+ kwargs as { additional_kwargs?: { reasoning?: { summary?: unknown[] } } }
812
+ ).additional_kwargs;
813
+ if (
814
+ additionalKwargs?.reasoning &&
815
+ Array.isArray(additionalKwargs.reasoning.summary)
816
+ ) {
817
+ const reasoningParts: string[] = [];
818
+ for (const summaryItem of additionalKwargs.reasoning.summary) {
819
+ if (
820
+ typeof summaryItem === 'object' &&
821
+ summaryItem !== null &&
822
+ 'text' in summaryItem &&
823
+ typeof (summaryItem as { text: unknown }).text === 'string'
824
+ ) {
825
+ reasoningParts.push((summaryItem as { text: string }).text);
826
+ }
827
+ }
828
+ if (reasoningParts.length > 0) {
829
+ return reasoningParts.join('');
830
+ }
831
+ }
832
+
833
+ return undefined;
834
+ }
835
+
836
+ /**
837
+ * Extracts reasoning content from a values event message.
838
+ * This checks response_metadata.output which contains the full accumulated reasoning.
839
+ *
840
+ * @param msg - The message to extract reasoning from.
841
+ * @returns The reasoning text if found, undefined otherwise.
842
+ */
843
+ export function extractReasoningFromValuesMessage(
844
+ msg: unknown,
845
+ ): string | undefined {
846
+ if (msg == null || typeof msg !== 'object') return undefined;
847
+
848
+ // For serialized LangChain messages, the data is in kwargs
849
+ const msgObj = msg as Record<string, unknown>;
850
+ const kwargs =
851
+ msgObj.kwargs && typeof msgObj.kwargs === 'object'
852
+ ? (msgObj.kwargs as Record<string, unknown>)
853
+ : msgObj;
854
+
855
+ // Check response_metadata.output (GPT-5 final style) - for values events
856
+ const responseMetadata = (
857
+ kwargs as { response_metadata?: { output?: unknown[] } }
858
+ ).response_metadata;
859
+ if (responseMetadata && Array.isArray(responseMetadata.output)) {
860
+ const reasoningParts: string[] = [];
861
+ for (const item of responseMetadata.output) {
862
+ if (isGPT5ReasoningOutput(item)) {
863
+ // Extract text from summary array - handles both { type: "summary_text", text } and { text } formats
864
+ for (const summaryItem of item.summary) {
865
+ if (typeof summaryItem === 'object' && summaryItem !== null) {
866
+ const text = (summaryItem as { text?: string }).text;
867
+ if (typeof text === 'string' && text) {
868
+ reasoningParts.push(text);
869
+ }
870
+ }
871
+ }
872
+ }
873
+ }
874
+ if (reasoningParts.length > 0) {
875
+ return reasoningParts.join('');
876
+ }
877
+ }
878
+
879
+ // Also check additional_kwargs.reasoning.summary as fallback
880
+ const additionalKwargs = (
881
+ kwargs as { additional_kwargs?: { reasoning?: { summary?: unknown[] } } }
882
+ ).additional_kwargs;
883
+ if (
884
+ additionalKwargs?.reasoning &&
885
+ Array.isArray(additionalKwargs.reasoning.summary)
886
+ ) {
887
+ const reasoningParts: string[] = [];
888
+ for (const summaryItem of additionalKwargs.reasoning.summary) {
889
+ if (
890
+ typeof summaryItem === 'object' &&
891
+ summaryItem !== null &&
892
+ 'text' in summaryItem &&
893
+ typeof (summaryItem as { text: unknown }).text === 'string'
894
+ ) {
895
+ reasoningParts.push((summaryItem as { text: string }).text);
896
+ }
897
+ }
898
+ if (reasoningParts.length > 0) {
899
+ return reasoningParts.join('');
900
+ }
901
+ }
902
+
903
+ return undefined;
904
+ }
905
+
906
+ /**
907
+ * Checks if an object is an image generation output
908
+ *
909
+ * @param obj - The object to check.
910
+ * @returns True if the object is an image generation output, false otherwise.
911
+ */
912
+ export function isImageGenerationOutput(
913
+ obj: unknown,
914
+ ): obj is ImageGenerationOutput {
915
+ return (
916
+ obj != null &&
917
+ typeof obj === 'object' &&
918
+ 'type' in obj &&
919
+ (obj as { type: string }).type === 'image_generation_call'
920
+ );
921
+ }
922
+
923
+ /**
924
+ * Extracts image generation outputs from `additional_kwargs`
925
+ *
926
+ * @param additionalKwargs - The additional kwargs to extract the image generation outputs from.
927
+ * @returns The image generation outputs.
928
+ */
929
+ export function extractImageOutputs(
930
+ additionalKwargs: Record<string, unknown> | undefined,
931
+ ): ImageGenerationOutput[] {
932
+ if (!additionalKwargs) return [];
933
+
934
+ const toolOutputs = additionalKwargs.tool_outputs;
935
+ if (!Array.isArray(toolOutputs)) return [];
936
+
937
+ return toolOutputs.filter(isImageGenerationOutput);
938
+ }
939
+
940
+ /**
941
+ * Processes a LangGraph event and emits UI message chunks.
942
+ *
943
+ * @param event - The event to process.
944
+ * @param state - The state of the LangGraph event.
945
+ * @param controller - The controller to use to emit the UI message chunks.
946
+ */
947
+ export function processLangGraphEvent(
948
+ event: unknown[],
949
+ state: LangGraphEventState,
950
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
951
+ ): void {
952
+ const {
953
+ messageSeen,
954
+ messageConcat,
955
+ emittedToolCalls,
956
+ emittedImages,
957
+ emittedReasoningIds,
958
+ messageReasoningIds,
959
+ toolCallInfoByIndex,
960
+ emittedToolCallsByKey,
961
+ } = state;
962
+ const [type, data] = event.length === 3 ? event.slice(1) : event;
963
+
964
+ switch (type) {
965
+ case 'custom': {
966
+ /**
967
+ * Extract custom event type from the data's 'type' field if present.
968
+ * This allows users to emit custom events like:
969
+ * writer({ type: 'progress', value: 50 }) -> { type: 'data-progress', data: {...} }
970
+ * writer({ type: 'status', message: '...' }) -> { type: 'data-status', data: {...} }
971
+ * writer({ key: 'value' }) -> { type: 'data-custom', data: {...} } (fallback)
972
+ *
973
+ * The 'id' field can be used to make parts persistent and updateable.
974
+ * Parts with an 'id' are NOT transient (added to message.parts).
975
+ * Parts without an 'id' are transient (only passed to onData callback).
976
+ */
977
+ let customTypeName = 'custom';
978
+ let partId: string | undefined;
979
+
980
+ if (data != null && typeof data === 'object' && !Array.isArray(data)) {
981
+ const dataObj = data as Record<string, unknown>;
982
+ if (typeof dataObj.type === 'string' && dataObj.type) {
983
+ customTypeName = dataObj.type;
984
+ }
985
+ if (typeof dataObj.id === 'string' && dataObj.id) {
986
+ partId = dataObj.id;
987
+ }
988
+ }
989
+
990
+ controller.enqueue({
991
+ type: `data-${customTypeName}` as `data-${string}`,
992
+ id: partId,
993
+ transient: partId == null,
994
+ data,
995
+ });
996
+ break;
997
+ }
998
+
999
+ case 'messages': {
1000
+ const [rawMsg, metadata] = data as [
1001
+ BaseMessageChunk | BaseMessage | undefined,
1002
+ Record<string, unknown> | undefined,
1003
+ ];
1004
+
1005
+ const msg = rawMsg;
1006
+ const msgId = getMessageId(msg);
1007
+
1008
+ if (!msgId) return;
1009
+
1010
+ /**
1011
+ * Track LangGraph step changes and emit start-step/finish-step events
1012
+ */
1013
+ const langgraphStep =
1014
+ typeof metadata?.langgraph_step === 'number'
1015
+ ? metadata.langgraph_step
1016
+ : null;
1017
+ if (langgraphStep !== null && langgraphStep !== state.currentStep) {
1018
+ if (state.currentStep !== null) {
1019
+ controller.enqueue({ type: 'finish-step' });
1020
+ }
1021
+ controller.enqueue({ type: 'start-step' });
1022
+ state.currentStep = langgraphStep;
1023
+ }
1024
+
1025
+ /**
1026
+ * Accumulate message chunks for later reference
1027
+ * Note: Only works for actual class instances, not serialized messages
1028
+ */
1029
+ if (AIMessageChunk.isInstance(msg)) {
1030
+ if (messageConcat[msgId]) {
1031
+ messageConcat[msgId] = messageConcat[msgId].concat(
1032
+ msg,
1033
+ ) as AIMessageChunk;
1034
+ } else {
1035
+ messageConcat[msgId] = msg;
1036
+ }
1037
+ }
1038
+
1039
+ if (isAIMessageChunk(msg)) {
1040
+ const concatChunk = messageConcat[msgId];
1041
+
1042
+ /**
1043
+ * Handle image generation outputs from additional_kwargs.tool_outputs
1044
+ * Handle both direct properties and serialized messages (kwargs)
1045
+ */
1046
+ const msgObj = msg as unknown as Record<string, unknown>;
1047
+ const dataSource =
1048
+ msgObj.type === 'constructor' &&
1049
+ msgObj.kwargs &&
1050
+ typeof msgObj.kwargs === 'object'
1051
+ ? (msgObj.kwargs as Record<string, unknown>)
1052
+ : msgObj;
1053
+ const additionalKwargs = dataSource.additional_kwargs as
1054
+ | Record<string, unknown>
1055
+ | undefined;
1056
+ const imageOutputs = extractImageOutputs(additionalKwargs);
1057
+
1058
+ for (const imageOutput of imageOutputs) {
1059
+ /**
1060
+ * Only emit if we have image data and haven't emitted this image yet
1061
+ */
1062
+ if (imageOutput.result && !emittedImages.has(imageOutput.id)) {
1063
+ emittedImages.add(imageOutput.id);
1064
+
1065
+ /**
1066
+ * Emit as a file part using proper AI SDK multimodal format
1067
+ */
1068
+ const mediaType = `image/${imageOutput.output_format || 'png'}`;
1069
+ controller.enqueue({
1070
+ type: 'file',
1071
+ mediaType,
1072
+ url: `data:${mediaType};base64,${imageOutput.result}`,
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Handle tool call chunks for streaming tool calls
1079
+ * Access from dataSource to handle both direct and serialized messages
1080
+ *
1081
+ * Tool call chunks are streamed as follows:
1082
+ * 1. First chunk: has name, id, but often empty args
1083
+ * 2. Subsequent chunks: have args but NO id or name
1084
+ *
1085
+ * We store tool call info by index when we first see it, then look it up
1086
+ * for subsequent chunks that don't include the id.
1087
+ */
1088
+ const toolCallChunks = dataSource.tool_call_chunks as
1089
+ | ToolCallChunk[]
1090
+ | undefined;
1091
+ if (toolCallChunks?.length) {
1092
+ for (const toolCallChunk of toolCallChunks) {
1093
+ const idx = toolCallChunk.index ?? 0;
1094
+
1095
+ /**
1096
+ * If this chunk has an id, store it for future lookups by index
1097
+ */
1098
+ if (toolCallChunk.id) {
1099
+ toolCallInfoByIndex[msgId] ??= {};
1100
+ toolCallInfoByIndex[msgId][idx] = {
1101
+ id: toolCallChunk.id,
1102
+ name:
1103
+ toolCallChunk.name ||
1104
+ concatChunk?.tool_call_chunks?.[idx]?.name ||
1105
+ 'unknown',
1106
+ };
1107
+ }
1108
+
1109
+ /**
1110
+ * Get the tool call ID from the chunk, stored info, or accumulated chunks
1111
+ */
1112
+ const toolCallId =
1113
+ toolCallChunk.id ||
1114
+ toolCallInfoByIndex[msgId]?.[idx]?.id ||
1115
+ concatChunk?.tool_call_chunks?.[idx]?.id;
1116
+
1117
+ /**
1118
+ * Skip if we don't have a proper tool call ID - we'll handle it in values
1119
+ */
1120
+ if (!toolCallId) {
1121
+ continue;
1122
+ }
1123
+
1124
+ const toolName =
1125
+ toolCallChunk.name ||
1126
+ toolCallInfoByIndex[msgId]?.[idx]?.name ||
1127
+ concatChunk?.tool_call_chunks?.[idx]?.name ||
1128
+ 'unknown';
1129
+
1130
+ /**
1131
+ * Emit tool-input-start when we first see this tool call
1132
+ * (even if args is empty - the first chunk often has empty args)
1133
+ * Set dynamic: true to enable HITL approval requests
1134
+ */
1135
+ if (!messageSeen[msgId]?.tool?.[toolCallId]) {
1136
+ controller.enqueue({
1137
+ type: 'tool-input-start',
1138
+ toolCallId: toolCallId,
1139
+ toolName: toolName,
1140
+ dynamic: true,
1141
+ });
1142
+
1143
+ messageSeen[msgId] ??= {};
1144
+ messageSeen[msgId].tool ??= {};
1145
+ messageSeen[msgId].tool![toolCallId] = true;
1146
+ emittedToolCalls.add(toolCallId);
1147
+ }
1148
+
1149
+ /**
1150
+ * Only emit tool-input-delta when args is non-empty
1151
+ */
1152
+ if (toolCallChunk.args) {
1153
+ controller.enqueue({
1154
+ type: 'tool-input-delta',
1155
+ toolCallId: toolCallId,
1156
+ inputTextDelta: toolCallChunk.args,
1157
+ });
1158
+ }
1159
+ }
1160
+
1161
+ return;
1162
+ }
1163
+
1164
+ /**
1165
+ * Handle reasoning content from contentBlocks
1166
+ * Streaming chunks contain DELTA text (not accumulated), so emit directly.
1167
+ * Use reasoning block ID for deduplication as it's consistent across streaming and values events.
1168
+ *
1169
+ * Important: Early chunks may have reasoning ID but no content, later chunks may
1170
+ * have content but no reasoning ID. We capture the ID when first seen and reuse it.
1171
+ * We also immediately add to emittedReasoningIds to prevent values events from
1172
+ * emitting the same reasoning (values events can arrive between streaming chunks).
1173
+ */
1174
+ // Capture reasoning ID when we first see it (even if no content yet)
1175
+ const chunkReasoningId = extractReasoningId(msg);
1176
+ if (chunkReasoningId) {
1177
+ if (!messageReasoningIds[msgId]) {
1178
+ messageReasoningIds[msgId] = chunkReasoningId;
1179
+ }
1180
+ // Immediately mark as emitted to prevent values from duplicating
1181
+ // This must happen as soon as we see the ID, before content arrives
1182
+ emittedReasoningIds.add(chunkReasoningId);
1183
+ }
1184
+
1185
+ const reasoning = extractReasoningFromContentBlocks(msg);
1186
+ if (reasoning) {
1187
+ // Use stored reasoning ID, or current chunk's ID, or fall back to message ID
1188
+ const reasoningId =
1189
+ messageReasoningIds[msgId] ?? chunkReasoningId ?? msgId;
1190
+
1191
+ if (!messageSeen[msgId]?.reasoning) {
1192
+ controller.enqueue({ type: 'reasoning-start', id: msgId });
1193
+ messageSeen[msgId] ??= {};
1194
+ messageSeen[msgId].reasoning = true;
1195
+ }
1196
+
1197
+ // Streaming chunks have delta text, emit directly without slicing
1198
+ controller.enqueue({
1199
+ type: 'reasoning-delta',
1200
+ delta: reasoning,
1201
+ id: msgId,
1202
+ });
1203
+ // Also ensure the reasoning ID is marked (handles case where ID wasn't in first chunk)
1204
+ emittedReasoningIds.add(reasoningId);
1205
+ }
1206
+
1207
+ /**
1208
+ * Handle text content
1209
+ */
1210
+ const text = getMessageText(msg);
1211
+ if (text) {
1212
+ if (!messageSeen[msgId]?.text) {
1213
+ controller.enqueue({ type: 'text-start', id: msgId });
1214
+ messageSeen[msgId] ??= {};
1215
+ messageSeen[msgId].text = true;
1216
+ }
1217
+
1218
+ controller.enqueue({
1219
+ type: 'text-delta',
1220
+ delta: text,
1221
+ id: msgId,
1222
+ });
1223
+ }
1224
+ } else if (isToolMessageType(msg)) {
1225
+ // Handle both direct properties and serialized messages (kwargs)
1226
+ const msgObj = msg as unknown as Record<string, unknown>;
1227
+ const dataSource =
1228
+ msgObj.type === 'constructor' &&
1229
+ msgObj.kwargs &&
1230
+ typeof msgObj.kwargs === 'object'
1231
+ ? (msgObj.kwargs as Record<string, unknown>)
1232
+ : msgObj;
1233
+
1234
+ const toolCallId = dataSource.tool_call_id as string | undefined;
1235
+ const status = dataSource.status as string | undefined;
1236
+
1237
+ if (toolCallId) {
1238
+ if (status === 'error') {
1239
+ // Tool execution failed
1240
+ controller.enqueue({
1241
+ type: 'tool-output-error',
1242
+ toolCallId,
1243
+ errorText:
1244
+ typeof dataSource.content === 'string'
1245
+ ? dataSource.content
1246
+ : 'Tool execution failed',
1247
+ });
1248
+ } else {
1249
+ // Tool execution succeeded
1250
+ controller.enqueue({
1251
+ type: 'tool-output-available',
1252
+ toolCallId,
1253
+ output: dataSource.content,
1254
+ });
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ return;
1260
+ }
1261
+
1262
+ case 'values': {
1263
+ /**
1264
+ * Finalize all pending message chunks
1265
+ */
1266
+ for (const [id, seen] of Object.entries(messageSeen)) {
1267
+ if (seen.text) controller.enqueue({ type: 'text-end', id });
1268
+ if (seen.tool) {
1269
+ for (const [toolCallId, toolCallSeen] of Object.entries(seen.tool)) {
1270
+ const concatMsg = messageConcat[id];
1271
+ const toolCall = concatMsg?.tool_calls?.find(
1272
+ call => call.id === toolCallId,
1273
+ );
1274
+
1275
+ if (toolCallSeen && toolCall) {
1276
+ emittedToolCalls.add(toolCallId);
1277
+ // Store mapping for HITL interrupt lookup
1278
+ const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`;
1279
+ emittedToolCallsByKey.set(toolCallKey, toolCallId);
1280
+ controller.enqueue({
1281
+ type: 'tool-input-available',
1282
+ toolCallId,
1283
+ toolName: toolCall.name,
1284
+ input: toolCall.args,
1285
+ dynamic: true,
1286
+ });
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ if (seen.reasoning) {
1292
+ controller.enqueue({ type: 'reasoning-end', id });
1293
+ }
1294
+
1295
+ delete messageSeen[id];
1296
+ delete messageConcat[id];
1297
+ delete messageReasoningIds[id];
1298
+ }
1299
+
1300
+ /**
1301
+ * Also check for tool calls in the final state that weren't streamed
1302
+ * This handles cases where tool calls appear directly in values without being in messages events
1303
+ */
1304
+ if (data != null && typeof data === 'object' && 'messages' in data) {
1305
+ const messages = (data as { messages?: unknown[] }).messages;
1306
+ if (Array.isArray(messages)) {
1307
+ /**
1308
+ * First pass: Collect all tool call IDs that have been responded to by ToolMessages.
1309
+ * These are historical tool calls that are already complete.
1310
+ */
1311
+ const completedToolCallIds = new Set<string>();
1312
+ for (const msg of messages) {
1313
+ if (!msg || typeof msg !== 'object') continue;
1314
+
1315
+ if (isToolMessageType(msg)) {
1316
+ // Handle both direct properties and serialized messages (kwargs)
1317
+ const msgObj = msg as unknown as Record<string, unknown>;
1318
+ const dataSource =
1319
+ msgObj.type === 'constructor' &&
1320
+ msgObj.kwargs &&
1321
+ typeof msgObj.kwargs === 'object'
1322
+ ? (msgObj.kwargs as Record<string, unknown>)
1323
+ : msgObj;
1324
+
1325
+ const toolCallId = dataSource.tool_call_id as string | undefined;
1326
+ if (toolCallId) {
1327
+ completedToolCallIds.add(toolCallId);
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * Second pass: Process messages and emit tool events only for NEW tool calls
1334
+ * (those not already completed by a ToolMessage in the history)
1335
+ */
1336
+ for (const msg of messages) {
1337
+ if (!msg || typeof msg !== 'object') continue;
1338
+
1339
+ // Use getMessageId to handle both class instances and serialized messages
1340
+ const msgId = getMessageId(msg);
1341
+ if (!msgId) continue;
1342
+
1343
+ /**
1344
+ * Check if this is an AI message with tool calls
1345
+ */
1346
+ let toolCalls: ToolCall[] | undefined;
1347
+
1348
+ /**
1349
+ * For class instances
1350
+ */
1351
+ if (AIMessageChunk.isInstance(msg) || AIMessage.isInstance(msg)) {
1352
+ toolCalls = msg.tool_calls;
1353
+ } else if (isPlainMessageObject(msg)) {
1354
+ /**
1355
+ * For plain objects from RemoteGraph API or serialized LangChain messages
1356
+ */
1357
+ const obj = msg as Record<string, unknown>;
1358
+
1359
+ /**
1360
+ * Determine the data source (handle both direct and serialized formats)
1361
+ */
1362
+ const isSerializedFormat =
1363
+ obj.type === 'constructor' &&
1364
+ Array.isArray(obj.id) &&
1365
+ ((obj.id as string[]).includes('AIMessageChunk') ||
1366
+ (obj.id as string[]).includes('AIMessage'));
1367
+ const dataSource = isSerializedFormat
1368
+ ? (obj.kwargs as Record<string, unknown>)
1369
+ : obj;
1370
+
1371
+ if (obj.type === 'ai' || isSerializedFormat) {
1372
+ /**
1373
+ * Try tool_calls first (normalized format)
1374
+ */
1375
+ if (Array.isArray(dataSource?.tool_calls)) {
1376
+ toolCalls = dataSource.tool_calls as ToolCall[];
1377
+ } else if (
1378
+ /**
1379
+ * Fall back to additional_kwargs.tool_calls (OpenAI format)
1380
+ */
1381
+ dataSource?.additional_kwargs &&
1382
+ typeof dataSource.additional_kwargs === 'object'
1383
+ ) {
1384
+ const additionalKwargs =
1385
+ dataSource.additional_kwargs as Record<string, unknown>;
1386
+ if (Array.isArray(additionalKwargs.tool_calls)) {
1387
+ /**
1388
+ * Convert OpenAI format to normalized format
1389
+ */
1390
+ toolCalls = (
1391
+ additionalKwargs.tool_calls as Array<{
1392
+ id?: string;
1393
+ function?: { name?: string; arguments?: string };
1394
+ }>
1395
+ ).map((tc, idx) => {
1396
+ const functionData = tc.function;
1397
+ let args: unknown;
1398
+ try {
1399
+ args = functionData?.arguments
1400
+ ? JSON.parse(functionData.arguments)
1401
+ : {};
1402
+ } catch {
1403
+ args = {};
1404
+ }
1405
+ return {
1406
+ id: tc.id || `call_${idx}`,
1407
+ name: functionData?.name || 'unknown',
1408
+ args,
1409
+ } as ToolCall;
1410
+ });
1411
+ }
1412
+ }
1413
+ }
1414
+ }
1415
+
1416
+ if (toolCalls && toolCalls.length > 0) {
1417
+ for (const toolCall of toolCalls) {
1418
+ /**
1419
+ * Only emit if we haven't already processed this tool call
1420
+ * AND if it's not a historical tool call that already has a ToolMessage response.
1421
+ * Historical completed tool calls should not be re-emitted as this would create
1422
+ * orphaned tool parts in the UI without corresponding outputs.
1423
+ */
1424
+ if (
1425
+ toolCall.id &&
1426
+ !emittedToolCalls.has(toolCall.id) &&
1427
+ !completedToolCallIds.has(toolCall.id)
1428
+ ) {
1429
+ emittedToolCalls.add(toolCall.id);
1430
+ // Store mapping for HITL interrupt lookup
1431
+ const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`;
1432
+ emittedToolCallsByKey.set(toolCallKey, toolCall.id);
1433
+ /**
1434
+ * Emit tool-input-start first to ensure proper lifecycle.
1435
+ * Tool calls that weren't streamed (no tool_call_chunks) need
1436
+ * the start event before tool-input-available.
1437
+ */
1438
+ controller.enqueue({
1439
+ type: 'tool-input-start',
1440
+ toolCallId: toolCall.id,
1441
+ toolName: toolCall.name,
1442
+ dynamic: true,
1443
+ });
1444
+ controller.enqueue({
1445
+ type: 'tool-input-available',
1446
+ toolCallId: toolCall.id,
1447
+ toolName: toolCall.name,
1448
+ input: toolCall.args,
1449
+ dynamic: true,
1450
+ });
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ /**
1456
+ * Check for reasoning content that wasn't streamed
1457
+ * Use reasoning block ID for deduplication as it's consistent across streaming and values.
1458
+ *
1459
+ * IMPORTANT: Handle two cases differently:
1460
+ * 1. Message has reasoning WITHOUT tool_calls → emit reasoning (pure reasoning case)
1461
+ * 2. Message has reasoning WITH tool_calls → only emit if streamed this request
1462
+ * (When resuming from HITL interrupt, historical messages have both reasoning
1463
+ * AND tool_calls. We skip those to avoid duplicate reasoning entries.)
1464
+ */
1465
+ const reasoningId = extractReasoningId(msg);
1466
+ const wasStreamedThisRequest = !!messageSeen[msgId];
1467
+ const hasToolCalls = toolCalls && toolCalls.length > 0;
1468
+
1469
+ /**
1470
+ * Determine if we should emit reasoning:
1471
+ * - If we already emitted this reasoning ID, skip
1472
+ * - If the message was streamed this request, emit (normal flow)
1473
+ * - If NOT streamed but has NO tool_calls, emit (pure reasoning in values case)
1474
+ * - If NOT streamed but HAS tool_calls, skip (historical HITL message)
1475
+ */
1476
+ const shouldEmitReasoning =
1477
+ reasoningId &&
1478
+ !emittedReasoningIds.has(reasoningId) &&
1479
+ (wasStreamedThisRequest || !hasToolCalls);
1480
+
1481
+ if (shouldEmitReasoning) {
1482
+ /**
1483
+ * Use extractReasoningFromValuesMessage which extracts from response_metadata.output
1484
+ * This is the full accumulated reasoning, not deltas
1485
+ */
1486
+ const reasoning = extractReasoningFromValuesMessage(msg);
1487
+
1488
+ if (reasoning) {
1489
+ controller.enqueue({ type: 'reasoning-start', id: msgId });
1490
+ controller.enqueue({
1491
+ type: 'reasoning-delta',
1492
+ delta: reasoning,
1493
+ id: msgId,
1494
+ });
1495
+ controller.enqueue({ type: 'reasoning-end', id: msgId });
1496
+ emittedReasoningIds.add(reasoningId);
1497
+ }
1498
+ }
1499
+ }
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Handle Human-in-the-Loop interrupts
1505
+ * When HITL middleware pauses execution, the interrupt data is in __interrupt__
1506
+ * Note: This is outside the 'messages' check because interrupt can come as a separate event
1507
+ */
1508
+ if (data != null && typeof data === 'object') {
1509
+ const interrupt = (data as Record<string, unknown>).__interrupt__;
1510
+ if (Array.isArray(interrupt) && interrupt.length > 0) {
1511
+ for (const interruptItem of interrupt) {
1512
+ const interruptValue = (interruptItem as { value?: unknown })
1513
+ ?.value as Record<string, unknown> | undefined;
1514
+
1515
+ if (!interruptValue) continue;
1516
+
1517
+ /**
1518
+ * Support both camelCase (JS SDK) and snake_case (Python SDK)
1519
+ */
1520
+ const actionRequests = (interruptValue.actionRequests ||
1521
+ interruptValue.action_requests) as
1522
+ | Array<{
1523
+ name: string;
1524
+ args?: Record<string, unknown>; // JS SDK uses 'args'
1525
+ arguments?: Record<string, unknown>; // Python SDK uses 'arguments'
1526
+ id?: string;
1527
+ }>
1528
+ | undefined;
1529
+
1530
+ if (!Array.isArray(actionRequests)) continue;
1531
+
1532
+ for (const actionRequest of actionRequests) {
1533
+ const toolName = actionRequest.name;
1534
+ /**
1535
+ * Support both 'args' (JS SDK) and 'arguments' (Python SDK)
1536
+ */
1537
+ const input = actionRequest.args || actionRequest.arguments;
1538
+
1539
+ /**
1540
+ * Look up the original tool call ID using the name+args key
1541
+ * Fall back to action request ID or generate one if not found
1542
+ */
1543
+ const toolCallKey = `${toolName}:${JSON.stringify(input)}`;
1544
+ const toolCallId =
1545
+ emittedToolCallsByKey.get(toolCallKey) ||
1546
+ actionRequest.id ||
1547
+ `hitl-${toolName}-${Date.now()}`;
1548
+
1549
+ /**
1550
+ * First emit tool-input-start then tool-input-available
1551
+ * so the UI knows what tool is being called with proper lifecycle
1552
+ */
1553
+ if (!emittedToolCalls.has(toolCallId)) {
1554
+ emittedToolCalls.add(toolCallId);
1555
+ emittedToolCallsByKey.set(toolCallKey, toolCallId);
1556
+ controller.enqueue({
1557
+ type: 'tool-input-start',
1558
+ toolCallId,
1559
+ toolName,
1560
+ dynamic: true,
1561
+ });
1562
+ controller.enqueue({
1563
+ type: 'tool-input-available',
1564
+ toolCallId,
1565
+ toolName,
1566
+ input,
1567
+ dynamic: true,
1568
+ });
1569
+ }
1570
+
1571
+ /**
1572
+ * Then emit tool-approval-request to mark it as awaiting approval
1573
+ */
1574
+ controller.enqueue({
1575
+ type: 'tool-approval-request',
1576
+ approvalId: toolCallId,
1577
+ toolCallId,
1578
+ });
1579
+ }
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ break;
1585
+ }
1586
+ }
1587
+ }