491 lines
14 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
});
|
|
});
|