This commit is contained in:
Bastian Wagner
2026-06-24 10:53:07 +02:00
parent cbf1451255
commit 712136a5e9
2 changed files with 170 additions and 10 deletions

View File

@@ -40,6 +40,7 @@ describe('AssistantService', () => {
listLists: jest.fn(), listLists: jest.fn(),
addItem: jest.fn(), addItem: jest.fn(),
}; };
listsService.listLists.mockResolvedValue([]);
service = new AssistantService( service = new AssistantService(
chatLogsRepository as never, chatLogsRepository as never,
listRealtimeService as never, listRealtimeService as never,
@@ -570,6 +571,107 @@ describe('AssistantService', () => {
); );
}); });
it('treats unknown list tool results as created lists for creation requests', async () => {
const createdList = {
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',
};
const providerResponse = {
choices: [
{
messages: [
{
role: 'tool',
content: [
{
type: 'text',
text: JSON.stringify({ list: createdList }),
},
],
},
{
role: 'assistant',
content: 'Erledigt.',
},
],
},
],
};
mockMistralResponse(providerResponse);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
});
expect(result.actions).toEqual([
{
type: 'list.created',
listId: 'list-1',
list: createdList,
},
]);
expect(result.message.content).toBe('Erledigt.');
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
'user-1',
createdList,
);
});
it('detects lists created through the connector when the provider omits tool details', async () => {
const createdList = {
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',
};
listsService.listLists
.mockResolvedValueOnce([])
.mockResolvedValueOnce([createdList]);
mockMistralResponse({
choices: [
{
message: {
content: 'Ich habe die Liste angelegt.',
},
},
],
});
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 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 () => { it('returns a clear message when list creation did not use the connector', async () => {
mockMistralResponse({ mockMistralResponse({
choices: [ choices: [

View File

@@ -106,6 +106,13 @@ export class AssistantService {
): Promise<AssistantChatResponse> { ): Promise<AssistantChatResponse> {
const messages = this.normalizeMessages(request.messages); const messages = this.normalizeMessages(request.messages);
const context = this.normalizeContext(request.context); const context = this.normalizeContext(request.context);
const latestUserMessage = this.latestUserMessage(messages);
const isCreationRequest = this.isListCreationRequest(
latestUserMessage?.content ?? '',
);
const listIdsBeforeMistral = isCreationRequest
? await this.listIdsForUser(userId)
: null;
const localResponse = await this.tryHandleLocalListQuery(userId, messages); const localResponse = await this.tryHandleLocalListQuery(userId, messages);
if (localResponse) { if (localResponse) {
@@ -123,18 +130,24 @@ export class AssistantService {
} }
const response = await this.callMistralAgent(userId, messages, context); const response = await this.callMistralAgent(userId, messages, context);
const actions = this.extractActions(response); let actions = this.extractActions(response, {
assumeCreatedListForUnknownToolResult: isCreationRequest,
});
actions = await this.addDetectedCreatedLists(
userId,
actions,
listIdsBeforeMistral,
);
const content = const content =
this.extractAssistantContent(response) ?? this.extractAssistantContent(response) ??
this.createActionContent(actions); this.createActionContent(actions);
const latestUserMessage = this.latestUserMessage(messages);
actions.forEach((action) => { actions.forEach((action) => {
this.listRealtimeService.publishSnapshot(userId, action.list); this.listRealtimeService.publishSnapshot(userId, action.list);
}); });
if ( if (
this.isListCreationRequest(latestUserMessage?.content ?? '') && isCreationRequest &&
!actions.some((action) => action.type === 'list.created') !actions.some((action) => action.type === 'list.created')
) { ) {
return { return {
@@ -175,6 +188,40 @@ export class AssistantService {
}; };
} }
private async listIdsForUser(userId: string): Promise<Set<string>> {
const lists = await this.listsService.listLists(userId);
return new Set(lists.map((list) => list.id));
}
private async addDetectedCreatedLists(
userId: string,
actions: AssistantAction[],
listIdsBeforeMistral: Set<string> | null,
): Promise<AssistantAction[]> {
if (!listIdsBeforeMistral) {
return actions;
}
const listsAfterMistral = await this.listsService.listLists(userId);
const existingCreatedIds = new Set(
actions
.filter((action) => action.type === 'list.created')
.map((action) => action.listId),
);
const detectedActions = listsAfterMistral
.filter((list) => !listIdsBeforeMistral.has(list.id))
.filter((list) => !existingCreatedIds.has(list.id))
.map(
(list): AssistantAction => ({
type: 'list.created',
listId: list.id,
list,
}),
);
return this.uniqueActions([...actions, ...detectedActions]);
}
async listChatLogs(userId: string): Promise<AssistantChatLog[]> { async listChatLogs(userId: string): Promise<AssistantChatLog[]> {
const logs = await this.chatLogsRepository.find({ const logs = await this.chatLogsRepository.find({
where: { userId }, where: { userId },
@@ -578,7 +625,10 @@ export class AssistantService {
return null; return null;
} }
private extractActions(responsePayload: unknown): AssistantAction[] { private extractActions(
responsePayload: unknown,
options: { assumeCreatedListForUnknownToolResult?: boolean } = {},
): AssistantAction[] {
if (!responsePayload || typeof responsePayload !== 'object') { if (!responsePayload || typeof responsePayload !== 'object') {
return []; return [];
} }
@@ -634,12 +684,20 @@ export class AssistantService {
continue; continue;
} }
actions.push({ if (options.assumeCreatedListForUnknownToolResult) {
type: 'list.item_added', actions.push({
listId: list.id, type: 'list.created',
itemTitle: this.lastItemTitle(list), listId: list.id,
list, list,
}); });
} else {
actions.push({
type: 'list.item_added',
listId: list.id,
itemTitle: this.lastItemTitle(list),
list,
});
}
} }
} }