feat(coding): keep one ongoing conversation per delivered agent
This commit is contained in:
1 parent
e54f3d234d
commit
7aa81da616
23 files changed
+1205
-160
No files matched your search
@@ -6,7 +6,7 @@ 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';
|
||||
|
||||
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() }));
|
||||
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('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
|
||||
const definition: TeacherDefinition = {
|
||||
@@ -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={(id) => { 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} onClose={() => setOpen(false)} />}</>;
|
||||
{open && <TeacherChatPanel key={companion.selectedAgent?.teacher_id ?? projectId + ':history'} selectedAgent={companion.selectedAgent} 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>;
|
||||
@@ -36,6 +36,7 @@ beforeEach(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [] });
|
||||
api.conversation.mockResolvedValue({ topic: null, before: null });
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
|
||||
@@ -43,6 +44,39 @@ beforeEach(() => {
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
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'));
|
||||
api.conversation.mockResolvedValue({topic: saved, before: null});
|
||||
api.seenConversation.mockResolvedValue(saved);
|
||||
await mount();
|
||||
expect(api.conversation).toHaveBeenCalledWith('disabled');
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true})));
|
||||
expect(screen.getByRole('heading', {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 () => {
|
||||
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) => {
|
||||
const chat = chats.find(chat => chat.definition.config_id === id)!;
|
||||
return {...chat, revision: 3, conversation: {...chat.conversation, seenThrough: requestId}};
|
||||
});
|
||||
const view = await mount();
|
||||
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(screen.getByRole('button', {name: '与b聊天'}).querySelector('.teacher-companion-unread')).toBeNull();
|
||||
await act(async () => view.rerender(<Harness projectId="other" />));
|
||||
expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
|
||||
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
|
||||
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
|
||||
@@ -66,7 +100,7 @@ describe('teacher presence and periodic guidance', () => {
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
api.send.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
api.sendConversation.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
await mount();
|
||||
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
||||
@@ -85,33 +119,33 @@ describe('teacher presence and periodic guidance', () => {
|
||||
await advance();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, 9);
|
||||
expect(api.send.mock.calls[0][2].text).toBe('体验的草稿');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.sendConversation).toHaveBeenCalledWith('experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'}));
|
||||
});
|
||||
|
||||
it('restores the selected agent history at its pinned version instead of the globally last topic', async () => {
|
||||
it('restores each selected agent conversation and uses its stable identity when sending', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||
const oldA = { ...checkInTopic(), id: 'a-topic', version: 1, definition: a.definition, requests: [] };
|
||||
const oldB = { ...checkInTopic(), id: 'b-topic', version: 8, definition: b.definition, requests: [] };
|
||||
const oldA = { ...checkInTopic(), id: 'a-topic', conversation: { agentId: 'method', segmentTurns: 0, discussions: {} }, version: 1, definition: a.definition, requests: [] };
|
||||
const oldB = { ...checkInTopic(), id: 'b-topic', conversation: { agentId: 'experience', segmentTurns: 0, discussions: {} }, version: 8, definition: b.definition, requests: [] };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [
|
||||
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
|
||||
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
|
||||
], lastSelectedTopicId: oldB.id });
|
||||
api.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB);
|
||||
api.send.mockResolvedValue({ ...oldA, revision: 3 });
|
||||
api.conversation.mockImplementation(async 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.read).toHaveBeenCalledWith('project', oldA.id);
|
||||
expect(api.conversation).toHaveBeenCalledWith('method');
|
||||
expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.send.mock.calls[0][1]).toBe(oldA.id);
|
||||
expect(api.sendConversation.mock.calls[0][0]).toBe('method');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(api.read).toHaveBeenCalledWith('project', oldB.id);
|
||||
expect(api.conversation).toHaveBeenCalledWith('experience');
|
||||
});
|
||||
|
||||
it('ignores a late initial history read after the student selects a different agent', async () => {
|
||||
@@ -120,14 +154,15 @@ describe('teacher presence and periodic guidance', () => {
|
||||
const old = { ...checkInTopic(), definition: a.definition };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
|
||||
let resolve!: (topic: TeacherTopic) => void;
|
||||
api.read.mockReturnValue(new Promise<TeacherTopic>((done) => { resolve = done; }));
|
||||
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}));
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
await act(async () => resolve(old));
|
||||
await act(async () => resolve({topic: old, before: null}));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.read).toHaveBeenCalledWith('project', old.id, false);
|
||||
expect(api.conversation).toHaveBeenCalledWith('a');
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -154,7 +189,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 per project', async () => {
|
||||
it('shows the published greeting immediately without a model request and remembers dismissal across projects', async () => {
|
||||
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } });
|
||||
const view = await mount();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
@@ -166,7 +201,7 @@ 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')).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives a real unread reply priority over the greeting and does not show the greeting after acknowledgment', async () => {
|
||||
|
||||
Reference in new issue
Block a user