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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user