fix: render consultation replies and typed tool activity

This commit is contained in:
2026-09-24 14:36:20 +08:00
parent a628860144
commit 97837cef90
19 changed files with 432 additions and 35 deletions

View File

@@ -96,6 +96,19 @@ async function ready() {
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('renders assistant Markdown, tables, code and images instead of literal markup', async () => {
const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n![项目截图](https://example.com/preview.png)';
api.read.mockResolvedValue({ ...first, requests: [request({ response })] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '项目建议' })).toBeVisible();
expect(screen.getByRole('table')).toHaveTextContent('main.ts');
expect(screen.getByText('保留现有玩法').closest('li')).not.toBeNull();
expect(screen.getByText('{"name":"flybot"}').closest('pre')).not.toBeNull();
expect(screen.getByRole('link', { name: '说明' })).toHaveAttribute('href', 'https://example.com/readme');
expect(screen.getByRole('img', { name: '项目截图' })).toHaveAttribute('src', 'https://example.com/preview.png');
});
it('does not render a zero for an empty legacy friend draft', async () => {
localStorage.setItem('makelore-consultation-draft:signed-out:p:friend', JSON.stringify({ text: '', references: [] }));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
@@ -105,6 +118,68 @@ describe('teacher side chat', () => {
expect(screen.queryByText('查看旧版朋友草稿')).not.toBeInTheDocument();
});
it('keeps malformed originals folded and literal while rendering the recovered answer', async () => {
const raw = '{"reply":"正文已保留","tool":<script>alert(1)</script>';
api.read.mockResolvedValue({ ...first, requests: [request({
response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText('正文已保留').tagName).toBe('STRONG');
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(details?.querySelector('script')).toBeNull();
});
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![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)',
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const body = screen.getByTestId('teacher-reply');
expect(body.querySelector('.katex')).not.toBeNull();
expect(body.querySelector('pre')?.textContent).toContain('const value = "\\n";');
expect(body.querySelector('script')).toBeNull();
expect(screen.queryByRole('link', { name: '危险' })).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '本地图片' })).not.toBeInTheDocument();
fireEvent.error(screen.getByRole('img', { name: '坏图片' }));
expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible();
});
it('updates streamed Markdown without duplicating the final answer', async () => {
const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] };
api.read.mockResolvedValue(running);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible();
const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] };
await act(async () => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(completed) })));
expect(screen.getAllByTestId('teacher-reply')).toHaveLength(1);
expect(screen.getByText('先检查文件').tagName).toBe('STRONG');
});
it('keeps tool activity separate from the answer and stops stale running indicators', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '已经看过项目文件。',
toolActivity: [
{ id: 'one', name: 'read_project_file', status: 'completed' },
{ id: 'two', name: 'read_conversation', status: 'failed' },
{ id: 'three', name: 'cloud-search', status: 'running' },
],
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const details = screen.getByText('使用了 3 项工具').closest('details')!;
expect(details).not.toHaveAttribute('open');
expect(details).toHaveTextContent('读取项目文件已完成');
expect(details).toHaveTextContent('读取会话原文未完成');
expect(details).toHaveTextContent('未收到执行结果');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('已经看过项目文件。');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('读取会话原文');
});
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
const bringBack = vi.fn();
api.send