145 lines
9.4 KiB
TypeScript
145 lines
9.4 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { CloudChannelConversations } from '@/pages/CloudAgents/CloudChannelConversations';
|
|
|
|
const api = vi.hoisted(() => ({ call: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn(), downloadChannelArtifact: vi.fn() }));
|
|
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
|
const session = { session_id: 'self-session', caller_id: 'self-caller', agent_slug: 'ml-' + 'a'.repeat(32), agent_name: '创作助手',
|
|
session_state: 'active', grant_state: 'authorized', access_mode: 'self_only', thread_id: 'private-channel-thread', sequence: 1, created_at: '2026-09-13T01:00:00Z' };
|
|
const run = { agent_run_id: 'native-run', status: 'interrupted', interrupt: { approval: { action_requests: [{ name: 'execute', args: { command: 'create slides' } }, { name: 'ls', args: { path: 'outputs' } }] } } };
|
|
const output = { name: '创作建议.pptx', path: '/outputs/创作建议.pptx', directory_path: '/outputs', is_dir: false, size: 40960 };
|
|
const history = { ...session, run, messages: [{ id: 1, role: 'user', content: '请制作演示文稿' }], queued_requests: [], next_offset: null };
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
|
api.downloadChannelArtifact.mockResolvedValue({ saved: true });
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'channelConversations') return { items: [session], next_offset: null };
|
|
if (operation === 'channelConversation') return history;
|
|
if (operation === 'channelFiles') return { session_id: session.session_id, thread_id: session.thread_id,
|
|
files: input.path === '/' ? [{ name: 'outputs', path: '/outputs', directory_path: '/outputs', is_dir: true, size: 0 }] : [output] };
|
|
throw new Error('Unexpected operation ' + operation);
|
|
});
|
|
});
|
|
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
|
|
|
it('approves the actual action batch through the self-session endpoint and automatically exposes the delivered file', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
let approved = false;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'channelConversationControl') { approved = true; return { session_id: session.session_id, status: 'completed' }; }
|
|
if (operation === 'channelConversation') return approved ? { ...history, run: { ...run, status: 'completed', interrupt: undefined }, messages: [{ id: 2, role: 'assistant', content: '演示文稿已完成' }] } : history;
|
|
if (operation === 'channelFiles' && !approved) return { files: [] };
|
|
return base(operation, input);
|
|
});
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
fireEvent.click(await screen.findByRole('button', { name: '允许执行' }));
|
|
await screen.findByText('演示文稿已完成');
|
|
expect(api.call).toHaveBeenCalledWith('channelConversationControl', expect.objectContaining({ session_id: session.session_id, action: 'resume', run_id: 'native-run',
|
|
decision: { decisions: [{ type: 'approve' }, { type: 'approve' }] } }));
|
|
fireEvent.click(await screen.findByRole('button', { name: '保存 创作建议.pptx' }));
|
|
await screen.findByText('已保存 创作建议.pptx');
|
|
expect(api.downloadChannelArtifact).toHaveBeenCalledWith('self-session', '/outputs/创作建议.pptx');
|
|
expect(api.call.mock.calls.some(call => ['resume', 'run', 'files', 'submit'].includes(call[0]))).toBe(false);
|
|
});
|
|
|
|
it('retries an uncertain stop with the identical operation ID and keeps other controls disabled', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
let original: unknown;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'channelConversationControl') {
|
|
original = input;
|
|
api.recovery.mockResolvedValue({ pending: [{ id: 'pending-stop', operation, input }] });
|
|
throw new Error('操作超时');
|
|
}
|
|
return base(operation, input);
|
|
});
|
|
api.resolvePending.mockImplementation(async () => {
|
|
api.recovery.mockResolvedValue({ pending: [] });
|
|
return { result: { session_id: session.session_id, status: 'cancelled' } };
|
|
});
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
const stop = await screen.findByRole('button', { name: '停止本次任务' });
|
|
await waitFor(() => expect(stop).toBeEnabled());
|
|
fireEvent.click(stop);
|
|
expect(await screen.findByRole('button', { name: '恢复上次操作' })).toBeEnabled();
|
|
expect(stop).toBeDisabled();
|
|
fireEvent.click(screen.getByRole('button', { name: '恢复上次操作' }));
|
|
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('pending-stop', false));
|
|
expect(api.call.mock.calls.filter(call => call[0] === 'channelConversationControl')).toEqual([['channelConversationControl', original]]);
|
|
});
|
|
|
|
it('allows ignoring a rejected new-session operation before stopping an intake not yet exposed as a Run', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'channelConversation') return { ...history, run: null };
|
|
if (operation === 'channelConversationControl') {
|
|
if (input.action === 'new-session') {
|
|
api.recovery.mockResolvedValue({ pending: [{ id: 'rejected-new', operation, input }] });
|
|
throw new Error('已有任务正在准备');
|
|
}
|
|
return { session_id: session.session_id, status: 'cancelled' };
|
|
}
|
|
return base(operation, input);
|
|
});
|
|
api.resolvePending.mockImplementation(async () => {
|
|
api.recovery.mockResolvedValue({ pending: [] }); return { discarded: true };
|
|
});
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
const start = await screen.findByRole('button', { name: '开始新对话' });
|
|
await waitFor(() => expect(start).toBeEnabled());
|
|
fireEvent.click(start);
|
|
fireEvent.click(await screen.findByRole('button', { name: '忽略本机记录' }));
|
|
const stop = screen.getByRole('button', { name: '停止本次任务' });
|
|
await waitFor(() => expect(stop).toBeEnabled());
|
|
fireEvent.click(stop);
|
|
await waitFor(() => expect(api.call).toHaveBeenCalledWith('channelConversationControl', expect.objectContaining({ action: 'stop' })));
|
|
expect(api.resolvePending).toHaveBeenCalledWith('rejected-new', true);
|
|
});
|
|
|
|
it('recovers a completed new-session receipt only after an explicit click and selects the returned session', async () => {
|
|
const pending = { id: 'pending-new', operation: 'channelConversationControl', input: { session_id: session.session_id, operation_id: 'old-operation', action: 'new-session' } };
|
|
api.recovery.mockResolvedValueOnce({ pending: [pending] }).mockResolvedValue({ pending: [] });
|
|
api.resolvePending.mockResolvedValue({ result: { session_id: 'next-self-session', previous_session_id: session.session_id } });
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
const restore = await screen.findByRole('button', { name: '恢复上次操作' });
|
|
expect(api.resolvePending).not.toHaveBeenCalled();
|
|
expect(api.call.mock.calls.some(call => call[0] === 'channelConversationControl')).toBe(false);
|
|
fireEvent.click(restore);
|
|
await waitFor(() => expect(api.call).toHaveBeenCalledWith('channelConversation', { session_id: 'next-self-session' }));
|
|
expect(api.resolvePending).toHaveBeenCalledWith('pending-new', false);
|
|
});
|
|
|
|
it('preserves historical self files while hiding controls for a revoked closed session', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation(async (operation, input) => operation === 'channelConversation'
|
|
? { ...history, session_state: 'closed', grant_state: 'revoked' } : base(operation, input));
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
await screen.findByRole('button', { name: '保存 创作建议.pptx' });
|
|
expect(screen.queryByRole('button', { name: '允许执行' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '停止本次任务' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('stops history polling when the desktop pane is hidden', async () => {
|
|
const view = render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
await screen.findByText('请制作演示文稿');
|
|
view.rerender(<CloudChannelConversations slug={session.agent_slug} activeView={false} />);
|
|
const count = api.call.mock.calls.filter(call => call[0] === 'channelConversation').length;
|
|
vi.useFakeTimers();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
|
|
expect(api.call.mock.calls.filter(call => call[0] === 'channelConversation')).toHaveLength(count);
|
|
});
|
|
|
|
it('reads subsequent native history pages so new results remain visible beyond the first 100 messages', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation !== 'channelConversation') return base(operation, input);
|
|
if (input.offset === 100) return { ...history, messages: [{ id: 101, role: 'assistant', content: '第 101 条消息的交付结果' }], next_offset: null };
|
|
return { ...history, next_offset: 100 };
|
|
});
|
|
render(<CloudChannelConversations slug={session.agent_slug} />);
|
|
await screen.findByText('第 101 条消息的交付结果');
|
|
expect(screen.getByText('请制作演示文稿')).toBeVisible();
|
|
expect(api.call).toHaveBeenCalledWith('channelConversation', { session_id: 'self-session', offset: 100 });
|
|
});
|