import type { ReactNode } from 'react'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudChat } from '@/pages/CloudAgents/CloudChat'; import type { CloudHistory, CloudRun, CloudThread } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), remember: vi.fn(), recovery: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); vi.mock('@/pages/CloudAgents/CloudComposerAttachments', () => ({ CloudComposerAttachments: ({ children, footer }: { children?: ReactNode; footer?: ReactNode }) => <>{children}{footer}, })); class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } const slug = 'ml-' + 'a'.repeat(32); const thread: CloudThread = { thread_id: 'thread-1', client_thread_id: 'thread-1', agent_slug: slug, title: '第一段', mode: 'published', updated_at: '', run_id: 'run-1', status: 'running', unread: false }; const run: CloudRun = { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: slug, status: 'running', output: '', version: '1' }; let history: CloudHistory; let stream: Stream; const savedAssistant = (reasoning = '完整思考') => ({ id: 2, role: 'assistant' as const, content: '完整回答', reasoning_content: reasoning, request_id: 'request-1', run_id: 'run-1', created_at: '' }); const emit = (fields: Record, id = '1-0', type = 'messages', extra = {}) => act(async () => stream.dispatchEvent(new MessageEvent(type, { lastEventId: id, data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-1', ...fields } } }, ...extra }), }))); beforeEach(() => { vi.resetAllMocks(); stream = new Stream(); history = { thread_id: 'thread-1', messages: [], run: { ...run }, next_offset: null }; api.events.mockResolvedValue(stream); api.remember.mockResolvedValue({ saved: true }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); api.call.mockImplementation(async (operation: string) => { if (operation === 'threads') return { threads: [thread], next_offset: null }; if (operation === 'history') return structuredClone(history); if (operation === 'viewed') return { viewed: true }; if (operation === 'run') return history.run; throw new Error('Unexpected operation: ' + operation); }); }); afterEach(() => { cleanup(); vi.useRealTimers(); }); async function open(preview = false) { render(); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1')); } it('shows reasoning without an answer, preserves manual folding during deltas and folds final history once', async () => { await open(); await emit({ reasoning_content: '流式思考' }); const details = screen.getByTestId('cloud-chat-reasoning'); expect(details).toHaveAttribute('open'); expect(screen.queryByRole('heading', { name: 'Hi,今天想聊些什么?' })).not.toBeInTheDocument(); act(() => { details.removeAttribute('open'); fireEvent(details, new Event('toggle')); }); await emit({ reasoning_content: '第二段', content: '流式回答' }, '2-0'); expect(details).not.toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('流式思考第二段'); expect(screen.getByText('流式回答')).toBeInTheDocument(); history = { ...history, run: { ...run, status: 'completed' }, messages: [savedAssistant()] }; await emit({}, '2-0', 'end'); await screen.findByText('完整回答'); expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('完整思考'); expect(screen.queryByText('流式回答')).not.toBeInTheDocument(); }); it('keeps partial thought and answer when terminal history fails and excludes late other-thread events', async () => { await open(); await emit({ reasoning_content: '已收思考', content: '已收片段' }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'history' ? Promise.reject(new Error('历史暂时离线')) : base(operation, input)); await emit({}, '1-0', 'end'); await screen.findByText('历史暂时离线'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已收思考'); expect(screen.getByText('已收片段')).toBeInTheDocument(); expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); await emit({ reasoning_content: '另一线程' }, '2-0', 'messages', { thread_id: 'other-thread' }); expect(screen.getByLabelText('智能体思考过程')).not.toHaveTextContent('另一线程'); }); it('restores only assistant reasoning from completed history and shows no block for models without it', async () => { history = { ...history, run: { ...run, status: 'completed' }, messages: [ { ...savedAssistant(), id: 1, role: 'user', reasoning_content: '不属于用户显示的思考', content: '用户问题' }, { ...savedAssistant(), id: 3, role: 'system', reasoning_content: '系统字段', content: '系统内容' }, savedAssistant(), { ...savedAssistant(''), id: 4, content: '没有思考的回答' }, ] }; render(); await screen.findByText('完整回答'); expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); expect(screen.queryByText('不属于用户显示的思考')).not.toBeInTheDocument(); expect(screen.queryByText('系统字段')).not.toBeInTheDocument(); expect(api.events).not.toHaveBeenCalled(); }); it('clears live reasoning on a new conversation and ignores its obsolete stream', async () => { await open(true); await emit({ reasoning_content: '旧对话思考' }); fireEvent.click(screen.getByRole('button', { name: '新对话' })); await waitFor(() => expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument()); await emit({ reasoning_content: '旧事件迟到' }, '2-0'); expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); it('hydrates a final message that is saved after its terminal Run stops the running poll', async () => { await open(); await emit({ reasoning_content: '还没落盘的思考', content: '还没落盘的回答' }); history = { ...history, run: { ...run, status: 'completed' } }; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); await emit({}, '1-0', 'end'); expect(screen.getByText('还没落盘的回答')).toBeInTheDocument(); history = { ...history, messages: [savedAssistant('迟到保存的思考')] }; await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByText('完整回答')).toBeInTheDocument(); expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('迟到保存的思考'); expect(screen.queryByText('还没落盘的回答')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); it('ignores an obsolete terminal history response after a new request starts in the same thread', async () => { await open(); await emit({ reasoning_content: '旧请求思考', content: '旧片段' }); history = { ...history, run: { ...run, status: 'completed' } }; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); await emit({}, '1-0', 'end'); let resolveOld!: (value: CloudHistory) => void; const oldRead = new Promise(resolve => { resolveOld = resolve; }); const base = api.call.getMockImplementation()!; let historyReads = 0; api.call.mockImplementation(async (operation, input) => { if (operation === 'history' && ++historyReads === 1) return oldRead; if (operation === 'submit') return { request_id: 'request-2', run_id: 'run-2', thread_id: 'thread-1', status: 'running', version: '1' }; return base(operation, input); }); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); const nextStream = new Stream(); api.events.mockResolvedValue(nextStream); history = { ...history, run: { ...run, agent_run_id: 'run-2', request_id: 'request-2', status: 'running' } }; fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新的问题' } }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); }); await act(async () => nextStream.dispatchEvent(new MessageEvent('messages', { lastEventId: '2-0', data: JSON.stringify({ run_id: 'run-2', thread_id: 'thread-1', request_id: 'request-2', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '新请求思考' } } }, }) }))); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'completed' }, messages: [savedAssistant()] }); }); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('新请求思考'); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); expect(screen.queryByText('完整回答')).not.toBeInTheDocument(); }); it('ignores a pending history read when approval resumes the same request in a new Run', async () => { await open(); await emit({ reasoning_content: '操作前的思考' }); await emit({}, '2-0', 'interrupt', { payload: { chunk: { approval: { action_requests: [{ name: 'execute', args: { command: 'fixture-only' } }] } } } }); let resolveOld!: (value: CloudHistory) => void; const oldRead = new Promise(resolve => { resolveOld = resolve; }); const base = api.call.getMockImplementation()!; const nextRun = { ...run, agent_run_id: 'run-2' }; api.call.mockImplementation(async (operation, input) => { if (operation === 'history') return oldRead; if (operation === 'resume') return { run_id: 'run-2', status: 'running' }; if (operation === 'run' && input.run_id === 'run-2') return nextRun; return base(operation, input); }); await emit({}, '2-0', 'end'); const nextStream = new Stream(); api.events.mockResolvedValue(nextStream); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); }); await act(async () => nextStream.dispatchEvent(new MessageEvent('messages', { lastEventId: '1-0', data: JSON.stringify({ run_id: 'run-2', thread_id: 'thread-1', request_id: 'request-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '操作后的思考' } } }, }) }))); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' } }); }); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作后的思考'); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); }); it('reopens a resumed same-Run stream and ignores its obsolete interrupted history response', async () => { await open(); await emit({ reasoning_content: '操作前的思考', content: '操作前的片段' }); await emit({}, '2-0', 'interrupt', { payload: { chunk: { approval: { action_requests: [{ name: 'execute', args: { command: 'fixture-only' } }] } } } }); let resolveOld!: (value: CloudHistory) => void; const oldRead = new Promise(resolve => { resolveOld = resolve; }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'history') return oldRead; if (operation === 'resume') return { run_id: 'run-1', status: 'running' }; if (operation === 'run' && input.run_id === 'run-1') return { ...run, status: 'running' }; return base(operation, input); }); await emit({}, '2-0', 'end'); expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); const resumedStream = new Stream(); api.events.mockResolvedValue(resumedStream); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); }); await waitFor(() => expect(api.events).toHaveBeenLastCalledWith('run-1', '2-0')); expect(api.events).toHaveBeenCalledTimes(2); await act(async () => resumedStream.dispatchEvent(new MessageEvent('messages', { lastEventId: '3-0', data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', request_id: 'request-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '恢复后的思考', content: '恢复后的回答', } } }, }) }))); expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); expect(screen.getByText(/恢复后的回答/)).toBeInTheDocument(); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' }, messages: [savedAssistant()] }); }); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('完整回答')).not.toBeInTheDocument(); expect(resumedStream.close).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); it('ignores a late approval receipt after the user discards input and starts a new conversation', async () => { await open(true); await emit({ reasoning_content: '离开前的思考' }); await emit({}, '2-0', 'interrupt', { payload: { chunk: { approval: { action_requests: [{ name: 'execute', args: { command: 'fixture-only' } }] } } } }); let resolveResume!: (value: { run_id: string; status: string }) => void; const pendingResume = new Promise<{ run_id: string; status: string }>(resolve => { resolveResume = resolve; }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'resume') return pendingResume; if (operation === 'run' && input.run_id === 'resumed-old-run') return { ...run, agent_run_id: 'resumed-old-run' }; return base(operation, input); }); fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); await waitFor(() => expect(api.call.mock.calls.some(([operation]) => operation === 'resume')).toBe(true)); fireEvent.click(screen.getByRole('button', { name: '新对话', exact: true })); fireEvent.click(await screen.findByRole('button', { name: '放弃内容', exact: true })); expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); await act(async () => { resolveResume({ run_id: 'resumed-old-run', status: 'running' }); }); expect(screen.queryByRole('button', { name: '停止回答', exact: true })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); expect(api.events).not.toHaveBeenCalledWith('resumed-old-run'); expect(api.events).toHaveBeenCalledTimes(1); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); });