Simplify Agent chat with inline attachments and separate thinking
This commit is contained in:
1 parent
7af43acebb
commit
faebfe9e84
18 files changed
+1558
-195
No files matched your search
@@ -71,7 +71,11 @@ it('starts once from the saved configuration and opens conversation only after a
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
await act(async () => finish({ version: 1, draft_revision: 3 }));
|
||||
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '调整智能体', exact: true })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: '写作搭档', exact: true })).toBeVisible();
|
||||
expect(screen.queryByLabelText('名称')).not.toBeInTheDocument();
|
||||
for (const name of ['调整智能体', '已保存', '已应用', '应用最新修改']) {
|
||||
expect(screen.queryByRole('button', { name, exact: true })).not.toBeInTheDocument();
|
||||
}
|
||||
expect(api.save).not.toHaveBeenCalled();
|
||||
expect(messageOperations()).toHaveLength(0);
|
||||
});
|
||||
@@ -166,6 +170,10 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let attempts = 0;
|
||||
let activated = false;
|
||||
let current = { ...draft, published_version: null as number | null };
|
||||
api.list.mockImplementation(async () => ({ agents: [current], next_cursor: null }));
|
||||
api.get.mockImplementation(async () => current);
|
||||
api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input, draft_revision: 4 }));
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'access') return {
|
||||
published_version: activated ? 1 : null, enabled: true, grants: [], applications: [],
|
||||
@@ -174,6 +182,7 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
|
||||
if (operation === 'publish') {
|
||||
if (++attempts === 1) throw new Error('连接中断');
|
||||
activated = true;
|
||||
current = { ...current, published_version: 1 };
|
||||
return { version: 1, draft_revision: input.expected_revision };
|
||||
}
|
||||
return base(operation, input);
|
||||
@@ -186,6 +195,9 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已保存', exact: true })).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试开始使用', exact: true }));
|
||||
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: '应用最新修改', exact: true })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
|
||||
await screen.findByText('修改已保存,尚未应用');
|
||||
expect(screen.getByText('这次修改还没有应用到正在使用的助手')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '应用最新修改', exact: true })).toBeEnabled();
|
||||
@@ -200,6 +212,8 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
|
||||
it('keeps successful activation and conversation when the following status read fails', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let activated = false;
|
||||
api.list.mockImplementation(async () => ({ agents: [{ ...draft, published_version: activated ? 1 : null }], next_cursor: null }));
|
||||
api.get.mockImplementation(async () => ({ ...draft, published_version: activated ? 1 : null }));
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'access' && activated) throw new Error('状态刷新失败');
|
||||
const result = await base(operation, input);
|
||||
@@ -209,10 +223,15 @@ it('keeps successful activation and conversation when the following status read
|
||||
await openConfiguration();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true }));
|
||||
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
|
||||
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(2));
|
||||
expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '重试开始使用', exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/状态刷新失败/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
|
||||
await screen.findByText(/状态刷新失败/);
|
||||
expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新应用状态', exact: true }));
|
||||
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(3));
|
||||
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(4));
|
||||
expect(publications()).toHaveLength(1);
|
||||
expect(messageOperations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -87,23 +87,31 @@ it('shows saved but unapplied changes and applies directly from the editor heade
|
||||
expect(screen.getByRole('tab', { name: '介绍与提示词' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('keeps unsent input accessible before adjusting and applying configuration', async () => {
|
||||
it('keeps chat read-only and protects unsent input before returning to the card to configure', async () => {
|
||||
publishedAssistant(4);
|
||||
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '和它聊聊', exact: true }));
|
||||
await screen.findByTestId('cloud-agent-editor');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
|
||||
const message = await screen.findByLabelText('消息');
|
||||
await waitFor(() => expect(message).toBeEnabled());
|
||||
expect(screen.getByRole('heading', { name: '写作搭档', exact: true })).toBeVisible();
|
||||
expect(screen.queryByLabelText('名称')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('navigation', { name: '对话来源' })).not.toBeInTheDocument();
|
||||
for (const name of ['修改名称', '修改头像', '已保存', '保存草稿', '已应用', '应用最新修改', '调整智能体', '查看历史对话', '新对话']) {
|
||||
expect(screen.queryByRole('button', { name, exact: true })).not.toBeInTheDocument();
|
||||
}
|
||||
fireEvent.change(message, { target: { value: '保留正式聊天的问题' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '调整智能体', exact: true }));
|
||||
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||
expect(api.save).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
|
||||
expect(screen.getByRole('dialog', { name: '未保存的修改' })).toHaveTextContent('还有未发送的内容或未确认的操作');
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑', exact: true }));
|
||||
expect(message).toBeVisible();
|
||||
expect(message).toHaveValue('保留正式聊天的问题');
|
||||
expect(screen.getByText('对话中还有未完成的内容,请先发送或处理后再调整智能体。')).toBeVisible();
|
||||
fireEvent.change(message, { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '调整智能体', exact: true }));
|
||||
expect(message).not.toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
|
||||
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '新的要求' } });
|
||||
expect(screen.getByText('修改尚未保存和应用')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled();
|
||||
@@ -113,10 +121,12 @@ it('keeps unsent input accessible before adjusting and applying configuration',
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
|
||||
expect(screen.getByRole('tab', { name: '介绍与提示词' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByLabelText('消息')).toBe(message);
|
||||
expect(message).not.toBeVisible();
|
||||
expect(message).toHaveValue('');
|
||||
expect(screen.queryByRole('button', { name: '聊天', exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '和它聊聊', exact: true }));
|
||||
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('');
|
||||
expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')[0][1]).toMatchObject({ expected_revision: 5 });
|
||||
expect(api.call.mock.calls.some(([operation]) => operation === 'preview' || operation === 'submit')).toBe(false);
|
||||
});
|
||||
@@ -558,8 +568,10 @@ it('restores the exact previous thread and presents an uncertain operation witho
|
||||
});
|
||||
open();
|
||||
await screen.findByText('恢复了旧对话');
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
|
||||
expect(screen.getByLabelText('历史对话')).toHaveValue('older-thread');
|
||||
expect(api.call).toHaveBeenCalledWith('history', { thread_id: 'older-thread' });
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'history' && call[1].thread_id === 'newer-thread')).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: '查看历史对话' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
|
||||
expect(api.resolvePending).not.toHaveBeenCalled();
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
|
||||
api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } });
|
||||
|
||||
@@ -59,7 +59,7 @@ it('recovers a server queue and cancels it after remount without resubmitting me
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns to the last selected thread on remount and requires restoring an archived conversation before sending', async () => {
|
||||
it('automatically restores the remembered thread and requires restoring archived conversations before sending', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
const threads = ['newest', 'older'].map(id => ({ thread_id: id, client_thread_id: id, agent_slug: slug, mode: 'published' as const, title: id, status: 'idle', updated_at: '', run_id: null, unread: false }));
|
||||
let recent = { slug, mode: 'published' as const, thread_id: 'older' };
|
||||
@@ -67,19 +67,20 @@ it('returns to the last selected thread on remount and requires restoring an arc
|
||||
api.remember.mockImplementation(async value => { recent = value; return { saved: true }; });
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'threads') return { threads, next_offset: null };
|
||||
if (operation === 'history') return { thread_id: input.thread_id, messages: [], run: null, next_offset: null };
|
||||
if (operation === 'history') return { thread_id: input.thread_id, messages: [{ id: 1, role: 'assistant', content: '保留的对话 ' + input.thread_id }], run: null, next_offset: null };
|
||||
if (operation === 'archiveThread') return { thread_id: input.thread_id, archived: false };
|
||||
return base(operation, input);
|
||||
});
|
||||
const first = render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('older'));
|
||||
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'newest' } });
|
||||
await waitFor(() => expect(recent.thread_id).toBe('newest'));
|
||||
expect(await screen.findByText('保留的对话 older')).toBeVisible();
|
||||
expect(api.call).toHaveBeenCalledWith('history', { thread_id: 'older' });
|
||||
expect(screen.queryByRole('button', { name: '查看历史对话' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '新对话' })).not.toBeInTheDocument();
|
||||
first.unmount();
|
||||
recent = { ...recent, thread_id: 'newest' };
|
||||
const second = render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('newest'));
|
||||
expect(await screen.findByText('保留的对话 newest')).toBeVisible();
|
||||
expect(api.call).toHaveBeenCalledWith('history', { thread_id: 'newest' });
|
||||
second.unmount();
|
||||
recent = { ...recent, thread_id: 'older' };
|
||||
const beforeArchive = api.call.getMockImplementation()!;
|
||||
@@ -157,13 +158,15 @@ it('retries the same request after a lost response and renders batched native SS
|
||||
const submissions = api.call.mock.calls.filter(call => call[0] === 'submit');
|
||||
expect(submissions[0][1]).toEqual(submissions[1][1]);
|
||||
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
|
||||
data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [
|
||||
data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { items: [
|
||||
{ stream_event: { type: 'message_delta', content: '第一段' } },
|
||||
{ stream_event: { type: 'message_delta', content: '文字' } },
|
||||
] } }),
|
||||
})));
|
||||
expect(screen.getByText('第一段文字')).toBeVisible();
|
||||
expect(screen.getByText(/版本 2/)).toBeVisible();
|
||||
expect(screen.queryByText(/版本 2/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('由创建者支付词元点数')).not.toBeInTheDocument();
|
||||
expect(api.events).toHaveBeenCalledWith('run-1');
|
||||
});
|
||||
|
||||
it('restores a durable question and follows the new resume run', async () => {
|
||||
@@ -179,9 +182,9 @@ it('restores a durable question and follows the new resume run', async () => {
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(await screen.findByRole('radio', { name: '海洋' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
expect(screen.queryByRole('button', { name: '新对话' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '查看历史对话' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: '海洋' })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-2'));
|
||||
expect(api.call.mock.calls.find(call => call[0] === 'resume')?.[1].decision).toEqual({ direction: '海洋' });
|
||||
@@ -340,41 +343,45 @@ it('keeps an unavailable existing notification target until the creator explicit
|
||||
|
||||
|
||||
|
||||
it('returns to a newly created conversation from history without remounting the page', async () => {
|
||||
it('automatically restores a newly submitted conversation after remount without submitting it again', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let created = false;
|
||||
let recent = { slug, mode: 'published' as const, thread_id: '' };
|
||||
api.recovery.mockImplementation(async () => ({ recent: recent.thread_id ? recent : null, pending: [] }));
|
||||
api.remember.mockImplementation(async value => { recent = value; return { saved: true }; });
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'submit') return { request_id: 'new-request', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' };
|
||||
if (operation === 'history') return { ...history, run: { ...run, status: 'completed' } };
|
||||
if (operation === 'threads') return { threads: created ? [{ thread_id: 'thread-1', client_thread_id: 'client', agent_slug: slug, title: '新对话主题', mode: 'published', status: 'completed', updated_at: '', run_id: 'run-1', unread: false }] : [], next_offset: null };
|
||||
if (operation === 'submit') { created = true; return { request_id: 'new-request', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' }; }
|
||||
if (operation === 'history') return { ...history, messages: [{ id: 1, role: 'user', content: '新对话主题' }], run: { ...run, status: 'completed' } };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
const first = render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled());
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toBeEnabled());
|
||||
expect(screen.getByRole('option', { name: '新对话主题 · 已完成' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
expect(screen.getByLabelText('历史对话')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } });
|
||||
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('thread-1'));
|
||||
expect(await screen.findByText('新对话主题')).toBeVisible();
|
||||
await waitFor(() => expect(recent.thread_id).toBe('thread-1'));
|
||||
first.unmount();
|
||||
render(<CloudChat slug={slug} />);
|
||||
expect(await screen.findByText('新对话主题')).toBeVisible();
|
||||
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'submit')).toHaveLength(1);
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'history' && call[1].thread_id === 'thread-1')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates an attachment conversation only when the selected file is confirmed', async () => {
|
||||
it('creates an attachment conversation only after a native pick and deselects the inline file without deleting it', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'createThread') return { thread_id: 'thread-1', client_thread_id: input.thread_id };
|
||||
if (operation === 'confirmAttachment') return { attachments: [{ file_id: 'attachment-1' }] };
|
||||
if (operation === 'confirmAttachment') return { attachments: [{ file_id: 'attachment-1', file_name: 'notes.txt', file_size: 1024 }] };
|
||||
return base(operation, input);
|
||||
});
|
||||
api.upload.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt',
|
||||
parse_supported: false, parse_methods: [],
|
||||
parse_supported: true, parse_methods: ['disable', 'rapid_ocr'],
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
fireEvent.click(screen.getByText('附件与产物'));
|
||||
const add = screen.getByRole('button', { name: '添加附件' });
|
||||
const add = screen.getByRole('button', { name: '添加文件或图片' });
|
||||
await waitFor(() => expect(add).toBeEnabled());
|
||||
fireEvent.click(add);
|
||||
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1));
|
||||
@@ -382,14 +389,117 @@ it('creates an attachment conversation only when the selected file is confirmed'
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false);
|
||||
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
|
||||
fireEvent.click(add);
|
||||
const confirm = await screen.findByRole('button', { name: '加入此对话' });
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false);
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', {
|
||||
thread_id: 'thread-1', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt' }],
|
||||
}));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1);
|
||||
await waitFor(() => expect(screen.getByText('附件与产物 · 本条消息附带 1 个文件')).toBeVisible());
|
||||
const remove = await screen.findByRole('button', { name: '移除附件 notes.txt' });
|
||||
expect(remove.closest('[aria-label="本条消息的附件"]')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled());
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'parseAttachment')).toBe(false);
|
||||
fireEvent.click(remove);
|
||||
expect(screen.queryByRole('button', { name: '移除附件 notes.txt' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
expect(await screen.findByRole('checkbox', { name: '选择附件 notes.txt' })).not.toBeChecked();
|
||||
expect(api.call.mock.calls.some(call => ['deleteAttachment', 'submit', 'preview'].includes(call[0]))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks preview switching while creating an attachment thread and ignores a late result after changing agents', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
const nextSlug = 'ml-' + 'b'.repeat(32);
|
||||
let finish!: (value: { thread_id: string; client_thread_id: string }) => void;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'createThread') return new Promise(resolve => { finish = resolve; });
|
||||
if (operation === 'threads') return { threads: input.slug === nextSlug ? [{ thread_id: 'new-agent-thread', client_thread_id: 'new-client', agent_slug: nextSlug, title: '另一个智能体的对话', mode: 'preview', draft_revision: 1, status: 'completed', updated_at: '', run_id: null, unread: false }] : [], next_offset: null };
|
||||
if (operation === 'history') return { thread_id: input.thread_id, messages: [{ id: 1, role: 'assistant', content: '另一个智能体保留的回答' }], run: null, next_offset: null };
|
||||
if (operation === 'preview') return { request_id: input.request_id, thread_id: input.thread_id, run_id: null, status: 'completed' };
|
||||
return base(operation, input);
|
||||
});
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/old-agent-notes.txt', file_name: 'old-agent-notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [] });
|
||||
const view = render(<CloudChat slug={slug} previewRevision={1} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await waitFor(() => expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1));
|
||||
expect(screen.getByRole('button', { name: '新对话' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
expect(screen.queryByText('放弃未发送的内容并新建对话?')).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'confirmAttachment')).toBe(false);
|
||||
view.rerender(<CloudChat slug={nextSlug} previewRevision={1} />);
|
||||
expect(await screen.findByText('另一个智能体保留的回答')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
|
||||
expect(screen.getByLabelText('历史对话')).toHaveValue('new-agent-thread');
|
||||
await act(async () => finish({ thread_id: 'late-old-agent-thread', client_thread_id: 'old-client' }));
|
||||
expect(screen.getByLabelText('历史对话')).toHaveValue('new-agent-thread');
|
||||
expect(screen.queryByRole('option', { name: /late-old-agent-thread/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '移除附件 old-agent-notes.txt' })).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'confirmAttachment')).toBe(false);
|
||||
expect(api.remember.mock.calls.some(([value]) => value?.thread_id === 'late-old-agent-thread')).toBe(false);
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '继续这个智能体的对话' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('preview', expect.objectContaining({ slug: nextSlug, thread_id: 'new-agent-thread', expected_revision: 1, query: '继续这个智能体的对话', attachment_file_ids: [] })));
|
||||
});
|
||||
|
||||
it('retries the same picked attachment and thread after confirmation fails, then sends that selection once', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let confirmations = 0;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'createThread') return { thread_id: 'attachment-thread', client_thread_id: input.thread_id };
|
||||
if (operation === 'confirmAttachment') {
|
||||
if (++confirmations === 1) throw new Error('附件确认结果丢失');
|
||||
return { attachments: [{ file_id: 'attachment-1', file_name: 'notes.txt', file_size: 1024 }] };
|
||||
}
|
||||
if (operation === 'submit') return { request_id: input.request_id, thread_id: 'attachment-thread', run_id: null, status: 'completed', version: '2' };
|
||||
if (operation === 'history') return { thread_id: 'attachment-thread', messages: [], run: null, next_offset: null };
|
||||
return base(operation, input);
|
||||
});
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [] });
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
const retry = await screen.findByRole('button', { name: '重试添加' });
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '请阅读这个附件' } });
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
fireEvent.keyDown(screen.getByLabelText('消息'), { key: 'Enter' });
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '' } });
|
||||
fireEvent.click(retry);
|
||||
await screen.findByRole('button', { name: '移除附件 notes.txt' });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled());
|
||||
const confirms = api.call.mock.calls.filter(call => call[0] === 'confirmAttachment');
|
||||
expect(confirms).toHaveLength(2);
|
||||
expect(confirms[1][1]).toEqual(confirms[0][1]);
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('submit', expect.objectContaining({ slug, thread_id: 'attachment-thread', query: '请看看这些附件。', attachment_file_ids: ['attachment-1'] })));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'submit')).toHaveLength(1);
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'deleteAttachment')).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels an unconfirmed attachment without deleting it and permits the typed message to send', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'createThread') return { thread_id: 'attachment-thread', client_thread_id: input.thread_id };
|
||||
if (operation === 'confirmAttachment') throw new Error('附件确认结果丢失');
|
||||
if (operation === 'submit') return { request_id: input.request_id, thread_id: 'attachment-thread', run_id: null, status: 'completed', version: '2' };
|
||||
if (operation === 'history') return { thread_id: 'attachment-thread', messages: [], run: null, next_offset: null };
|
||||
return base(operation, input);
|
||||
});
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [] });
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '继续讨论我的想法' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await screen.findByRole('button', { name: '重试添加' });
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消添加' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled());
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('继续讨论我的想法');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('submit', expect.objectContaining({ thread_id: 'attachment-thread', query: '继续讨论我的想法', attachment_file_ids: [] })));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'confirmAttachment')).toHaveLength(1);
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'deleteAttachment')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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-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(<CloudChat slug={slug} />);
|
||||
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<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.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<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-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);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { consumeCloudChatEvent, type CloudChatStream } from '@/pages/CloudAgents/cloud-chat-stream';
|
||||
|
||||
const envelope = (items: unknown[], extra = {}) => ({ run_id: 'run-1', thread_id: 'thread-1', request_id: 'request-1', payload: { items }, ...extra });
|
||||
const delta = (fields = {}) => ({ stream_event: { type: 'message_delta', message_id: 'message-1', ...fields } });
|
||||
const consume = (state: CloudChatStream | null, value: unknown, id = '1-0', type = 'messages') =>
|
||||
consumeCloudChatEvent(state, value, type, id, 'run-1', 'thread-1', 'request-1');
|
||||
|
||||
describe('personal Agent main-thread stream projection', () => {
|
||||
it('separates reasoning and answers in batched and single chunks with legacy fallback', () => {
|
||||
let state = consume(null, envelope([delta({ reasoning_content: '思考一', additional_reasoning_content: '不应重复', content: '回答一' })]));
|
||||
state = consume(state, { run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: delta({ additional_reasoning_content: '思考二', content: '回答二' }) } }, '2-0');
|
||||
expect(state).toMatchObject({ reasoning: '思考一思考二', content: '回答一回答二', cursor: '2-0' });
|
||||
expect(JSON.stringify(state)).not.toContain('不应重复');
|
||||
});
|
||||
|
||||
it('separates model messages across tool steps without treating tools as reasoning', () => {
|
||||
const state = consume(null, envelope([
|
||||
delta({ reasoning_content: '先看资料', content: '第一段' }),
|
||||
{ stream_event: { type: 'tool_call', name: 'search', arguments: 'private tool args' } },
|
||||
delta({ message_id: 'message-2', reasoning_content: '核对资料', content: '第二段' }),
|
||||
]));
|
||||
expect(state).toMatchObject({ reasoning: '先看资料\n\n核对资料', content: '第一段\n\n第二段', toolName: 'search' });
|
||||
expect(JSON.stringify(state)).not.toContain('private tool args');
|
||||
});
|
||||
|
||||
it('ignores other Runs/requests and excludes absent or child thread identities while advancing the cursor', () => {
|
||||
const state = consume(null, envelope([delta({ reasoning_content: '父线程' })]));
|
||||
expect(consume(state, envelope([delta({ reasoning_content: '另一个运行' })], { run_id: 'run-2' }), '2-0')).toBe(state);
|
||||
expect(consume(state, envelope([delta({ reasoning_content: '另一个请求' })], { request_id: 'request-2' }), '2-0')).toBe(state);
|
||||
const child = consume(state, envelope([delta({ reasoning_content: '子线程' })], { thread_id: 'child-thread' }), '2-0');
|
||||
const missing = consume(child, envelope([delta({ content: '缺身份' })], { thread_id: undefined }), '3-0');
|
||||
expect(missing).toMatchObject({ reasoning: '父线程', content: '', cursor: '3-0', ended: false });
|
||||
expect(consume(missing, envelope([], { thread_id: 'child-thread' }), '4-0', 'end')).toMatchObject({ ended: false });
|
||||
});
|
||||
|
||||
it('deduplicates replayed/out-of-order message IDs with exact large Redis IDs but accepts terminal reuse', () => {
|
||||
const state = consume(null, envelope([delta({ reasoning_content: '一次' })]), '99999999999999999999-1');
|
||||
expect(consume(state, envelope([delta({ reasoning_content: '重放' })]), '99999999999999999999-1')).toBe(state);
|
||||
expect(consume(state, envelope([delta({ reasoning_content: '迟到' })]), '99999999999999999998-99')).toBe(state);
|
||||
const next = consume(state, envelope([delta({ reasoning_content: '二次' })]), '99999999999999999999-2');
|
||||
expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ reasoning: '一次二次', ended: true });
|
||||
});
|
||||
|
||||
it('keeps missing-ID compatibility and retains same-request reasoning over a resumed Run only', () => {
|
||||
const first = consume(null, envelope([delta({ reasoning_content: '之前', content: '旧预览' })]), '');
|
||||
const next = consumeCloudChatEvent(first, envelope([delta({ message_id: 'message-2', reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }),
|
||||
'messages', '1-0', 'run-2', 'thread-1', 'request-1');
|
||||
expect(next).toMatchObject({ cursor: '1-0', reasoning: '之前\n\n继续', content: '新预览', ended: false });
|
||||
const other = consumeCloudChatEvent(next, envelope([delta({ reasoning_content: '新问题' })], { run_id: 'run-3', request_id: 'request-3' }),
|
||||
'messages', '1-0', 'run-3', 'thread-1', 'request-3');
|
||||
expect(other).toMatchObject({ reasoning: '新问题', content: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
|
||||
import { CloudPendingContext } from '@/pages/CloudAgents/CloudPending';
|
||||
import type { CloudAttachment, CloudUpload } from '../../shared/cloud-agents';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn(), upload: vi.fn(), download: vi.fn() }));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
|
||||
const photo: CloudAttachment = { file_id: 'photo-id', file_name: '我的画.png', file_size: 8192, path: '/attachments/photo', original_path: '/original/photo' };
|
||||
const document: CloudAttachment = { file_id: 'document-id', file_name: '故事.docx', file_size: 1200, path: '/attachments/story', original_path: '/original/story' };
|
||||
const upload: CloudUpload = { object_name: 'temporary/photo-object', file_name: photo.file_name, file_type: 'image/png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] };
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function operationCalls(operation: string) {
|
||||
return api.call.mock.calls.filter(([name]) => name === operation);
|
||||
}
|
||||
|
||||
function open({ initialSelected = [], threadId, disabled = false, ensureThread = vi.fn().mockResolvedValue('thread-created') }: {
|
||||
initialSelected?: string[]; threadId?: string; disabled?: boolean; ensureThread?: () => Promise<string>;
|
||||
} = {}) {
|
||||
const onBusy = vi.fn();
|
||||
const onSelected = vi.fn();
|
||||
const pending = vi.fn();
|
||||
const submit = vi.fn();
|
||||
function Harness({ disabled, threadId }: { disabled: boolean; threadId?: string }) {
|
||||
const [selected, setSelected] = useState(initialSelected);
|
||||
const [text, setText] = useState('保留这些文字');
|
||||
return <CloudPendingContext.Provider value={pending}><form onSubmit={event => { event.preventDefault(); submit(); }}>
|
||||
<CloudComposerAttachments threadId={threadId} disabled={disabled} ensureThread={ensureThread} selected={selected} onBusy={onBusy}
|
||||
onSelected={value => { onSelected(value); setSelected(value); }} footer={<span data-testid="composer-footer">发送区</span>}>
|
||||
<textarea aria-label="消息" value={text} onChange={event => setText(event.target.value)} />
|
||||
</CloudComposerAttachments>
|
||||
</form></CloudPendingContext.Provider>;
|
||||
}
|
||||
const view = render(<Harness disabled={disabled} threadId={threadId} />);
|
||||
const hasPending = () => [...new Map(pending.mock.calls.map(([id, value]) => [id, value])).values()].some(Boolean);
|
||||
return { ...view, onBusy, onSelected, pending, submit, ensureThread, hasPending,
|
||||
update: (next: { disabled?: boolean; threadId?: string }) => view.rerender(<Harness disabled={next.disabled ?? disabled} threadId={next.threadId} />),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.upload.mockResolvedValue(upload);
|
||||
api.download.mockResolvedValue({ saved: true });
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
if (operation === 'files') return { files: [] };
|
||||
if (operation === 'confirmAttachment') return { attachments: [photo] };
|
||||
if (operation === 'parseAttachment') return { parsed_object_name: 'temporary/parsed-object' };
|
||||
throw new Error('Unexpected operation: ' + operation);
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CloudComposerAttachments', () => {
|
||||
it.each([
|
||||
{ file: photo, type: 'image/png', kind: 'image', label: '图片文件' },
|
||||
{ file: document, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', kind: 'file', label: '文件' },
|
||||
])('adds a $kind file directly with the original reading method', async ({ file, type, kind, label }) => {
|
||||
api.upload.mockResolvedValue({ ...upload, file_name: file.file_name, file_type: type });
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => operation === 'confirmAttachment' ? { attachments: [file] } : base(operation, input));
|
||||
const view = open({ initialSelected: ['existing-id'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
const chip = await screen.findByRole('button', { name: '移除附件 ' + file.file_name });
|
||||
expect(chip.closest('.agent-attachment-card')).toHaveAttribute('data-kind', kind);
|
||||
expect(within(chip.closest('.agent-attachment-card') as HTMLElement).getByLabelText(label)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', file.file_id]);
|
||||
expect(operationCalls('confirmAttachment')).toEqual([['confirmAttachment', { thread_id: 'thread-created', attachments: [{ object_name: upload.object_name, file_type: type }] }]]);
|
||||
expect(operationCalls('parseAttachment')).toHaveLength(0);
|
||||
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
||||
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
||||
await waitFor(() => expect(view.hasPending()).toBe(false));
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
||||
expect(screen.getByTestId('composer-footer').closest('.agent-attachment-toolbar')).toContainElement(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
for (const button of screen.getAllByRole('button')) expect(button).toHaveAttribute('type', 'button');
|
||||
expect(view.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a conversation or change existing input when the native picker is canceled', async () => {
|
||||
api.upload.mockResolvedValue(null);
|
||||
const view = open({ initialSelected: ['existing-id'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await waitFor(() => expect(view.onBusy).toHaveBeenLastCalledWith(false));
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
||||
expect(view.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('retains the uploaded object and resolved conversation for an explicit confirmation retry', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let attempt = 0;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'confirmAttachment' && ++attempt === 1) throw new Error('确认时连接中断');
|
||||
return base(operation, input);
|
||||
});
|
||||
const view = open({ initialSelected: ['existing-id'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('确认时连接中断');
|
||||
expect(view.hasPending()).toBe(true);
|
||||
expect(view.onBusy).toHaveBeenLastCalledWith(false);
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '失败后还可以继续写' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
||||
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
||||
const attempts = operationCalls('confirmAttachment');
|
||||
expect(attempts).toHaveLength(2);
|
||||
expect(attempts[1]).toEqual(attempts[0]);
|
||||
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', photo.file_id]);
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('失败后还可以继续写');
|
||||
expect(view.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the picked object when ensuring the conversation fails and cancels without deleting files', async () => {
|
||||
const ensureThread = vi.fn().mockRejectedValueOnce(new Error('对话暂时无法创建')).mockResolvedValue('same-client-thread');
|
||||
const view = open({ ensureThread, initialSelected: ['existing-id'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await screen.findByText('对话暂时无法创建');
|
||||
expect(view.hasPending()).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
||||
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
expect(operationCalls('confirmAttachment')[0][1]).toMatchObject({ thread_id: 'same-client-thread' });
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'confirmAttachment') throw new Error('第二个文件未确认');
|
||||
return operation === 'attachments' ? { attachments: [photo] } : { files: [] };
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await screen.findByText('第二个文件未确认');
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消添加' }));
|
||||
expect(screen.queryByRole('button', { name: '重试添加' })).not.toBeInTheDocument();
|
||||
expect(view.hasPending()).toBe(false);
|
||||
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', photo.file_id]);
|
||||
expect(operationCalls('deleteAttachment')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only deselects a removed chip and can select the uploaded file again', async () => {
|
||||
api.call.mockImplementation(async (operation: string) => operation === 'attachments' ? { attachments: [photo, document] } : { files: [] });
|
||||
const view = open({ threadId: 'thread-existing', initialSelected: [photo.file_id] });
|
||||
fireEvent.click(await screen.findByRole('button', { name: '移除附件 ' + photo.file_name }));
|
||||
expect(view.onSelected).toHaveBeenLastCalledWith([]);
|
||||
expect(operationCalls('deleteAttachment')).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
const choice = await screen.findByRole('checkbox', { name: '选择附件 ' + photo.file_name });
|
||||
expect(choice).not.toBeChecked();
|
||||
fireEvent.click(choice);
|
||||
expect(view.onSelected).toHaveBeenLastCalledWith([photo.file_id]);
|
||||
expect(screen.getByRole('button', { name: '移除附件 ' + photo.file_name })).toBeInTheDocument();
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents duplicate picker and confirmation calls while an operation is busy', async () => {
|
||||
const picker = deferred<CloudUpload | null>();
|
||||
const confirmation = deferred<{ attachments: CloudAttachment[] }>();
|
||||
api.upload.mockReturnValue(picker.promise);
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
|
||||
const view = open();
|
||||
const add = screen.getByRole('button', { name: '添加文件或图片' });
|
||||
fireEvent.click(add); fireEvent.click(add);
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
expect(add).toBeDisabled();
|
||||
expect(screen.getByLabelText('消息')).toBeEnabled();
|
||||
await act(async () => { picker.resolve(upload); });
|
||||
await waitFor(() => expect(operationCalls('confirmAttachment')).toHaveLength(1));
|
||||
fireEvent.click(add);
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { confirmation.resolve({ attachments: [photo] }); });
|
||||
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
||||
expect(view.onSelected).toHaveBeenCalledTimes(1);
|
||||
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('does not add, remove or select attachments when disabled', async () => {
|
||||
api.call.mockImplementation(async (operation: string) => operation === 'attachments' ? { attachments: [photo] } : { files: [] });
|
||||
const view = open({ threadId: 'thread-existing', disabled: true, initialSelected: [photo.file_id] });
|
||||
const remove = await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
||||
expect(remove).toBeDisabled();
|
||||
fireEvent.click(remove);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
const choice = screen.getByRole('checkbox', { name: '选择附件 ' + photo.file_name });
|
||||
expect(choice).toBeDisabled();
|
||||
fireEvent.click(choice);
|
||||
await screen.findByText('还没有生成文件。');
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases busy and pending state on unmount and ignores a later picker response', async () => {
|
||||
const picker = deferred<CloudUpload | null>();
|
||||
api.upload.mockReturnValue(picker.promise);
|
||||
const view = open();
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
expect(view.hasPending()).toBe(true);
|
||||
view.unmount();
|
||||
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
||||
expect(view.hasPending()).toBe(false);
|
||||
await act(async () => { picker.resolve(upload); });
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a confirmation response after unmount without replacing selected input', async () => {
|
||||
const confirmation = deferred<{ attachments: CloudAttachment[] }>();
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
|
||||
const view = open();
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await waitFor(() => expect(operationCalls('confirmAttachment')).toHaveLength(1));
|
||||
view.unmount();
|
||||
await act(async () => { confirmation.resolve({ attachments: [photo] }); });
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
||||
expect(view.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a loading error without a false empty state and restores long names on refresh', async () => {
|
||||
const list = deferred<{ attachments: CloudAttachment[] }>();
|
||||
const longName = '小朋友写的故事'.repeat(30) + '.docx';
|
||||
const longFile = { ...document, file_name: longName };
|
||||
let reads = 0;
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'attachments') return ++reads === 1 ? list.promise : { attachments: [longFile] };
|
||||
return { files: [] };
|
||||
});
|
||||
const view = open({ threadId: 'thread-existing', initialSelected: [document.file_id] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
expect(screen.getByText('正在读取文件…')).toBeInTheDocument();
|
||||
await act(async () => { list.reject(new Error('文件读取失败')); });
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('文件读取失败');
|
||||
expect(screen.queryByText('还没有上传文件。')).not.toBeInTheDocument();
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新文件' }));
|
||||
const remove = await screen.findByRole('button', { name: '移除附件 ' + longName });
|
||||
expect(within(remove.closest('.agent-attachment-card') as HTMLElement).getByTitle(longName)).toHaveClass('agent-attachment-name');
|
||||
expect(screen.getByRole('checkbox', { name: '选择附件 ' + longName })).toBeChecked();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(view.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the user retry or cancel a picker failure without touching existing input', async () => {
|
||||
api.upload.mockRejectedValueOnce(new Error('上传失败')).mockResolvedValue(null);
|
||||
const view = open({ initialSelected: ['existing-id'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await screen.findByText('上传失败');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试选择文件' }));
|
||||
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(view.onBusy).toHaveBeenLastCalledWith(false));
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
||||
});
|
||||
|
||||
it('only parses when explicitly requested and reuses the parsed object after a confirmation failure', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let confirms = 0;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'confirmAttachment' && ++confirms === 1) throw new Error('确认响应丢失');
|
||||
return base(operation, input);
|
||||
});
|
||||
const view = open();
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加并选择读取方式' }));
|
||||
const choice = await screen.findByRole('combobox', { name: '附件读取方式' });
|
||||
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
||||
expect(operationCalls('parseAttachment')).toHaveLength(0);
|
||||
fireEvent.change(choice, { target: { value: 'rapid_ocr' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加到消息' }));
|
||||
await screen.findByText('确认响应丢失');
|
||||
expect(screen.getByRole('combobox', { name: '附件读取方式' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
||||
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
||||
expect(operationCalls('parseAttachment')).toEqual([['parseAttachment', { object_name: upload.object_name, parse_method: 'rapid_ocr' }]]);
|
||||
const confirmsMade = operationCalls('confirmAttachment');
|
||||
expect(confirmsMade).toHaveLength(2);
|
||||
expect(confirmsMade[1]).toEqual(confirmsMade[0]);
|
||||
expect(confirmsMade[0][1]).toEqual({ thread_id: 'thread-created', attachments: [{ object_name: upload.object_name, file_type: 'image/png', parsed_object_name: 'temporary/parsed-object' }] });
|
||||
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('browses generated files and uses the existing native save path', async () => {
|
||||
api.call.mockImplementation(async (operation: string, input: { path?: string }) => {
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
if (input.path === '/') return { files: [{ name: '作品', path: '/作品', directory_path: '/作品', is_dir: true, size: 0 }] };
|
||||
return { files: [{ name: '故事.txt', path: '/作品/故事.txt', directory_path: '/作品', is_dir: false, size: 500 }] };
|
||||
});
|
||||
const view = open({ threadId: 'thread-existing' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '作品' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '保存 故事.txt' }));
|
||||
await waitFor(() => expect(api.download).toHaveBeenCalledWith('thread-existing', '/作品/故事.txt'));
|
||||
expect(screen.getByRole('button', { name: '上一级' })).toHaveAttribute('type', 'button');
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
expect(view.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user