491 lines
15 KiB
TypeScript
491 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 feedback 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,
|
|
feedback: 'like',
|
|
attachments: message.attachments,
|
|
},
|
|
}),
|
|
});
|
|
expect(message.attachments).toHaveLength(1);
|
|
expect(message.feedback).toBe('like');
|
|
});
|
|
|
|
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' },
|
|
});
|
|
});
|
|
});
|