merge: retain agent refresh in the updated teacher trial

This commit is contained in:
鲨鱼辣椒
2026-09-28 14:09:50 +08:00
14 changed files with 527 additions and 60 deletions

View File

@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
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() }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
@@ -25,7 +25,7 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
else { if (id) companion.selectAgent(id); setOpen(true); }
}} />
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} catalogState={companion.catalogState} reloadCatalog={companion.reloadCatalog} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
@@ -51,6 +51,160 @@ beforeEach(() => {
});
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('manual agent refresh', () => {
it('refreshes while busy and composing, shares one request, and uses the new version for the first question', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', welcome_message: '一起想想办法' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴', welcome_message: '我们一起试试新办法' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount({ busy: true });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我正在写的问题' } });
const listCalls = api.list.mock.calls.length;
const catalogCalls = api.catalog.mock.calls.length;
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeDisabled();
expect(screen.getByText('正在刷新…', { selector: '[role="status"]' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }));
expect(api.catalog).toHaveBeenCalledTimes(catalogCalls + 1);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
await act(async () => resolve({ items: [updated, added] }));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(updated.definition.welcome_message)).toBeVisible();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
expect(api.list).toHaveBeenCalledTimes(listCalls);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
const created = { ...checkInTopic(), id: 'new-topic', definition: updated.definition, version: updated.version, requests: [] };
api.create.mockResolvedValue(created);
api.send.mockResolvedValue(created);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
expect(api.send).toHaveBeenCalledWith('project', created.id, expect.objectContaining({ text: '我正在写的问题' }));
});
it('preserves the selected pinned topic, unread reply and draft until the user starts a new topic', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
const pinned = { ...checkInTopic(), version: 1, definition: original.definition };
api.catalog.mockResolvedValue({ items: [original] });
api.list.mockResolvedValue({ items: [{ id: pinned.id, teacherId: original.teacher_id, title: '之前的讨论' }], lastSelectedTopicId: pinned.id });
api.read.mockResolvedValue(pinned);
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '接着刚才说' } });
const readCalls = api.read.mock.calls.length;
const listCalls = api.list.mock.calls.length;
api.catalog.mockResolvedValue({ items: [updated, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(pinned.requests[0].response)).toBeVisible();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent(pinned.requests[0].response);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('接着刚才说');
expect(api.read).toHaveBeenCalledTimes(readCalls);
expect(api.list).toHaveBeenCalledTimes(listCalls);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
api.send.mockResolvedValue(pinned);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.send).toHaveBeenCalledWith('project', pinned.id, expect.objectContaining({ text: '接着刚才说' }));
expect(api.create).not.toHaveBeenCalled();
api.create.mockResolvedValue({ ...pinned, id: 'new-topic', version: updated.version, definition: updated.definition, requests: [] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '以往讨论' })));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体新话题' })));
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
});
it.each(['catalog', 'config'] as const)('keeps the current catalog when %s fails and lets the user retry', async (failedEndpoint) => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount();
api.catalog.mockResolvedValue({ items: [original, added] });
api[failedEndpoint].mockRejectedValueOnce(new Error('offline'));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('刷新失败,请重试')).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('方法老师');
expect(screen.queryByRole('button', { name: '与体验朋友聊天' })).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.queryByText('刷新失败,请重试')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('can fetch the first delivered agent while the previous catalog was unavailable', async () => {
api.config.mockResolvedValue({ enabled: false, definition: null });
await mount();
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('体验朋友');
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('keeps an open project draft in place when the first delivered agent appears', async () => {
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '还没有选择智能体的问题' } });
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(screen.queryByText('查看之前的项目草稿')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(added.definition.name);
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'false');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.getByText('查看之前的项目草稿')).toBeVisible();
expect(localStorage.getItem('makelore-consultation-draft:kid:project:teacher')).toContain('还没有选择智能体的问题');
});
it('ignores a slow refresh from the previous project without changing the new project status', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '旧项目智能体' } };
const current = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '当前项目智能体' } };
api.catalog.mockResolvedValue({ items: [original] });
const view = await mount();
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
api.catalog.mockResolvedValue({ items: [current] });
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
await act(async () => resolve({ items: [original] }));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
expect(screen.queryByText(original.definition.name)).not.toBeInTheDocument();
expect(screen.queryByText('已更新', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.queryByText('正在刷新…', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(api.checkIn).not.toHaveBeenCalled();
});
});
describe('teacher presence and periodic guidance', () => {
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };