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 { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess'; import { CloudSchedules } from '@/pages/CloudAgents/CloudSchedules'; import { CloudBudgetEditor, CloudCosts } from '@/pages/CloudAgents/CloudCosts'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication'; function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) { const publication = useCloudPublication(props.slug, props.revision, props.onPublished); return ; } const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: vi.fn(), remember: vi.fn(), recovery: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } const stream = new Stream(); const slug = 'ml-' + 'a'.repeat(32); const budget = { agent_slug: slug, request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00', timezone: 'Asia/Shanghai', unit: '词元点数', resets_at: '2026-09-11T00:00:00+08:00' }; const scheduleContext = { version: 2, enabled: true, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'model-a' }, result_destination: '任务历史对话与活动', payer: 'creator' }; const run = { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: slug, status: 'running', version: '2', output: '' }; const history = { thread_id: 'thread-1', messages: [], run, next_offset: null }; beforeEach(() => { vi.resetAllMocks(); api.remember.mockResolvedValue({ saved: true }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); api.events.mockResolvedValue(stream); api.call.mockImplementation(async (operation: string) => { if (operation === 'budget') return budget; if (operation === 'scheduleContext') return scheduleContext; if (operation === 'channelSelfCallers') return { items: [] }; if (operation === 'channelBindings') return { items: [], available_accounts: [] }; if (operation === 'threads') return { threads: [], next_offset: null }; if (operation === 'history') return history; if (operation === 'attachments') return { attachments: [] }; if (operation === 'files') return { files: [] }; if (operation === 'run') return run; throw new Error('Unexpected operation: ' + operation); }); }); afterEach(cleanup); async function chooseFileFromMenu(button = screen.getByRole('button', { name: '添加文件或图片' })) { fireEvent.keyDown(button, { key: 'ArrowDown' }); fireEvent.click(await screen.findByRole('menuitem', { name: '添加文件或图片', exact: true })); } it('recovers a server queue and cancels it after remount without resubmitting messages', async () => { const base = api.call.getMockImplementation()!; let cancelled = false; api.call.mockImplementation(async (operation, input) => { if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null }; if (operation === 'history') return { ...history, run: { ...run, status: 'completed' }, queued_requests: cancelled ? [] : [{ request_id: 'queued-request', thread_id: 'thread-1', run_id: null, status: 'queued', version: '1' }] }; if (operation === 'request') return { request_id: 'queued-request', thread_id: 'thread-1', run_id: null, status: 'queued', version: '1' }; if (operation === 'cancelRequest') { cancelled = true; return { status: 'cancelled' }; } return base(operation, input); }); render(); fireEvent.click(await screen.findByRole('button', { name: '取消排队' })); await waitFor(() => expect(screen.queryByRole('button', { name: '取消排队' })).not.toBeInTheDocument()); expect(api.call).toHaveBeenCalledWith('cancelRequest', { request_id: 'queued-request' }); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); }); 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' }; api.recovery.mockImplementation(async () => ({ recent, pending: [] })); 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: [{ 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(); 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(); 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()!; api.call.mockImplementation(async (operation, input) => operation === 'threads' ? { threads: input.archived ? [{ ...threads[1], archived: true }] : [threads[0]], next_offset: null } : beforeArchive(operation, input)); render(); const restore = await screen.findByRole('button', { name: '恢复对话' }); expect(screen.getByLabelText('消息')).toBeDisabled(); fireEvent.click(restore); await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled()); expect(api.call).toHaveBeenCalledWith('archiveThread', { thread_id: 'older', archived: false }); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); }); it('keeps proposed schedules disabled until the creator enables and reviews them', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'schedules') return { jobs: [] }; if (operation === 'createSchedule') return { id: 'job', ...input }; return base(operation, input); }); render( undefined} proposal={{ id: 'proposal-1', name: '周末选题', prompt: '整理五个方向', cron_expression: '30 10 * * 6', timezone: 'Asia/Shanghai' }} />); expect(screen.getByLabelText('任务名称')).toHaveValue('周末选题'); expect(screen.getByLabelText('执行时间')).toHaveValue('10:30'); expect(screen.getByLabelText('星期')).toHaveValue('6'); expect(screen.getByLabelText('保存后启用')).not.toBeChecked(); expect(api.call.mock.calls.some(call => call[0] === 'createSchedule')).toBe(false); await screen.findByText('启用前确认'); fireEvent.change(screen.getByLabelText('执行时间'), { target: { value: '08:45' } }); fireEvent.click(screen.getByLabelText('保存后启用')); fireEvent.click(screen.getByRole('button', { name: '保存并启用' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('createSchedule', expect.objectContaining({ cron_expression: '45 8 * * 6', enabled: true }))); }); it('saves independent Agent ceilings and pages cost results under the active source filter', async () => { api.call.mockImplementation(async (operation, input) => { if (operation === 'budget') return budget; if (operation === 'saveBudget') return { ...budget, ...input }; if (operation === 'costs') return { items: [], next_offset: input.offset ? null : 50, summary: { count: 75, settled_points: '12.30', pending_points: '4.50' }, unit: '词元点数' }; throw new Error(operation); }); render(<>); const request = await screen.findByLabelText('每次任务上限(词元点数)'); await waitFor(() => expect(request).toBeEnabled()); expect(screen.getByRole('option', { name: '知识库模型调用' })).toBeInTheDocument(); expect(screen.getByText(/知识处理触发的对话模型调用仍按实际用量计入费用/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限'); fireEvent.change(request, { target: { value: '20.50' } }); fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '100' } }); fireEvent.click(screen.getByRole('button', { name: '保存费用上限' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', { slug, request_limit_points: '20.50', daily_limit_points: '100' })); fireEvent.change(screen.getByLabelText('费用来源'), { target: { value: 'api' } }); await waitFor(() => expect(screen.getByRole('button', { name: '加载更多费用' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '加载更多费用' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('costs', expect.objectContaining({ slug, source: 'api', offset: 50 }))); expect(screen.getByText(/共 75 次模型调用/)).toHaveTextContent('12.30'); }); it('retries the same request after a lost response and renders batched native SSE', async () => { let attempts = 0; const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation !== 'submit') return base(operation, input); if (++attempts === 1) throw new Error('响应丢失'); return { request_id: 'request-1', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' }; }); render(); await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '生成文章' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await screen.findByRole('button', { name: '重试本次发送' }); expect(screen.getByLabelText('消息')).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试本次发送' })); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1')); 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({ 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.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 () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null }; if (operation === 'history') return { ...history, run: { ...run, status: 'interrupted', interrupt: { questions: [{ question_id: 'direction', question: '选择方向', options: ['海洋', '森林'] }], } } }; if (operation === 'resume') return { run_id: 'run-2', status: 'pending' }; if (operation === 'run' && input.run_id === 'run-2') return { ...run, agent_run_id: 'run-2', status: 'pending' }; return base(operation, input); }); render(); fireEvent.click(await screen.findByRole('radio', { 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: '提交回答' })); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-2')); expect(api.call.mock.calls.find(call => call[0] === 'resume')?.[1].decision).toEqual({ direction: '海洋' }); }); it('keeps channel and application access without offering selected-user sharing', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'access') return { published_version: 1, enabled: true, share_url: 'niancode://agents/' + slug, versions: [], grants: [{ account_id: 'previously-shared-user', enabled: true }], applications: [], }; if (operation === 'costs') return { items: [], next_offset: null, unit: '词元点数', summary: { count: 0, settled_points: '0.00', pending_points: '0.00' } }; if (operation === 'createApplication') return { id: 'application-1', name: input.name, enabled: true }; return base(operation, input); }); render( undefined} />); expect(await screen.findByRole('heading', { name: '应用与 API' })).toBeVisible(); expect(screen.getByRole('button', { name: '打开渠道' })).toBeEnabled(); expect(screen.getByRole('button', { name: '停用智能体' })).toBeEnabled(); expect(screen.queryByRole('heading', { name: '分享给指定用户' })).not.toBeInTheDocument(); expect(screen.queryByLabelText('查找分享用户')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '复制分享链接' })).not.toBeInTheDocument(); expect(screen.queryByText('previously-shared-user')).not.toBeInTheDocument(); expect(screen.queryByText('niancode://agents/' + slug)).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText('应用名称'), { target: { value: '我的网站' } }); fireEvent.click(screen.getByRole('button', { name: '创建应用' })); await screen.findByText('应用已创建,可为它生成 API 凭据'); expect(api.call).toHaveBeenCalledWith('createApplication', expect.objectContaining({ slug, name: '我的网站' })); expect(api.call.mock.calls.some(([operation]) => operation === 'users' || operation === 'share')).toBe(false); }); it('keeps publication and access controls usable when cost history is unavailable', async () => { api.call.mockImplementation(async (operation) => { if (operation === 'access') return { published_version: 1, enabled: true, share_url: 'niancode://agents/' + slug, versions: [], grants: [], applications: [] }; if (operation === 'costs') throw new Error('费用暂不可用'); if (operation === 'channelBindings') return { items: [], available_accounts: [] }; if (operation === 'budget') return budget; throw new Error(operation); }); render( undefined} />); expect(await screen.findByText('助手已准备好')).toBeVisible(); expect(screen.getByRole('button', { name: '停用智能体' })).toBeEnabled(); expect(await screen.findByText('费用暂不可用')).toBeVisible(); }); it('saves a manually created schedule disabled unless the creator selects enable', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'schedules') return { jobs: [] }; if (operation === 'createSchedule') return { id: 'job', ...input }; return base(operation, input); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '添加任务' })); expect(screen.getByLabelText('保存后启用')).not.toBeChecked(); fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '早间选题' } }); fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '给出三个选题' } }); fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('createSchedule', expect.objectContaining({ enabled: false }))); }); it('an uncertain schedule save retries the same operation and explicit enabled state', async () => { let attempt = 0; api.call.mockImplementation(async (operation) => { if (operation === 'schedules') return { jobs: [] }; if (operation === 'budget') return budget; if (operation === 'scheduleContext') return scheduleContext; if (operation === 'channelSelfCallers') return { items: [] }; if (operation === 'channelBindings') return { items: [], available_accounts: [] }; if (operation === 'createSchedule') { if (++attempt === 1) throw new Error('超时'); return { id: 'job' }; } throw new Error(operation); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '添加任务' })); fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '早间选题' } }); fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '给出三个选题' } }); expect(screen.getByLabelText('保存后启用')).not.toBeChecked(); fireEvent.click(screen.getByLabelText('保存后启用')); await waitFor(() => expect(screen.getByRole('button', { name: '保存并启用' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '保存并启用' })); fireEvent.click(await screen.findByRole('button', { name: '重试保存' })); await screen.findByRole('button', { name: '添加任务' }); const calls = api.call.mock.calls.filter(call => call[0] === 'createSchedule'); expect(calls).toHaveLength(2); expect(calls[0][1]).toEqual(calls[1][1]); expect(calls[0][1].enabled).toBe(true); }); it('sends scheduled results only to an explicitly selected own pairing and preserves that intent on retry', async () => { const base = api.call.getMockImplementation()!; let saves = 0; api.call.mockImplementation(async (operation, input) => { if (operation === 'schedules') return { jobs: [] }; if (operation === 'channelSelfCallers') return { items: [{ caller_id: 'self-caller', channel_account_id: 'wechat-a' }] }; if (operation === 'channelBindings') return { items: [{ id: 'wechat-a', display_name: '我的创作助手' }], available_accounts: [] }; if (operation === 'createSchedule') { if (++saves === 1) throw new Error('连接超时'); return { id: 'job', ...input }; } return base(operation, input); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '添加任务' })); await screen.findByRole('option', { name: '我的创作助手 · 对话 1' }); expect(screen.getByLabelText('结果发送到')).toHaveValue(''); fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '微信选题' } }); fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '整理三个选题' } }); fireEvent.change(screen.getByLabelText('结果发送到'), { target: { value: 'self-caller' } }); fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' })); fireEvent.click(await screen.findByRole('button', { name: '重试保存' })); await screen.findByRole('button', { name: '添加任务' }); const calls = api.call.mock.calls.filter(call => call[0] === 'createSchedule'); expect(calls).toHaveLength(2); expect(calls[0][1]).toEqual(calls[1][1]); expect(calls[0][1].result_notification).toEqual({ enabled: true, channel_account_id: 'wechat-a', caller_id: 'self-caller' }); expect(calls[0][1].enabled).toBe(false); }); it('pauses a schedule without dropping its own-WeChat notification target', async () => { const notification = { enabled: true, channel_account_id: 'wechat-a', caller_id: 'self-caller' }; const job = { id: 'job', name: '选题', prompt: '整理选题', cron_expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: true, result_notification: notification }; api.call.mockImplementation(async (operation, input) => { if (operation === 'schedules') return { jobs: [job] }; if (operation === 'updateSchedule') return { ...job, ...input }; throw new Error(operation); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '暂停' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('updateSchedule', expect.objectContaining({ enabled: false }))); // Yuxi retains the notification when the key is absent; resending a revoked // target would revalidate it and incorrectly block the pause. expect(api.call.mock.calls.find(call => call[0] === 'updateSchedule')![1]).not.toHaveProperty('result_notification'); }); it('keeps an unavailable existing notification target until the creator explicitly clears it', async () => { const base = api.call.getMockImplementation()!; const job = { id: 'job', name: '选题', prompt: '整理选题', cron_expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: false, result_notification: { enabled: true, channel_account_id: 'old-wechat', caller_id: 'revoked-caller' } }; api.call.mockImplementation(async (operation, input) => { if (operation === 'schedules') return { jobs: [job] }; if (operation === 'updateSchedule') return { ...job, ...input }; return base(operation, input); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '编辑' })); await screen.findByRole('option', { name: '原微信对话暂不可用(保留配置)' }); expect(screen.getByLabelText('结果发送到')).toHaveValue('revoked-caller'); expect(api.call.mock.calls.some(call => call[0] === 'updateSchedule')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' })); await screen.findByRole('button', { name: '添加任务' }); expect(api.call.mock.calls.find(call => call[0] === 'updateSchedule')![1]).not.toHaveProperty('result_notification'); fireEvent.click(screen.getByRole('button', { name: '编辑' })); await screen.findByRole('option', { name: '原微信对话暂不可用(保留配置)' }); fireEvent.change(screen.getByLabelText('结果发送到'), { target: { value: '' } }); fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('updateSchedule', expect.objectContaining({ result_notification: null }))); }); 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 === '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); }); const first = render(); await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); expect(await screen.findByText('新对话主题')).toBeVisible(); await waitFor(() => expect(recent.thread_id).toBe('thread-1')); first.unmount(); render(); 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 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', 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: true, parse_methods: ['disable', 'rapid_ocr'], }); render(); const add = screen.getByRole('button', { name: '添加文件或图片' }); await waitFor(() => expect(add).toBeEnabled()); await chooseFileFromMenu(add); await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1)); await waitFor(() => expect(add).toBeEnabled()); expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false); expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); await chooseFileFromMenu(add); 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); 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: '附件,共 1 个文件' })); const checkbox = await screen.findByRole('checkbox', { name: '选择附件 notes.txt' }); expect(checkbox).not.toBeChecked(); fireEvent.click(checkbox); expect(checkbox).toBeChecked(); fireEvent.click(screen.getByRole('button', { name: '返回聊天 · 已选 1 个附件' })); expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: '移除附件 notes.txt' })); expect(screen.queryByRole('button', { name: '移除附件 notes.txt' })).not.toBeInTheDocument(); 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(); await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled()); await chooseFileFromMenu(); 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(); 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(); await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled()); await chooseFileFromMenu(); 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(); await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '继续讨论我的想法' } }); await chooseFileFromMenu(); 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); }); it('pins uncertain preview retries to the original request and revision after a draft save', async () => { const base = api.call.getMockImplementation()!; let attempts = 0; api.call.mockImplementation(async (operation, input) => { if (operation === 'preview') { if (++attempts === 1) throw new Error('响应丢失'); return { request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', status: 'completed', version: 'draft:1' }; } if (operation === 'history') return { ...history, run: { ...run, status: 'completed', version: 'draft:1' }, messages: [{ id: 1, role: 'assistant', content: '第一版回答' }] }; return base(operation, input); }); const view = render(); await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '原来的问题' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await screen.findByRole('button', { name: '重试本次发送' }); view.rerender(); expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试本次发送' })); await screen.findByText('第一版回答'); const submissions = api.call.mock.calls.filter(call => call[0] === 'preview'); expect(submissions).toHaveLength(2); expect(submissions[0][1]).toEqual(submissions[1][1]); expect(submissions[1][1]).toMatchObject({ expected_revision: 1, query: '原来的问题' }); fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' })); await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled()); expect(screen.getByText('对比上一次试用 · 修订 1')).toBeVisible(); expect(screen.getByText('第一版回答')).not.toBeVisible(); fireEvent.click(screen.getByRole('button', { name: '查看历史对话' })); fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } }); await screen.findByText('第一版回答'); expect(screen.getByText('当前试用 · 修订 1')).toBeVisible(); }); it('uses Enter to send while preserving Shift+Enter and Chinese IME composition', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'preview' ? { request_id: input.request_id, thread_id: 'thread-1', status: 'completed' } : base(operation, input)); render(); const input = screen.getByLabelText('消息'); await waitFor(() => expect(input).toBeEnabled()); fireEvent.change(input, { target: { value: '整理我的思路' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }); expect(api.call.mock.calls.some(call => call[0] === 'preview')).toBe(false); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(api.call).toHaveBeenCalledWith('preview', expect.objectContaining({ expected_revision: 3, query: '整理我的思路' }))); }); it('does not mark a retained hidden conversation as read until it is shown', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', mode: 'published' }], next_offset: null }; if (operation === 'history') return { ...history, run: { ...run, status: 'completed' }, messages: [{ id: 1, role: 'assistant', content: '后台的新回答' }] }; if (operation === 'viewed') return { viewed: true }; return base(operation, input); }); const view = render(); await screen.findByText('后台的新回答'); expect(api.call.mock.calls.some(call => call[0] === 'viewed')).toBe(false); expect(api.remember).not.toHaveBeenCalled(); view.rerender(); await waitFor(() => expect(api.call).toHaveBeenCalledWith('viewed', { thread_id: 'thread-1', run_id: 'run-1' })); }); it('discovers generated files after the authoritative completed reply without reopening the conversation', async () => { const base = api.call.getMockImplementation()!; const events = new Stream(); api.events.mockResolvedValue(events); let completed = false; api.call.mockImplementation(async (operation, input) => { if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null }; if (operation === 'history') return { ...history, messages: completed ? [{ id: 'answer-1', role: 'assistant', content: '故事文件已做好。\n[下载故事](sandbox:/story.txt)', run_id: 'run-1' }] : [], run: { ...run, status: completed ? 'completed' : 'running' } }; if (operation === 'files') return { files: completed ? [{ name: 'story.txt', path: '/story.txt', directory_path: '/', is_dir: false, size: 1024 }] : [] }; return base(operation, input); }); render(); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1')); await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: 'thread-1' })); expect(api.call.mock.calls.some(([operation]) => operation === 'files')).toBe(false); expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument(); completed = true; await act(async () => events.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: { status: 'completed' } } }), }))); expect(await screen.findByText('故事文件已做好。')).toBeVisible(); fireEvent.click(await screen.findByRole('button', { name: '附件,共 1 个文件' })); expect(await screen.findByRole('button', { name: '保存 story.txt' })).toBeEnabled(); expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2); expect(api.call.mock.calls.some(([operation]) => ['createThread', 'confirmAttachment', 'submit'].includes(operation))).toBe(false); expect(api.upload).not.toHaveBeenCalled(); });