fix: isolate delivered agent chats by project

This commit is contained in:
brother7 committed 2026-09-28 16:21:14 +08:00
1 parent a21a1f077c
commit 4b41c23a3a
15 files changed
+518 -165

No files matched your search

+31 -22
View File
@@ -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();
});