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

394 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAcpMessage } from '@/renderer/pages/conversation/platforms/acp/useAcpMessage';
import { getConversationOrNull } from '@/renderer/pages/conversation/utils/conversationCache';
import { resetEnsureConversationRuntimeStateForTests } from '@/renderer/pages/conversation/utils/ensureConversationRuntime';
import type { IResponseMessage } from '@/common/adapter/ipcBridge';
const {
addOrUpdateMessageMock,
ensureRuntimeInvokeMock,
getSlashCommandsInvokeMock,
responseStreamOnMock,
responseStreamHandlerRef,
} = vi.hoisted(() => ({
addOrUpdateMessageMock: vi.fn(),
ensureRuntimeInvokeMock: vi.fn(),
getSlashCommandsInvokeMock: vi.fn(),
responseStreamOnMock: vi.fn(),
responseStreamHandlerRef: {
current: undefined as ((message: IResponseMessage) => void) | undefined,
},
}));
vi.mock('@/renderer/pages/conversation/Messages/hooks', () => ({
useAddOrUpdateMessage: () => addOrUpdateMessageMock,
useMergeLiveMessage: () => addOrUpdateMessageMock,
}));
vi.mock('@/renderer/pages/conversation/utils/conversationCache', () => ({
getConversationOrNull: vi.fn(),
}));
vi.mock('@/common', () => ({
ipcBridge: {
acpConversation: {
responseStream: {
on: responseStreamOnMock.mockImplementation((handler: (message: IResponseMessage) => void) => {
responseStreamHandlerRef.current = handler;
return vi.fn();
}),
},
},
conversation: {
ensureRuntime: {
invoke: ensureRuntimeInvokeMock,
},
getSlashCommands: {
invoke: getSlashCommandsInvokeMock,
},
},
},
}));
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('useAcpMessage', () => {
beforeEach(() => {
vi.clearAllMocks();
resetEnsureConversationRuntimeStateForTests();
ensureRuntimeInvokeMock.mockResolvedValue({ recovered: false, config_options: [], runtime: null });
getSlashCommandsInvokeMock.mockResolvedValue([]);
responseStreamHandlerRef.current = undefined;
});
it('completes hydration when the conversation lookup fails', async () => {
vi.mocked(getConversationOrNull).mockRejectedValue(new TypeError('Failed to fetch'));
const { result } = renderHook(() => useAcpMessage('conv-1'));
await waitFor(() => {
expect(result.current.hasHydratedRunningState).toBe(true);
});
expect(result.current.running).toBe(false);
expect(result.current.aiProcessing).toBe(false);
});
it('emits a synthetic thinking done update on finish when the stream never sends one', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
const now = Date.now();
renderHook(() => useAcpMessage('conv-1'));
expect(responseStreamHandlerRef.current).toBeTypeOf('function');
responseStreamHandlerRef.current?.({
type: 'request_trace',
data: {
timestamp: now - 4200,
backend: 'claude',
model_id: 'model-1',
},
msg_id: 'msg-1',
conversation_id: 'conv-1',
});
responseStreamHandlerRef.current?.({
type: 'thinking',
data: {
content: 'alpha',
status: 'thinking',
},
msg_id: 'msg-1',
conversation_id: 'conv-1',
});
responseStreamHandlerRef.current?.({
type: 'finish',
data: null,
msg_id: 'msg-1',
conversation_id: 'conv-1',
});
expect(addOrUpdateMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'thinking',
msg_id: 'msg-1',
conversation_id: 'conv-1',
content: expect.objectContaining({
status: 'done',
duration: expect.any(Number),
}),
})
);
});
it('completes thinking as soon as the first non-thinking message arrives', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
renderHook(() => useAcpMessage('conv-1'));
responseStreamHandlerRef.current?.({
type: 'thinking',
data: {
content: 'alpha',
status: 'thinking',
},
msg_id: 'msg-1',
conversation_id: 'conv-1',
created_at: 1_000,
});
responseStreamHandlerRef.current?.({
type: 'text',
data: 'beta',
msg_id: 'msg-1',
conversation_id: 'conv-1',
created_at: 4_200,
});
expect(addOrUpdateMessageMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
type: 'thinking',
msg_id: 'msg-1',
content: expect.objectContaining({
status: 'thinking',
}),
})
);
expect(addOrUpdateMessageMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: 'thinking',
msg_id: 'msg-1',
content: expect.objectContaining({
status: 'done',
duration: 3200,
}),
})
);
expect(addOrUpdateMessageMock).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
type: 'text',
msg_id: 'msg-1',
})
);
});
it('preserves slash-command metadata from available_commands stream updates', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
const { result } = renderHook(() => useAcpMessage('conv-1'));
act(() => {
responseStreamHandlerRef.current?.({
type: 'available_commands',
data: {
commands: [
{
name: 'review',
description: 'Review the current diff',
input: {
hint: '⌘R',
},
_meta: {
completion_behavior: 'neutral_tip_on_empty',
empty_turn_tip_code: 'acp.empty_turn.choose_command',
empty_turn_tip_params: {
command_count: 1,
},
},
},
],
},
msg_id: 'cmd-1',
conversation_id: 'conv-1',
});
});
await waitFor(() => {
expect(result.current.slashCommands).toEqual([
{
name: 'review',
description: 'Review the current diff',
hint: '⌘R',
kind: 'template',
source: 'acp',
selectionBehavior: 'insert',
completionBehavior: 'neutral_tip_on_empty',
emptyTurnTipCode: 'acp.empty_turn.choose_command',
emptyTurnTipParams: {
command_count: 1,
},
},
]);
});
});
it('loads initial slash commands after runtime ensure without legacy warmup', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
getSlashCommandsInvokeMock.mockResolvedValue([
{
command: 'review',
description: 'Review the current diff',
completion_behavior: 'neutral_tip_on_empty',
},
]);
const { result } = renderHook(() => useAcpMessage('conv-1'));
await waitFor(() => {
expect(ensureRuntimeInvokeMock).toHaveBeenCalledWith({ conversation_id: 'conv-1' });
expect(getSlashCommandsInvokeMock).toHaveBeenCalledWith({ conversation_id: 'conv-1' });
});
await waitFor(() => {
expect(result.current.slashCommands).toEqual([
{
name: 'review',
description: 'Review the current diff',
kind: 'template',
source: 'acp',
selectionBehavior: 'insert',
completionBehavior: 'neutral_tip_on_empty',
},
]);
});
});
it('uses injected runtime preparation for initial slash commands in team mode', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
const prepareRuntime = vi.fn().mockResolvedValue(undefined);
getSlashCommandsInvokeMock.mockResolvedValue([
{
command: 'review',
description: 'Review the current diff',
},
]);
const { result } = renderHook(() => useAcpMessage('conv-1', { prepareRuntime }));
await waitFor(() => {
expect(prepareRuntime).toHaveBeenCalled();
expect(getSlashCommandsInvokeMock).toHaveBeenCalledWith({ conversation_id: 'conv-1' });
});
expect(ensureRuntimeInvokeMock).not.toHaveBeenCalled();
act(() => {
result.current.fetchSlashCommands();
});
await waitFor(() => {
expect(prepareRuntime).toHaveBeenCalledTimes(2);
});
expect(ensureRuntimeInvokeMock).not.toHaveBeenCalled();
});
it('deduplicates slash command fetches while a request is in flight', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
const slashCommandsDeferred = deferred<
Array<{
command: string;
description: string;
}>
>();
getSlashCommandsInvokeMock.mockReturnValue(slashCommandsDeferred.promise);
const { result } = renderHook(() => useAcpMessage('conv-1'));
await waitFor(() => {
expect(getSlashCommandsInvokeMock).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.fetchSlashCommands();
});
await waitFor(() => {
expect(getSlashCommandsInvokeMock).toHaveBeenCalledTimes(1);
});
await act(async () => {
slashCommandsDeferred.resolve([
{
command: 'review',
description: 'Review the current diff',
},
]);
await slashCommandsDeferred.promise;
});
await waitFor(() => {
expect(result.current.slashCommands).toEqual([
{
name: 'review',
description: 'Review the current diff',
kind: 'template',
source: 'acp',
selectionBehavior: 'insert',
},
]);
});
});
it('normalizes team teammate messages before inserting them into the message list', async () => {
vi.mocked(getConversationOrNull).mockResolvedValue(null);
renderHook(() => useAcpMessage('leader-conversation-1'));
act(() => {
responseStreamHandlerRef.current?.({
type: 'teammate_message',
data: {
id: 'projected-message-1',
type: 'text',
msg_id: 'projected-message-1',
conversation_id: 'leader-conversation-1',
position: 'left',
status: 'finish',
content: {
content: '[Codex Assistant] idle',
teammate_message: true,
sender_name: 'Codex Assistant',
sender_backend: 'codex',
sender_conversation_id: 'teammate-conversation-1',
},
},
msg_id: 'projected-message-1',
conversation_id: 'leader-conversation-1',
} as unknown as IResponseMessage);
});
expect(addOrUpdateMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'text',
msg_id: 'projected-message-1',
conversation_id: 'leader-conversation-1',
content: {
content: '[Codex Assistant] idle',
teammateMessage: true,
senderName: 'Codex Assistant',
senderAgentType: 'codex',
senderConversationId: 'teammate-conversation-1',
},
})
);
});
});