166 lines
11 KiB
TypeScript
166 lines
11 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
|
|
import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream';
|
|
import type { CloudHistory } from '../../shared/cloud-agents';
|
|
|
|
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() }));
|
|
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
|
class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }
|
|
let stream: Stream;
|
|
let history: CloudHistory;
|
|
const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' };
|
|
const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' };
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
stream = new Stream();
|
|
history = { thread_id: 'thread', messages: [], run, next_offset: null };
|
|
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
|
api.remember.mockResolvedValue({});
|
|
api.download.mockResolvedValue({ saved: true });
|
|
api.events.mockResolvedValue(stream);
|
|
api.call.mockImplementation(async (operation: string) => {
|
|
if (operation === 'threads') return { threads: [thread], next_offset: null };
|
|
if (operation === 'history') return history;
|
|
if (operation === 'run') return history.run;
|
|
if (operation === 'files') return { files: [] };
|
|
if (operation === 'attachments') return { attachments: [] };
|
|
if (operation === 'viewed') return {};
|
|
throw new Error(operation);
|
|
});
|
|
});
|
|
afterEach(cleanup);
|
|
async function emit(items: unknown[], cursor: string, threadId = 'thread') {
|
|
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
|
|
lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }),
|
|
})));
|
|
}
|
|
const delta = (message_id: string, fields: Record<string, unknown>) => ({ stream_event: { type: 'message_delta', message_id, ...fields } });
|
|
|
|
it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => {
|
|
const view = render(<CloudChat slug="agent" />);
|
|
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run'));
|
|
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
|
|
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料');
|
|
expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open');
|
|
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
|
|
await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child');
|
|
await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3');
|
|
expect(screen.getByText('核对完成').tagName).toBe('STRONG');
|
|
expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument();
|
|
expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/);
|
|
expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open');
|
|
await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4');
|
|
expect(screen.getByRole('table')).toHaveTextContent('校验通过');
|
|
fireEvent.click(screen.getByRole('button', { name: '报告' }));
|
|
expect(api.download).toHaveBeenCalledWith('thread', '/report.md');
|
|
history = { ...history, run: { ...run, status: 'completed' }, messages: [
|
|
{ id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' },
|
|
{ id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' },
|
|
] };
|
|
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'thread', payload: { status: 'completed' } }) })));
|
|
await screen.findByText('最终结论');
|
|
expect(screen.getAllByText('核对完成')).toHaveLength(1);
|
|
expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2);
|
|
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
view.unmount();
|
|
render(<CloudChat slug="agent" />);
|
|
await screen.findByText('最终结论');
|
|
expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论');
|
|
expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true);
|
|
});
|
|
|
|
it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => {
|
|
render(<CloudChat slug="agent" />);
|
|
await waitFor(() => expect(api.events).toHaveBeenCalled());
|
|
await emit([
|
|
{ stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: '查资料', args_delta: '{"query":', index: 0 } },
|
|
{ stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } },
|
|
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } },
|
|
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } },
|
|
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } },
|
|
], '101-0');
|
|
const process = screen.getByTestId('cloud-message-process');
|
|
expect(process).toHaveTextContent('查资料 · 已完成');
|
|
expect(process).toHaveTextContent('写文件 · 失败');
|
|
expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument();
|
|
expect(within(process).getByText('晴天')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
|
await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument());
|
|
await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0');
|
|
expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('retains partial thinking and answer if final history cannot be read', async () => {
|
|
render(<CloudChat slug="agent" />);
|
|
await waitFor(() => expect(api.events).toHaveBeenCalled());
|
|
await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0');
|
|
const implementation = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation(async (operation, ...args) => {
|
|
if (operation === 'history') throw new Error('历史暂不可用');
|
|
return implementation(operation, ...args);
|
|
});
|
|
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'failed' } }) })));
|
|
await screen.findByText('历史暂不可用');
|
|
expect(screen.getByText('已生成部分结果')).toBeVisible();
|
|
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析');
|
|
});
|
|
|
|
it('leaves ordinary answers without an empty thinking panel', async () => {
|
|
history = { ...history, run: { ...run, status: 'completed' }, messages: [
|
|
{ id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' },
|
|
] };
|
|
render(<CloudChat slug="agent" />);
|
|
await screen.findByText('普通回答');
|
|
expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => {
|
|
history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段',
|
|
request_id: 'request', run_id: 'run', created_at: '' }] };
|
|
render(<CloudChat slug="agent" />);
|
|
await waitFor(() => expect(api.events).toHaveBeenCalled());
|
|
await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1');
|
|
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
|
|
const implementation = api.call.getMockImplementation()!;
|
|
let finish!: (value: CloudHistory) => void;
|
|
api.call.mockImplementation((operation, ...args) => operation === 'history'
|
|
? new Promise<CloudHistory>(resolve => { finish = resolve; }) : implementation(operation, ...args));
|
|
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'completed' } }) })));
|
|
expect(screen.getByText('完整的新答案')).toBeVisible();
|
|
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
|
|
await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{
|
|
...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考',
|
|
}] }));
|
|
expect(screen.getAllByText('完整的新答案')).toHaveLength(1);
|
|
});
|
|
|
|
it('uses effective execution input once each tool starts, including empty arguments', () => {
|
|
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
|
|
state = reduceCloudStream(state, { payload: { items: [
|
|
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } },
|
|
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } },
|
|
] } }, 'thread', '1-0');
|
|
state = reduceCloudStream(state, { payload: { items: [
|
|
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } },
|
|
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } },
|
|
] } }, 'thread', '2-0');
|
|
expect(state.messages[0].tool_calls).toMatchObject([
|
|
{ id: 'search', args: { query: '实际执行参数' }, status: 'running' },
|
|
{ id: 'other', args: {}, status: 'running' },
|
|
]);
|
|
expect(state.messages[0].tool_calls[1].args).toEqual({});
|
|
state = reduceCloudStream(state, { payload: { chunk: {
|
|
stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 },
|
|
} } }, 'thread', '3-0');
|
|
expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' });
|
|
});
|
|
|
|
it('keeps Redis cursor precision while ignoring other runs', () => {
|
|
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
|
|
const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } };
|
|
state = reduceCloudStream(state, event, 'thread', '9007199254740993-0');
|
|
expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state);
|
|
expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state);
|
|
});
|