feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -66,14 +66,18 @@ function createService(
describe('AiChatService', () => {
it('按 userId 查询会话,无法借 id 访问其他用户会话', async () => {
const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(null) });
const { service, conversations } = createService({
findOne: jest.fn().mockResolvedValue(null),
});
await expect(service.getMessages(7, 99)).rejects.toBeInstanceOf(NotFoundException);
expect(conversations.findOne).toHaveBeenCalledWith({ where: { id: 99, userId: 7 } });
});
it('生成中的会话禁止删除', async () => {
const entity = { id: 2, userId: 7 };
const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(entity) });
const { service, conversations } = createService({
findOne: jest.fn().mockResolvedValue(entity),
});
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(2);
await expect(service.deleteConversation(7, 2)).rejects.toBeInstanceOf(ConflictException);
expect(conversations.remove).not.toHaveBeenCalled();
@@ -136,14 +140,16 @@ describe('AiChatService', () => {
it('并发获取同一会话时只允许一个请求进入生成流程', async () => {
let resolveExists!: (value: boolean) => void;
const exists = jest.fn(
() => new Promise<boolean>((resolve) => {
resolveExists = resolve;
}),
() =>
new Promise<boolean>((resolve) => {
resolveExists = resolve;
}),
);
const { service } = createService();
(service as unknown as { messages: { exists: typeof exists } }).messages.exists = exists;
const acquire = (service as unknown as { acquireConversation(id: number): Promise<void> })
.acquireConversation.bind(service);
const acquire = (
service as unknown as { acquireConversation(id: number): Promise<void> }
).acquireConversation.bind(service);
const first = acquire(5);
await expect(acquire(5)).rejects.toBeInstanceOf(ConflictException);
@@ -153,7 +159,9 @@ describe('AiChatService', () => {
it('工具摘要脱敏并限制长度', () => {
const { service } = createService();
const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind(service);
const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind(
service,
);
const summary = summarize({
phone: '13800138000',
idCard: '11010519491231002X',
@@ -170,17 +178,21 @@ describe('AiChatService', () => {
it('超大附件文本在进入模型前被截断并提示', async () => {
const { service } = createService();
(service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = {
toModelParts: jest.fn().mockResolvedValue([
{ attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) },
]),
toModelParts: jest
.fn()
.mockResolvedValue([
{ attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) },
]),
};
const build = (service as unknown as {
buildUserContent(
text: string,
attachments: unknown[],
supportsVision: boolean,
): Promise<string | unknown[]>;
}).buildUserContent.bind(service);
const build = (
service as unknown as {
buildUserContent(
text: string,
attachments: unknown[],
supportsVision: boolean,
): Promise<string | unknown[]>;
}
).buildUserContent.bind(service);
const result = await build('请看这个文件', [{ id: 1 }], false);
expect(typeof result).toBe('string');
expect(result as string).toContain('内容过长');
@@ -205,13 +217,15 @@ describe('AiChatService', () => {
},
]),
};
const build = (service as unknown as {
buildUserContent(
text: string,
attachments: unknown[],
supportsVision: boolean,
): Promise<string | unknown[]>;
}).buildUserContent.bind(service);
const build = (
service as unknown as {
buildUserContent(
text: string,
attachments: unknown[],
supportsVision: boolean,
): Promise<string | unknown[]>;
}
).buildUserContent.bind(service);
const result = await build('请看这个文件', [{ id: 1 }], false);
expect(result as string).toContain('# 名单(共 100 行)');
expect(result as string).toContain('office_analyze');
@@ -220,113 +234,116 @@ describe('AiChatService', () => {
it.each([
{ abort: false, expectedStatus: 'failed', expectedCode: 'UPSTREAM_ERROR' },
{ abort: true, expectedStatus: 'cancelled', expectedCode: 'CLIENT_ABORTED' },
])('流中断后保存已生成内容和 $expectedStatus 状态', async ({ abort, expectedStatus, expectedCode }) => {
const conversation = {
id: 3,
userId: 7,
title: '测试',
lockedSkillKey: null,
lastMessageAt: null,
};
const assistant = {
id: 12,
conversationId: 3,
role: 'assistant',
content: '',
reasoningContent: null,
status: 'pending',
errorCode: null,
};
const messageSave = jest.fn(async (value) => value);
const messages = {
exists: jest.fn().mockResolvedValue(false),
find: jest.fn().mockResolvedValue([]),
save: messageSave,
};
const manager = {
create: jest.fn((_entity, value) => value),
save: jest
.fn()
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' })
.mockResolvedValueOnce(assistant),
update: jest.fn(),
};
const abortController = new AbortController();
const modelStream = {
stream: async function* () {
yield { type: 'content' as const, delta: '部分回答' };
if (abort) {
abortController.abort(new Error('client disconnected'));
yield { type: 'complete' as const, toolCalls: [] };
return;
}
throw new Error('upstream failed');
},
};
const service = new AiChatService(
{ findOne: jest.fn().mockResolvedValue(conversation) } as never,
messages as never,
{ save: jest.fn() } as never,
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
modelStream as never,
{
requireReadyOwned: jest.fn().mockResolvedValue([]),
toModelParts: jest.fn().mockResolvedValue([]),
serialize: jest.fn((value) => value),
} as never,
{
createForm: jest.fn(),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
{
createChart: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const run = service.streamMessage(
authenticatedUser as never,
3,
{
message: '查询',
attachmentIds: [],
skillKey: null,
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
},
abortController.signal,
(event, data) => emitted.push({ event, data }),
jest.fn(),
);
if (abort) await expect(run).resolves.toBeUndefined();
else await expect(run).rejects.toThrow('upstream failed');
expect(messageSave).toHaveBeenCalledWith(
expect.objectContaining({
])(
'流中断后保存已生成内容和 $expectedStatus 状态',
async ({ abort, expectedStatus, expectedCode }) => {
const conversation = {
id: 3,
userId: 7,
title: '测试',
lockedSkillKey: null,
lastMessageAt: null,
};
const assistant = {
id: 12,
content: '部分回答',
status: expectedStatus,
errorCode: expectedCode,
}),
);
expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true);
expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort);
});
conversationId: 3,
role: 'assistant',
content: '',
reasoningContent: null,
status: 'pending',
errorCode: null,
};
const messageSave = jest.fn(async (value) => value);
const messages = {
exists: jest.fn().mockResolvedValue(false),
find: jest.fn().mockResolvedValue([]),
save: messageSave,
};
const manager = {
create: jest.fn((_entity, value) => value),
save: jest
.fn()
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' })
.mockResolvedValueOnce(assistant),
update: jest.fn(),
};
const abortController = new AbortController();
const modelStream = {
stream: async function* () {
yield { type: 'content' as const, delta: '部分回答' };
if (abort) {
abortController.abort(new Error('client disconnected'));
yield { type: 'complete' as const, toolCalls: [] };
return;
}
throw new Error('upstream failed');
},
};
const service = new AiChatService(
{ findOne: jest.fn().mockResolvedValue(conversation) } as never,
messages as never,
{ save: jest.fn() } as never,
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
modelStream as never,
{
requireReadyOwned: jest.fn().mockResolvedValue([]),
toModelParts: jest.fn().mockResolvedValue([]),
serialize: jest.fn((value) => value),
} as never,
{
createForm: jest.fn(),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
{
createChart: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const run = service.streamMessage(
authenticatedUser as never,
3,
{
message: '查询',
attachmentIds: [],
skillKey: null,
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
},
abortController.signal,
(event, data) => emitted.push({ event, data }),
jest.fn(),
);
if (abort) await expect(run).resolves.toBeUndefined();
else await expect(run).rejects.toThrow('upstream failed');
expect(messageSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 12,
content: '部分回答',
status: expectedStatus,
errorCode: expectedCode,
}),
);
expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true);
expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort);
},
);
it('普通对话中模型直接调用 create_student 被拒绝', async () => {
const { service } = createService();
@@ -337,13 +354,15 @@ describe('AiChatService', () => {
};
(service as unknown as { toolRuns: typeof toolRuns }).toolRuns = toolRuns;
const emitted: Array<{ event: string }> = [];
const deny = (service as unknown as {
denyWriteTool(
messageId: number,
call: { id: string },
emit: (event: string, data: Record<string, unknown>) => void,
): Promise<string>;
}).denyWriteTool.bind(service);
const deny = (
service as unknown as {
denyWriteTool(
messageId: number,
call: { id: string },
emit: (event: string, data: Record<string, unknown>) => void,
): Promise<string>;
}
).denyWriteTool.bind(service);
const payload = await deny(12, { id: 'call-1' }, (event) => emitted.push({ event }));
expect(JSON.parse(payload)).toEqual({ status: 'failed', error: '该操作需要表单确认' });
expect(emitted).toEqual([{ event: 'tool.failed' }]);
@@ -366,8 +385,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const formShape = {
@@ -392,7 +409,12 @@ describe('AiChatService', () => {
create: jest.fn((_entity, value) => value),
save: jest
.fn()
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '帮我新增一个学生' })
.mockResolvedValueOnce({
id: 11,
conversationId: 3,
role: 'user',
content: '帮我新增一个学生',
})
.mockResolvedValueOnce(assistant),
update: jest.fn(),
};
@@ -497,8 +519,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const reviewShape = {
@@ -523,7 +543,8 @@ describe('AiChatService', () => {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockImplementation((options?: unknown) => {
const opts = options as { select?: { metadata?: boolean } } | undefined;
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
if (opts?.select?.metadata)
return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
return Promise.resolve(assistant);
}),
save: messageSave,
@@ -532,7 +553,12 @@ describe('AiChatService', () => {
create: jest.fn((_entity, value) => value),
save: jest
.fn()
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
.mockResolvedValueOnce({
id: 11,
conversationId: 3,
role: 'user',
content: '导入这个Excel',
})
.mockResolvedValueOnce(assistant),
update: jest.fn(),
};
@@ -645,8 +671,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const chartShape = {
@@ -668,7 +692,8 @@ describe('AiChatService', () => {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockImplementation((options?: unknown) => {
const opts = options as { select?: { metadata?: boolean } } | undefined;
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiChart: [chartShape] } });
if (opts?.select?.metadata)
return Promise.resolve({ metadata: { a2uiChart: [chartShape] } });
return Promise.resolve(assistant);
}),
save: messageSave,
@@ -790,8 +815,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const messageSave = jest.fn(async (value) => value);
@@ -926,8 +949,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const reviewShape = {
@@ -952,7 +973,8 @@ describe('AiChatService', () => {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockImplementation((options?: unknown) => {
const opts = options as { select?: { metadata?: boolean } } | undefined;
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
if (opts?.select?.metadata)
return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
return Promise.resolve(assistant);
}),
save: messageSave,
@@ -961,7 +983,12 @@ describe('AiChatService', () => {
create: jest.fn((_entity, value) => value),
save: jest
.fn()
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
.mockResolvedValueOnce({
id: 11,
conversationId: 3,
role: 'user',
content: '导入这个Excel',
})
.mockResolvedValueOnce(assistant),
update: jest.fn(),
};
@@ -1184,8 +1211,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
};
const review = {
@@ -1207,7 +1232,9 @@ describe('AiChatService', () => {
findOne: jest.fn().mockImplementation((options?: unknown) => {
const opts = options as { select?: { metadata?: boolean } } | undefined;
if (opts?.select?.metadata) {
return Promise.resolve({ metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } } });
return Promise.resolve({
metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } },
});
}
return Promise.resolve(assistant);
}),
@@ -1302,10 +1329,7 @@ describe('AiChatService', () => {
() => order.push('onReady'),
);
expect(assertPermission).toHaveBeenCalledWith(
expect.anything(),
'student:create',
);
expect(assertPermission).toHaveBeenCalledWith(expect.anything(), 'student:create');
expect(submitAll).toHaveBeenCalledTimes(1);
expect(emitted[0]).toMatchObject({
event: 'ui.review',
@@ -1355,7 +1379,11 @@ describe('AiChatService', () => {
save: jest.fn(async (value) => value),
};
reviewService.findOwned.mockResolvedValue(review);
reviewService.submitSection.mockResolvedValue({ review: updated, result: { created: 1, skipped: 0, issues: [] }, message: '成功导入学生 1 人' });
reviewService.submitSection.mockResolvedValue({
review: updated,
result: { created: 1, skipped: 0, issues: [] },
message: '成功导入学生 1 人',
});
const data = await service.confirmReviewStep(
authenticatedUser as never,
@@ -1364,11 +1392,7 @@ describe('AiChatService', () => {
);
expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7);
expect(reviewService.submitSection).toHaveBeenCalledWith(
'review-1',
7,
'students',
);
expect(reviewService.submitSection).toHaveBeenCalledWith('review-1', 7, 'students');
expect(data).toMatchObject({ id: 'review-1' });
});
@@ -1462,8 +1486,6 @@ describe('AiChatService', () => {
status: 'pending',
errorCode: null,
replyToMessageId: 11,
feedback: null,
feedbackReason: null,
metadata: {},
};
const messageSave = jest.fn(async (value) => value);
@@ -1580,4 +1602,273 @@ describe('AiChatService', () => {
]);
expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true);
});
it('删除用户消息时连同其 AI 回答一起删除并更新会话时间', async () => {
const conversation = { id: 3, userId: 7, title: '新对话' };
const execute = jest.fn().mockResolvedValue(undefined);
const queryBuilder = {
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ id: 30 }, { id: 31 }]),
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
execute,
};
const lastMessageAt = new Date('2026-08-04T10:00:00.000Z');
const messages = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 10, conversationId: 3, role: 'user' })
.mockResolvedValueOnce({ createdAt: lastMessageAt }),
find: jest.fn().mockResolvedValue([{ id: 11 }]),
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
delete: jest.fn().mockResolvedValue({ affected: 2 }),
};
const conversations = {
findOne: jest.fn().mockResolvedValue(conversation),
update: jest.fn().mockResolvedValue(undefined),
};
const removeOrphans = jest.fn().mockResolvedValue(undefined);
const service = new AiChatService(
conversations as never,
messages as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ removeOrphans } as never,
{
createForm: jest.fn(),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
{
createChart: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
);
await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] });
expect(messages.delete).toHaveBeenCalledWith([10, 11]);
expect(execute).toHaveBeenCalled();
expect(removeOrphans).toHaveBeenCalledWith(7, [30, 31]);
expect(conversations.update).toHaveBeenCalledWith({ id: 3, userId: 7 }, { lastMessageAt });
});
it('生成中的会话禁止删除单条消息', async () => {
const service = new AiChatService(
{ findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
createForm: jest.fn(),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
{
createChart: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
);
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(3);
await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException);
});
it('编辑用户消息后截断后续消息并重新生成回答', async () => {
const conversation = { id: 3, userId: 7, title: '旧问题' };
const target = {
id: 10,
conversationId: 3,
role: 'user',
status: 'completed',
content: '旧问题',
metadata: null,
attachments: [],
};
const assistant = { id: 13, conversationId: 3, role: 'assistant' };
const manager = {
update: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([{ id: 11 }, { id: 12 }]),
createQueryBuilder: jest.fn().mockReturnValue({
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
}),
delete: jest.fn().mockResolvedValue({ affected: 2 }),
create: jest.fn((_entity, value) => value),
save: jest.fn().mockResolvedValue(assistant),
};
const messages = {
exists: jest.fn().mockResolvedValue(false),
findOne: jest.fn().mockResolvedValue(target),
find: jest.fn().mockResolvedValue([]),
save: jest.fn(async (value) => value),
};
const conversations = {
findOne: jest.fn().mockResolvedValue(conversation),
update: jest.fn().mockResolvedValue(undefined),
};
const toolRuns = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn().mockResolvedValue([]),
};
const modelStream = {
stream: async function* () {
yield { type: 'complete' as const, toolCalls: [] };
},
};
const removeOrphans = jest.fn().mockResolvedValue(undefined);
const service = new AiChatService(
conversations as never,
messages as never,
toolRuns as never,
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
modelStream as never,
{ removeOrphans } as never,
{
createForm: jest.fn(),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
{
createChart: jest.fn(),
serialize: jest.fn((value) => value),
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
await service.editMessage(
authenticatedUser as never,
3,
10,
{
content: '新问题',
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
},
new AbortController().signal,
(event, data) => emitted.push({ event, data }),
jest.fn(),
);
expect(manager.update).toHaveBeenCalledWith(
expect.anything(),
{ id: 10, conversationId: 3 },
expect.objectContaining({ content: '新问题' }),
);
expect(manager.delete).toHaveBeenCalledWith(expect.anything(), [11, 12]);
expect(conversations.update).toHaveBeenCalledWith(
{ id: 3, userId: 7 },
expect.objectContaining({ title: '新问题' }),
);
expect(emitted.some(({ event }) => event === 'message.completed')).toBe(true);
expect(removeOrphans).toHaveBeenCalledWith(7, []);
});
it('start_import_wizard 阶段缺少 sheet 时失败,且不创建导入任务', async () => {
const { service } = createService();
const toolRun = { id: 1, status: 'running' };
const toolRuns = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
};
const messages = {
findOne: jest.fn().mockResolvedValue({ id: 42, metadata: null }),
save: jest.fn(async (value) => value),
};
const attachmentService = {
requireReadyOwned: jest.fn().mockResolvedValue([
{
id: 9,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
originalName: 'students.xlsx',
},
]),
readStoredBuffer: jest.fn(),
};
const importsService = { createRun: jest.fn() };
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
(service as unknown as { messages: unknown }).messages = messages;
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
(service as unknown as { importsService: unknown }).importsService = importsService;
const emitted: Array<{ event: string }> = [];
const result = await (
service as unknown as {
executeStartImportWizard(
messageId: number,
call: { id: string; name: string; arguments: string },
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
emit: (event: string, data?: unknown) => void,
): Promise<string>;
}
).executeStartImportWizard(
42,
{
id: 'call-1',
name: 'start_import_wizard',
arguments: JSON.stringify({
attachmentId: 9,
stages: [{ stepKey: 'students' }],
}),
},
{ userId: 7, permissions: [], isSuperAdmin: false },
(event) => emitted.push({ event }),
);
const parsed = JSON.parse(result) as { status: string; error: string };
expect(parsed.status).toBe('failed');
expect(parsed.error).toContain('缺少工作表 sheet');
expect(importsService.createRun).not.toHaveBeenCalled();
expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true);
});
});