@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
432 lines (431 loc) • 14 kB
JavaScript
import { normalizeToolResult } from "../../utilities/tool-result.js";
//#region src/activities/chat/messages.ts
/**
* Check if a MessagePart is a content part (text, image, audio, video, document)
* that maps directly to a ModelMessage ContentPart.
*/
function isContentPart(part) {
return part.type === "text" || part.type === "image" || part.type === "audio" || part.type === "video" || part.type === "document";
}
function safeJsonStringify(value) {
try {
return JSON.stringify(value);
} catch {
return "";
}
}
function parseToolResultContent(content) {
try {
return JSON.parse(content);
} catch {
return content;
}
}
/**
* Collapse an array of ContentParts into the most compact ModelMessage content:
* - Empty array → null
* - All text parts → joined string (or null if empty)
* - Mixed content → ContentPart array as-is
*/
function collapseContentParts(parts) {
if (parts.length === 0) return null;
if (parts.every((p) => p.type === "text")) return parts.map((p) => p.content).join("") || null;
return parts;
}
/**
* Extract text content from ModelMessage content (string, null, or ContentPart array).
* Used when only the text portion is needed (e.g., tool result content).
*/
function getTextContent(content) {
if (content === null) return "";
if (typeof content === "string") return content;
return content.filter((part) => part.type === "text").map((part) => part.content).join("");
}
/**
* Convert UIMessages or ModelMessages to ModelMessages
*/
function convertMessagesToModelMessages(messages) {
const anchoredToolCallIds = /* @__PURE__ */ new Set();
for (const msg of messages) if ("parts" in msg) {
for (const part of msg.parts) if (part.type === "tool-result") anchoredToolCallIds.add(part.toolCallId);
}
const modelMessages = [];
for (const msg of messages) {
if ("parts" in msg) {
modelMessages.push(...uiMessageToModelMessages(msg));
continue;
}
const role = msg.role;
if (role === "tool" && msg.toolCallId && anchoredToolCallIds.has(msg.toolCallId)) continue;
if (role === "reasoning" || role === "activity") continue;
if (role === "developer") {
modelMessages.push({
role: "system",
content: msg.content
});
continue;
}
modelMessages.push(msg);
}
return modelMessages;
}
/**
* Convert a UIMessage to ModelMessage(s)
*
* Walks the parts array IN ORDER to preserve the interleaving of text,
* tool calls, and tool results. This is critical for multi-round tool
* flows where the model generates text, calls a tool, gets the result,
* then generates more text and calls another tool.
*
* The output preserves the sequential structure:
* text1 → toolCall1 → toolResult1 → text2 → toolCall2 → toolResult2
* becomes:
* assistant: {content: "text1", toolCalls: [toolCall1]}
* tool: toolResult1
* assistant: {content: "text2", toolCalls: [toolCall2]}
* tool: toolResult2
*
* @param uiMessage - The UIMessage to convert
* @returns An array of ModelMessages preserving part ordering
*/
function uiMessageToModelMessages(uiMessage) {
if (uiMessage.role === "system") return [];
if (uiMessage.role !== "assistant") return [buildUserOrToolMessage(uiMessage)];
return buildAssistantMessages(uiMessage);
}
/**
* Build a single ModelMessage for user messages (simple path).
* Preserves ordering of text and multimodal content parts.
*/
function buildUserOrToolMessage(uiMessage) {
const contentParts = [];
for (const part of uiMessage.parts) if (isContentPart(part)) contentParts.push(part);
return {
role: uiMessage.role,
content: collapseContentParts(contentParts)
};
}
function createSegment() {
return {
contentParts: [],
toolCalls: []
};
}
function isToolCallIncluded(part) {
return part.state === "input-complete" || part.state === "complete" || part.state === "approval-requested" || part.state === "approval-responded" || part.state === "error" || part.output !== void 0;
}
/**
* Build ModelMessages for an assistant UIMessage, preserving the
* sequential interleaving of text, tool calls, and tool results.
*
* Walks parts in order. Text and tool-call parts accumulate into the
* current "segment". When a tool-result part is encountered, the
* current segment is flushed as an assistant message, then the tool
* result is emitted as a tool message.
*/
function buildAssistantMessages(uiMessage) {
const messageList = [];
let current = createSegment();
let pendingThinking = [];
const emittedToolResultIds = /* @__PURE__ */ new Set();
function flushSegment() {
const content = collapseContentParts(current.contentParts);
const hasContent = content !== null;
const hasToolCalls = current.toolCalls.length > 0;
if (hasContent || hasToolCalls) {
messageList.push({
role: "assistant",
content,
...hasToolCalls && { toolCalls: current.toolCalls },
...pendingThinking.length > 0 && { thinking: pendingThinking }
});
pendingThinking = [];
}
current = createSegment();
}
for (const part of uiMessage.parts) switch (part.type) {
case "text":
case "image":
case "audio":
case "video":
case "document":
current.contentParts.push(part);
break;
case "tool-call":
if (isToolCallIncluded(part)) current.toolCalls.push({
id: part.id,
type: "function",
function: {
name: part.name,
arguments: part.arguments
},
...part.metadata !== void 0 && { metadata: part.metadata }
});
break;
case "tool-result":
flushSegment();
if ((part.state === "complete" || part.state === "error") && !emittedToolResultIds.has(part.toolCallId)) {
messageList.push({
role: "tool",
content: part.content,
toolCallId: part.toolCallId
});
emittedToolResultIds.add(part.toolCallId);
}
break;
case "thinking":
if (part.content) pendingThinking.push({
content: part.content,
...part.signature && { signature: part.signature }
});
break;
case "structured-output":
if (part.status === "complete") {
const serialized = part.raw !== "" ? part.raw : part.data !== void 0 ? safeJsonStringify(part.data) : "";
if (serialized !== "") current.contentParts.push({
type: "text",
content: serialized
});
}
break;
case "ui-resource": break;
default: break;
}
flushSegment();
for (const part of uiMessage.parts) {
if (part.type !== "tool-call") continue;
if (part.output !== void 0 && !emittedToolResultIds.has(part.id)) {
messageList.push({
role: "tool",
content: normalizeToolResult(part.output),
toolCallId: part.id
});
emittedToolResultIds.add(part.id);
}
if (part.output === void 0 && part.state === "approval-responded" && part.approval?.approved !== void 0 && !emittedToolResultIds.has(part.id)) {
const approved = part.approval.approved;
messageList.push({
role: "tool",
content: JSON.stringify({
approved,
...approved && { pendingExecution: true },
message: approved ? "User approved this action" : "User denied this action"
}),
toolCallId: part.id
});
emittedToolResultIds.add(part.id);
}
}
if (messageList.length === 0) messageList.push({
role: "assistant",
content: null
});
return messageList;
}
/**
* Convert a ModelMessage to UIMessage
*
* This conversion creates a parts-based structure:
* - content field → TextPart
* - toolCalls array → ToolCallPart[]
* - role="tool" messages should be converted separately and merged
*
* @param modelMessage - The ModelMessage to convert
* @param id - Optional ID for the UIMessage (generated if not provided)
* @returns A UIMessage with parts
*/
function modelMessageToUIMessage(modelMessage, id) {
const parts = [];
if (modelMessage.role === "assistant" && modelMessage.thinking?.length) for (const thinking of modelMessage.thinking) {
if (!thinking.content) continue;
parts.push({
type: "thinking",
content: thinking.content,
...thinking.signature && { signature: thinking.signature }
});
}
if (modelMessage.role === "tool" && modelMessage.toolCallId) parts.push({
type: "tool-result",
toolCallId: modelMessage.toolCallId,
content: getTextContent(modelMessage.content),
state: "complete"
});
else if (Array.isArray(modelMessage.content)) for (const part of modelMessage.content) parts.push(part);
else {
const textContent = getTextContent(modelMessage.content);
if (textContent) parts.push({
type: "text",
content: textContent
});
}
if (modelMessage.toolCalls && modelMessage.toolCalls.length > 0) for (const toolCall of modelMessage.toolCalls) {
let input;
try {
input = JSON.parse(toolCall.function.arguments);
} catch {
input = void 0;
}
parts.push({
type: "tool-call",
id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
state: "input-complete",
...input !== void 0 && { input },
...toolCall.metadata !== void 0 && { metadata: toolCall.metadata }
});
}
return {
id: id || generateMessageId(),
role: modelMessage.role === "tool" ? "assistant" : modelMessage.role,
parts
};
}
/**
* Normalize a single AG-UI `MESSAGES_SNAPSHOT` message into a `UIMessage`.
*
* AG-UI snapshot messages use the wire shape `{ id, role, content }` and have
* no `parts` array. Casting them directly to `UIMessage` is unsafe: any code
* that later reads `message.parts` (e.g. the devtools `onToolCallStateChange`
* handler) crashes with "Cannot read properties of undefined (reading 'find')".
*
* Each role is mapped to the canonical `UIMessage` shape, reusing
* `modelMessageToUIMessage` for the roles that share `ModelMessage`'s structure.
* The original AG-UI `id` is preserved so later `TEXT_MESSAGE_CONTENT` /
* `TOOL_CALL_*` events still route by `messageId` (falling back to a generated
* id only when the snapshot omits one). Messages that already carry `parts`
* (e.g. a TanStack server echoing `UIMessage`s back over the wire) pass through
* unchanged apart from ensuring an id.
*/
function aguiSnapshotMessageToUIMessage(message) {
if ("parts" in message) return {
...message,
id: message.id || generateMessageId()
};
const id = message.id || generateMessageId();
switch (message.role) {
case "user": return {
id,
role: "user",
parts: aguiUserContentToParts(message.content)
};
case "assistant": return modelMessageToUIMessage({
role: "assistant",
content: message.content ?? null,
...message.toolCalls && { toolCalls: message.toolCalls }
}, id);
case "tool": return modelMessageToUIMessage({
role: "tool",
content: message.content,
toolCallId: message.toolCallId
}, id);
case "system":
case "developer": return {
id,
role: "system",
parts: message.content ? [{
type: "text",
content: message.content
}] : []
};
case "reasoning": return {
id,
role: "assistant",
parts: message.content ? [{
type: "thinking",
content: message.content
}] : []
};
default: return {
id,
role: "assistant",
parts: []
};
}
}
/**
* Convert AG-UI user message content into `UIMessage` parts.
*
* AG-UI user content is either a plain string or a multimodal array whose text
* entries use `{ type: 'text', text }` (vs. TanStack's `{ type: 'text', content }`).
* Text entries are rewritten to the TanStack shape; image/audio/video/document
* entries already match `ContentPart` and pass through. `binary` entries have no
* TanStack equivalent and are dropped.
*/
function aguiUserContentToParts(content) {
if (typeof content === "string") return content ? [{
type: "text",
content
}] : [];
const parts = [];
for (const part of content) if (part.type === "text") parts.push({
type: "text",
content: part.text
});
else if (part.type !== "binary") parts.push(part);
return parts;
}
/**
* Convert an array of ModelMessages to UIMessages
*
* This handles merging tool result messages with their corresponding assistant messages
*
* @param modelMessages - Array of ModelMessages to convert
* @returns Array of UIMessages
*/
function modelMessagesToUIMessages(modelMessages) {
const uiMessages = [];
let currentAssistantMessage = null;
for (const msg of modelMessages) if (msg.role === "tool") if (msg.toolCallId !== void 0 && currentAssistantMessage && currentAssistantMessage.role === "assistant") {
const content = getTextContent(msg.content);
const toolCallPart = currentAssistantMessage.parts.find((part) => part.type === "tool-call" && part.id === msg.toolCallId);
if (toolCallPart) {
toolCallPart.output = parseToolResultContent(content);
toolCallPart.state = "complete";
}
currentAssistantMessage.parts.push({
type: "tool-result",
toolCallId: msg.toolCallId,
content,
state: "complete"
});
} else {
const toolResultUIMessage = modelMessageToUIMessage(msg, msg.id);
uiMessages.push(toolResultUIMessage);
}
else {
const uiMessage = modelMessageToUIMessage(msg, msg.id);
uiMessages.push(uiMessage);
if (msg.role === "assistant") currentAssistantMessage = uiMessage;
else currentAssistantMessage = null;
}
return uiMessages;
}
/**
* Normalize a message (UIMessage or ModelMessage) to a UIMessage
* Ensures the message has an ID and createdAt timestamp
*
* @param message - Either a UIMessage or ModelMessage
* @param generateId - Function to generate a message ID if needed
* @returns A UIMessage with guaranteed id and createdAt
*/
function normalizeToUIMessage(message, generateId) {
if ("parts" in message) return {
...message,
id: message.id || generateId(),
createdAt: message.createdAt || /* @__PURE__ */ new Date()
};
else return {
...modelMessageToUIMessage(message, generateId()),
createdAt: /* @__PURE__ */ new Date()
};
}
/**
* Generate a unique message ID
*/
function generateMessageId() {
return `msg-${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
//#endregion
export { aguiSnapshotMessageToUIMessage, convertMessagesToModelMessages, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, uiMessageToModelMessages };
//# sourceMappingURL=messages.js.map