706 lines
20 KiB
TypeScript
706 lines
20 KiB
TypeScript
import { ServiceUnavailableException } from '@nestjs/common';
|
|
import { AssistantService } from './assistant.service';
|
|
|
|
describe('AssistantService', () => {
|
|
const connectorSystemMessage = [
|
|
'Benutze fuer Listify-Daten immer den listify Connector.',
|
|
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
|
|
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
|
|
].join(' ');
|
|
const originalFetch = global.fetch;
|
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
|
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
|
let chatLogsRepository: {
|
|
create: jest.Mock;
|
|
find: jest.Mock;
|
|
save: jest.Mock;
|
|
};
|
|
let listRealtimeService: {
|
|
publishSnapshot: jest.Mock;
|
|
};
|
|
let listsService: {
|
|
listLists: jest.Mock;
|
|
addItem: jest.Mock;
|
|
};
|
|
let service: AssistantService;
|
|
|
|
beforeEach(() => {
|
|
process.env.MISTRAL_API_KEY = 'test-key';
|
|
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
|
global.fetch = jest.fn();
|
|
chatLogsRepository = {
|
|
create: jest.fn((input) => input),
|
|
find: jest.fn(),
|
|
save: jest.fn(async (input) => input),
|
|
};
|
|
listRealtimeService = {
|
|
publishSnapshot: jest.fn(),
|
|
};
|
|
listsService = {
|
|
listLists: jest.fn(),
|
|
addItem: jest.fn(),
|
|
};
|
|
service = new AssistantService(
|
|
chatLogsRepository as never,
|
|
listRealtimeService as never,
|
|
listsService as never,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
process.env.MISTRAL_API_KEY = originalApiKey;
|
|
process.env.MISTRAL_AGENT_ID = originalAgentId;
|
|
});
|
|
|
|
it('forwards messages to the configured Mistral agent', async () => {
|
|
const providerResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'Ich habe den Listify-Connector verwendet.',
|
|
},
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 8,
|
|
},
|
|
};
|
|
mockMistralResponse(providerResponse);
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [
|
|
{ role: 'assistant', content: 'Hallo' },
|
|
{ role: 'user', content: 'Hallo' },
|
|
],
|
|
});
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'https://api.mistral.ai/v1/agents/completions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: 'Bearer test-key',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
agent_id: 'agent-listify',
|
|
messages: [
|
|
{ role: 'assistant', content: 'Hallo' },
|
|
{ role: 'user', content: 'Hallo' },
|
|
{ role: 'system', content: connectorSystemMessage },
|
|
],
|
|
tools: [
|
|
{
|
|
type: 'connector',
|
|
connector_id: 'listify',
|
|
},
|
|
],
|
|
stream: false,
|
|
response_format: {
|
|
type: 'text',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
expect(result).toEqual({
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'Ich habe den Listify-Connector verwendet.',
|
|
},
|
|
actions: [],
|
|
});
|
|
expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled();
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
provider: 'mistral',
|
|
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
|
agentId: 'agent-listify',
|
|
statusCode: 200,
|
|
requestPayload: expect.objectContaining({
|
|
agent_id: 'agent-listify',
|
|
tools: [{ type: 'connector', connector_id: 'listify' }],
|
|
}),
|
|
responsePayload: providerResponse,
|
|
assistantContent: 'Ich habe den Listify-Connector verwendet.',
|
|
errorMessage: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('answers open list read requests locally without calling Mistral', async () => {
|
|
listsService.listLists.mockResolvedValue([
|
|
{
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
kind: 'shopping',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
title: 'Milch',
|
|
required: true,
|
|
checked: false,
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'item-2',
|
|
title: 'Brot',
|
|
required: true,
|
|
checked: true,
|
|
position: 1,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'list-2',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Fertig',
|
|
kind: 'todo',
|
|
items: [
|
|
{
|
|
id: 'item-3',
|
|
title: 'Done',
|
|
required: true,
|
|
checked: true,
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
]);
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Zeige mir meine offenen Listen' }],
|
|
});
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(result.message.content).toContain('Du hast 1 offene Liste');
|
|
expect(result.message.content).toContain('**Einkauf**');
|
|
expect(result.message.content).toContain('Milch');
|
|
expect(result.message.content).not.toContain('Fertig');
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
provider: 'listify',
|
|
endpoint: 'local:listLists',
|
|
agentId: null,
|
|
statusCode: 200,
|
|
assistantContent: expect.stringContaining('Einkauf'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('adds a normalized list context system message when context is present', async () => {
|
|
const providerResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'Ich nutze diese Liste.',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
mockMistralResponse(providerResponse);
|
|
|
|
await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Was ist hier wichtig?' }],
|
|
context: {
|
|
page: 'list_detail',
|
|
route: '/lists/list-1',
|
|
list: {
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
ownerName: 'Ada',
|
|
ownerEmail: 'ada@example.com',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
description: 'Wochenende',
|
|
kind: 'shopping',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
title: 'Milch',
|
|
notes: '1,5 Prozent',
|
|
quantity: 2,
|
|
required: true,
|
|
checked: false,
|
|
checkedByUserId: 'user-2',
|
|
checkedByName: 'Grace',
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [
|
|
{
|
|
id: 'user-2',
|
|
email: 'grace@example.com',
|
|
role: 'collaborator',
|
|
},
|
|
],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
},
|
|
});
|
|
|
|
const payload = getMistralRequestPayload();
|
|
const contextMessage = payload.messages.at(-2);
|
|
const contextContent = contextMessage?.content ?? '';
|
|
|
|
expect(contextMessage).toEqual(
|
|
expect.objectContaining({
|
|
role: 'system',
|
|
content: expect.stringContaining('Aktueller Listify-Kontext:'),
|
|
}),
|
|
);
|
|
expect(contextContent).toContain(
|
|
'Der User befindet sich auf einer Listendetailseite.',
|
|
);
|
|
expect(contextContent).toContain('Route: /lists/list-1');
|
|
expect(contextContent).toContain(
|
|
'Offene Liste: Einkauf (ID: list-1, Typ: shopping)',
|
|
);
|
|
expect(contextContent).toContain(
|
|
'- Milch (ID: item-1, Menge: 2, Pflicht: ja, Erledigt: nein, Notizen: 1,5 Prozent)',
|
|
);
|
|
expect(contextContent).toContain(
|
|
'bezieht sich das auf die Liste mit ID list-1.',
|
|
);
|
|
expect(contextContent).not.toContain('ada@example.com');
|
|
expect(contextContent).not.toContain('grace@example.com');
|
|
expect(contextContent).not.toContain('checkedByUserId');
|
|
expect(payload.messages.at(-1)).toEqual({
|
|
role: 'system',
|
|
content: connectorSystemMessage,
|
|
});
|
|
});
|
|
|
|
it('adds list items locally when the user writes onto the current list', async () => {
|
|
const updatedList = {
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
kind: 'shopping',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
title: 'Brot',
|
|
required: true,
|
|
checked: false,
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
};
|
|
listsService.addItem.mockResolvedValue(updatedList);
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }],
|
|
context: {
|
|
page: 'list_detail',
|
|
route: '/lists/list-1',
|
|
list: {
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
kind: 'shopping',
|
|
items: [],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
|
title: 'Brot',
|
|
});
|
|
expect(result).toEqual({
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'Ich habe **Brot** zu **Einkauf** hinzugefuegt.',
|
|
},
|
|
actions: [
|
|
{
|
|
type: 'list.item_added',
|
|
listId: 'list-1',
|
|
itemTitle: 'Brot',
|
|
list: updatedList,
|
|
},
|
|
],
|
|
});
|
|
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
|
'user-1',
|
|
updatedList,
|
|
);
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: 'listify',
|
|
endpoint: 'local:addItem',
|
|
requestPayload: expect.objectContaining({
|
|
intent: 'list.add_item',
|
|
listId: 'list-1',
|
|
itemTitle: 'Brot',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('logs full failed provider responses before throwing', async () => {
|
|
const providerResponse = {
|
|
message: 'connector failed',
|
|
details: { connector_id: 'listify' },
|
|
};
|
|
mockMistralResponse(providerResponse, false, 502);
|
|
|
|
await expect(
|
|
service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Hallo' }],
|
|
}),
|
|
).rejects.toThrow(ServiceUnavailableException);
|
|
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
statusCode: 502,
|
|
responsePayload: providerResponse,
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral agent request failed.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('extracts the final assistant message from multi-completion responses', async () => {
|
|
const updatedList = {
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
kind: 'shopping',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
title: 'Milch',
|
|
required: true,
|
|
checked: false,
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
};
|
|
const providerResponse = {
|
|
id: 'chatcmpl-test',
|
|
object: 'chat.multi_completion',
|
|
choices: [
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
index: 0,
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'listify_add_list_item',
|
|
arguments: '{"listId": "list-1", "title": "Milch"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
index: 1,
|
|
content: [{ type: 'text', text: '{"lists":[]}' }],
|
|
tool_call_id: 'call-1',
|
|
metadata: {
|
|
mcp_meta: {
|
|
structuredContent: {
|
|
list: updatedList,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
index: 2,
|
|
content: 'Hier sind deine bestehenden Listen.',
|
|
},
|
|
],
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
};
|
|
mockMistralResponse(providerResponse);
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Welche Listen habe ich?' }],
|
|
});
|
|
|
|
expect(result.message.content).toBe('Hier sind deine bestehenden Listen.');
|
|
expect(result.actions).toEqual([
|
|
{
|
|
type: 'list.item_added',
|
|
listId: 'list-1',
|
|
itemTitle: 'Milch',
|
|
list: updatedList,
|
|
},
|
|
]);
|
|
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
|
'user-1',
|
|
updatedList,
|
|
);
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
responsePayload: providerResponse,
|
|
assistantContent: 'Hier sind deine bestehenden Listen.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('extracts created list actions from raw tool JSON content', async () => {
|
|
const createdList = {
|
|
id: 'list-1',
|
|
ownerId: 'user-1',
|
|
accessRole: 'owner',
|
|
name: 'Einkauf',
|
|
kind: 'shopping',
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
title: 'Milch',
|
|
required: true,
|
|
checked: false,
|
|
position: 0,
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
collaborators: [],
|
|
createdAt: '2026-06-12T00:00:00.000Z',
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
};
|
|
const providerResponse = {
|
|
choices: [
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'listify_create_list',
|
|
arguments: '{"name": "Einkauf"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({ list: createdList }),
|
|
},
|
|
],
|
|
tool_call_id: 'call-1',
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: JSON.stringify({ list: createdList }),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
mockMistralResponse(providerResponse);
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'Ich habe die Liste **Einkauf** angelegt.',
|
|
},
|
|
actions: [
|
|
{
|
|
type: 'list.created',
|
|
listId: 'list-1',
|
|
list: createdList,
|
|
},
|
|
],
|
|
});
|
|
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
|
'user-1',
|
|
createdList,
|
|
);
|
|
});
|
|
|
|
it('returns a clear message when list creation did not use the connector', async () => {
|
|
mockMistralResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify({
|
|
name: 'Einkauf',
|
|
items: [{ title: 'Milch' }],
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
message: {
|
|
role: 'assistant',
|
|
content:
|
|
'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.',
|
|
},
|
|
actions: [],
|
|
});
|
|
expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails clearly when the api key is missing', async () => {
|
|
delete process.env.MISTRAL_API_KEY;
|
|
|
|
await expect(
|
|
service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Hallo' }],
|
|
}),
|
|
).rejects.toThrow(ServiceUnavailableException);
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
statusCode: null,
|
|
responsePayload: null,
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral API key is not configured.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('fails clearly when the agent id is missing', async () => {
|
|
delete process.env.MISTRAL_AGENT_ID;
|
|
|
|
await expect(
|
|
service.chat('user-1', {
|
|
messages: [{ role: 'user', content: 'Hallo' }],
|
|
}),
|
|
).rejects.toThrow(ServiceUnavailableException);
|
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
agentId: null,
|
|
statusCode: null,
|
|
responsePayload: null,
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral agent id is not configured.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('lists the latest chat logs for the current user', async () => {
|
|
chatLogsRepository.find.mockResolvedValue([
|
|
{
|
|
id: 'log-1',
|
|
userId: 'user-1',
|
|
provider: 'mistral',
|
|
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
|
agentId: 'agent-listify',
|
|
statusCode: 502,
|
|
durationMs: 123,
|
|
requestPayload: { messages: [] },
|
|
responsePayload: { message: 'connector failed' },
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral agent request failed.',
|
|
createdAt: new Date('2026-06-24T08:00:00.000Z'),
|
|
},
|
|
]);
|
|
|
|
const logs = await service.listChatLogs('user-1');
|
|
|
|
expect(chatLogsRepository.find).toHaveBeenCalledWith({
|
|
where: { userId: 'user-1' },
|
|
order: { createdAt: 'DESC' },
|
|
take: 50,
|
|
});
|
|
expect(logs).toEqual([
|
|
{
|
|
id: 'log-1',
|
|
provider: 'mistral',
|
|
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
|
agentId: 'agent-listify',
|
|
statusCode: 502,
|
|
durationMs: 123,
|
|
requestPayload: { messages: [] },
|
|
responsePayload: { message: 'connector failed' },
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral agent request failed.',
|
|
createdAt: '2026-06-24T08:00:00.000Z',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
function mockMistralResponse(response: object, ok = true, status = 200): void {
|
|
jest.mocked(global.fetch).mockResolvedValue({
|
|
ok,
|
|
status,
|
|
text: async () => JSON.stringify(response),
|
|
} as Response);
|
|
}
|
|
|
|
function getMistralRequestPayload(): {
|
|
messages: Array<{ role: string; content: string }>;
|
|
} {
|
|
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
|
const body = init?.body;
|
|
|
|
if (typeof body !== 'string') {
|
|
throw new Error('Expected Mistral request body to be JSON.');
|
|
}
|
|
|
|
return JSON.parse(body) as {
|
|
messages: Array<{ role: string; content: string }>;
|
|
};
|
|
}
|