Files
makelore/tests/unit/cloud-agents-workflows.test.tsx

424 lines
27 KiB
TypeScript

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 } 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';
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);
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(<CloudChat slug={slug} />);
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('returns to the last selected thread on remount and requires restoring an archived conversation 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: [], 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'));
first.unmount();
const second = render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('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(<CloudChat slug={slug} />);
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(<CloudSchedules slug={slug} onOpen={() => 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(<><CloudBudgetEditor slug={slug} /><CloudCosts slug={slug} applications={[]} /></>);
const request = await screen.findByLabelText('每次任务上限(词元点数)');
await waitFor(() => expect(request).toBeEnabled());
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(<CloudChat slug={slug} />);
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({ 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();
});
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(<CloudChat slug={slug} />);
fireEvent.click(await screen.findByRole('radio', { name: '海洋' }));
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
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: '海洋' });
});
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(<CloudAccessPanel slug={slug} revision={2} onPublished={() => undefined} />);
expect(await screen.findByText('当前发布版本 1')).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(<CloudSchedules slug={slug} onOpen={() => 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(<CloudSchedules slug={slug} onOpen={() => 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(<CloudSchedules slug={slug} onOpen={() => 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(<CloudSchedules slug={slug} onOpen={() => 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(<CloudSchedules slug={slug} onOpen={() => 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('returns to a newly created conversation from history without remounting the page', async () => {
const base = api.call.getMockImplementation()!;
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' } };
return base(operation, input);
});
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(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 () => {
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' }] };
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: [],
});
render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByText('附件与产物'));
const add = screen.getByRole('button', { name: '添加附件' });
await waitFor(() => expect(add).toBeEnabled());
fireEvent.click(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();
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());
});
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(<CloudChat slug={slug} previewRevision={1} />);
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(<CloudChat slug={slug} previewRevision={2} />);
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: '用最新草稿试用' }));
expect(screen.queryByText('第一版回答')).not.toBeInTheDocument();
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(<CloudChat slug={slug} previewRevision={3} />);
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(<CloudChat slug={slug} activeView={false} />);
await screen.findByText('后台的新回答');
expect(api.call.mock.calls.some(call => call[0] === 'viewed')).toBe(false);
expect(api.remember).not.toHaveBeenCalled();
view.rerender(<CloudChat slug={slug} activeView />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('viewed', { thread_id: 'thread-1', run_id: 'run-1' }));
});