fix: isolate delivered agent chats by project
This commit is contained in:
1 parent
a21a1f077c
commit
4b41c23a3a
15 files changed
+518
-165
No files matched your search
@@ -7,7 +7,8 @@ import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project,
|
||||
teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations' }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [],
|
||||
@@ -89,7 +90,7 @@ describe('manual agent refresh', () => {
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' }));
|
||||
expect(api.sendConversation).toHaveBeenCalledWith('project', original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' }));
|
||||
});
|
||||
|
||||
it('preserves the ongoing chat, old turn version and draft across refresh and later removal', async () => {
|
||||
@@ -100,7 +101,7 @@ describe('manual agent refresh', () => {
|
||||
requests: [{ ...checkInTopic().requests[0], teacherVersion: 1 }],
|
||||
conversation: { agentId: original.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'check-1' } };
|
||||
api.catalog.mockResolvedValue({ items: [original] });
|
||||
api.conversation.mockImplementation(async (id) => ({ topic: id === original.teacher_id ? history : null, before: null }));
|
||||
api.conversation.mockImplementation(async (project, id) => ({ topic: project === 'project' && id === original.teacher_id ? history : null, before: null }));
|
||||
const view = await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
||||
const input = screen.getByLabelText('向智能体提问');
|
||||
@@ -122,16 +123,19 @@ describe('manual agent refresh', () => {
|
||||
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
await act(async () => view.rerender(<Harness projectId="other" />));
|
||||
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
||||
expect(input).toHaveValue('接着刚才说');
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||
expect(screen.queryByText(history.requests[0].response)).toBeNull();
|
||||
await act(async () => view.rerender(<Harness projectId="project" />));
|
||||
const restoredInput = screen.getByLabelText('向智能体提问');
|
||||
expect(restoredInput).toHaveValue('接着刚才说');
|
||||
api.sendConversation.mockResolvedValue(history);
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'other' }));
|
||||
fireEvent.change(input, { target: { value: '停用时保留的问题' } });
|
||||
expect(api.sendConversation).toHaveBeenCalledWith('project', original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'project' }));
|
||||
fireEvent.change(restoredInput, { target: { value: '停用时保留的问题' } });
|
||||
api.catalog.mockResolvedValue({ items: [added] });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(input).toHaveValue('停用时保留的问题');
|
||||
expect(restoredInput).toHaveValue('停用时保留的问题');
|
||||
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
||||
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
@@ -220,23 +224,23 @@ describe('teacher presence and periodic guidance', () => {
|
||||
it('restores the last selected disabled agent as read-only history after a restart', async () => {
|
||||
const saved = { ...checkInTopic(), definition: {...definition, config_id: 'disabled', name: '已停用的老师'},
|
||||
conversation: {agentId: 'disabled', segmentTurns: 1, discussions: {}, seenThrough: 'check-1'} };
|
||||
localStorage.setItem('makelore-teacher-presence:kid:selected-agent', JSON.stringify('disabled'));
|
||||
localStorage.setItem('makelore-teacher-presence:kid:project:selected-agent', JSON.stringify('disabled'));
|
||||
api.conversation.mockResolvedValue({topic: saved, before: null});
|
||||
api.seenConversation.mockResolvedValue(saved);
|
||||
await mount();
|
||||
expect(api.conversation).toHaveBeenCalledWith('disabled');
|
||||
expect(api.conversation).toHaveBeenCalledWith('project', 'disabled');
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true})));
|
||||
expect(screen.getByRole('complementary', {name: '已停用的老师咨询'})).toBeVisible();
|
||||
expect(screen.getByRole('button', {name: '提问', exact: true})).toBeDisabled();
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
it('tracks unread replies per agent without creating invitation bubbles or losing the selected chat on a project switch', async () => {
|
||||
it('tracks unread replies and selected agents separately for each project', async () => {
|
||||
const agents = ['a', 'b'].map(id => ({teacher_id: id, version: 1, is_default: id === 'a', definition: {...definition, config_id: id, name: id}}));
|
||||
const chats = agents.map(agent => ({...checkInTopic(), id: agent.teacher_id + '-chat', definition: agent.definition,
|
||||
conversation: {agentId: agent.teacher_id, segmentTurns: 1, discussions: {}}}));
|
||||
api.catalog.mockResolvedValue({items: agents});
|
||||
api.conversation.mockImplementation(async id => ({topic: chats.find(chat => chat.definition.config_id === id), before: null}));
|
||||
api.seenConversation.mockImplementation(async (id, requestId) => {
|
||||
api.conversation.mockImplementation(async (project, id) => ({topic: project === 'project' ? chats.find(chat => chat.definition.config_id === id) : null, before: null}));
|
||||
api.seenConversation.mockImplementation(async (_project, id, requestId) => {
|
||||
const chat = chats.find(chat => chat.definition.config_id === id)!;
|
||||
return {...chat, revision: 3, conversation: {...chat.conversation, seenThrough: requestId}};
|
||||
});
|
||||
@@ -244,9 +248,12 @@ describe('teacher presence and periodic guidance', () => {
|
||||
expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(2);
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', {name: '与b聊天'})));
|
||||
expect(api.seenConversation).toHaveBeenCalledWith('b', 'check-1');
|
||||
expect(api.seenConversation).toHaveBeenCalledWith('project', 'b', 'check-1');
|
||||
expect(screen.getByRole('button', {name: '与b聊天'}).querySelector('.teacher-companion-unread')).toBeNull();
|
||||
await act(async () => view.rerender(<Harness projectId="other" />));
|
||||
expect(screen.getByRole('button', {name: '与a聊天'})).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(0);
|
||||
await act(async () => view.rerender(<Harness projectId="project" />));
|
||||
expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -372,7 +379,7 @@ describe('teacher presence and periodic guidance', () => {
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.sendConversation).toHaveBeenCalledWith('experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'}));
|
||||
expect(api.sendConversation).toHaveBeenCalledWith('project', 'experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'}));
|
||||
});
|
||||
|
||||
it('restores each selected agent conversation and uses its stable identity when sending', async () => {
|
||||
@@ -385,19 +392,19 @@ describe('teacher presence and periodic guidance', () => {
|
||||
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
|
||||
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
|
||||
], lastSelectedTopicId: oldB.id });
|
||||
api.conversation.mockImplementation(async id => ({topic: id === 'method' ? oldA : oldB, before: null}));
|
||||
api.conversation.mockImplementation(async (_project, id) => ({topic: id === 'method' ? oldA : oldB, before: null}));
|
||||
api.sendConversation.mockResolvedValue({ ...oldA, revision: 3 });
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
||||
expect(api.conversation).toHaveBeenCalledWith('method');
|
||||
expect(api.conversation).toHaveBeenCalledWith('project', 'method');
|
||||
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.sendConversation.mock.calls[0][0]).toBe('method');
|
||||
expect(api.sendConversation.mock.calls[0][1]).toBe('method');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
|
||||
expect(api.conversation).toHaveBeenCalledWith('experience');
|
||||
expect(api.conversation).toHaveBeenCalledWith('project', 'experience');
|
||||
});
|
||||
|
||||
it('ignores a late initial history read after the student selects a different agent', async () => {
|
||||
@@ -408,13 +415,13 @@ describe('teacher presence and periodic guidance', () => {
|
||||
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
|
||||
let resolve!: (view: {topic: TeacherTopic; before: null}) => void;
|
||||
const pending = new Promise<{topic: TeacherTopic; before: null}>((done) => { resolve = done; });
|
||||
api.conversation.mockImplementation(id => id === 'a' ? pending : Promise.resolve({topic: null, before: null}));
|
||||
api.conversation.mockImplementation((_project, id) => id === 'a' ? pending : Promise.resolve({topic: null, before: null}));
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
await act(async () => resolve({topic: old, before: null}));
|
||||
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.conversation).toHaveBeenCalledWith('a');
|
||||
expect(api.conversation).toHaveBeenCalledWith('project', 'a');
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -441,7 +448,7 @@ describe('teacher presence and periodic guidance', () => {
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the published greeting immediately without a model request and remembers dismissal across projects', async () => {
|
||||
it('shows the published greeting without a model request and remembers dismissal in its project', async () => {
|
||||
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } });
|
||||
const view = await mount();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
@@ -453,6 +460,8 @@ describe('teacher presence and periodic guidance', () => {
|
||||
const next = await mount();
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
await act(async () => next.rerender(<Harness projectId="other" />));
|
||||
expect(screen.getByTestId('teacher-invitation')).toBeVisible();
|
||||
await act(async () => next.rerender(<Harness projectId="project" />));
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user