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.form events into the assistant message by id', () => { const form = { id: 'form-1', title: '新增学生', submitLabel: '提交创建', fields: [ { name: 'name', label: '姓名', type: 'input', required: true }, { name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] }, ], }; let message = reduceAiSseMessage(undefined, { event: 'ui.form', data: JSON.stringify({ messageId: 8, form }), }); message = reduceAiSseMessage(message, { event: 'ui.form', data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }), }); message = reduceAiSseMessage(message, { event: 'ui.form', data: JSON.stringify({ messageId: 8, form: { id: 'form-2', title: '入住确认', fields: [] }, }), }); expect(message.forms).toHaveLength(2); expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); }); 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('merges ui.review events into the assistant message and updates by id', () => { const review = { id: 'review-1', title: '开学导入', summary: '来自报名 Excel', status: 'pending', sections: [ { key: 'students', type: 'students', title: '学生', kind: 'table', columns: [ { key: 'name', title: '姓名' }, { key: 'phone', title: '手机号' }, ], rows: [{ name: '张三', phone: '13800138000' }], issues: [], }, ], }; let message = reduceAiSseMessage(undefined, { event: 'ui.review', data: JSON.stringify({ messageId: 8, review }), }); message = reduceAiSseMessage(message, { event: 'ui.review', data: JSON.stringify({ messageId: 8, review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' }, }), }); expect(message.reviews).toHaveLength(1); expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' }); }); 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('merges ui.chart events into the assistant message by id', () => { const chart = { id: 'chart-1', title: '各班级人数', chartType: 'bar', columns: [ { key: 'className', title: '班级' }, { key: 'count', title: '人数' }, ], rows: [ { className: '一班', count: 20 }, { className: '二班', count: 15 }, ], }; let message = reduceAiSseMessage(undefined, { event: 'ui.chart', data: JSON.stringify({ messageId: 8, chart }), }); message = reduceAiSseMessage(message, { event: 'ui.chart', data: JSON.stringify({ messageId: 8, chart: { ...chart, id: 'chart-2', title: '女生人数' }, }), }); expect(message.charts).toHaveLength(2); expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' }); expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' }); }); 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 submit-time ui.review to the original message instead of the streaming one', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const onExternalReview = vi.fn(); provider.onExternalReview = onExternalReview; const review = { id: 'review-1', title: '批量导入', status: 'submitted', sections: [], }; const origin = { id: 13, role: 'assistant' as const, content: '生成中', reasoningContent: '', toolRuns: [], attachments: [], reviews: [], }; const next = provider.transformMessage({ originMessage: origin, chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); expect(onExternalReview).toHaveBeenCalledWith(12, review); expect(next).toBe(origin); expect(next.reviews ?? []).toHaveLength(0); }); it('routes ui.review without an origin message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const onExternalReview = vi.fn(); provider.onExternalReview = onExternalReview; const next = provider.transformMessage({ chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, }), }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); expect(onExternalReview).toHaveBeenCalledWith( 12, expect.objectContaining({ id: 'review-1' }), ); expect(next.reviews ?? []).toHaveLength(0); }); it('tolerates non-JSON event data', () => { expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({ event: 'content.delta', payload: { delta: 'plain text' }, }); }); });