/** * @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() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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', }, }) ); }); });