Files
2026-07-13 13:20:22 +08:00

491 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type { IResponseMessage } from '@/common/adapter/ipcBridge';
import {
composeMessage,
normalizeAgentStreamError,
normalizeTextMessageContent,
transformMessage,
type IMessageText,
type IMessageTips,
type IMessageAcpToolCall,
type IMessageThinking,
type TMessage,
} from '@/common/chat/chatLib';
const CONVERSATION_ID = 'conversation-1';
function createThinkingMessage(msgId: string, content: string): IMessageThinking {
return {
id: `thinking-${content}`,
type: 'thinking',
msg_id: msgId,
conversation_id: CONVERSATION_ID,
position: 'left',
content: {
content,
status: 'thinking',
},
};
}
function createThinkingDoneMessage(msgId: string, duration: number): IMessageThinking {
return {
id: `thinking-done-${msgId}`,
type: 'thinking',
msg_id: msgId,
conversation_id: CONVERSATION_ID,
position: 'left',
content: {
content: '',
duration,
status: 'done',
},
};
}
function createToolCallMessage(toolCallId: string): IMessageAcpToolCall {
return {
id: toolCallId,
type: 'acp_tool_call',
msg_id: toolCallId,
conversation_id: CONVERSATION_ID,
position: 'left',
content: {
session_id: 'session-1',
update: {
sessionUpdate: 'tool_call',
tool_call_id: toolCallId,
status: 'completed',
title: 'Read file',
kind: 'read',
},
},
};
}
describe('composeMessage', () => {
it('preserves thinking boundaries once a tool message has been inserted', () => {
let list: TMessage[] = [];
list = composeMessage(createThinkingMessage('msg-1', 'alpha'), list);
list = composeMessage(createThinkingMessage('msg-1', 'beta'), list);
expect(list).toHaveLength(1);
expect(list[0].type).toBe('thinking');
expect((list[0] as IMessageThinking).content.content).toBe('alphabeta');
list = composeMessage(createToolCallMessage('tool-1'), list);
list = composeMessage(createThinkingMessage('msg-1', 'gamma'), list);
expect(list).toHaveLength(3);
expect(list.map((message) => message.type)).toEqual(['thinking', 'acp_tool_call', 'thinking']);
expect((list[0] as IMessageThinking).content.content).toBe('alphabeta');
expect((list[2] as IMessageThinking).content.content).toBe('gamma');
});
it('merges thinking done updates back into the latest matching thinking message', () => {
let list: TMessage[] = [];
list = composeMessage(createThinkingMessage('msg-1', 'alpha'), list);
list = composeMessage(createToolCallMessage('tool-1'), list);
list = composeMessage(createThinkingDoneMessage('msg-1', 3200), list);
expect(list).toHaveLength(2);
expect(list.map((message) => message.type)).toEqual(['thinking', 'acp_tool_call']);
expect((list[0] as IMessageThinking).content.status).toBe('done');
expect((list[0] as IMessageThinking).content.duration).toBe(3200);
});
});
describe('normalizeAgentStreamError', () => {
it('treats resolution-only error metadata as structured', () => {
expect(
normalizeAgentStreamError({
message: 'Agent is still responding',
resolution: {
kind: 'wait_for_current_response',
},
})
).toEqual({
message: 'Agent is still responding',
resolution: {
kind: 'wait_for_current_response',
},
});
});
it('drops unknown resolution kind and target values', () => {
expect(
normalizeAgentStreamError({
message: 'Provider authentication failed',
resolution: {
kind: 'check_provider_credentials',
target: 'unexpected_settings',
},
})
).toEqual({
message: 'Provider authentication failed',
resolution: {
kind: 'check_provider_credentials',
},
});
expect(
normalizeAgentStreamError({
message: 'Unknown recovery action',
resolution: {
kind: 'open_secret_panel',
target: 'provider_settings',
},
})
).toBeUndefined();
});
it('preserves workspace path metadata on structured errors', () => {
expect(
normalizeAgentStreamError({
message: 'The current Agent failed to run in this workspace path.',
code: 'WORKSPACE_PATH_RUNTIME_UNAVAILABLE',
workspacePath: '/tmp/Archive ',
})
).toEqual({
message: 'The current Agent failed to run in this workspace path.',
code: 'WORKSPACE_PATH_RUNTIME_UNAVAILABLE',
workspacePath: '/tmp/Archive ',
});
});
it('preserves the rawError diagnostic summary on internal errors', () => {
expect(
normalizeAgentStreamError({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
rawError: {
name: 'Error',
message: 'connect ECONNREFUSED',
code: 'ECONNREFUSED',
status: 500,
stack: 'Error: connect ECONNREFUSED\n at frame',
},
})
).toEqual({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
rawError: {
name: 'Error',
message: 'connect ECONNREFUSED',
code: 'ECONNREFUSED',
status: 500,
stack: 'Error: connect ECONNREFUSED\n at frame',
},
});
});
it('drops malformed rawError fields and keeps only valid ones', () => {
expect(
normalizeAgentStreamError({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
rawError: {
name: 'Error',
message: 42,
status: 'not-a-number',
extra: 'ignored',
},
})
).toEqual({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
rawError: {
name: 'Error',
},
});
});
it('omits rawError when it has no usable fields', () => {
expect(
normalizeAgentStreamError({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
rawError: { unrelated: true },
})
).toEqual({
message: 'Something went wrong, please try again.',
code: 'AIONUI_INTERNAL_ERROR',
});
});
});
describe('normalizeTextMessageContent', () => {
it('normalizes snake_case team metadata from an object payload', () => {
expect(
normalizeTextMessageContent({
content: '已向 Lead Agent 报告就绪状态',
teammate_message: true,
sender_name: 'Codex Assistant',
sender_backend: 'codex',
sender_conversation_id: 'teammate-conversation-1',
})
).toEqual({
content: '已向 Lead Agent 报告就绪状态',
teammateMessage: true,
senderName: 'Codex Assistant',
senderAgentType: 'codex',
senderConversationId: 'teammate-conversation-1',
});
});
it('preserves camelCase team metadata from an already-normalized object payload', () => {
expect(
normalizeTextMessageContent({
content: 'online and ready',
teammateMessage: true,
senderName: 'Claude Assistant',
senderAgentType: 'claude',
senderConversationId: 'teammate-conversation-2',
replace: true,
})
).toEqual({
content: 'online and ready',
teammateMessage: true,
senderName: 'Claude Assistant',
senderAgentType: 'claude',
senderConversationId: 'teammate-conversation-2',
replace: true,
});
});
it('normalizes JSON string text content from persisted DB payloads', () => {
expect(
normalizeTextMessageContent(
JSON.stringify({
content: '[Codex Assistant] idle',
teammate_message: true,
sender_name: 'Codex Assistant',
sender_backend: 'codex',
sender_conversation_id: 'teammate-conversation-3',
})
)
).toEqual({
content: '[Codex Assistant] idle',
teammateMessage: true,
senderName: 'Codex Assistant',
senderAgentType: 'codex',
senderConversationId: 'teammate-conversation-3',
});
});
it('keeps ordinary string text messages as plain content', () => {
expect(normalizeTextMessageContent('hello')).toEqual({
content: 'hello',
});
});
it('lets stream-level replace override a plain text payload', () => {
expect(normalizeTextMessageContent('replacement text', { replace: true })).toEqual({
content: 'replacement text',
replace: true,
});
});
});
describe('transformMessage', () => {
it('returns undefined for hidden system stream messages', () => {
const message: IResponseMessage = {
type: 'system',
data: 'cron metadata',
msg_id: 'system-1',
conversation_id: CONVERSATION_ID,
hidden: true,
};
expect(transformMessage(message)).toBeUndefined();
});
it('uses explicit stream position for team projected user text messages', () => {
const message: IResponseMessage = {
type: 'text',
data: {
content: '你好',
},
msg_id: 'team-user-message-1',
conversation_id: CONVERSATION_ID,
position: 'right',
status: 'finish',
replace: true,
};
const transformed = transformMessage(message) as IMessageText;
expect(transformed.type).toBe('text');
expect(transformed.position).toBe('right');
expect(transformed.status).toBe('finish');
expect(transformed.content).toEqual({
content: '你好',
replace: true,
});
});
it('normalizes team teammate metadata on realtime text messages', () => {
const message: IResponseMessage = {
type: 'text',
data: {
content: '[Codex Assistant] idle',
teammate_message: true,
sender_name: 'Codex Assistant',
sender_backend: 'codex',
sender_conversation_id: 'teammate-conversation-1',
},
msg_id: 'team-teammate-message-1',
conversation_id: CONVERSATION_ID,
position: 'left',
status: 'finish',
};
const transformed = transformMessage(message) as IMessageText;
expect(transformed.type).toBe('text');
expect(transformed.position).toBe('left');
expect(transformed.status).toBe('finish');
expect(transformed.content).toEqual({
content: '[Codex Assistant] idle',
teammateMessage: true,
senderName: 'Codex Assistant',
senderAgentType: 'codex',
senderConversationId: 'teammate-conversation-1',
});
});
it('keeps plain text stream messages left when no explicit position is present', () => {
const message: IResponseMessage = {
type: 'text',
data: {
content: 'agent response',
},
msg_id: 'agent-message-1',
conversation_id: CONVERSATION_ID,
};
const transformed = transformMessage(message) as IMessageText;
expect(transformed.type).toBe('text');
expect(transformed.position).toBe('left');
expect(transformed.content.content).toBe('agent response');
});
it('preserves structured agent stream error metadata', () => {
const message: IResponseMessage = {
type: 'error',
data: {
message: 'The model provider rejected the request',
code: 'USER_LLM_PROVIDER_AUTH_FAILED',
ownership: 'user_llm_provider',
detail: 'Provider returned 401.',
workspacePath: '/tmp/provider-test',
retryable: false,
feedback_recommended: false,
resolution: {
kind: 'check_provider_credentials',
target: 'provider_settings',
},
},
msg_id: 'error-1',
conversation_id: CONVERSATION_ID,
};
const transformed = transformMessage(message) as IMessageTips;
expect(transformed.type).toBe('tips');
expect(transformed.content.content).toBe('The model provider rejected the request');
expect(transformed.content.error).toEqual({
message: 'The model provider rejected the request',
code: 'USER_LLM_PROVIDER_AUTH_FAILED',
ownership: 'user_llm_provider',
detail: 'Provider returned 401.',
workspacePath: '/tmp/provider-test',
retryable: false,
feedback_recommended: false,
resolution: {
kind: 'check_provider_credentials',
target: 'provider_settings',
},
});
});
it('preserves structured metadata on live tips error messages', () => {
const message: IResponseMessage = {
type: 'tips',
data: {
content: 'AionUI failed while sending the message',
type: 'error',
source: 'send_failed',
code: 'INTERNAL_ERROR',
error: {
message: 'AionUI failed while sending the message',
code: 'AIONUI_INTERNAL_ERROR',
ownership: 'aionui',
detail: 'Failed to write Codex sandbox config',
retryable: true,
feedback_recommended: true,
resolution: {
kind: 'send_feedback',
target: 'feedback',
},
},
},
msg_id: 'tips-error-1',
conversation_id: CONVERSATION_ID,
};
const transformed = transformMessage(message) as IMessageTips;
expect(transformed.type).toBe('tips');
expect(transformed.content.error).toEqual({
message: 'AionUI failed while sending the message',
code: 'AIONUI_INTERNAL_ERROR',
ownership: 'aionui',
detail: 'Failed to write Codex sandbox config',
retryable: true,
feedback_recommended: true,
resolution: {
kind: 'send_feedback',
target: 'feedback',
},
});
});
it('preserves info tip type with code and params', () => {
const message: IResponseMessage = {
type: 'tips',
data: {
content: 'Select a slash command to continue',
type: 'info',
code: 'acp.empty_turn.choose_command',
params: {
command_count: 3,
},
},
msg_id: 'tips-info-1',
conversation_id: CONVERSATION_ID,
};
const transformed = transformMessage(message) as IMessageTips;
expect(transformed.type).toBe('tips');
expect(transformed.content).toMatchObject({
content: 'Select a slash command to continue',
type: 'info',
code: 'acp.empty_turn.choose_command',
params: {
command_count: 3,
},
});
});
});