394 lines
11 KiB
TypeScript
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',
|
|
},
|
|
})
|
|
);
|
|
});
|
|
});
|