feat(consultation): unify server-distributed agents without fixed roles

This commit is contained in:
brother7 committed 2026-09-24 11:56:42 +08:00
1 parent 248776d855
commit 8a157be4f6
35 files changed
+625 -668

No files matched your search

+41 -18
View File
@@ -7,10 +7,10 @@ import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({ 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, teacherTopicsPath: (project: string) => project }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
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: [],
schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [],
model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
function checkInTopic(projectId = 'project', id = 'check-1'): TeacherTopic {
@@ -21,7 +21,7 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
const [teacherComposing, setTeacherComposing] = useState(false);
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
return <><TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(true)} />
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" role="teacher" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
@@ -42,6 +42,29 @@ beforeEach(() => {
afterEach(() => { cleanup(); vi.useRealTimers(); });
describe('teacher presence and periodic guidance', () => {
it('shows the selected agent greeting instead of borrowing the default agent greeting', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '默认智能体欢迎语' } });
const selected = { ...checkInTopic(), requests: [], definition: { ...definition, name: '体验朋友', avatar_id: 'avatar-09', welcome_message: '我们聊聊你的体验' } };
api.list.mockResolvedValue({ items: [{ id: selected.id }], lastSelectedTopicId: selected.id });
api.read.mockResolvedValue(selected);
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('我们聊聊你的体验');
expect(screen.queryByText('默认智能体欢迎语')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true }).querySelector('img')?.src).toContain('avatar-09.png');
expect(api.checkIn).not.toHaveBeenCalled();
});
it('does not dispatch background turns or subscribe while viewing legacy friend history', async () => {
const legacy = { ...checkInTopic(), role: 'friend' as const, requests: [] };
api.list.mockResolvedValue({ items: [{ id: legacy.id, legacyRole: 'friend' }], lastSelectedTopicId: legacy.id });
api.read.mockResolvedValue(legacy);
await mount();
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.events).not.toHaveBeenCalled();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('shows the published greeting immediately without a model request and remembers dismissal per project', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } });
const view = await mount();
@@ -107,10 +130,10 @@ describe('teacher presence and periodic guidance', () => {
it('keeps the pixel teacher in the header and only shows its companion avatar with a real message', async () => {
await mount();
expect(screen.getByText(definition.name)).toBeVisible();
const headerAvatar = screen.getByRole('button', { name: '老师', exact: true }).querySelector('img');
expect(headerAvatar?.src).toContain('teacher-pixel.png');
const headerAvatar = screen.getByRole('button', { name: '智能体', exact: true }).querySelector('img');
expect(headerAvatar?.src).toContain('avatar-06.png');
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '老师偶尔来看看' })).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '智能体偶尔来看看' })).not.toBeInTheDocument();
expect(api.checkIn).not.toHaveBeenCalled();
await advance(TEACHER_CHECK_IN_INTERVAL_MS - 1);
expect(api.checkIn).not.toHaveBeenCalled();
@@ -118,25 +141,25 @@ describe('teacher presence and periodic guidance', () => {
expect(api.checkIn).toHaveBeenCalledWith('project', expect.objectContaining({ sourceConversationId: 'source', requestId: expect.any(String) }));
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(screen.getByTestId('teacher-presence')).toBeVisible();
expect(screen.getByRole('button', { name: '打开这条老师消息' }).querySelector('img')?.src).toBe(headerAvatar?.src);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '和老师聊聊' })); });
expect(screen.getByRole('button', { name: '打开这条智能体消息' }).querySelector('img')?.src).toBe(headerAvatar?.src);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '和智能体聊聊' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '老师', exact: true })).toBeVisible();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toBeVisible();
const panel = screen.getByTestId('teacher-chat-panel');
expect(within(panel).getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
expect(panel.querySelector('.consultation-user')).toBeNull();
expect(screen.getByLabelText('向老师提问')).toHaveFocus();
expect(screen.getByLabelText('向智能体提问')).toHaveFocus();
expect(api.send).not.toHaveBeenCalled();
});
it('dismisses both the short bubble and its attached portrait without opening a conversation', async () => {
await mount(); await advance();
const presence = screen.getByTestId('teacher-presence');
expect(within(presence).getByTestId('teacher-invitation')).toBeVisible();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起老师气泡' })));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起智能体气泡' })));
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '老师', exact: true })).toBeVisible();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toBeVisible();
expect(api.send).not.toHaveBeenCalled();
});
it('does not stack invitations and remembers dismissal when the project is reopened', async () => {
@@ -153,18 +176,18 @@ describe('teacher presence and periodic guidance', () => {
});
it('waits for a teacher draft, then keeps the bubble visible even with consultation open until explicitly read', async () => {
await mount();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '老师', exact: true })); });
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还在想怎么说' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })); });
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还在想怎么说' } });
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
expect(screen.getByLabelText('向老师提问')).toHaveValue('我还在想怎么说');
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '' } });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还在想怎么说');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
await advance(15_000);
expect(within(screen.getByTestId('teacher-chat-panel')).getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
expect(screen.getByTestId('teacher-invitation')).toBeVisible();
await advance();
expect(api.checkIn).toHaveBeenCalledTimes(1);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '看看老师说的' })); });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '看看智能体说的' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
@@ -178,7 +201,7 @@ describe('teacher presence and periodic guidance', () => {
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '老师', exact: true })); });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })); });
expect(screen.getByTestId('teacher-invitation')).toBeVisible();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '等会儿聊' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();