fix: preserve complete consultation replies and recover retained history
This commit is contained in:
@@ -158,6 +158,122 @@ describe('teacher side chat', () => {
|
||||
expect(details?.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an incomplete completed reply instead of displaying a half sentence as a finished answer', async () => {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——还有后面的回答';
|
||||
const bringBack = vi.fn();
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
text: '现在项目是什么阶段?', response: '它早就不是', replyIncomplete: true,
|
||||
replyParseError: 'format error', unparsedResponse: raw, suggestedQuestions: ['尚未确认的建议'],
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
|
||||
await ready();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
|
||||
expect(screen.queryByTestId('teacher-reply')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '尚未确认的建议' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeEnabled();
|
||||
const details = screen.getByText('查看收到的原始内容').closest('details');
|
||||
expect(details).not.toHaveAttribute('open');
|
||||
expect(details?.querySelector('pre')?.textContent).toBe(raw);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps complete Markdown visible when only auxiliary fields could not be recovered', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '**它早就不是"刚搭好架子"那种阶段了。** 后面的回答也已经收到。',
|
||||
replyParseError: 'invalid quickReplies', unparsedResponse: '{"reply":"完整回答","quickReplies":',
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('它早就不是"刚搭好架子"那种阶段了。 后面的回答也已经收到。');
|
||||
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
|
||||
expect(screen.queryByText('这次回复未能完整显示')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '重新回答' })).not.toBeInTheDocument();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['question', 'suggestions', 'guided-help'] as const)('retries an incomplete %s only on click with its original input while preserving the current draft', async intent => {
|
||||
const originalReferences = [{ messageId: 'original-message', text: '原来的引用', role: 'assistant' as const }];
|
||||
const currentQuote = { messageId: 'current-message', text: '草稿里的新引用', role: 'assistant' as const };
|
||||
const incomplete = request({
|
||||
intent, text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: originalReferences,
|
||||
response: '它早就不是', replyIncomplete: true,
|
||||
});
|
||||
const before = { ...first, requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
let finish!: (topic: TeacherTopic) => void;
|
||||
api.send.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" quote={currentQuote} />);
|
||||
await ready();
|
||||
const input = screen.getByLabelText('向智能体提问');
|
||||
fireEvent.change(input, { target: { value: '还没说完的新想法' } });
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
const retry = screen.getByRole('button', { name: '重新回答' });
|
||||
fireEvent.click(retry);
|
||||
fireEvent.click(retry);
|
||||
expect(api.send).toHaveBeenCalledTimes(1);
|
||||
const sent = api.send.mock.calls[0][2] as TeacherSend;
|
||||
expect(sent).toEqual({ requestId: expect.any(String), text: incomplete.text, intent, references: originalReferences, sourceConversationId: 'original-source' });
|
||||
expect(sent.requestId).not.toBe(incomplete.id);
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: '等待时又补充了想法' } });
|
||||
await act(async () => finish({ ...before, revision: 2, requests: [...before.requests, request({ id: sent.requestId, text: sent.text, response: '这次的完整回答。' })] }));
|
||||
expect(screen.getByText('这次的完整回答。')).toBeVisible();
|
||||
expect(input).toHaveValue('等待时又补充了想法');
|
||||
expect(screen.getByText('草稿里的新引用')).toBeVisible();
|
||||
expect(JSON.parse(localStorage.getItem('makelore-consultation-draft:signed-out:p:teacher')!)).toMatchObject({ text: '等待时又补充了想法', references: [currentQuote] });
|
||||
});
|
||||
|
||||
it('keeps the retry id and draft after an uncertain send failure and uses the original topic source for older replies', async () => {
|
||||
const incomplete = request({ text: '原来的问题', response: '', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'older-source', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockRejectedValueOnce(new Error('暂时没连上')).mockImplementation(async (_base, _id, sent: TeacherSend) => ({
|
||||
...before, revision: 2, requests: [incomplete, request({ id: sent.requestId, text: sent.text, response: '完整回答' })],
|
||||
}));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="new-source" />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '草稿不能丢' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByText('完整回答');
|
||||
expect(api.send).toHaveBeenCalledTimes(2);
|
||||
expect(api.send.mock.calls[0][2]).toEqual(api.send.mock.calls[1][2]);
|
||||
expect(api.send.mock.calls[1][2]).toMatchObject({ text: '原来的问题', sourceConversationId: 'older-source', references: [] });
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
});
|
||||
|
||||
it('retries a project-only historical question without attaching another conversation or the project sentinel', async () => {
|
||||
const incomplete = request({ text: '原来的问题', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'project', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockResolvedValue(before);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" />);
|
||||
await ready();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
|
||||
expect(api.send.mock.calls[0][2]).not.toHaveProperty('sourceConversationId');
|
||||
});
|
||||
|
||||
it('does not retry an incomplete reply from a disabled Agent or turn a check-in into a normal question', async () => {
|
||||
const archived = { ...first, definition: { ...definition, config_id: 'unavailable-agent' }, requests: [
|
||||
request({ response: '', replyIncomplete: true }),
|
||||
request({ id: 'check-in', intent: 'check-in', text: '', response: '', replyIncomplete: true }),
|
||||
] };
|
||||
api.read.mockResolvedValue(archived);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getAllByText('这次回复未能完整显示')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: '重新回答' })).toHaveLength(1);
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n\n\n',
|
||||
|
||||
Reference in New Issue
Block a user