fix: isolate teacher references from unrelated selections

This commit is contained in:
鲨鱼辣椒
2026-09-30 11:19:37 +08:00
parent c55e9c7182
commit 36202e0642
6 changed files with 235 additions and 11 deletions

View File

@@ -321,6 +321,108 @@ describe('CodingChatPanel first Conversation', () => {
expect(conversationApi.submit).not.toHaveBeenCalled();
});
async function renderQuotationWorkspace() {
localStorage.clear();
window.getSelection()?.removeAllRanges();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(withForkSource(createLocalConversationSnapshot(project.id, conversation), 'quote-source'));
const definitions: TeacherDefinition[] = ['测试Alice对比', '产品工程师-Alice'].map((name, index) => ({
schema_version: 1, config_id: `quote-agent-${index}`, teacher_id: 'teacher', name,
description: '', avatar_id: 'avatar-01', welcome_message: '聊聊吧', suggested_questions: [],
system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1000 },
}));
const topics = definitions.map((definition, index): TeacherTopic => ({
schemaVersion: 1, revision: 1, id: `quote-history-${index}`, accountId: 'signed-out',
projectId: project.id, sourceConversationId: conversation.id, definition, version: 1,
createdAt: '2026-09-30T00:00:00.000Z', updatedAt: '2026-09-30T00:00:00.000Z',
requests: [{ id: `quote-reply-${index}`, text: '看看项目', references: [], createdAt: '2026-09-30T00:00:00.000Z',
sourceCursor: { seq: 0, workerGeneration: 0 }, sourceCapturedAt: '2026-09-30T00:00:00.000Z',
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed',
response: index ? '这是产品老师的回复' : '只有测试老师里的内容',
projectId: project.id, sourceConversationId: conversation.id, teacherVersion: 1 }],
conversation: { agentId: definition.config_id!, segmentTurns: 1, discussions: {}, seenThrough: `quote-reply-${index}` },
}));
const { teacherApi } = await import('@/lib/coding-teacher');
vi.spyOn(teacherApi, 'config').mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: definitions[0] });
vi.spyOn(teacherApi, 'catalog').mockResolvedValue({ items: definitions.map((definition, index) => ({ teacher_id: definition.config_id!, version: 1, definition, is_default: index === 0 })) });
vi.spyOn(teacherApi, 'conversation').mockImplementation(async (_projectId, id) => ({ topic: topics.find(topic => topic.definition.config_id === id)!, before: null }));
vi.spyOn(teacherApi, 'events').mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const send = vi.spyOn(teacherApi, 'sendConversation').mockImplementation(async (_projectId, id, input) => {
const topic = topics.find(topic => topic.definition.config_id === id)!;
return { ...topic, revision: 2, requests: [...topic.requests, { ...topic.requests[0], id: input.requestId, text: input.text, references: input.references ?? [], response: '这次提问已收到' }] };
});
const browserApi = await import('@/lib/agent-browser');
const browser = { browserId: null, projectId: project.id, projectPath: null, state: 'closed' as const, generation: 0,
url: '', title: '', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0 };
vi.spyOn(browserApi, 'getAgentBrowserState').mockResolvedValue(browser);
vi.spyOn(browserApi, 'closeAgentBrowser').mockResolvedValue(browser);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
await screen.findByRole('button', { name: '与测试Alice对比聊天' });
await screen.findByText('Fork source');
return { send };
}
function selectText(element: Element) {
const range = document.createRange();
range.selectNodeContents(element);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
}
it('does not capture one teacher message into another teacher draft or request', async () => {
const { send } = await renderQuotationWorkspace();
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
selectText(await screen.findByText('只有测试老师里的内容'));
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
await screen.findByText('这是产品老师的回复');
const input = screen.getByRole('textbox', { name: '向智能体提问' });
await waitFor(() => expect(input).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:signed-out:project:${project.id}:agent:quote-agent-1`)!)).toMatchObject({ references: [] });
fireEvent.change(input, { target: { value: '产品老师帮我看看' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(send).toHaveBeenCalledWith(project.id, 'quote-agent-1', expect.objectContaining({ text: '产品老师帮我看看', references: [] })));
});
it('keeps an operation quote with its recipient and does not resurrect it after removal or sending', async () => {
const { send } = await renderQuotationWorkspace();
selectText(screen.getByText('Fork source'));
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.getByRole('button', { name: '移除引用' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
await screen.findByText('这是产品老师的回复');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
await screen.findByText('只有测试老师里的内容');
fireEvent.click(screen.getByRole('button', { name: '移除引用' }));
fireEvent.click(screen.getByRole('button', { name: '收起老师对话栏' }));
fireEvent.click(screen.getByRole('button', { name: '展开老师对话栏' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '操作对话' }));
selectText(screen.getByText('Fork source'));
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
const input = screen.getByRole('textbox', { name: '向智能体提问' });
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: '解释这段操作内容' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(send).toHaveBeenCalledWith(project.id, 'quote-agent-1', expect.objectContaining({ references: [{ kind: 'code', text: 'Fork source' }] })));
await screen.findByText('这次提问已收到');
fireEvent.click(screen.getByRole('button', { name: '收起老师对话栏' }));
fireEvent.click(screen.getByRole('button', { name: '展开老师对话栏' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(send).toHaveBeenCalledTimes(1);
window.getSelection()?.removeAllRanges();
});
it('toggles the teacher dock from the header without changing tabs, drafts, history, or sending prompts', async () => {
localStorage.clear();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });