From f31519bc84b352342a9fdf218cbe8922fe212e77 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 6 Jul 2026 12:03:13 +0700 Subject: [PATCH 1/3] Fix AI agent rooms Signed-off-by: Artem Savchenko --- .../src/__tests__/onEmployee.test.ts | 217 ++++++++++++++++++ server-plugins/love-resources/src/index.ts | 43 +++- 2 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 server-plugins/love-resources/src/__tests__/onEmployee.test.ts diff --git a/server-plugins/love-resources/src/__tests__/onEmployee.test.ts b/server-plugins/love-resources/src/__tests__/onEmployee.test.ts new file mode 100644 index 0000000000..14c8f48729 --- /dev/null +++ b/server-plugins/love-resources/src/__tests__/onEmployee.test.ts @@ -0,0 +1,217 @@ +// +// Copyright © 2026 TraceX. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { aiBotEmailSocialKey } from '@hcengineering/ai-bot' +import contact, { type Employee, type Person } from '@hcengineering/contact' +import core, { + generateId, + TxFactory, + type Class, + type Doc, + type DocumentQuery, + type Ref, + type Tx, + type TxMixin, + type TxUpdateDoc +} from '@hcengineering/core' +import love, { type Office } from '@hcengineering/love' +import { type TriggerControl } from '@hcengineering/server-core' + +import { OnEmployee } from '../index' + +interface MockData { + employees: Array<{ _id: Ref, role: string }> + offices: Array<{ _id: Ref, person: Ref | null }> + botIdentities: Array<{ attachedTo: Ref, key: string }> +} + +function createControl (data: MockData): TriggerControl { + const findAll = jest.fn( + async (_ctx: any, _class: Ref>, query: DocumentQuery, _options?: any): Promise => { + if (_class === contact.mixin.Employee) { + return data.employees.filter((it) => it._id === (query as any)._id) + } + if (_class === love.class.Office) { + const person = (query as any).person + return data.offices + .filter((it) => (person === null ? it.person === null : it.person === person)) + .map((it) => ({ ...it, _class: love.class.Office, space: core.space.Workspace })) + } + if (_class === contact.class.SocialIdentity) { + return data.botIdentities.filter((it) => it.key === (query as any).key) + } + return [] + } + ) + return { + ctx: {} as any, + findAll, + txFactory: new TxFactory(core.account.System), + cache: new Map() + } as unknown as TriggerControl +} + +function employeeTx (person: Ref, active: boolean): Tx { + const tx: Partial> = { + _id: generateId(), + _class: core.class.TxMixin, + space: core.space.Tx, + objectId: person, + objectClass: contact.class.Person, + objectSpace: contact.space.Contacts, + mixin: contact.mixin.Employee, + attributes: { active }, + modifiedBy: core.account.System, + modifiedOn: Date.now() + } + return tx as Tx +} + +function socialIdentityQueries (control: TriggerControl): number { + return (control.findAll as jest.Mock).mock.calls.filter((call) => call[1] === contact.class.SocialIdentity).length +} + +describe('OnEmployee', () => { + const person = generateId() + const bot = generateId() + + it('assigns a free office to an activated employee', async () => { + const office = generateId() + const control = createControl({ + employees: [{ _id: person, role: 'USER' }], + offices: [{ _id: office, person: null }], + botIdentities: [] + }) + + const result = await OnEmployee([employeeTx(person, true)], control) + + expect(result).toHaveLength(1) + const update = result[0] as TxUpdateDoc + expect(update._class).toBe(core.class.TxUpdateDoc) + expect(update.objectId).toBe(office) + expect(update.operations.person).toBe(person) + }) + + it('does not assign an office if the person already has one', async () => { + const control = createControl({ + employees: [{ _id: person, role: 'USER' }], + offices: [ + { _id: generateId(), person }, + { _id: generateId(), person: null } + ], + botIdentities: [] + }) + + const result = await OnEmployee([employeeTx(person, true)], control) + + expect(result).toHaveLength(0) + }) + + it('does not assign an office to the AI bot', async () => { + const control = createControl({ + employees: [{ _id: bot, role: 'USER' }], + offices: [{ _id: generateId(), person: null }], + botIdentities: [{ attachedTo: bot, key: aiBotEmailSocialKey }] + }) + + const result = await OnEmployee([employeeTx(bot, true)], control) + + expect(result).toHaveLength(0) + }) + + it('skips guests', async () => { + const control = createControl({ + employees: [{ _id: person, role: 'GUEST' }], + offices: [{ _id: generateId(), person: null }], + botIdentities: [] + }) + + const result = await OnEmployee([employeeTx(person, true)], control) + + expect(result).toHaveLength(0) + }) + + it('releases all offices held by a deactivated employee', async () => { + const office1 = generateId() + const office2 = generateId() + const control = createControl({ + employees: [{ _id: person, role: 'USER' }], + offices: [ + { _id: office1, person }, + { _id: office2, person } + ], + botIdentities: [] + }) + + const result = await OnEmployee([employeeTx(person, false)], control) + + expect(result).toHaveLength(2) + const updates = result as Array> + expect(updates.map((it) => it.objectId).sort()).toEqual([office1, office2].sort()) + for (const update of updates) { + expect(update.operations.person).toBeNull() + } + }) + + it('does not assign the same free office twice within one batch', async () => { + const person2 = generateId() + const office1 = generateId() + const office2 = generateId() + const control = createControl({ + employees: [ + { _id: person, role: 'USER' }, + { _id: person2, role: 'USER' } + ], + offices: [ + { _id: office1, person: null }, + { _id: office2, person: null } + ], + botIdentities: [] + }) + + const result = await OnEmployee([employeeTx(person, true), employeeTx(person2, true)], control) + + expect(result).toHaveLength(2) + const updates = result as Array> + expect(new Set(updates.map((it) => it.objectId)).size).toBe(2) + }) + + it('caches the AI bot person between invocations', async () => { + const control = createControl({ + employees: [{ _id: bot, role: 'USER' }], + offices: [{ _id: generateId(), person: null }], + botIdentities: [{ attachedTo: bot, key: aiBotEmailSocialKey }] + }) + + await OnEmployee([employeeTx(bot, true)], control) + await OnEmployee([employeeTx(bot, true)], control) + + expect(socialIdentityQueries(control)).toBe(1) + }) + + it('does not cache the negative AI bot lookup', async () => { + const control = createControl({ + employees: [{ _id: person, role: 'USER' }], + offices: [{ _id: generateId(), person: null }], + botIdentities: [] + }) + + await OnEmployee([employeeTx(person, true)], control) + // The bot may join the workspace later, so the lookup should be repeated + await OnEmployee([employeeTx(person, true)], control) + + expect(socialIdentityQueries(control)).toBe(2) + }) +}) diff --git a/server-plugins/love-resources/src/index.ts b/server-plugins/love-resources/src/index.ts index 955df80e69..a37fcaa872 100644 --- a/server-plugins/love-resources/src/index.ts +++ b/server-plugins/love-resources/src/index.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { aiBotEmailSocialKey } from '@hcengineering/ai-bot' import contact, { Employee, getName, Person } from '@hcengineering/contact' import core, { type AccountUuid, @@ -48,6 +49,7 @@ import { workbenchId } from '@hcengineering/workbench' export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise { const result: Tx[] = [] + const assigned = new Set>() for (const tx of txes) { const actualTx = tx as TxMixin if (actualTx._class !== core.class.TxMixin) { @@ -70,17 +72,32 @@ export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise< continue } if (val) { - const freeRoom = (await control.findAll(control.ctx, love.class.Office, { person: null }))[0] + // AI bot does not need an office + if (await isAiBotPerson(actualTx.objectId, control)) { + continue + } + // Do not assign one more office if the person already has one + const existingOffice = ( + await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId }, { limit: 1 }) + )[0] + if (existingOffice !== undefined) { + continue + } + const freeRoom = (await control.findAll(control.ctx, love.class.Office, { person: null })).find( + (it) => !assigned.has(it._id) + ) if (freeRoom !== undefined) { - return [ + assigned.add(freeRoom._id) + result.push( control.txFactory.createTxUpdateDoc(freeRoom._class, freeRoom.space, freeRoom._id, { person: actualTx.objectId }) - ] + ) } } else { - const room = (await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId }))[0] - if (room !== undefined) { + // Release all offices held by the person + const rooms = await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId }) + for (const room of rooms) { result.push( control.txFactory.createTxUpdateDoc(room._class, room.space, room._id, { person: null @@ -92,6 +109,22 @@ export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise< return result } +const aiBotPersonsCacheKey = 'love:ai-bot-persons' + +async function isAiBotPerson (person: Ref, control: TriggerControl): Promise { + let persons = control.cache.get(aiBotPersonsCacheKey) as Set> | undefined + if (persons === undefined) { + const identities = await control.findAll(control.ctx, contact.class.SocialIdentity, { + key: aiBotEmailSocialKey + }) + // Do not cache the negative result: the bot may join the workspace later + if (identities.length === 0) return false + persons = new Set(identities.map((it) => it.attachedTo)) + control.cache.set(aiBotPersonsCacheKey, persons) + } + return persons.has(person) +} + async function createUserInfo (user: AccountUuid, control: TriggerControl): Promise { const person = (await control.findAll(control.ctx, contact.class.Person, { personUuid: user }))[0] if (person === undefined) return [] From 9661fe6ff0bf13064bd31fd23bafbe2258142439 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 6 Jul 2026 15:23:54 +0700 Subject: [PATCH 2/3] Apply fixes Signed-off-by: Artem Savchenko --- .../love-resources/src/__tests__/onEmployee.test.ts | 3 +-- server-plugins/love-resources/src/index.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server-plugins/love-resources/src/__tests__/onEmployee.test.ts b/server-plugins/love-resources/src/__tests__/onEmployee.test.ts index 14c8f48729..44aa852ae4 100644 --- a/server-plugins/love-resources/src/__tests__/onEmployee.test.ts +++ b/server-plugins/love-resources/src/__tests__/onEmployee.test.ts @@ -13,7 +13,6 @@ // limitations under the License. // -import { aiBotEmailSocialKey } from '@hcengineering/ai-bot' import contact, { type Employee, type Person } from '@hcengineering/contact' import core, { generateId, @@ -29,7 +28,7 @@ import core, { import love, { type Office } from '@hcengineering/love' import { type TriggerControl } from '@hcengineering/server-core' -import { OnEmployee } from '../index' +import { aiBotEmailSocialKey, OnEmployee } from '../index' interface MockData { employees: Array<{ _id: Ref, role: string }> diff --git a/server-plugins/love-resources/src/index.ts b/server-plugins/love-resources/src/index.ts index a37fcaa872..e4aa7c5d0b 100644 --- a/server-plugins/love-resources/src/index.ts +++ b/server-plugins/love-resources/src/index.ts @@ -17,11 +17,13 @@ import { aiBotEmailSocialKey } from '@hcengineering/ai-bot' import contact, { Employee, getName, Person } from '@hcengineering/contact' import core, { type AccountUuid, + buildSocialIdString, combineAttributes, concatLink, Doc, generateId, Ref, + SocialIdType, Timestamp, Tx, TxCreateDoc, @@ -109,6 +111,15 @@ export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise< return result } +// Keep in sync with aiBotAccountEmail from @hcengineering/ai-bot +// (not imported to avoid adding the package to server bundles) +export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering' + +export const aiBotEmailSocialKey = buildSocialIdString({ + type: SocialIdType.EMAIL, + value: aiBotAccountEmail +}) + const aiBotPersonsCacheKey = 'love:ai-bot-persons' async function isAiBotPerson (person: Ref, control: TriggerControl): Promise { From a87b404420d3f599db70e8b52e35ee59570230ff Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 6 Jul 2026 16:26:49 +0700 Subject: [PATCH 3/3] Fix validation Signed-off-by: Artem Savchenko --- server-plugins/love-resources/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server-plugins/love-resources/src/index.ts b/server-plugins/love-resources/src/index.ts index e4aa7c5d0b..0fccd0c18f 100644 --- a/server-plugins/love-resources/src/index.ts +++ b/server-plugins/love-resources/src/index.ts @@ -13,7 +13,6 @@ // limitations under the License. // -import { aiBotEmailSocialKey } from '@hcengineering/ai-bot' import contact, { Employee, getName, Person } from '@hcengineering/contact' import core, { type AccountUuid,