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

+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);
});