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

279 lines
8.4 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import React, { type PropsWithChildren } from 'react';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ipcBridge } from '@/common';
import type { IMessageAcpToolCall, IMessageText, IMessageThinking } from '@/common/chat/chatLib';
import {
MessageListLoadingProvider,
MessageListProvider,
MessagePaginationProvider,
useAddOrUpdateMessage,
useMessageLstCache,
useMessageList,
useReplaceWithAnchorWindow,
} from '@/renderer/pages/conversation/Messages/hooks';
vi.mock('@/common', () => ({
ipcBridge: {
conversation: {
userCreated: {
on: vi.fn().mockReturnValue(() => {}),
},
},
database: {
getConversationMessages: {
invoke: vi.fn(),
},
},
},
}));
const CONVERSATION_ID = 'conversation-1';
function createTextMessage(msgId: string, content: string): IMessageText {
return {
id: `text-${msgId}-${content}`,
type: 'text',
msg_id: msgId,
conversation_id: CONVERSATION_ID,
position: 'left',
content: {
content,
},
};
}
function createThinkingMessage(msgId: string, content: string): IMessageThinking {
return {
id: `thinking-${msgId}-${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',
},
},
};
}
function TestWrapper({ children }: PropsWithChildren): JSX.Element {
return <MessageListProvider value={[]}>{children}</MessageListProvider>;
}
function CacheWrapper({ children }: PropsWithChildren): JSX.Element {
return (
<MessageListLoadingProvider value={false}>
<MessagePaginationProvider
value={{ hasMoreBefore: false, hasMoreAfter: false, isLoadingBefore: false, isLoadingAnchor: false }}
>
<MessageListProvider value={[]}>{children}</MessageListProvider>
</MessagePaginationProvider>
</MessageListLoadingProvider>
);
}
function useMessageHarness() {
return {
addOrUpdateMessage: useAddOrUpdateMessage(),
messages: useMessageList(),
};
}
function useAnchorMessageHarness() {
return {
addOrUpdateMessage: useAddOrUpdateMessage(),
replaceWithAnchorWindow: useReplaceWithAnchorWindow(),
messages: useMessageList(),
};
}
async function flushMessageQueue(): Promise<void> {
await act(async () => {
vi.runAllTimers();
});
}
describe('message merging', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('keeps text segments split when tool calls interrupt the same msg_id stream', async () => {
const { result } = renderHook(() => useMessageHarness(), {
wrapper: TestWrapper,
});
act(() => {
result.current.addOrUpdateMessage(createTextMessage('msg-1', 'hello'));
result.current.addOrUpdateMessage(createTextMessage('msg-1', ' world'));
});
await flushMessageQueue();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].type).toBe('text');
expect((result.current.messages[0] as IMessageText).content.content).toBe('hello world');
act(() => {
result.current.addOrUpdateMessage(createToolCallMessage('tool-1'));
result.current.addOrUpdateMessage(createTextMessage('msg-1', 'again'));
});
await flushMessageQueue();
expect(result.current.messages.map((message) => message.type)).toEqual(['text', 'acp_tool_call', 'text']);
expect((result.current.messages[0] as IMessageText).content.content).toBe('hello world');
expect((result.current.messages[2] as IMessageText).content.content).toBe('again');
});
it('keeps thinking segments split when tool calls interrupt the same msg_id stream', async () => {
const { result } = renderHook(() => useMessageHarness(), {
wrapper: TestWrapper,
});
act(() => {
result.current.addOrUpdateMessage(createThinkingMessage('msg-1', 'alpha'));
result.current.addOrUpdateMessage(createThinkingMessage('msg-1', 'beta'));
});
await flushMessageQueue();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].type).toBe('thinking');
expect((result.current.messages[0] as IMessageThinking).content.content).toBe('alphabeta');
act(() => {
result.current.addOrUpdateMessage(createToolCallMessage('tool-1'));
result.current.addOrUpdateMessage(createThinkingMessage('msg-1', 'gamma'));
});
await flushMessageQueue();
expect(result.current.messages.map((message) => message.type)).toEqual(['thinking', 'acp_tool_call', 'thinking']);
expect((result.current.messages[0] as IMessageThinking).content.content).toBe('alphabeta');
expect((result.current.messages[2] as IMessageThinking).content.content).toBe('gamma');
});
it('merges thinking done updates into the existing thinking message instead of appending a completion message', async () => {
const { result } = renderHook(() => useMessageHarness(), {
wrapper: TestWrapper,
});
act(() => {
result.current.addOrUpdateMessage(createThinkingMessage('msg-1', 'alpha'));
result.current.addOrUpdateMessage(createToolCallMessage('tool-1'));
result.current.addOrUpdateMessage(createThinkingDoneMessage('msg-1', 4200));
});
await flushMessageQueue();
expect(result.current.messages.map((message) => message.type)).toEqual(['thinking', 'acp_tool_call']);
expect((result.current.messages[0] as IMessageThinking).content.status).toBe('done');
expect((result.current.messages[0] as IMessageThinking).content.duration).toBe(4200);
});
it('ignores non-renderable transformed stream messages', async () => {
const { result } = renderHook(() => useMessageHarness(), {
wrapper: TestWrapper,
});
act(() => {
result.current.addOrUpdateMessage(undefined);
});
await flushMessageQueue();
expect(result.current.messages).toEqual([]);
});
it('keeps live-only and richer streaming messages when replacing with an anchor window', async () => {
const { result } = renderHook(() => useAnchorMessageHarness(), {
wrapper: TestWrapper,
});
act(() => {
result.current.addOrUpdateMessage(createTextMessage('agent-1', 'partial streaming response'));
result.current.addOrUpdateMessage(createTextMessage('agent-2', 'live tail'));
});
await flushMessageQueue();
act(() => {
result.current.replaceWithAnchorWindow(CONVERSATION_ID, [
createTextMessage('user-anchor', 'anchor'),
createTextMessage('agent-1', 'partial'),
]);
});
expect(result.current.messages.map((message) => message.msg_id)).toEqual(['user-anchor', 'agent-1', 'agent-2']);
expect((result.current.messages[1] as IMessageText).content.content).toBe('partial streaming response');
expect((result.current.messages[2] as IMessageText).content.content).toBe('live tail');
});
it('requests compact tool content when hydrating historical messages', async () => {
const invoke = vi.mocked(ipcBridge.database.getConversationMessages.invoke);
invoke.mockClear();
invoke.mockResolvedValue({
items: [],
oldest_cursor: null,
newest_cursor: null,
has_more_before: false,
has_more_after: false,
});
renderHook(() => useMessageLstCache(CONVERSATION_ID), {
wrapper: CacheWrapper,
});
await act(async () => {
await Promise.resolve();
});
expect(invoke).toHaveBeenCalledWith({
conversation_id: CONVERSATION_ID,
limit: 50,
content_mode: 'compact',
});
});
});