Simplify Agent chat with inline attachments and separate thinking

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 21:57:09 +08:00
1 parent 7af43acebb
commit faebfe9e84
18 files changed
+1558 -195

No files matched your search

+22 -3
View File
@@ -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);
});
+24 -12
View File
@@ -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' } });
+142 -32
View File
@@ -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);
});
+228
View File
@@ -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);
});
+54
View File
@@ -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();
});
});