Files
makelore/tests/unit/cloud-chat-reasoning.test.tsx
T

229 lines
16 KiB
TypeScript

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<string, unknown>, 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(<CloudChat slug={slug} previewRevision={preview ? 1 : undefined}
initialThread={preview ? { ...thread, mode: 'preview', draft_revision: 1 } : undefined} />);
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-message-process');
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-message-process')).toHaveLength(1);
expect(screen.getByTestId('cloud-message-process')).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-message-process')).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(<CloudChat slug={slug} />);
await screen.findByText('完整回答');
expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1);
expect(screen.getByTestId('cloud-message-process')).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-message-process')).not.toBeInTheDocument());
await emit({ reasoning_content: '旧事件迟到' }, '2-0');
expect(screen.queryByTestId('cloud-message-process')).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-message-process')).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<CloudHistory>(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<CloudHistory>(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.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考');
expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作后的思考');
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<CloudHistory>(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-message-process')).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.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true);
expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考');
expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考');
expect(screen.getByText(/恢复后的回答/)).toBeInTheDocument();
await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' }, messages: [savedAssistant()] }); });
expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考');
expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true);
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-message-process')).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-message-process')).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);
});