diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue
index d51b00eb62..237beb9823 100644
--- a/packages/plugins/robot/src/Main.vue
+++ b/packages/plugins/robot/src/Main.vue
@@ -19,11 +19,11 @@
v-model:show="robotVisible"
v-model:input="inputMessage"
:status="mappedStatus"
- :chat-mode="robotSettingState.chatMode"
:prompt-items="promptItems"
:bubble-renderers="bubbleRenderers"
:allowFiles="isVisualModel && robotSettingState.chatMode === ChatMode.Agent"
:show-aborted="robotSettingState.chatMode !== ChatMode.Agent"
+ :message-content-resolver="resolveChatMessageContent"
:beforeSubmit="checkApiKey"
:promptClickHandler="promptClickHandler"
@fileSelected="handleFileSelected"
@@ -93,6 +93,16 @@ import { AgentRenderer } from './components/renderers'
import useChat from './composables/useChat'
import useModelConfig from './composables/core/useConfig'
import { ChatMode } from './types/mode.types'
+import { STATUS } from './constants/status'
+import {
+ AgentMessageStatus,
+ RobotMessageContentType,
+ RobotMessageRole,
+ isAgentFinalStatus,
+ type MessageResolverContext,
+ type RobotMessage,
+ type RobotRenderContentItem
+} from './types'
import apiService from './services/api'
const props = defineProps({
@@ -106,6 +116,7 @@ const { robotSettingState, getModelCapabilities, updateThinkingState, getSelecte
const robotVisible = ref(false)
const fullscreen = ref(false)
+const inputMessage = ref('')
watch(robotVisible, (visible) => {
useLayout().layoutState.toolbars.render = visible ? META_APP.Robot : ''
@@ -150,7 +161,6 @@ const showSetting = ref(false)
const {
mappedStatus,
- inputMessage,
messages,
changeChatMode,
abortRequest,
@@ -224,9 +234,9 @@ const promptClickHandler = (item: PromptProps & { mode?: 'chat' | 'agent' }) =>
changeChatMode(item.mode)
}
messages.value.push({
- role: 'user',
+ role: RobotMessageRole.User,
content: item.description || '',
- renderContent: [{ type: 'text', content: item.description }]
+ renderContent: [{ type: RobotMessageContentType.Text, content: item.description }]
})
sendUserMessage()
}
@@ -245,8 +255,67 @@ const openAIRobot = () => {
useLayout().closeSetting(true)
}
-// 当前Robot的bubbleRenderers无法做到响应式更新,因此Agent模式的type要与Chat模式不同
-const bubbleRenderers = { 'agent-content': AgentRenderer, 'agent-loading': AgentRenderer }
+// 当前 Robot 的 bubbleRenderers 无法做到响应式更新,因此 Agent 模式需要独立的内容类型。
+// `agent-content` 表示 Agent 的最终内容片段;`agent-loading` 表示 Agent 处理中间态占位片段。
+const bubbleRenderers = {
+ [RobotMessageContentType.AgentContent]: AgentRenderer,
+ [RobotMessageContentType.AgentLoading]: AgentRenderer
+}
+
+const resolveChatMessageContent = (message: RobotMessage, context: MessageResolverContext) => {
+ const renderContent = Array.isArray(message.renderContent) ? message.renderContent : []
+ const hasAgentContent = renderContent.some((item) => {
+ return item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading
+ })
+ const isAgentMessage = message.metadata?.chatMode === ChatMode.Agent || hasAgentContent
+
+ if (!isAgentMessage || message.role !== RobotMessageRole.Assistant) {
+ return Array.isArray(message.renderContent) && message.renderContent.length > 0
+ ? message.renderContent
+ : message.content
+ }
+
+ // context.status 是当前整轮对话请求的运行状态,不是单条渲染片段状态。
+ const isLastMessage = context.messages.at(-1) === message
+ const isGenerating = Boolean(message.loading) || (isLastMessage && context.status !== STATUS.FINISHED)
+ const resolvedRenderContent = isGenerating
+ ? renderContent
+ : renderContent.filter((item) => item.type !== RobotMessageContentType.AgentLoading)
+ const agentContents = resolvedRenderContent.filter(
+ (item): item is RobotRenderContentItem =>
+ item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading
+ )
+ // item.status 是单个 agent 片段的业务结果,例如 success/failed/fix/loading。
+ const finalStatus = agentContents.findLast((item) => isAgentFinalStatus(item.status))?.status
+
+ if (!Array.isArray(message.renderContent) || message.renderContent.length === 0) {
+ const agentStatus = isAgentFinalStatus(message.metadata?.agentStatus)
+ ? message.metadata.agentStatus
+ : AgentMessageStatus.Failed
+ return [
+ {
+ type: RobotMessageContentType.AgentContent,
+ status: agentStatus,
+ content: message.content
+ }
+ ]
+ }
+
+ return resolvedRenderContent.map((item) => {
+ if (item.type !== RobotMessageContentType.AgentContent || isGenerating) {
+ return item
+ }
+
+ if (!item.status || item.status === AgentMessageStatus.Loading) {
+ return {
+ ...item,
+ status: finalStatus || message.metadata?.agentStatus || AgentMessageStatus.Failed
+ }
+ }
+
+ return item
+ })
+}
const handleFileSelected = async (formData: FormData, updateAttachment: (resourceUrl: string) => void) => {
try {
diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue
index 8206bdccae..f69c0a8ea6 100644
--- a/packages/plugins/robot/src/components/chat/RobotChat.vue
+++ b/packages/plugins/robot/src/components/chat/RobotChat.vue
@@ -11,7 +11,7 @@
-
+
+ },
allowFiles: {
type: Boolean,
default: false
@@ -139,7 +149,7 @@ const selectedAttachments = ref([])
const robotVisible = defineModel('show', { required: true })
const fullscreen = defineModel('fullscreen')
const inputMessage = defineModel('input', { required: true })
-const messages = defineModel('messages', { required: true })
+const messages = defineModel('messages', { required: true })
const senderRef = ref | null>(null)
watch(
@@ -159,63 +169,29 @@ const contentRendererMatches = computed(() => [
},
...Object.entries(props.bubbleRenderers).map(([type, renderer]) => ({
priority: BubbleRendererMatchPriority.NORMAL,
- find: (_message: any, content: any) => content?.type === type,
+ find: (_message: RobotMessage, content: RobotRenderContentItem) => content?.type === type,
renderer
})),
{
priority: BubbleRendererMatchPriority.NORMAL,
- find: (message: any, content: any) => content?.type === 'tool' && message.tool_calls?.length,
+ find: (message: RobotMessage, content: RobotRenderContentItem) =>
+ content?.type === RobotMessageContentType.Tool && Boolean(message.tool_calls?.length),
renderer: BubbleRenderers.Tools
},
{
priority: BubbleRendererMatchPriority.NORMAL,
- find: (message: any, content: any) =>
- !message.loading && message.content && (!content?.type || ['markdown', 'text'].includes(content.type)),
+ find: (_message: RobotMessage, content: RobotRenderContentItem) =>
+ !content?.type || [RobotMessageContentType.Markdown, RobotMessageContentType.Text].includes(content.type as any),
renderer: MarkdownRenderer
},
{
priority: BubbleRendererMatchPriority.NORMAL,
- find: (message: any) => message?.content?.[0]?.type === 'img' || message?.content?.[0]?.type === 'image',
+ find: (_message: RobotMessage, content: RobotRenderContentItem) =>
+ [RobotMessageContentType.Img, RobotMessageContentType.Image].includes(content?.type as any),
renderer: ImgRenderer
}
])
-const isAgentMessage = (message: any) => {
- const hasAgentContent = message.renderContent?.some((item: any) => {
- return item.type === 'agent-content' || item.type === 'agent-loading'
- })
- return message.metadata?.chatMode === 'agent' || hasAgentContent
-}
-
-const resolveAgentRenderContent = (message: any) => {
- if (!isAgentMessage(message) || message.role !== 'assistant') {
- return message.renderContent
- }
-
- const isLastMessage = messages.value.at(-1) === message
- const isGenerating = Boolean(message.loading) || (isLastMessage && GeneratingStatus.includes(props.status as any))
- const renderContent = isGenerating
- ? message.renderContent
- : message.renderContent.filter((item: any) => item.type !== 'agent-loading')
- const agentContents = renderContent.filter((item: any) => item.type === 'agent-content')
- const finalStatus = agentContents.findLast((item: any) => ['success', 'failed', 'fix'].includes(item.status))?.status
-
- return renderContent.map((item: any) => {
- if (item.type !== 'agent-content' || isGenerating) {
- return item
- }
-
- if (!item.status || item.status === 'loading') {
- return {
- ...item,
- status: finalStatus || message.metadata?.agentStatus || 'failed'
- }
- }
-
- return item
- })
-}
-
// 处理文件选择事件
const handleSingleFilesSelected = (files: File[] | null, retry = false) => {
if (!files?.length) return
@@ -271,36 +247,56 @@ const getSvgIcon = (name: string, style?: CSSProperties) => {
const aiAvatar = getSvgIcon('AI')
const welcomeIcon = getSvgIcon('AI', { fontSize: '44px' })
-const resolveMessageContent = (message: any) => {
- if (Array.isArray(message.renderContent) && message.renderContent.length > 0) {
- return resolveAgentRenderContent(message)
+const resolveMessageContent = (message: RobotMessage) => {
+ if (props.messageContentResolver) {
+ return props.messageContentResolver(message, {
+ messages: messages.value,
+ status: props.status
+ })
}
- if (isAgentMessage(message) && message.role === 'assistant' && message.content) {
- const agentStatus = ['success', 'failed', 'fix'].includes(message.metadata?.agentStatus)
- ? message.metadata.agentStatus
- : 'failed'
- return [
- {
- type: 'agent-content',
- status: agentStatus,
- content: message.content
+ if (Array.isArray(message.renderContent) && message.renderContent.length > 0) {
+ return message.renderContent.map((item) => {
+ if (item?.type === RobotMessageContentType.Img || item?.type === RobotMessageContentType.Image) {
+ return {
+ type: RobotMessageContentType.Img,
+ content: item.content || item.url || item.image_url?.url || ''
+ }
}
- ]
+ if (item?.type === RobotMessageContentType.Text) {
+ return {
+ type: RobotMessageContentType.Text,
+ content: item.content ?? item.text ?? ''
+ }
+ }
+ return item
+ })
+ }
+
+ if (Array.isArray(message.content) && message.content.length > 0) {
+ const textContent = extractMessageText(message.content)
+ if (textContent) {
+ return textContent
+ }
+ }
+
+ const textContent = extractMessageText(message.content)
+ if (textContent) {
+ return textContent
}
return message.content
}
const roleConfigs: Record = {
- assistant: {
+ [RobotMessageRole.Assistant]: {
placement: 'start',
avatar: aiAvatar
},
- user: {
+ [RobotMessageRole.User]: {
placement: 'end'
},
- system: {
+ [RobotMessageRole.System]: {
hidden: true
}
}
@@ -320,37 +316,38 @@ const handleSendMessage = async (content: string) => {
return
}
- const userMessage: ChatMessage = {
- role: 'user',
+ const userMessage: RobotMessage = {
+ role: RobotMessageRole.User,
content: messageContent
}
const files = selectedAttachments.value.filter((item) => item.status === 'success')
if (files.length > 0) {
- const fileMessages: ChatMessage[] = files.map((file) => ({
- role: 'user',
- content: '',
- renderContent: [
- {
- type: 'img',
- content: file.url
- }
- ]
- }))
- messages.value.push(...fileMessages)
- userMessage.content = files
- .map((item) => ({
- type: 'image_url',
+ userMessage.content = [
+ {
+ type: RobotMessageContentType.Text,
+ text: messageContent
+ },
+ ...files.map((item) => ({
+ type: RobotMessageContentType.ImageUrl,
image_url: {
url: item.url
}
}))
- .concat({
- type: 'text',
- text: messageContent
- })
+ ] as RobotInputContentPart[]
+ userMessage.renderContent = [
+ {
+ type: RobotMessageContentType.Text,
+ content: messageContent
+ },
+ ...files.map((item) => ({
+ type: RobotMessageContentType.Img,
+ content: item.url
+ }))
+ ]
+ } else {
userMessage.renderContent = [
{
- type: 'text',
+ type: RobotMessageContentType.Text,
content: messageContent
}
]
diff --git a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue
index 6774d28f4f..f2a3d7e012 100644
--- a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue
+++ b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue
@@ -8,6 +8,7 @@ import DOMPurify from 'dompurify'
import MarkdownIt from 'markdown-it'
import type { Options } from 'markdown-it'
import hljs from 'highlight.js/lib/core'
+import { extractMessageText } from '../../utils'
import 'highlight.js/styles/github.css'
// 按需加载语言
@@ -29,7 +30,7 @@ hljs.registerLanguage('xml', xml)
hljs.registerLanguage('shell', shell)
interface MarkdownMessage {
- content: string
+ content: unknown
}
const props = defineProps({
@@ -71,7 +72,15 @@ const markdownIt = new MarkdownIt({
})
const renderContent = computed(() => {
- return DOMPurify.sanitize(markdownIt.render(props.message.content))
+ let content = ''
+
+ if (typeof props.message.content === 'string') {
+ content = props.message.content
+ } else if (Array.isArray(props.message.content)) {
+ content = extractMessageText(props.message.content)
+ }
+
+ return DOMPurify.sanitize(markdownIt.render(content))
})
diff --git a/packages/plugins/robot/src/composables/core/useConversation.ts b/packages/plugins/robot/src/composables/core/useConversation.ts
index c5dec73ba3..94c6045b60 100644
--- a/packages/plugins/robot/src/composables/core/useConversation.ts
+++ b/packages/plugins/robot/src/composables/core/useConversation.ts
@@ -12,6 +12,8 @@ import {
import type { CompletionChoice } from '@opentiny/tiny-robot-kit'
import { STATUS, type MessageState } from '../../constants/status'
import type { OpenAICompatibleProvider } from '../../services/OpenAICompatibleProvider'
+import { extractMessageText } from '../../utils/chat.utils'
+import { RobotMessageRole } from '../../types/chat.types'
export interface ConversationAdapterOptions {
provider: Pick
@@ -100,7 +102,8 @@ const createResponseProvider = (
}
const updateMessageMetadata = (currentMessage: ChatMessage, chunk: ChatCompletion, choice?: CompletionChoice) => {
- currentMessage.role = choice?.delta?.role || choice?.message?.role || currentMessage.role || 'assistant'
+ currentMessage.role =
+ choice?.delta?.role || choice?.message?.role || currentMessage.role || RobotMessageRole.Assistant
currentMessage.loading = undefined
currentMessage.renderContent ||= []
currentMessage.metadata ||= {}
@@ -180,7 +183,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) {
const saveConversations = () => {
conversations.value.forEach((conversation) => {
- void saveConversation(conversation)
+ saveConversation(conversation)
})
}
@@ -198,7 +201,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) {
currentConversationMetadata = conversation.metadata
}
conversation.updatedAt = Date.now()
- void saveConversation(conversation)
+ saveConversation(conversation)
}
const updateTitle = (conversationId: string, title?: string) => {
@@ -258,7 +261,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) {
}
}
currentConversationMetadata = conversation.metadata || {}
- void saveConversation(conversation)
+ saveConversation(conversation)
return currentId
}
}
@@ -328,7 +331,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) {
const currentTitle = currentConversation.title
if (currentTitle === defaultTitle && currentId) {
const messageContent = getActiveEngine()?.messages.value.find((item) => item.role === 'user')?.content
- const contentStr = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent)
+ const contentStr = extractMessageText(messageContent) || JSON.stringify(messageContent)
updateTitle(currentId, contentStr.substring(0, 20))
}
}
diff --git a/packages/plugins/robot/src/types/chat.types.ts b/packages/plugins/robot/src/types/chat.types.ts
index f5a1d7f691..dfde3ec246 100644
--- a/packages/plugins/robot/src/types/chat.types.ts
+++ b/packages/plugins/robot/src/types/chat.types.ts
@@ -1,6 +1,8 @@
import type { BubbleContentItem } from '@opentiny/tiny-robot'
import type { ResponseToolCall } from './mcp.types'
import type { ChatMessage } from '@opentiny/tiny-robot-kit'
+import type { ChatMode } from './mode.types'
+import type { STATUS } from '../constants/status'
export interface RequestOptions {
url?: string
@@ -36,18 +38,183 @@ export interface LLMMessage {
[prop: string]: unknown
}
+/**
+ * 消息角色枚举。
+ * 与模型协议中的 role 对齐,用于区分消息发送方。
+ */
+export enum RobotMessageRole {
+ Assistant = 'assistant',
+ User = 'user',
+ System = 'system',
+ Tool = 'tool'
+}
+
+/**
+ * 气泡内容类型枚举。
+ * type 描述的是一条消息中“某一段渲染内容”应该如何展示。
+ */
+export enum RobotMessageContentType {
+ Text = 'text',
+ Markdown = 'markdown',
+ Tool = 'tool',
+ Img = 'img',
+ Image = 'image',
+ ImageUrl = 'image_url',
+ Loading = 'loading',
+ AgentContent = 'agent-content',
+ AgentLoading = 'agent-loading',
+ Reasoning = 'reasoning'
+}
+
+/**
+ * Agent 渲染片段状态。
+ * item.status 描述的是单个 agent 片段的业务执行结果,不等同于会话级请求状态。
+ */
+export enum AgentMessageStatus {
+ Loading = 'loading',
+ Reasoning = 'reasoning',
+ Running = 'running',
+ Success = 'success',
+ Failed = 'failed',
+ Fix = 'fix'
+}
+
+export const AgentFinalStatuses = [
+ AgentMessageStatus.Success,
+ AgentMessageStatus.Failed,
+ AgentMessageStatus.Fix
+] as const
+
+export type AgentFinalStatus = typeof AgentFinalStatuses[number]
+
+export interface RobotTextContentPart {
+ type: RobotMessageContentType.Text
+ text?: string
+ content?: string
+}
+
+export interface RobotImageUrlContentPart {
+ type: RobotMessageContentType.ImageUrl
+ image_url: {
+ url: string
+ }
+}
+
+export type RobotInputContentPart = string | RobotTextContentPart | RobotImageUrlContentPart
+
+export interface BubbleTextContentItem extends BubbleContentItem {
+ type: RobotMessageContentType.Text | RobotMessageContentType.Markdown
+ content?: string
+ text?: string
+}
+
+export interface BubbleImageContentItem extends BubbleContentItem {
+ type: RobotMessageContentType.Img | RobotMessageContentType.Image
+ content?: string
+ url?: string
+ image_url?: {
+ url: string
+ }
+}
+
+export interface BubbleToolContentItem extends BubbleContentItem {
+ type: RobotMessageContentType.Tool
+ name?: string
+ status?: string
+ content?: unknown
+ toolCallId?: string
+ formatPretty?: boolean
+}
+
+export interface BubbleLoadingContentItem extends BubbleContentItem {
+ type: RobotMessageContentType.Loading
+ content?: string
+}
+
+export interface BubbleAgentContentItem extends BubbleContentItem {
+ type: RobotMessageContentType.AgentContent | RobotMessageContentType.AgentLoading
+ status?: AgentMessageStatus | string
+ content?: unknown
+ contentType?: RobotMessageContentType | string
+}
+
+export type RobotRenderContentItem =
+ | BubbleTextContentItem
+ | BubbleImageContentItem
+ | BubbleToolContentItem
+ | BubbleLoadingContentItem
+ | BubbleAgentContentItem
+ | (BubbleContentItem & {
+ type?: string
+ content?: unknown
+ status?: string
+ text?: string
+ url?: string
+ image_url?: { url: string }
+ toolCallId?: string
+ contentType?: string
+ formatPretty?: boolean
+ name?: string
+ })
+
+export interface RobotMessageMetadata {
+ chatMode?: ChatMode | string
+ agentStatus?: AgentMessageStatus | string
+ createdAt?: number
+ updatedAt?: number
+ id?: string
+ model?: string
+ [key: string]: unknown
+}
+
+export interface RobotMessageState {
+ thinking?: boolean
+ toolsHandled?: boolean
+ toolCall?: Record
+ toolCallResults?: Record
+ [key: string]: unknown
+}
+
+export type RobotMessageContent = string | RobotInputContentPart[]
+
export type Message = ChatMessage & {
- renderContent: BubbleContentItem[]
+ role: RobotMessageRole | string
+ content: RobotMessageContent
+ renderContent: RobotRenderContentItem[]
tool_calls: ResponseToolCall[]
+ metadata?: RobotMessageMetadata
+ state?: RobotMessageState
}
-export interface RobotMessage {
- role: string
- content: string | BubbleContentItem[]
- renderContent?: Array
+export interface RobotMessage extends Omit, 'role' | 'content' | 'renderContent' | 'tool_calls'> {
+ role: RobotMessageRole | string
+ content: RobotMessageContent
+ renderContent?: RobotRenderContentItem[]
+ tool_calls?: ResponseToolCall[]
+ metadata?: RobotMessageMetadata
+ state?: RobotMessageState
+ loading?: boolean
+ aborted?: boolean
+ reasoning_content?: string
+ originContent?: string
[prop: string]: unknown
}
+export interface MessageResolverContext {
+ messages: RobotMessage[]
+ /**
+ * context.status 是当前整轮请求的生命周期状态:
+ * pending/streaming/finished/error/aborted。
+ */
+ status: STATUS | string
+}
+
+export type MessageContentResolver = (message: RobotMessage, context: MessageResolverContext) => unknown
+
+export const isAgentFinalStatus = (status: unknown): status is AgentFinalStatus => {
+ return typeof status === 'string' && AgentFinalStatuses.includes(status as AgentFinalStatus)
+}
+
export interface LLMRequestBody {
baseUrl?: string
model?: string
diff --git a/packages/plugins/robot/src/utils/chat.utils.ts b/packages/plugins/robot/src/utils/chat.utils.ts
index 702a2cc343..00d822a854 100644
--- a/packages/plugins/robot/src/utils/chat.utils.ts
+++ b/packages/plugins/robot/src/utils/chat.utils.ts
@@ -1,6 +1,13 @@
import { toRaw } from 'vue'
import type { StreamHandler } from '@opentiny/tiny-robot-kit'
-import type { LLMMessage, RobotMessage } from '../types'
+import {
+ RobotMessageContentType,
+ type LLMMessage,
+ type RobotInputContentPart,
+ type RobotMessage,
+ type RobotMessageContent,
+ type RobotRenderContentItem
+} from '../types'
// 格式化LLM输入messages消息
export const formatMessages = (messages: LLMMessage[]) => {
@@ -31,6 +38,32 @@ export const serializeError = (err: unknown): string => {
}
}
+export const extractMessageText = (content: RobotMessageContent | RobotRenderContentItem[] | unknown): string => {
+ if (typeof content === 'string') {
+ return content
+ }
+
+ if (!Array.isArray(content)) {
+ return ''
+ }
+
+ return content
+ .map((item) => {
+ if (typeof item === 'string') {
+ return item
+ }
+
+ const typedItem = item as RobotInputContentPart | RobotRenderContentItem
+ if (typedItem?.type === RobotMessageContentType.Text) {
+ return typedItem.text ?? typedItem.content ?? ''
+ }
+
+ return ''
+ })
+ .filter(Boolean)
+ .join('\n')
+}
+
/**
* 合并字符串字段。如果值是对象,则递归合并字符串字段
* @param target 目标对象
diff --git a/packages/plugins/robot/test/composables/core/useConversation.test.ts b/packages/plugins/robot/test/composables/core/useConversation.test.ts
new file mode 100644
index 0000000000..1521b8dbde
--- /dev/null
+++ b/packages/plugins/robot/test/composables/core/useConversation.test.ts
@@ -0,0 +1,691 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { markRaw, ref } from 'vue'
+
+type MockConversation = {
+ id: string
+ title: string
+ createdAt: number
+ updatedAt: number
+ metadata?: Record
+ engine?: {
+ messages: ReturnType>
+ sendMessage: ReturnType
+ send: ReturnType
+ }
+}
+
+const storageSaveConversation = vi.fn()
+const deleteConversation = vi.fn()
+const clear = vi.fn()
+const updateConversationTitle = vi.fn((conversationId: string, title?: string) => {
+ const conversation = conversations.value.find((item) => item.id === conversationId)
+ if (conversation) {
+ conversation.title = title || conversation.title
+ }
+})
+const saveMessages = vi.fn()
+const abortActiveRequest = vi.fn()
+const switchConversationKit = vi.fn()
+const createConversationKit = vi.fn()
+
+const activeConversationId = ref('conv-1')
+const conversations = ref([])
+const activeConversation = ref(null)
+
+let lastUseConversationOptions: any = null
+
+const syncActiveConversation = () => {
+ activeConversation.value = conversations.value.find((item) => item.id === activeConversationId.value) || null
+}
+
+const updateCurrentEngine = (engine?: MockConversation['engine']) => {
+ const index = conversations.value.findIndex((item) => item.id === activeConversationId.value)
+ if (index !== -1) {
+ conversations.value[index] = {
+ ...conversations.value[index],
+ engine
+ }
+ }
+ syncActiveConversation()
+}
+
+const createEngine = (messages: any[] = []) => ({
+ messages: ref(messages),
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ send: vi.fn().mockResolvedValue(undefined)
+})
+
+const createRawEngine = (
+ messages: any[] = [],
+ overrides: Partial = {}
+): MockConversation['engine'] =>
+ markRaw({
+ ...createEngine(messages),
+ ...overrides
+ }) as MockConversation['engine']
+
+const createConversationRecord = (
+ overrides: Partial = {},
+ messageOverrides: any[] = []
+): MockConversation => ({
+ id: overrides.id || 'conv-1',
+ title: overrides.title || '新会话',
+ createdAt: overrides.createdAt || 100,
+ updatedAt: overrides.updatedAt || 100,
+ metadata: overrides.metadata || {},
+ engine: overrides.engine || createRawEngine(messageOverrides),
+ ...overrides
+})
+
+vi.mock('@opentiny/tiny-robot-kit', () => ({
+ localStorageStrategyFactory: () => ({
+ saveConversation: storageSaveConversation
+ }),
+ useConversation: (options: any) => {
+ lastUseConversationOptions = options
+ return {
+ conversations,
+ activeConversationId,
+ activeConversation,
+ createConversation: createConversationKit,
+ switchConversation: switchConversationKit,
+ deleteConversation,
+ clear,
+ updateConversationTitle,
+ saveMessages,
+ abortActiveRequest
+ }
+ }
+}))
+
+describe('useConversationAdapter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ lastUseConversationOptions = null
+ activeConversationId.value = 'conv-1'
+ conversations.value = [
+ createConversationRecord(
+ {
+ id: 'conv-1',
+ title: '新会话',
+ metadata: { chatMode: 'chat' }
+ },
+ []
+ )
+ ]
+ syncActiveConversation()
+
+ createConversationKit.mockImplementation(({ title, metadata }: any) => {
+ const conversation = createConversationRecord({
+ id: `conv-${conversations.value.length + 1}`,
+ title,
+ metadata: metadata || {},
+ createdAt: 100 + conversations.value.length,
+ updatedAt: 100 + conversations.value.length
+ })
+ conversations.value.push(conversation)
+ activeConversationId.value = conversation.id
+ syncActiveConversation()
+ return conversation
+ })
+
+ switchConversationKit.mockImplementation(async (conversationId: string) => {
+ activeConversationId.value = conversationId
+ syncActiveConversation()
+ return true
+ })
+ })
+
+ const createAdapter = async () => {
+ const module = await import('../../../src/composables/core/useConversation')
+
+ return module.useConversationAdapter({
+ provider: {
+ chatStream: vi.fn().mockResolvedValue(undefined)
+ } as any,
+ onStreamData: vi.fn(),
+ onFinishRequest: vi.fn().mockResolvedValue(undefined),
+ onMessageProcessed: vi.fn().mockResolvedValue(undefined),
+ statusManager: {
+ isProcessing: vi.fn(() => false),
+ setProcessing: vi.fn(),
+ resetProcessing: vi.fn()
+ }
+ })
+ }
+
+ describe('conversation state and save helpers', () => {
+ it('exposes current conversation id and conversation list', async () => {
+ const adapter = await createAdapter()
+
+ expect(adapter.conversationState.currentId).toBe('conv-1')
+ expect(adapter.conversationState.conversations).toHaveLength(1)
+ expect(adapter.conversationState.conversations[0].id).toBe('conv-1')
+ })
+
+ it('saves all conversations through the storage strategy', async () => {
+ conversations.value.push(
+ createConversationRecord({
+ id: 'conv-2',
+ title: '第二个会话',
+ metadata: { chatMode: 'agent' }
+ })
+ )
+ const adapter = await createAdapter()
+
+ adapter.saveConversations()
+
+ expect(storageSaveConversation).toHaveBeenCalledTimes(2)
+ expect(storageSaveConversation).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ id: 'conv-1',
+ title: '新会话',
+ metadata: { chatMode: 'chat' }
+ })
+ )
+ expect(storageSaveConversation).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ id: 'conv-2',
+ title: '第二个会话',
+ metadata: { chatMode: 'agent' }
+ })
+ )
+ })
+
+ it('does nothing when updateMetadata targets a missing conversation', async () => {
+ const adapter = await createAdapter()
+
+ adapter.updateMetadata('missing', { chatMode: 'agent' })
+
+ expect(storageSaveConversation).not.toHaveBeenCalled()
+ expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'chat' })
+ })
+
+ it('merges metadata and persists the active conversation', async () => {
+ const adapter = await createAdapter()
+
+ adapter.updateMetadata('conv-1', { chatMode: 'agent', feature: 'vision' })
+
+ expect(adapter.conversationState.conversations[0].metadata).toEqual({
+ chatMode: 'agent',
+ feature: 'vision'
+ })
+ expect(storageSaveConversation).toHaveBeenCalledTimes(1)
+ expect(storageSaveConversation).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'conv-1',
+ metadata: {
+ chatMode: 'agent',
+ feature: 'vision'
+ }
+ })
+ )
+ })
+
+ it('updates the current metadata cache so future assistant chunks inherit the latest mode', async () => {
+ const onStreamData = vi.fn()
+ const onFinishRequest = vi.fn().mockResolvedValue(undefined)
+ const onMessageProcessed = vi.fn().mockResolvedValue(undefined)
+ const statusManager = {
+ isProcessing: vi.fn(() => false),
+ setProcessing: vi.fn(),
+ resetProcessing: vi.fn()
+ }
+ const module = await import('../../../src/composables/core/useConversation')
+ const adapter = module.useConversationAdapter({
+ provider: {
+ chatStream: vi.fn().mockResolvedValue(undefined)
+ } as any,
+ onStreamData,
+ onFinishRequest,
+ onMessageProcessed,
+ statusManager
+ })
+
+ adapter.updateMetadata('conv-1', { chatMode: 'agent' })
+
+ const currentMessage = {
+ role: '',
+ renderContent: [],
+ metadata: {}
+ }
+ const chunk = {
+ created: 123,
+ id: 'cmpl-1',
+ model: 'model-a'
+ }
+ const choice = {
+ delta: {
+ role: 'assistant'
+ }
+ }
+
+ lastUseConversationOptions.useMessageOptions.onCompletionChunk(
+ {
+ currentMessage,
+ messages: [currentMessage],
+ chunk,
+ choice
+ },
+ () => {}
+ )
+
+ expect(currentMessage.metadata.chatMode).toBe('agent')
+ expect(currentMessage.metadata.createdAt).toBe(123)
+ expect(currentMessage.metadata.id).toBe('cmpl-1')
+ expect(currentMessage.metadata.model).toBe('model-a')
+ expect(onStreamData).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('message manager', () => {
+ it('exposes messages from the active engine', async () => {
+ updateCurrentEngine(createRawEngine([{ role: 'user', content: 'hello' }]))
+ const adapter = await createAdapter()
+
+ expect(adapter.messageManager.messages.value).toEqual([{ role: 'user', content: 'hello' }])
+ })
+
+ it('delegates sendMessage to the active engine', async () => {
+ const sendMessage = vi.fn().mockResolvedValue('sent')
+ updateCurrentEngine(
+ createRawEngine([], {
+ messages: ref([]),
+ sendMessage,
+ send: vi.fn().mockResolvedValue(undefined)
+ })
+ )
+ const adapter = await createAdapter()
+
+ await adapter.messageManager.sendMessage('hello world')
+
+ expect(sendMessage).toHaveBeenCalledWith('hello world')
+ })
+
+ it('delegates send to the active engine', async () => {
+ const send = vi.fn().mockResolvedValue('sent')
+ updateCurrentEngine(
+ createRawEngine([], {
+ messages: ref([]),
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ send
+ })
+ )
+ const adapter = await createAdapter()
+ const message = { role: 'user', content: 'hello' }
+
+ await adapter.messageManager.send(message as any)
+
+ expect(send).toHaveBeenCalledWith(message)
+ })
+
+ it('falls back to resolved promises when there is no active engine', async () => {
+ updateCurrentEngine(undefined)
+ const adapter = await createAdapter()
+
+ await expect(adapter.messageManager.sendMessage('hello')).resolves.toBeUndefined()
+ await expect(adapter.messageManager.send({ role: 'user', content: 'msg' } as any)).resolves.toBeUndefined()
+ })
+
+ it('delegates abortRequest to the underlying conversation hook', async () => {
+ const adapter = await createAdapter()
+
+ adapter.messageManager.abortRequest()
+
+ expect(abortActiveRequest).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('createConversation', () => {
+ it('reuses the current empty conversation instead of creating a new one', async () => {
+ const adapter = await createAdapter()
+
+ const result = adapter.createConversation('重命名会话', { chatMode: 'agent' })
+
+ expect(result).toBe('conv-1')
+ expect(createConversationKit).not.toHaveBeenCalled()
+ expect(adapter.conversationState.conversations[0].title).toBe('重命名会话')
+ expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'agent' })
+ expect(storageSaveConversation).toHaveBeenCalledTimes(1)
+ })
+
+ it('creates a brand new conversation when the current one already contains user content', async () => {
+ updateCurrentEngine(createRawEngine([{ role: 'user', content: '已有消息' }]))
+ const adapter = await createAdapter()
+
+ const result = adapter.createConversation('新的会话', { chatMode: 'agent' })
+
+ expect(result).toBe('conv-2')
+ expect(createConversationKit).toHaveBeenCalledWith({
+ title: '新的会话',
+ metadata: { chatMode: 'agent' }
+ })
+ expect(adapter.conversationState.currentId).toBe('conv-2')
+ expect(adapter.conversationState.conversations).toHaveLength(2)
+ })
+
+ it('treats tool calls as non-empty conversation content', async () => {
+ updateCurrentEngine(createRawEngine([{ role: 'assistant', content: '', tool_calls: [{ id: 'call-1' }] }]))
+ const adapter = await createAdapter()
+
+ adapter.createConversation('工具会话', { chatMode: 'chat' })
+
+ expect(createConversationKit).toHaveBeenCalledTimes(1)
+ })
+
+ it('treats tool_call_id as non-empty conversation content', async () => {
+ updateCurrentEngine(createRawEngine([{ role: 'tool', content: '', tool_call_id: 'call-1' }]))
+ const adapter = await createAdapter()
+
+ adapter.createConversation('工具结果会话', { chatMode: 'chat' })
+
+ expect(createConversationKit).toHaveBeenCalledTimes(1)
+ })
+
+ it('treats renderContent-only messages as non-empty conversation content', async () => {
+ updateCurrentEngine(createRawEngine([{ role: 'user', content: '', renderContent: [{ type: 'text' }] }]))
+ const adapter = await createAdapter()
+
+ adapter.createConversation('渲染内容会话', { chatMode: 'chat' })
+
+ expect(createConversationKit).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps existing metadata when reusing an empty conversation without new metadata', async () => {
+ conversations.value[0].metadata = { chatMode: 'chat', tag: 'old' }
+ const adapter = await createAdapter()
+
+ adapter.createConversation('空会话改名')
+
+ expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'chat', tag: 'old' })
+ expect(storageSaveConversation).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('switchConversation', () => {
+ it('returns null when the target conversation does not exist', async () => {
+ const adapter = await createAdapter()
+
+ const result = await adapter.switchConversation('missing')
+
+ expect(result).toBeNull()
+ expect(switchConversationKit).not.toHaveBeenCalled()
+ })
+
+ it('delegates to the underlying switch function and calls onStart with wrapped apis', async () => {
+ conversations.value.push(
+ createConversationRecord({
+ id: 'conv-2',
+ title: '第二个会话',
+ metadata: { chatMode: 'agent' }
+ })
+ )
+ const adapter = await createAdapter()
+ const onStart = vi.fn()
+
+ const result = await adapter.switchConversation('conv-2', onStart)
+
+ expect(result).toBe(true)
+ expect(switchConversationKit).toHaveBeenCalledWith('conv-2')
+ expect(onStart).toHaveBeenCalledTimes(1)
+ const [state, messages, methods] = onStart.mock.calls[0]
+ expect(state.currentId).toBe('conv-2')
+ expect(messages).toEqual([])
+ expect(methods.createConversation).toBeTypeOf('function')
+ expect(methods.switchConversation).toBeTypeOf('function')
+ expect(methods.updateMetadata).toBeTypeOf('function')
+ })
+
+ it('does not call onStart when the underlying switch returns a falsy result', async () => {
+ conversations.value.push(
+ createConversationRecord({
+ id: 'conv-2',
+ title: '第二个会话',
+ metadata: { chatMode: 'agent' }
+ })
+ )
+ switchConversationKit.mockResolvedValueOnce(false)
+ const adapter = await createAdapter()
+ const onStart = vi.fn()
+
+ const result = await adapter.switchConversation('conv-2', onStart)
+
+ expect(result).toBe(false)
+ expect(onStart).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('autoSetTitle', () => {
+ it('updates the default title using the first user text message', async () => {
+ updateCurrentEngine(
+ createRawEngine([
+ { role: 'system', content: 'system prompt' },
+ { role: 'user', content: '请帮我生成一个表单页面' },
+ { role: 'assistant', content: '好的' }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '请帮我生成一个表单页面')
+ })
+
+ it('extracts title text from multimodal user content arrays', async () => {
+ updateCurrentEngine(
+ createRawEngine([
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: '对比这两张图片的布局差异并总结' },
+ { type: 'image_url', image_url: { url: 'https://example.com/1.png' } },
+ { type: 'image_url', image_url: { url: 'https://example.com/2.png' } }
+ ]
+ }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '对比这两张图片的布局差异并总结')
+ })
+
+ it('joins multiple text fragments from multimodal content before truncation', async () => {
+ updateCurrentEngine(
+ createRawEngine([
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: '第一段需求' },
+ { type: 'image_url', image_url: { url: 'https://example.com/1.png' } },
+ { type: 'text', text: '第二段补充说明' }
+ ]
+ }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '第一段需求\n第二段补充说明')
+ })
+
+ it('falls back to JSON when multimodal content does not contain any text part', async () => {
+ const imageOnlyMessage = [
+ { type: 'image_url', image_url: { url: 'https://example.com/1.png' } },
+ { type: 'image_url', image_url: { url: 'https://example.com/2.png' } }
+ ]
+ updateCurrentEngine(
+ createRawEngine([
+ {
+ role: 'user',
+ content: imageOnlyMessage
+ }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', JSON.stringify(imageOnlyMessage).substring(0, 20))
+ })
+
+ it('truncates long titles to 20 characters', async () => {
+ updateCurrentEngine(
+ createRawEngine([
+ {
+ role: 'user',
+ content: '这是一个非常长非常长非常长非常长的标题文本,用于测试截断逻辑'
+ }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith(
+ 'conv-1',
+ '这是一个非常长非常长非常长非常长的标题文本'.substring(0, 20)
+ )
+ })
+
+ it('uses the first user message instead of later user messages', async () => {
+ updateCurrentEngine(
+ createRawEngine([
+ {
+ role: 'user',
+ content: '第一次提问标题'
+ },
+ {
+ role: 'assistant',
+ content: '回答'
+ },
+ {
+ role: 'user',
+ content: '第二次提问标题'
+ }
+ ])
+ )
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '第一次提问标题')
+ })
+
+ it('does not update title when the conversation title has already been customized', async () => {
+ conversations.value[0].title = '用户手动标题'
+ updateCurrentEngine(createRawEngine([{ role: 'user', content: '不会被使用' }]))
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-1')
+
+ expect(updateConversationTitle).not.toHaveBeenCalled()
+ })
+
+ it('does not update title for inactive conversations', async () => {
+ conversations.value.push(
+ createConversationRecord({
+ id: 'conv-2',
+ title: '新会话',
+ metadata: { chatMode: 'agent' }
+ })
+ )
+ conversations.value[1] = {
+ ...conversations.value[1],
+ engine: createRawEngine([{ role: 'user', content: 'inactive conversation title' }])
+ }
+ syncActiveConversation()
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('conv-2')
+
+ expect(updateConversationTitle).not.toHaveBeenCalled()
+ })
+
+ it('does not update title when the conversation cannot be found', async () => {
+ const adapter = await createAdapter()
+
+ adapter.autoSetTitle('missing')
+
+ expect(updateConversationTitle).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('adapter plugin behavior', () => {
+ it('marks message state as error when the useMessage plugin reports an error', async () => {
+ const adapter = await createAdapter()
+ const error = new Error('stream failed')
+
+ lastUseConversationOptions.useMessageOptions.plugins[0].onError({ error })
+
+ expect(adapter.messageManager.messageState.status).toBe('error')
+ expect(adapter.messageManager.messageState.errorMsg).toBe(error)
+ })
+
+ it('calls onFinishRequest after the adapter plugin observes a finished response', async () => {
+ const onFinishRequest = vi.fn().mockResolvedValue(undefined)
+ const statusManager = {
+ isProcessing: vi.fn(() => false),
+ setProcessing: vi.fn(),
+ resetProcessing: vi.fn()
+ }
+ const module = await import('../../../src/composables/core/useConversation')
+ module.useConversationAdapter({
+ provider: {
+ chatStream: vi.fn().mockResolvedValue(undefined)
+ } as any,
+ onStreamData: vi.fn(),
+ onFinishRequest,
+ onMessageProcessed: vi.fn().mockResolvedValue(undefined),
+ statusManager
+ })
+ const messages = [
+ { role: 'user', content: 'hello' },
+ { role: 'assistant', content: 'world' }
+ ]
+
+ await lastUseConversationOptions.useMessageOptions.plugins[0].onAfterRequest({
+ messages,
+ lastChoice: {
+ finish_reason: 'stop'
+ }
+ })
+
+ expect(statusManager.setProcessing).toHaveBeenCalledTimes(1)
+ expect(onFinishRequest).toHaveBeenCalledWith('stop', messages, [messages[0]], expect.any(Object))
+ })
+
+ it('skips onFinishRequest when the status manager is already processing', async () => {
+ const onFinishRequest = vi.fn().mockResolvedValue(undefined)
+ const statusManager = {
+ isProcessing: vi.fn(() => true),
+ setProcessing: vi.fn(),
+ resetProcessing: vi.fn()
+ }
+ const module = await import('../../../src/composables/core/useConversation')
+ module.useConversationAdapter({
+ provider: {
+ chatStream: vi.fn().mockResolvedValue(undefined)
+ } as any,
+ onStreamData: vi.fn(),
+ onFinishRequest,
+ onMessageProcessed: vi.fn().mockResolvedValue(undefined),
+ statusManager
+ })
+
+ await lastUseConversationOptions.useMessageOptions.plugins[0].onAfterRequest({
+ messages: [{ role: 'user', content: 'hello' }],
+ lastChoice: {
+ finish_reason: 'stop'
+ }
+ })
+
+ expect(statusManager.setProcessing).not.toHaveBeenCalled()
+ expect(onFinishRequest).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/plugins/robot/test/utils/chat.utils.test.ts b/packages/plugins/robot/test/utils/chat.utils.test.ts
new file mode 100644
index 0000000000..9a102ab6bd
--- /dev/null
+++ b/packages/plugins/robot/test/utils/chat.utils.test.ts
@@ -0,0 +1,458 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ addSystemPrompt,
+ extractMessageText,
+ formatMessages,
+ mergeStringFields,
+ processSSEStream,
+ removeLoading,
+ serializeError
+} from '../../src/utils/chat.utils'
+
+describe('chat utils', () => {
+ describe('formatMessages', () => {
+ it('filters out completely empty messages', () => {
+ const result = formatMessages([
+ { role: 'user', content: '' },
+ { role: 'assistant', content: 'hello' }
+ ] as any)
+
+ expect(result).toEqual([{ role: 'assistant', content: 'hello' }])
+ })
+
+ it('keeps messages that only contain tool calls', () => {
+ const result = formatMessages([
+ {
+ role: 'assistant',
+ content: '',
+ tool_calls: [{ id: 'tool-1', type: 'function' }]
+ }
+ ] as any)
+
+ expect(result).toEqual([
+ {
+ role: 'assistant',
+ content: '',
+ tool_calls: [{ id: 'tool-1', type: 'function' }]
+ }
+ ])
+ })
+
+ it('keeps messages that only contain tool_call_id', () => {
+ const result = formatMessages([
+ {
+ role: 'tool',
+ content: '',
+ tool_call_id: 'tool-1'
+ }
+ ] as any)
+
+ expect(result).toEqual([
+ {
+ role: 'tool',
+ content: '',
+ tool_call_id: 'tool-1'
+ }
+ ])
+ })
+
+ it('preserves multimodal content arrays', () => {
+ const content = [
+ { type: 'text', text: '请对比图片差异' },
+ { type: 'image_url', image_url: { url: 'https://example.com/1.png' } }
+ ]
+
+ const result = formatMessages([
+ {
+ role: 'user',
+ content
+ }
+ ] as any)
+
+ expect(result).toEqual([
+ {
+ role: 'user',
+ content
+ }
+ ])
+ })
+
+ it('preserves reasoning_content when present', () => {
+ const result = formatMessages([
+ {
+ role: 'assistant',
+ content: 'answer',
+ reasoning_content: 'thoughts'
+ }
+ ] as any)
+
+ expect(result).toEqual([
+ {
+ role: 'assistant',
+ content: 'answer',
+ reasoning_content: 'thoughts'
+ }
+ ])
+ })
+
+ it('keeps tool fields together with normal content', () => {
+ const result = formatMessages([
+ {
+ role: 'assistant',
+ content: 'tool output',
+ tool_calls: [{ id: 'tool-1' }],
+ reasoning_content: 'internal'
+ }
+ ] as any)
+
+ expect(result[0]).toEqual({
+ role: 'assistant',
+ content: 'tool output',
+ tool_calls: [{ id: 'tool-1' }],
+ reasoning_content: 'internal'
+ })
+ })
+ })
+
+ describe('serializeError', () => {
+ it('returns an empty string for undefined and null', () => {
+ expect(serializeError(undefined)).toBe('')
+ expect(serializeError(null)).toBe('')
+ })
+
+ it('serializes Error instances into structured json strings', () => {
+ expect(serializeError(new TypeError('boom'))).toBe('{"name":"TypeError","message":"boom"}')
+ })
+
+ it('returns plain strings unchanged', () => {
+ expect(serializeError('plain text')).toBe('plain text')
+ })
+
+ it('json-stringifies ordinary objects', () => {
+ expect(serializeError({ code: 500, message: 'fail' })).toBe('{"code":500,"message":"fail"}')
+ })
+
+ it('falls back to String() for non-json-serializable values', () => {
+ const value = 1n
+
+ expect(serializeError(value)).toBe('1')
+ })
+ })
+
+ describe('extractMessageText', () => {
+ it('returns plain string content as-is', () => {
+ expect(extractMessageText('hello')).toBe('hello')
+ })
+
+ it('joins text items from mixed content arrays', () => {
+ const result = extractMessageText([
+ 'line 1',
+ { type: 'text', text: 'line 2' },
+ { type: 'text', content: 'line 3' },
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } }
+ ])
+
+ expect(result).toBe('line 1\nline 2\nline 3')
+ })
+
+ it('returns an empty string for unsupported content', () => {
+ expect(extractMessageText({ foo: 'bar' })).toBe('')
+ })
+ })
+
+ describe('mergeStringFields', () => {
+ it('concatenates sibling string fields', () => {
+ const result = mergeStringFields(
+ {
+ content: 'hello',
+ title: 'foo'
+ },
+ {
+ content: ' world',
+ title: ' bar'
+ }
+ )
+
+ expect(result).toEqual({
+ content: 'hello world',
+ title: 'foo bar'
+ })
+ })
+
+ it('recursively merges nested object fields', () => {
+ const result = mergeStringFields(
+ {
+ delta: {
+ content: 'hello',
+ metadata: {
+ reason: 'a'
+ }
+ }
+ },
+ {
+ delta: {
+ content: ' world',
+ metadata: {
+ reason: 'b'
+ }
+ }
+ }
+ )
+
+ expect(result).toEqual({
+ delta: {
+ content: 'hello world',
+ metadata: {
+ reason: 'ab'
+ }
+ }
+ })
+ })
+
+ it('copies missing fields from the source object', () => {
+ const result = mergeStringFields(
+ {
+ delta: {
+ content: 'hello'
+ }
+ },
+ {
+ delta: {
+ role: 'assistant'
+ },
+ finish_reason: 'stop'
+ }
+ )
+
+ expect(result).toEqual({
+ delta: {
+ content: 'hello',
+ role: 'assistant'
+ },
+ finish_reason: 'stop'
+ })
+ })
+
+ it('overwrites falsy non-string values and recursively merges nested objects', () => {
+ const result = mergeStringFields(
+ {
+ index: 0,
+ done: false,
+ meta: {
+ step: 'a'
+ }
+ },
+ {
+ index: 1,
+ done: true,
+ meta: {
+ step: 'b'
+ }
+ }
+ )
+
+ expect(result).toEqual({
+ index: 1,
+ done: true,
+ meta: {
+ step: 'ab'
+ }
+ })
+ })
+ })
+
+ describe('processSSEStream', () => {
+ it('parses standard SSE chunks and forwards them to the handler', () => {
+ const handler = {
+ onData: vi.fn(),
+ onDone: vi.fn(),
+ onError: vi.fn()
+ }
+ const data = [
+ 'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}',
+ '',
+ 'data: {"choices":[{"delta":{"content":" world"},"finish_reason":"stop"}]}',
+ '',
+ 'data: [DONE]',
+ ''
+ ].join('\n\n')
+
+ processSSEStream(data, handler as any)
+
+ expect(handler.onData).toHaveBeenCalledTimes(2)
+ expect(handler.onDone).toHaveBeenCalledWith('stop')
+ })
+
+ it('uses the latest finish reason before DONE', () => {
+ const handler = {
+ onData: vi.fn(),
+ onDone: vi.fn(),
+ onError: vi.fn()
+ }
+ const data = [
+ 'data: {"choices":[{"delta":{"content":"a"},"finish_reason":null}]}',
+ '',
+ 'data: {"choices":[{"delta":{"content":"b"},"finish_reason":"tool_calls"}]}',
+ '',
+ 'data: [DONE]',
+ ''
+ ].join('\n\n')
+
+ processSSEStream(data, handler as any)
+
+ expect(handler.onDone).toHaveBeenCalledWith('tool_calls')
+ })
+
+ it('ignores blank segments and malformed lines that do not start with data', () => {
+ const handler = {
+ onData: vi.fn(),
+ onDone: vi.fn(),
+ onError: vi.fn()
+ }
+ const data = ['event: ping', '', 'data: [DONE]', ''].join('\n\n')
+
+ processSSEStream(data, handler as any)
+
+ expect(handler.onData).not.toHaveBeenCalled()
+ expect(handler.onDone).toHaveBeenCalledWith(undefined)
+ })
+
+ it('swallows JSON parse errors and continues parsing later chunks', () => {
+ const handler = {
+ onData: vi.fn(),
+ onDone: vi.fn(),
+ onError: vi.fn()
+ }
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const data = [
+ 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
+ '',
+ 'data: {"choices":',
+ '',
+ 'data: {"choices":[{"delta":{"content":"still ok"},"finish_reason":"stop"}]}',
+ '',
+ 'data: [DONE]',
+ ''
+ ].join('\n\n')
+
+ processSSEStream(data, handler as any)
+
+ expect(handler.onData).toHaveBeenCalledTimes(2)
+ expect(handler.onDone).toHaveBeenCalledWith('stop')
+ expect(consoleError).toHaveBeenCalled()
+
+ consoleError.mockRestore()
+ })
+ })
+
+ describe('removeLoading', () => {
+ it('removes the last loading item by default', () => {
+ const messages = [
+ {
+ renderContent: [
+ { type: 'text', content: 'hello' },
+ { type: 'loading', content: '' },
+ { type: 'agent-loading', content: '' }
+ ]
+ }
+ ]
+
+ removeLoading(messages as any)
+
+ expect(messages[0].renderContent).toEqual([
+ { type: 'text', content: 'hello' },
+ { type: 'loading', content: '' }
+ ])
+ })
+
+ it('removes the last loading item that matches the provided name', () => {
+ const messages = [
+ {
+ renderContent: [
+ { type: 'loading', content: 'tool-a' },
+ { type: 'loading', content: 'tool-b' },
+ { type: 'loading', content: 'tool-a' }
+ ]
+ }
+ ]
+
+ removeLoading(messages as any, 'tool-a')
+
+ expect(messages[0].renderContent).toEqual([
+ { type: 'loading', content: 'tool-a' },
+ { type: 'loading', content: 'tool-b' }
+ ])
+ })
+
+ it('does nothing when there is no renderContent', () => {
+ const messages = [{}]
+
+ expect(() => removeLoading(messages as any)).not.toThrow()
+ expect(messages).toEqual([{}])
+ })
+
+ it('does nothing when no loading item matches the provided name', () => {
+ const messages = [
+ {
+ renderContent: [
+ { type: 'text', content: 'hello' },
+ { type: 'loading', content: 'tool-a' }
+ ]
+ }
+ ]
+
+ removeLoading(messages as any, 'tool-b')
+
+ expect(messages[0].renderContent).toEqual([
+ { type: 'text', content: 'hello' },
+ { type: 'loading', content: 'tool-a' }
+ ])
+ })
+ })
+
+ describe('addSystemPrompt', () => {
+ it('adds a system prompt when the message list is empty', () => {
+ const messages: any[] = []
+
+ addSystemPrompt(messages, '你是一个助手')
+
+ expect(messages).toEqual([{ role: 'system', content: '你是一个助手' }])
+ })
+
+ it('prepends a system prompt when the first message is not system', () => {
+ const messages = [{ role: 'user', content: 'hello' }]
+
+ addSystemPrompt(messages as any, '你是一个助手')
+
+ expect(messages).toEqual([
+ { role: 'system', content: '你是一个助手' },
+ { role: 'user', content: 'hello' }
+ ])
+ })
+
+ it('updates the existing system prompt when content has changed', () => {
+ const messages = [
+ { role: 'system', content: '旧提示词' },
+ { role: 'user', content: 'hello' }
+ ]
+
+ addSystemPrompt(messages as any, '新提示词')
+
+ expect(messages[0]).toEqual({ role: 'system', content: '新提示词' })
+ })
+
+ it('keeps the existing system prompt when it already matches', () => {
+ const messages = [
+ { role: 'system', content: '固定提示词' },
+ { role: 'user', content: 'hello' }
+ ]
+
+ addSystemPrompt(messages as any, '固定提示词')
+
+ expect(messages).toEqual([
+ { role: 'system', content: '固定提示词' },
+ { role: 'user', content: 'hello' }
+ ])
+ })
+ })
+})