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(); 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(); 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(); 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(); 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(); 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(); await screen.findByText('请制作演示文稿'); view.rerender(); 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(); await screen.findByText('第 101 条消息的交付结果'); expect(screen.getByText('请制作演示文稿')).toBeVisible(); expect(api.call).toHaveBeenCalledWith('channelConversation', { session_id: 'self-session', offset: 100 }); });