import { describe, expect, it, vi } from 'vitest'; import { authenticatedFetch, GongxueAiChatProvider, parseSsePayload, reduceAiSseMessage, } from './provider'; describe('AI chat SSE message reducer', () => { it('separates reasoning and answer deltas', () => { let message = reduceAiSseMessage(undefined, { event: 'reasoning.delta', data: JSON.stringify({ messageId: 8, delta: '分析' }), }); message = reduceAiSseMessage(message, { event: 'content.delta', data: JSON.stringify({ delta: '**答案**' }), }); expect(message.reasoningContent).toBe('分析'); expect(message.content).toBe('**答案**'); }); it('tracks tool lifecycle without exposing raw payloads', () => { let message = reduceAiSseMessage(undefined, { event: 'tool.started', data: JSON.stringify({ toolCallId: 'call-1', toolName: 'search_students', summary: '姓名条件', }), }); message = reduceAiSseMessage(message, { event: 'tool.completed', data: JSON.stringify({ toolCallId: 'call-1', toolName: 'search_students', status: 'success', summary: '找到 1 条记录', }), }); expect(message.toolRuns).toHaveLength(1); expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' }); }); it('tracks processed attachments and final message state', () => { let message = reduceAiSseMessage(undefined, { event: 'attachment.processed', data: JSON.stringify({ attachment: { id: 4, name: '名单.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 1200, status: 'ready', url: '/api/ai/chat/attachments/4', createdAt: '2026-07-24T00:00:00.000Z', }, }), }); message = reduceAiSseMessage(message, { event: 'message.completed', data: JSON.stringify({ message: { id: 12, content: '完成', reasoningContent: null, attachments: message.attachments, }, }), }); expect(message.attachments).toHaveLength(1); }); it('uses final content and records cancellation and errors', () => { let message = reduceAiSseMessage(undefined, { event: 'message.completed', data: JSON.stringify({ message: { id: 12, content: '最终回答', reasoningContent: '完成', status: 'completed', toolRuns: [ { toolCallId: 'nested-tool', toolName: 'search_rooms', status: 'success', resultSummary: '共 4 间', }, ], }, }), }); message = reduceAiSseMessage(message, { event: 'error', data: JSON.stringify({ message: '上游服务不可用' }), }); message = reduceAiSseMessage(message, { event: 'message.cancelled', data: JSON.stringify({ messageId: 12 }), }); expect(message).toMatchObject({ id: 12, content: '最终回答', reasoningContent: '完成', error: '上游服务不可用', cancelled: true, }); expect(message.toolRuns[0]).toMatchObject({ toolCallId: 'nested-tool', status: 'success' }); }); it('reads nested assistant message from message.created', () => { const message = reduceAiSseMessage(undefined, { event: 'message.created', data: JSON.stringify({ message: { id: 9, content: '', reasoningContent: null, status: 'pending' }, }), }); expect(message.id).toBe(9); }); it('merges ui.artifact events into uiArtifacts by id', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact: { id: 'form-1', type: 'form', status: 'pending', messageId: 12, conversationId: 3, payload: { id: 'form-1', title: '新增学生', fields: [] }, }, }), }); message = reduceAiSseMessage(message, { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact: { id: 'review-1', type: 'review', status: 'expired', messageId: 12, conversationId: 3, payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] }, }, }), }); expect(message.uiArtifacts).toHaveLength(2); expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' }); }); it('restores a persisted form from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', data: JSON.stringify({ message: { id: 12, content: '请填写表单', status: 'completed', metadata: { a2uiForm: { id: 'form-9', title: '新增学生', fields: [{ name: 'name', label: '姓名', type: 'input', required: true }], }, }, }, }), }); expect(message.forms).toHaveLength(1); expect(message.forms?.[0].id).toBe('form-9'); }); it('shows model retrying state and clears it when content starts', () => { let message = reduceAiSseMessage(undefined, { event: 'model.retrying', data: JSON.stringify({ messageId: 8, retry: { attempt: 1, maxRetries: 3, delayMs: 500, reason: '上游返回 503' }, }), }); expect(message.retrying).toMatchObject({ attempt: 1, maxRetries: 3 }); message = reduceAiSseMessage(message, { event: 'content.delta', data: JSON.stringify({ messageId: 8, delta: '你好' }), }); expect(message.retrying).toBeNull(); expect(message.content).toContain('你好'); }); it('restores a persisted review from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', data: JSON.stringify({ message: { id: 12, content: '请审阅', status: 'completed', metadata: { a2uiReview: { id: 'review-9', title: '批量导入', status: 'pending', sections: [ { key: 'rooms', type: 'rooms', title: '宿舍', kind: 'table', columns: [{ key: 'roomNumber', title: '房间号' }], rows: [{ roomNumber: '3-301' }], issues: [], }, ], }, }, }, }), }); expect(message.reviews).toHaveLength(1); expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); }); it('restores persisted charts from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', data: JSON.stringify({ message: { id: 12, content: '这是图表', status: 'completed', metadata: { a2uiChart: [ { id: 'chart-9', title: '男女比例', chartType: 'pie', columns: [ { key: 'name', title: '性别' }, { key: 'value', title: '人数' }, ], rows: [ { name: '男', value: 20 }, { name: '女', value: 15 }, ], }, ], }, }, }), }); expect(message.charts).toHaveLength(1); expect(message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'pie' }); }); it('rewrites review submissions to the review submit stream endpoint', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal('fetch', fetchMock); try { await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '确认批量导入', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: 'high', reviewSubmission: { reviewId: 'review-1', reviewTitle: '开学导入' }, }), }); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://x/api/ai/chat/reviews/review-1/submit/stream', ); const body = JSON.parse( (fetchMock.mock.calls[0][1] as RequestInit).body as string, ) as Record; expect(body).toEqual({ clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: 'high', }); } finally { vi.unstubAllGlobals(); } }); it('keeps reasoningEffort when rewriting regenerate requests', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal('fetch', fetchMock); try { await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: 'high', regenerateMessageId: 99, }), }); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://x/api/ai/chat/conversations/3/messages/99/regenerate/stream', ); const body = JSON.parse( (fetchMock.mock.calls[0][1] as RequestInit).body as string, ) as Record; expect(body).toEqual({ clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: 'high', }); } finally { vi.unstubAllGlobals(); } }); it('keeps reasoningEffort when rewriting form submissions', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal('fetch', fetchMock); try { await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: 'high', formSubmission: { formId: 'form-1', values: { name: '张三' } }, }), }); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://x/api/ai/chat/forms/form-1/submit/stream', ); const body = JSON.parse( (fetchMock.mock.calls[0][1] as RequestInit).body as string, ) as Record; expect(body).toEqual({ clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', values: { name: '张三' }, reasoningEffort: 'high', }); } finally { vi.unstubAllGlobals(); } }); it('routes ui.artifact targeting another message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const onExternalArtifact = vi.fn(); provider.onExternalArtifact = onExternalArtifact; const artifact = { id: 'artifact-1', type: 'form', status: 'submitted', messageId: 12, payload: { id: 'form-1', title: '批量导入', status: 'submitted' }, }; const origin = { id: 13, role: 'assistant' as const, content: '生成中', reasoningContent: '', toolRuns: [], attachments: [], uiArtifacts: [], }; const next = provider.transformMessage({ originMessage: origin, chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact); expect(next).toBe(origin); expect(next.uiArtifacts ?? []).toHaveLength(0); }); it('routes ui.artifact without an origin message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const onExternalArtifact = vi.fn(); provider.onExternalArtifact = onExternalArtifact; const next = provider.transformMessage({ chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact: { id: 'artifact-1', type: 'review', status: 'submitted', messageId: 12, payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, }, }), }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); expect(onExternalArtifact).toHaveBeenCalledWith( 12, expect.objectContaining({ id: 'artifact-1' }), ); expect(next.uiArtifacts ?? []).toHaveLength(0); }); it('tolerates non-JSON event data', () => { expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({ event: 'content.delta', payload: { delta: 'plain text' }, }); }); });