import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import type { TeacherDefinition, TeacherTopic, TeacherRequest } from '../../shared/coding-teacher'; const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), list: vi.fn(), read: vi.fn(), legacyHistory: vi.fn(), legacyRead: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), updateDiscussion: vi.fn(), cancel: vi.fn(), seenConversation: vi.fn() })); const identity = vi.hoisted(() => ({ id: 0 })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => '/legacy/' + project })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid-' + identity.id } }) })); const definition: TeacherDefinition = { schema_version: 1, config_id: 'agent-a', teacher_id: 'teacher', name: '设计老师', description: '', avatar_id: 'avatar-01', welcome_message: '聊聊吧', suggested_questions: [], system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1000 } }; const agent = { teacher_id: 'agent-a', version: 1, definition, is_default: true }; function request(id: string, text = '旧的问题'): TeacherRequest { return { id, text, references: [], createdAt: id, sourceCursor: { seq: 0, workerGeneration: 0 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '原来的回答', projectId: 'project-a', projectName: '天气', sourceConversationId: 'source-a', teacherVersion: 1 }; } function topic(requests = [request('1')]): TeacherTopic { return { schemaVersion: 1, revision: 1, id: 'chat', accountId: 'kid', projectId: 'project-a', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests, conversation: { agentId: 'agent-a', segmentTurns: 1, discussions: {} } }; } let stream: EventTarget; beforeEach(() => { vi.resetAllMocks(); identity.id++; localStorage.clear(); stream = new EventTarget(); api.config.mockResolvedValue({ enabled: true, definition }); api.catalog.mockResolvedValue({ items: [agent] }); api.conversation.mockResolvedValue({ topic: topic(), before: null }); api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.events.mockResolvedValue(Object.assign(stream, { close: vi.fn() })); api.seenConversation.mockImplementation(async (_agent, requestId) => ({ ...topic(), conversation: { ...topic().conversation!, seenThrough: requestId } })); api.sendConversation.mockImplementation(async (_agent, input) => ({ ...topic(), revision: 2, requests: [...topic().requests, { ...request(input.requestId, input.text), projectId: input.projectId, sourceConversationId: input.sourceConversationId }] })); }); afterEach(cleanup); const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source-a', selectedAgent: agent }; async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); return view; } describe('continuous agent panel', () => { it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => { const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true, references: [{ kind: 'code' as const, text: '原项目代码' }] }; api.conversation.mockResolvedValue({ topic: topic([original]), before: null }); api.seenConversation.mockImplementation(async () => topic([original])); let finish!: (value: TeacherTopic) => void; api.sendConversation.mockImplementation(() => new Promise(resolve => { finish = resolve; })); const view = await mount(); expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示'); expect(screen.queryByTestId('teacher-reply')).toBeNull(); expect(api.sendConversation).not.toHaveBeenCalled(); fireEvent.change(screen.getByRole('textbox'), { target: { value: '仍在编辑的草稿' } }); view.rerender(); expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); view.rerender(); expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); view.rerender(); const retry = screen.getByRole('button', { name: '重新回答' }); fireEvent.click(retry); fireEvent.click(retry); expect(api.sendConversation).toHaveBeenCalledTimes(1); const sent = api.sendConversation.mock.calls[0][1]; expect(sent).toEqual({ requestId: expect.any(String), projectId: 'project-a', sourceConversationId: 'source-a', text: original.text, references: original.references }); expect(sent.requestId).not.toBe(original.id); fireEvent.change(screen.getByRole('textbox'), { target: { value: '等待时补充的草稿' } }); await act(async () => finish({ ...topic([original, { ...request(sent.requestId), response: '完整回答' }]), revision: 2 })); expect(screen.getByText('完整回答')).toBeVisible(); expect(screen.getByRole('textbox')).toHaveValue('等待时补充的草稿'); expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:kid-${identity.id}:agent:agent-a`)!)).toMatchObject({ text: '等待时补充的草稿' }); }); it('opens one chat without topic controls or creating a topic, and preserves it across project switches', async () => { const view = await mount(); expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull(); expect(screen.queryByRole('combobox')).toBeNull(); expect(api.create).not.toHaveBeenCalled(); expect(screen.getByText('旧的问题')).toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox'), { target: { value: '跨项目草稿' } }); view.rerender(); expect(screen.getByRole('textbox')).toHaveValue('跨项目草稿'); expect(screen.getByText('旧的问题')).toBeInTheDocument(); expect(screen.getByText('本轮项目:游戏 · 当前操作对话')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '提问' })); await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('agent-a', expect.objectContaining({ projectId: 'project-b', sourceConversationId: 'source-b', text: '跨项目草稿' }))); }); it('merges older pages and streamed current turns without replacing earlier messages', async () => { api.conversation.mockImplementation(async (_id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' })); await mount(); fireEvent.click(screen.getByText('查看更早消息')); await screen.findByText('更早的问题'); await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...topic([request('2', '新的问题')]), revision: 2, }) }))); expect(screen.getByText('更早的问题')).toBeInTheDocument(); expect(screen.getByText('旧的问题')).toBeInTheDocument(); expect(screen.getByText('新的问题')).toBeInTheDocument(); }); it('rejects stale references and prevents carrying an old project reply into the new Pi draft', async () => { const bring = vi.fn(); let view!: ReturnType; await act(async () => { view = render(); }); fireEvent.change(screen.getByRole('textbox'), { target: { value: '看看' } }); view.rerender(); fireEvent.click(screen.getByRole('button', { name: '提问' })); expect(api.sendConversation).not.toHaveBeenCalled(); expect(screen.getByRole('alert')).toHaveTextContent('引用来自另一个项目'); expect(screen.getByText('请打开来源项目和操作对话后带回')).toBeDisabled(); }); it('opens an empty chat without a model call and creates it only through the first send', async () => { api.conversation.mockResolvedValue({ topic: null, before: null }); await mount(); expect(api.sendConversation).not.toHaveBeenCalled(); fireEvent.change(screen.getByRole('textbox'), { target: { value: '你好' } }); fireEvent.click(screen.getByRole('button', { name: '提问' })); await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(1)); expect(api.create).not.toHaveBeenCalled(); }); it('merges a delayed older page without overwriting a newer streamed answer', async () => { let resolve!: (value: {topic: TeacherTopic; before: null}) => void; const page = new Promise<{topic: TeacherTopic; before: null}>(done => { resolve = done; }); api.conversation.mockImplementation((_id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' })); await mount(); fireEvent.click(screen.getByText('查看更早消息')); await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', {data: JSON.stringify({ ...topic([{...request('1'), response: '更新后的回答'}]), revision: 3, })}))); await act(async () => resolve({topic: topic([request('0', '分页里较早的问题'), request('1')]), before: null})); expect(screen.getByText('分页里较早的问题')).toBeVisible(); expect(screen.getByText('更新后的回答')).toBeVisible(); }); it('keeps legacy records read only when a live update arrives and returns to the same chat', async () => { const old = {...topic([request('old', '旧来源会话')]), id: 'legacy', conversation: undefined}; api.legacyHistory.mockResolvedValue({items: [{id: 'legacy', title: '来源记录', legacySourceId: 'removed-source'}], lastSelectedTopicId: null}); api.legacyRead.mockResolvedValue(old); const view = await mount(); fireEvent.click(screen.getByText('旧记录')); fireEvent.click(await screen.findByText('来源记录(只读)')); await screen.findByText('旧来源会话'); view.rerender(); expect(screen.getByText('旧来源会话')).toBeVisible(); expect(screen.getByRole('button', {name: '提问'})).toBeDisabled(); fireEvent.click(screen.getByText('返回与智能体的聊天')); await screen.findByText('旧的问题'); expect(api.sendConversation).not.toHaveBeenCalled(); }); });