Collapse teacher introductions after three operation replies

This commit is contained in:
鲨鱼辣椒
2026-09-29 14:29:52 +08:00
parent c82b1a43be
commit 4199cadd1e
6 changed files with 257 additions and 1 deletions

View File

@@ -4,6 +4,9 @@ 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 { codingConversationStore } from '@/stores/coding-conversations';
import type { ConversationRunState } from '@/types/coding-conversation';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
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() }));
@@ -38,6 +41,7 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
}
beforeEach(() => {
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
codingConversationStore.setState({ entriesByConversationId: {}, summariesByConversationId: {} });
identity.id = 'kid';
vi.stubGlobal('ResizeObserver', class {
observe() {}
@@ -60,6 +64,130 @@ describe('individual teacher introductions', () => {
definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` },
}));
async function operation(runId: string, overrides: Partial<ConversationRunState> = {}, projectId = 'project', sourceId = 'source') {
const snapshot = createProductSnapshot(sourceId);
snapshot.conversation.projectId = projectId;
snapshot.run = { status: 'idle', mode: 'prompt', runId, startedAt: Date.now(), settledAt: Date.now(), terminalReason: 'completed', ...overrides };
snapshot.cursor.seq = (codingConversationStore.getState().entriesByConversationId[sourceId]?.reducer.snapshot?.cursor.seq ?? 0) + 1;
await act(async () => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot', conversationId: sourceId, workerGeneration: 1, seq: snapshot.cursor.seq, snapshot,
}));
}
it('leaves introductions until the third completed operation round and preserves hover and unread receipts', async () => {
api.catalog.mockResolvedValue({ items: agents });
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
await mount();
await operation('one');
await operation('one'); // A duplicate/recovered completion is still the same round.
await operation('two');
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
await operation('three', { status: 'running', terminalReason: undefined });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' });
expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍');
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('ignores historical, failed, aborted, tool/retry, compaction and other-project activity', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
for (let i = 0; i < 3; i++) {
await operation(`history-${i}`, { startedAt: Date.now() - 60_000 });
await operation(`failed-${i}`, { status: 'error', terminalReason: 'failed' });
await operation(`aborted-${i}`, { terminalReason: 'aborted' });
await operation(`retry-${i}`, { status: 'retrying', terminalReason: undefined });
await operation(`tools-${i}`, { status: 'running', terminalReason: undefined });
await operation(`compaction-${i}`, { mode: undefined });
await operation(`other-project-${i}`, {}, 'other');
}
await operation('one');
await operation('two');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('continues the count across reload and operation-chat switches, including a completion during reload', async () => {
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
await operation('same-run-id');
await act(async () => first.rerender(<Harness sourceId="second-source" />));
await operation('same-run-id', {}, 'project', 'second-source');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
first.unmount();
const second = await mount({ sourceId: 'second-source' });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
second.unmount();
await operation('third-during-reload', {}, 'project', 'source');
const third = await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
third.unmount();
await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('keeps round windows independent for projects/accounts and newly delivered teachers', async () => {
api.catalog.mockResolvedValue({ items: agents });
const view = await mount();
await operation('one');
await operation('two');
await advance(1);
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-one', {}, 'other');
await act(async () => view.rerender(<Harness />));
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-two', {}, 'other');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-three', {}, 'other');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await advance(1);
identity.id = 'new-kid';
await act(async () => view.rerender(<Harness />));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
identity.id = 'kid';
await act(async () => view.rerender(<Harness />));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师' } };
api.catalog.mockResolvedValue({ items: [...agents, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(1);
await operation('new-one');
await operation('new-two');
expect(screen.getByRole('button', { name: '收起新老师的介绍' })).toBeVisible();
await operation('new-three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('does not count teacher turns and still collapses the other teachers while consultation is open', async () => {
api.catalog.mockResolvedValue({ items: agents });
const topic = { ...checkInTopic(), definition: agents[0].definition, requests: [],
conversation: { agentId: agents[0].teacher_id, segmentTurns: 0, discussions: {} } };
api.sendConversation.mockResolvedValue(topic);
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '和方法老师聊聊' })));
for (let i = 0; i < 3; i++) {
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: `老师问题${i}` } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
}
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(2);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
await operation('one'); await operation('two'); await operation('three');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(api.sendConversation).toHaveBeenCalledTimes(3);
});
it('introduces every delivered teacher independently of the old shared welcome dismissal without sending requests', async () => {
localStorage.setItem('makelore-teacher-presence:kid:welcome-dismissed', 'true');
api.catalog.mockResolvedValue({ items: agents });