@tanstack/ai 0.4.2 → 0.5.1

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 (37) hide show
  1. package/dist/esm/activities/chat/index.js +3 -4
  2. package/dist/esm/activities/chat/index.js.map +1 -1
  3. package/dist/esm/activities/chat/messages.d.ts +13 -6
  4. package/dist/esm/activities/chat/messages.js +115 -86
  5. package/dist/esm/activities/chat/messages.js.map +1 -1
  6. package/dist/esm/activities/chat/stream/processor.d.ts +143 -26
  7. package/dist/esm/activities/chat/stream/processor.js +205 -77
  8. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  9. package/dist/esm/activities/chat/tools/tool-calls.js +2 -3
  10. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  11. package/dist/esm/activities/generateImage/adapter.d.ts +8 -2
  12. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  13. package/dist/esm/activities/generateImage/index.d.ts +8 -6
  14. package/dist/esm/activities/generateImage/index.js.map +1 -1
  15. package/dist/esm/activities/generateVideo/adapter.d.ts +11 -5
  16. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  17. package/dist/esm/activities/generateVideo/index.d.ts +20 -14
  18. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  19. package/dist/esm/extend-adapter.d.ts +114 -0
  20. package/dist/esm/extend-adapter.js +15 -0
  21. package/dist/esm/extend-adapter.js.map +1 -0
  22. package/dist/esm/index.d.ts +2 -0
  23. package/dist/esm/index.js +3 -0
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/types.d.ts +10 -10
  26. package/package.json +1 -1
  27. package/src/activities/chat/index.ts +12 -9
  28. package/src/activities/chat/messages.ts +211 -138
  29. package/src/activities/chat/stream/processor.ts +240 -116
  30. package/src/activities/chat/tools/tool-calls.ts +1 -4
  31. package/src/activities/generateImage/adapter.ts +9 -2
  32. package/src/activities/generateImage/index.ts +19 -9
  33. package/src/activities/generateVideo/adapter.ts +19 -4
  34. package/src/activities/generateVideo/index.ts +30 -16
  35. package/src/extend-adapter.ts +182 -0
  36. package/src/index.ts +4 -0
  37. package/src/types.ts +10 -8
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extend-adapter.js","sources":["../../src/extend-adapter.ts"],"sourcesContent":["import type { Modality } from './types'\n\n// ===========================\n// Extended Model Definition\n// ===========================\n\n/**\n * Definition for a custom model to add to an adapter.\n *\n * @template TName - The model name as a literal string type\n * @template TInput - Array of supported input modalities\n * @template TOptions - Provider options type for this model\n *\n * @example\n * ```typescript\n * const customModels = [\n * createModel('my-custom-model', ['text', 'image']),\n * ] as const\n * ```\n */\nexport interface ExtendedModelDef<\n TName extends string = string,\n TInput extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,\n TOptions = unknown,\n> {\n /** The model name identifier */\n name: TName\n /** Supported input modalities for this model */\n input: TInput\n /** Type brand for provider options - use `{} as YourOptionsType` */\n modelOptions: TOptions\n}\n\n/**\n * Creates a custom model definition for use with `extendAdapter`.\n *\n * This is a helper function that provides proper type inference without\n * requiring manual `as const` casts on individual properties.\n *\n * @template TName - The model name (inferred from argument)\n * @template TInput - The input modalities array (inferred from argument)\n *\n * @param name - The model name identifier (literal string)\n * @param input - Array of supported input modalities\n * @returns A properly typed model definition for use with `extendAdapter`\n *\n * @example\n * ```typescript\n * import { extendAdapter, createModel } from '@tanstack/ai'\n * import { openaiText } from '@tanstack/ai-openai'\n *\n * // Define custom models with full type inference\n * const customModels = [\n * createModel('my-fine-tuned-gpt4', ['text', 'image']),\n * createModel('local-llama', ['text']),\n * ] as const\n *\n * const myOpenai = extendAdapter(openaiText, customModels)\n * ```\n */\nexport function createModel<\n const TName extends string,\n const TInput extends ReadonlyArray<Modality>,\n>(name: TName, input: TInput): ExtendedModelDef<TName, TInput> {\n return {\n name,\n input,\n modelOptions: {} as unknown,\n }\n}\n\n// ===========================\n// Type Extraction Utilities\n// ===========================\n\n/**\n * Extract the model name union from an array of model definitions.\n */\ntype ExtractCustomModelNames<TDefs extends ReadonlyArray<ExtendedModelDef>> =\n TDefs[number]['name']\n\n// ===========================\n// Factory Type Inference\n// ===========================\n\n/**\n * Infer the model parameter type from an adapter factory function.\n * For generic functions like `<T extends Union>(model: T)`, this gets `T` which\n * TypeScript treats as the constraint union when used in parameter position.\n */\ntype InferFactoryModels<TFactory> = TFactory extends (\n model: infer TModel,\n ...args: Array<any>\n) => any\n ? TModel extends string\n ? TModel\n : string\n : string\n\n/**\n * Infer the config parameter type from an adapter factory function.\n */\ntype InferConfig<TFactory> = TFactory extends (\n model: any,\n config?: infer TConfig,\n) => any\n ? TConfig\n : undefined\n\n/**\n * Infer the adapter return type from a factory function.\n */\ntype InferAdapterReturn<TFactory> = TFactory extends (\n ...args: Array<any>\n) => infer TReturn\n ? TReturn\n : never\n\n// ===========================\n// extendAdapter Function\n// ===========================\n\n/**\n * Extends an existing adapter factory with additional custom models.\n *\n * The extended adapter accepts both original models (with full original type inference)\n * and custom models (with types from your definitions).\n *\n * At runtime, this simply passes through to the original factory - no validation is performed.\n * The original factory's signature is fully preserved, including any config parameters.\n *\n * @param factory - The original adapter factory function (e.g., `openaiText`, `anthropicText`)\n * @param models - Array of custom model definitions with `name` and `input`\n * @returns A new factory function that accepts both original and custom models\n *\n * @example\n * ```typescript\n * import { extendAdapter, createModel } from '@tanstack/ai'\n * import { openaiText } from '@tanstack/ai-openai'\n *\n * // Define custom models\n * const customModels = [\n * createModel('my-fine-tuned-gpt4', ['text', 'image']),\n * createModel('local-llama', ['text']),\n * ] as const\n *\n * // Create extended adapter\n * const myOpenai = extendAdapter(openaiText, customModels)\n *\n * // Use with original models - full type inference preserved\n * const gpt4 = myOpenai('gpt-4o')\n *\n * // Use with custom models\n * const custom = myOpenai('my-fine-tuned-gpt4')\n *\n * // Type error: 'invalid-model' is not a valid model\n * // myOpenai('invalid-model')\n *\n * // Works with chat()\n * chat({\n * adapter: myOpenai('my-fine-tuned-gpt4'),\n * messages: [...]\n * })\n * ```\n */\nexport function extendAdapter<\n TFactory extends (...args: Array<any>) => any,\n const TDefs extends ReadonlyArray<ExtendedModelDef>,\n>(\n factory: TFactory,\n _customModels: TDefs,\n): (\n model: InferFactoryModels<TFactory> | ExtractCustomModelNames<TDefs>,\n ...args: InferConfig<TFactory> extends undefined\n ? []\n : [config?: InferConfig<TFactory>]\n) => InferAdapterReturn<TFactory> {\n // At runtime, we simply pass through to the original factory.\n // The _customModels parameter is only used for type inference.\n // No runtime validation - users are trusted to pass valid model names.\n return factory as any\n}\n"],"names":[],"mappings":"AA4DO,SAAS,YAGd,MAAa,OAAgD;AAC7D,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,cAAc,CAAA;AAAA,EAAC;AAEnB;AAgGO,SAAS,cAId,SACA,eAMgC;AAIhC,SAAO;AACT;"}
@@ -17,3 +17,5 @@ export * from './event-client.js';
17
17
  export { convertMessagesToModelMessages, generateMessageId, uiMessageToModelMessages, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, } from './activities/chat/messages.js';
18
18
  export { StreamProcessor, createReplayStream, ImmediateStrategy, PunctuationStrategy, BatchStrategy, WordBoundaryStrategy, CompositeStrategy, PartialJSONParser, defaultJSONParser, parsePartialJSON, } from './activities/chat/stream/index.js';
19
19
  export type { ChunkStrategy, ChunkRecording, InternalToolCallState, ProcessorResult, ProcessorState, StreamProcessorEvents, StreamProcessorOptions, ToolCallState, ToolResultState, JSONParser, } from './activities/chat/stream/index.js';
20
+ export { createModel, extendAdapter } from './extend-adapter.js';
21
+ export type { ExtendedModelDef } from './extend-adapter.js';
package/dist/esm/index.js CHANGED
@@ -12,6 +12,7 @@ import { combineStrategies, maxIterations, untilFinishReason } from "./activitie
12
12
  import { detectImageMimeType } from "./utils.js";
13
13
  import { aiEventClient } from "./event-client.js";
14
14
  import { convertMessagesToModelMessages, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, uiMessageToModelMessages } from "./activities/chat/messages.js";
15
+ import { createModel, extendAdapter } from "./extend-adapter.js";
15
16
  import { StreamProcessor, createReplayStream } from "./activities/chat/stream/processor.js";
16
17
  import { BatchStrategy, CompositeStrategy, ImmediateStrategy, PunctuationStrategy, WordBoundaryStrategy } from "./activities/chat/stream/strategies.js";
17
18
  import { PartialJSONParser, defaultJSONParser, parsePartialJSON } from "./activities/chat/stream/json-parser.js";
@@ -31,6 +32,7 @@ export {
31
32
  convertSchemaToJsonSchema,
32
33
  createChatOptions,
33
34
  createImageOptions,
35
+ createModel,
34
36
  createReplayStream,
35
37
  createSpeechOptions,
36
38
  createSummarizeOptions,
@@ -38,6 +40,7 @@ export {
38
40
  createVideoOptions,
39
41
  defaultJSONParser,
40
42
  detectImageMimeType,
43
+ extendAdapter,
41
44
  generateImage,
42
45
  generateMessageId,
43
46
  generateSpeech,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;"}
@@ -652,9 +652,9 @@ export interface TextMessageContentEvent extends BaseAGUIEvent {
652
652
  type: 'TEXT_MESSAGE_CONTENT';
653
653
  /** Message identifier */
654
654
  messageId: string;
655
- /** The incremental content token (may be undefined if only content is provided) */
656
- delta?: string;
657
- /** Full accumulated content so far */
655
+ /** The incremental content token */
656
+ delta: string;
657
+ /** Full accumulated content so far (optional, for debugging) */
658
658
  content?: string;
659
659
  }
660
660
  /**
@@ -721,8 +721,8 @@ export interface StepFinishedEvent extends BaseAGUIEvent {
721
721
  /** Step identifier */
722
722
  stepId: string;
723
723
  /** Incremental thinking content */
724
- delta?: string;
725
- /** Full accumulated thinking content */
724
+ delta: string;
725
+ /** Full accumulated thinking content (optional, for debugging) */
726
726
  content?: string;
727
727
  }
728
728
  /**
@@ -793,7 +793,7 @@ export interface SummarizationResult {
793
793
  * Options for image generation.
794
794
  * These are the common options supported across providers.
795
795
  */
796
- export interface ImageGenerationOptions<TProviderOptions extends object = object> {
796
+ export interface ImageGenerationOptions<TProviderOptions extends object = object, TSize extends string = string> {
797
797
  /** The model to use for image generation */
798
798
  model: string;
799
799
  /** Text description of the desired image(s) */
@@ -801,7 +801,7 @@ export interface ImageGenerationOptions<TProviderOptions extends object = object
801
801
  /** Number of images to generate (default: 1) */
802
802
  numberOfImages?: number;
803
803
  /** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */
804
- size?: string;
804
+ size?: TSize;
805
805
  /** Model-specific options for image generation */
806
806
  modelOptions?: TProviderOptions;
807
807
  }
@@ -839,13 +839,13 @@ export interface ImageGenerationResult {
839
839
  *
840
840
  * @experimental Video generation is an experimental feature and may change.
841
841
  */
842
- export interface VideoGenerationOptions<TProviderOptions extends object = object> {
842
+ export interface VideoGenerationOptions<TProviderOptions extends object = object, TSize extends string = string> {
843
843
  /** The model to use for video generation */
844
844
  model: string;
845
845
  /** Text description of the desired video */
846
846
  prompt: string;
847
- /** Video size in WIDTHxHEIGHT format (e.g., "1280x720") */
848
- size?: string;
847
+ /** Video size — format depends on the provider (e.g., "16:9", "1280x720") */
848
+ size?: TSize;
849
849
  /** Video duration in seconds */
850
850
  duration?: number;
851
851
  /** Model-specific options for video generation */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "Core TanStack AI library - Open source AI SDK",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -555,15 +555,6 @@ class TextEngine<
555
555
  })
556
556
  }
557
557
 
558
- // Don't overwrite a tool_calls finishReason with a stop finishReason
559
- if (
560
- this.finishedEvent?.finishReason === 'tool_calls' &&
561
- chunk.finishReason === 'stop'
562
- ) {
563
- this.lastFinishReason = chunk.finishReason
564
- return
565
- }
566
-
567
558
  this.finishedEvent = chunk
568
559
  this.lastFinishReason = chunk.finishReason
569
560
  }
@@ -800,6 +791,18 @@ class TextEngine<
800
791
  } catch {
801
792
  output = message.content
802
793
  }
794
+ // Skip approval response messages (they have pendingExecution marker)
795
+ // These are NOT real client tool results — they are synthetic tool messages
796
+ // created by uiMessageToModelMessages for approved-but-not-yet-executed tools.
797
+ // Treating them as results would prevent the server from requesting actual
798
+ // client-side execution after approval (see GitHub issue #225).
799
+ if (
800
+ output &&
801
+ typeof output === 'object' &&
802
+ (output as any).pendingExecution === true
803
+ ) {
804
+ continue
805
+ }
803
806
  clientToolResults.set(message.toolCallId, output)
804
807
  }
805
808
  }
@@ -1,27 +1,22 @@
1
1
  import type {
2
- AudioPart,
3
2
  ContentPart,
4
- DocumentPart,
5
- ImagePart,
6
3
  MessagePart,
7
4
  ModelMessage,
8
5
  TextPart,
9
6
  ToolCallPart,
10
- ToolResultPart,
11
7
  UIMessage,
12
- VideoPart,
13
8
  } from '../../types'
14
9
  // ===========================
15
10
  // Message Converters
16
11
  // ===========================
17
12
 
18
13
  /**
19
- * Helper to check if a part is a multimodal content part (image, audio, video, document)
14
+ * Check if a MessagePart is a content part (text, image, audio, video, document)
15
+ * that maps directly to a ModelMessage ContentPart.
20
16
  */
21
- function isMultimodalPart(
22
- part: MessagePart,
23
- ): part is ImagePart | AudioPart | VideoPart | DocumentPart {
17
+ function isContentPart(part: MessagePart): part is ContentPart {
24
18
  return (
19
+ part.type === 'text' ||
25
20
  part.type === 'image' ||
26
21
  part.type === 'audio' ||
27
22
  part.type === 'video' ||
@@ -30,19 +25,34 @@ function isMultimodalPart(
30
25
  }
31
26
 
32
27
  /**
33
- * Helper to extract text content from string or ContentPart array
34
- * For multimodal content, this extracts only the text parts
28
+ * Collapse an array of ContentParts into the most compact ModelMessage content:
29
+ * - Empty array → null
30
+ * - All text parts → joined string (or null if empty)
31
+ * - Mixed content → ContentPart array as-is
35
32
  */
36
- function getTextContent(content: string | null | Array<ContentPart>): string {
37
- if (content === null) {
38
- return ''
39
- }
40
- if (typeof content === 'string') {
41
- return content
33
+ function collapseContentParts(
34
+ parts: Array<ContentPart>,
35
+ ): string | null | Array<ContentPart> {
36
+ if (parts.length === 0) return null
37
+
38
+ const allText = parts.every((p) => p.type === 'text')
39
+ if (allText) {
40
+ const joined = parts.map((p) => p.content).join('')
41
+ return joined || null
42
42
  }
43
- // Extract text from ContentPart array
43
+
44
+ return parts
45
+ }
46
+
47
+ /**
48
+ * Extract text content from ModelMessage content (string, null, or ContentPart array).
49
+ * Used when only the text portion is needed (e.g., tool result content).
50
+ */
51
+ function getTextContent(content: string | null | Array<ContentPart>): string {
52
+ if (content === null) return ''
53
+ if (typeof content === 'string') return content
44
54
  return content
45
- .filter((part) => part.type === 'text')
55
+ .filter((part): part is TextPart => part.type === 'text')
46
56
  .map((part) => part.content)
47
57
  .join('')
48
58
  }
@@ -69,157 +79,214 @@ export function convertMessagesToModelMessages(
69
79
  /**
70
80
  * Convert a UIMessage to ModelMessage(s)
71
81
  *
72
- * This conversion handles the parts-based structure:
73
- * - Text parts → content field (string or as part of ContentPart array)
74
- * - Multimodal parts (image, audio, video, document) → ContentPart array
75
- * - ToolCall parts → toolCalls array
76
- * - ToolResult parts → separate role="tool" messages
82
+ * Walks the parts array IN ORDER to preserve the interleaving of text,
83
+ * tool calls, and tool results. This is critical for multi-round tool
84
+ * flows where the model generates text, calls a tool, gets the result,
85
+ * then generates more text and calls another tool.
86
+ *
87
+ * The output preserves the sequential structure:
88
+ * text1 → toolCall1 → toolResult1 → text2 → toolCall2 → toolResult2
89
+ * becomes:
90
+ * assistant: {content: "text1", toolCalls: [toolCall1]}
91
+ * tool: toolResult1
92
+ * assistant: {content: "text2", toolCalls: [toolCall2]}
93
+ * tool: toolResult2
77
94
  *
78
95
  * @param uiMessage - The UIMessage to convert
79
- * @returns An array of ModelMessages (may be multiple if tool results are present)
96
+ * @returns An array of ModelMessages preserving part ordering
80
97
  */
81
98
  export function uiMessageToModelMessages(
82
99
  uiMessage: UIMessage,
83
100
  ): Array<ModelMessage> {
84
- const messageList: Array<ModelMessage> = []
85
-
86
101
  // Skip system messages - they're handled via systemPrompts, not ModelMessages
87
102
  if (uiMessage.role === 'system') {
88
- return messageList
103
+ return []
89
104
  }
90
105
 
91
- // Separate parts by type
92
- // Note: thinking parts are UI-only and not included in ModelMessages
93
- const textParts: Array<TextPart> = []
94
- const multimodalParts: Array<
95
- ImagePart | AudioPart | VideoPart | DocumentPart
96
- > = []
97
- const toolCallParts: Array<ToolCallPart> = []
98
- const toolResultParts: Array<ToolResultPart> = []
106
+ // For non-assistant messages (user), use the simpler path since they
107
+ // don't have tool calls or tool results to interleave
108
+ if (uiMessage.role !== 'assistant') {
109
+ return [buildUserOrToolMessage(uiMessage)]
110
+ }
99
111
 
112
+ // For assistant messages, walk parts in order to preserve interleaving
113
+ return buildAssistantMessages(uiMessage)
114
+ }
115
+
116
+ /**
117
+ * Build a single ModelMessage for user messages (simple path).
118
+ * Preserves ordering of text and multimodal content parts.
119
+ */
120
+ function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage {
121
+ const contentParts: Array<ContentPart> = []
100
122
  for (const part of uiMessage.parts) {
101
- if (part.type === 'text') {
102
- textParts.push(part)
103
- } else if (isMultimodalPart(part)) {
104
- multimodalParts.push(part)
105
- } else if (part.type === 'tool-call') {
106
- toolCallParts.push(part)
107
- } else if (part.type === 'tool-result') {
108
- toolResultParts.push(part)
123
+ if (isContentPart(part)) {
124
+ contentParts.push(part)
109
125
  }
110
- // thinking parts are skipped - they're UI-only
111
126
  }
112
127
 
113
- // Build the content field
114
- // If we have multimodal parts, use ContentPart array format
115
- // Otherwise, use simple string format for backward compatibility
116
- let content: string | null | Array<ContentPart>
117
- if (multimodalParts.length > 0) {
118
- // Build ContentPart array preserving the order of text and multimodal parts
119
- const contentParts: Array<ContentPart> = []
120
- for (const part of uiMessage.parts) {
121
- if (part.type === 'text') {
122
- contentParts.push(part)
123
- } else if (isMultimodalPart(part)) {
124
- contentParts.push(part)
125
- }
128
+ return {
129
+ role: uiMessage.role as 'user' | 'assistant' | 'tool',
130
+ content: collapseContentParts(contentParts),
131
+ }
132
+ }
133
+
134
+ // Accumulator for building an assistant segment (content + tool calls)
135
+ interface AssistantSegment {
136
+ contentParts: Array<ContentPart>
137
+ toolCalls: Array<{
138
+ id: string
139
+ type: 'function'
140
+ function: { name: string; arguments: string }
141
+ }>
142
+ }
143
+
144
+ function createSegment(): AssistantSegment {
145
+ return { contentParts: [], toolCalls: [] }
146
+ }
147
+
148
+ function isToolCallIncluded(part: ToolCallPart): boolean {
149
+ return (
150
+ part.state === 'input-complete' ||
151
+ part.state === 'approval-responded' ||
152
+ part.output !== undefined
153
+ )
154
+ }
155
+
156
+ /**
157
+ * Build ModelMessages for an assistant UIMessage, preserving the
158
+ * sequential interleaving of text, tool calls, and tool results.
159
+ *
160
+ * Walks parts in order. Text and tool-call parts accumulate into the
161
+ * current "segment". When a tool-result part is encountered, the
162
+ * current segment is flushed as an assistant message, then the tool
163
+ * result is emitted as a tool message.
164
+ */
165
+ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
166
+ const messageList: Array<ModelMessage> = []
167
+ let current = createSegment()
168
+
169
+ // Track emitted tool result IDs to avoid duplicates.
170
+ // A tool call can have BOTH an explicit tool-result part AND an output
171
+ // field on the tool-call part. We only want one per tool call ID.
172
+ const emittedToolResultIds = new Set<string>()
173
+
174
+ function flushSegment(): void {
175
+ const content = collapseContentParts(current.contentParts)
176
+ const hasContent = content !== null
177
+ const hasToolCalls = current.toolCalls.length > 0
178
+
179
+ if (hasContent || hasToolCalls) {
180
+ messageList.push({
181
+ role: 'assistant',
182
+ content,
183
+ ...(hasToolCalls && { toolCalls: current.toolCalls }),
184
+ })
126
185
  }
127
- content = contentParts
128
- } else {
129
- // Simple string content for text-only messages
130
- content = textParts.map((p) => p.content).join('') || null
186
+ current = createSegment()
131
187
  }
132
188
 
133
- const toolCalls =
134
- toolCallParts.length > 0
135
- ? toolCallParts
136
- .filter(
137
- (p) =>
138
- p.state === 'input-complete' ||
139
- p.state === 'approval-responded' ||
140
- p.output !== undefined, // Include if has output (client tool result)
141
- )
142
- .map((p) => ({
143
- id: p.id,
189
+ for (const part of uiMessage.parts) {
190
+ switch (part.type) {
191
+ case 'text':
192
+ case 'image':
193
+ case 'audio':
194
+ case 'video':
195
+ case 'document':
196
+ current.contentParts.push(part)
197
+ break
198
+
199
+ case 'tool-call':
200
+ if (isToolCallIncluded(part)) {
201
+ current.toolCalls.push({
202
+ id: part.id,
144
203
  type: 'function' as const,
145
204
  function: {
146
- name: p.name,
147
- arguments: p.arguments,
205
+ name: part.name,
206
+ arguments: part.arguments,
148
207
  },
149
- }))
150
- : undefined
208
+ })
209
+ }
210
+ break
151
211
 
152
- // Create the main message
153
- // For multimodal content, we always create a message even if content is an empty array
154
- const hasContent = Array.isArray(content) ? true : content !== null
155
- if (uiMessage.role !== 'assistant' || hasContent || !toolCalls) {
156
- messageList.push({
157
- role: uiMessage.role,
158
- content,
159
- ...(toolCalls && toolCalls.length > 0 && { toolCalls }),
160
- })
161
- } else if (toolCalls.length > 0) {
162
- // Assistant message with only tool calls
163
- messageList.push({
164
- role: 'assistant',
165
- content,
166
- toolCalls,
167
- })
168
- }
212
+ case 'tool-result':
213
+ // Flush the current assistant segment before emitting the tool result
214
+ flushSegment()
169
215
 
170
- // Add tool result messages for completed tool calls
171
- // This includes:
172
- // 1. Explicit tool-result parts (from server tools)
173
- // 2. Client tool calls with output set
174
- // 3. Approval-responded tool calls (approval result)
175
- for (const toolResultPart of toolResultParts) {
176
- if (
177
- toolResultPart.state === 'complete' ||
178
- toolResultPart.state === 'error'
179
- ) {
180
- messageList.push({
181
- role: 'tool',
182
- content: toolResultPart.content,
183
- toolCallId: toolResultPart.toolCallId,
184
- })
216
+ // Emit the tool result
217
+ if (
218
+ (part.state === 'complete' || part.state === 'error') &&
219
+ !emittedToolResultIds.has(part.toolCallId)
220
+ ) {
221
+ messageList.push({
222
+ role: 'tool',
223
+ content: part.content,
224
+ toolCallId: part.toolCallId,
225
+ })
226
+ emittedToolResultIds.add(part.toolCallId)
227
+ }
228
+ break
229
+
230
+ // thinking parts are skipped - they're UI-only
231
+ default:
232
+ break
185
233
  }
186
234
  }
187
235
 
188
- // Add tool result messages for client tool results (tools with output)
189
- // and approval responses (so iteration tracking works correctly)
190
- for (const toolCallPart of toolCallParts) {
191
- // Client tool with output - add as tool result
192
- if (toolCallPart.output !== undefined && !toolCallPart.approval) {
236
+ // Flush any remaining accumulated content
237
+ flushSegment()
238
+
239
+ // Emit tool results from client tool-call parts with output or approval,
240
+ // but only if not already covered by an explicit tool-result part above.
241
+ // These are appended at the end since they don't have explicit tool-result
242
+ // parts in the parts array to trigger inline emission.
243
+ for (const part of uiMessage.parts) {
244
+ if (part.type !== 'tool-call') continue
245
+
246
+ // Client tool with output - add as tool result (if not already emitted)
247
+ if (
248
+ part.output !== undefined &&
249
+ !part.approval &&
250
+ !emittedToolResultIds.has(part.id)
251
+ ) {
193
252
  messageList.push({
194
253
  role: 'tool',
195
- content: JSON.stringify(toolCallPart.output),
196
- toolCallId: toolCallPart.id,
254
+ content: JSON.stringify(part.output),
255
+ toolCallId: part.id,
197
256
  })
257
+ emittedToolResultIds.add(part.id)
198
258
  }
199
259
 
200
260
  // Approval response - add as tool result for iteration tracking
201
- // For APPROVED: includes pendingExecution marker so the tool still executes
202
- // For DENIED: just marks the tool as complete (no execution needed)
203
261
  if (
204
- toolCallPart.state === 'approval-responded' &&
205
- toolCallPart.approval?.approved !== undefined
262
+ part.state === 'approval-responded' &&
263
+ part.approval?.approved !== undefined &&
264
+ !emittedToolResultIds.has(part.id)
206
265
  ) {
207
- const approved = toolCallPart.approval.approved
266
+ const approved = part.approval.approved
208
267
  messageList.push({
209
268
  role: 'tool',
210
269
  content: JSON.stringify({
211
270
  approved,
212
- // Mark approved tools as pending execution - they still need to run
213
271
  ...(approved && { pendingExecution: true }),
214
272
  message: approved
215
273
  ? 'User approved this action'
216
274
  : 'User denied this action',
217
275
  }),
218
- toolCallId: toolCallPart.id,
276
+ toolCallId: part.id,
219
277
  })
278
+ emittedToolResultIds.add(part.id)
220
279
  }
221
280
  }
222
281
 
282
+ // If no messages were produced (e.g., empty parts), emit a minimal assistant message
283
+ if (messageList.length === 0) {
284
+ messageList.push({
285
+ role: 'assistant',
286
+ content: null,
287
+ })
288
+ }
289
+
223
290
  return messageList
224
291
  }
225
292
 
@@ -241,13 +308,29 @@ export function modelMessageToUIMessage(
241
308
  ): UIMessage {
242
309
  const parts: Array<MessagePart> = []
243
310
 
244
- // Handle content (convert multimodal content to text for UI)
245
- const textContent = getTextContent(modelMessage.content)
246
- if (textContent) {
311
+ // Handle tool results (when role is "tool") - only produce tool-result part,
312
+ // not a text part (the content IS the tool result, not display text)
313
+ if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
247
314
  parts.push({
248
- type: 'text',
249
- content: textContent,
315
+ type: 'tool-result',
316
+ toolCallId: modelMessage.toolCallId,
317
+ content: getTextContent(modelMessage.content),
318
+ state: 'complete',
250
319
  })
320
+ } else if (Array.isArray(modelMessage.content)) {
321
+ // Multimodal content - preserve all content parts as MessageParts
322
+ for (const part of modelMessage.content) {
323
+ parts.push(part)
324
+ }
325
+ } else {
326
+ // String or null content
327
+ const textContent = getTextContent(modelMessage.content)
328
+ if (textContent) {
329
+ parts.push({
330
+ type: 'text',
331
+ content: textContent,
332
+ })
333
+ }
251
334
  }
252
335
 
253
336
  // Handle tool calls
@@ -263,16 +346,6 @@ export function modelMessageToUIMessage(
263
346
  }
264
347
  }
265
348
 
266
- // Handle tool results (when role is "tool")
267
- if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
268
- parts.push({
269
- type: 'tool-result',
270
- toolCallId: modelMessage.toolCallId,
271
- content: getTextContent(modelMessage.content),
272
- state: 'complete',
273
- })
274
- }
275
-
276
349
  return {
277
350
  id: id || generateMessageId(),
278
351
  role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role,