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

@@ -2174,6 +2174,46 @@ test('settled prompt releases the composer without a client receipt and keeps th
} finally { await releaseSnapshot(electronApp); }
});
test('teacher introductions collapse after three operation replies and remain available on hover', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const introductions = page.getByTestId('teacher-welcomes');
await expect(introductions.locator('.teacher-welcome-card')).toHaveCount(2);
const startedAt = await page.evaluate(() => Date.now());
for (let round = 1; round <= 3; round++) {
for (const status of ['running', 'idle'] as const) {
const seq = round * 2 + (status === 'idle' ? 1 : 0);
const snapshot = {
schemaVersion: 1,
conversation: { id: 'conversation-pi-first-chat', projectId: 'project-pi-first-chat', agentId: 'builder', title: '新对话', model: { model: { accountId: 'account-e2e', modelId: 'model-a', thinkingLevel: 'off' }, modelResolution: 'resolved' } },
nodes: [], run: { status, mode: 'prompt', runId: `intro-round-${round}`, startedAt,
...(status === 'idle' ? { terminalReason: 'completed', settledAt: startedAt + round } : {}) },
worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq },
queue: { items: [] }, context: { usedTokens: 0, contextWindow: 4096, compaction: 'idle' }, pendingInteractions: [],
};
await emitCodingEvent(page, 'snapshot', { type: 'snapshot', conversationId: snapshot.conversation.id, workerGeneration: 1, seq, snapshot });
if (round < 3 || status === 'running') await expect(introductions).toBeVisible();
}
}
await expect(introductions).toHaveCount(0);
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).hover();
await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘');
await page.screenshot({ path: test.info().outputPath('teacher-introduction-after-three-rounds.png') });
await page.reload();
await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toBeVisible();
await expect(introductions).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }
});
test('teacher avatar consumes the welcome bubble through close, reopen and reload without a model request', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);

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 });