Files
gongxue-base/apps/admin/src/components/AiChat/provider.integration.test.ts

489 lines
15 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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' },
});
});
});